Preserve active subagents across reload
This commit is contained in:
@@ -19,6 +19,8 @@ The backend is selected automatically: Herdr is used when Pi runs in a Herdr-man
|
|||||||
|
|
||||||
Spawn several in parallel — they run concurrently and steer results back independently as each finishes.
|
Spawn several in parallel — they run concurrently and steer results back independently as each finishes.
|
||||||
|
|
||||||
|
Running subagents survive a parent `/reload`. Their terminal sessions and completion watchers stay active, then rebind result delivery and the status widget to the fresh Pi extension runtime. Replacing or closing the parent session still cancels its running subagents. This lifecycle behavior requires Pi 0.68 or newer.
|
||||||
|
|
||||||
tmux panes are kept evenly sized: the extension re-applies an `even-horizontal` layout after every spawn and exit (debounced). The layout is a single constant, `SUBAGENT_TMUX_LAYOUT` in `pi-extension/subagents/tmux.ts` — change it to any named tmux layout (`main-vertical`, `tiled`, …). Herdr subagents use separate tabs, so they do not resize the parent tab.
|
tmux panes are kept evenly sized: the extension re-applies an `even-horizontal` layout after every spawn and exit (debounced). The layout is a single constant, `SUBAGENT_TMUX_LAYOUT` in `pi-extension/subagents/tmux.ts` — change it to any named tmux layout (`main-vertical`, `tiled`, …). Herdr subagents use separate tabs, so they do not resize the parent tab.
|
||||||
|
|
||||||
If your shell startup is slow and launch commands get dropped before the prompt is ready, raise the delay:
|
If your shell startup is slow and launch commands get dropped before the prompt is ready, raise the delay:
|
||||||
|
|||||||
Generated
+344
-1352
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -17,8 +17,8 @@
|
|||||||
"test:integration": "node --test --test-concurrency=1 test/integration/*.test.ts"
|
"test:integration": "node --test --test-concurrency=1 test/integration/*.test.ts"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@mariozechner/pi-coding-agent": "*",
|
"@mariozechner/pi-coding-agent": ">=0.68.0",
|
||||||
"@mariozechner/pi-tui": "*",
|
"@mariozechner/pi-tui": ">=0.68.0",
|
||||||
"@sinclair/typebox": "*"
|
"@sinclair/typebox": "*"
|
||||||
},
|
},
|
||||||
"pi": {
|
"pi": {
|
||||||
@@ -27,8 +27,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mariozechner/pi-coding-agent": "^0.65.0",
|
"@mariozechner/pi-coding-agent": "^0.68.0",
|
||||||
"@mariozechner/pi-tui": "^0.65.0",
|
"@mariozechner/pi-tui": "^0.68.0",
|
||||||
"@sinclair/typebox": "^0.34.49"
|
"@sinclair/typebox": "^0.34.49"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+221
-161
@@ -66,32 +66,11 @@ import {
|
|||||||
/** Absolute path to `pi-extension/subagents`. https://github.com/nodejs/node/issues/37845 */
|
/** Absolute path to `pi-extension/subagents`. https://github.com/nodejs/node/issues/37845 */
|
||||||
const SUBAGENTS_DIR = dirname(fileURLToPath(import.meta.url));
|
const SUBAGENTS_DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
// Survive /reload: clear timers and abort poll loops from the previous module load.
|
// Pi re-imports extension modules during /reload while callbacks from the old
|
||||||
// /reload re-imports this file, giving fresh module-level state, but closures from
|
// module can still be running. Keep live subagent ownership process-global so a
|
||||||
// the old module keep running. See https://github.com/HazAT/pi-interactive-subagents/issues/5
|
// fresh extension runtime can rebind to existing watchers instead of cancelling
|
||||||
const WIDGET_INTERVAL_KEY = Symbol.for("pi-subagents/widget-interval");
|
// their terminal sessions.
|
||||||
const STATUS_INTERVAL_KEY = Symbol.for("pi-subagents/status-interval");
|
const SHARED_RUNTIME_KEY = Symbol.for("pi-subagents/shared-runtime");
|
||||||
const POLL_ABORT_KEY = Symbol.for("pi-subagents/poll-abort-controller");
|
|
||||||
|
|
||||||
{
|
|
||||||
const prevInterval = (globalThis as any)[WIDGET_INTERVAL_KEY];
|
|
||||||
if (prevInterval) {
|
|
||||||
clearInterval(prevInterval);
|
|
||||||
(globalThis as any)[WIDGET_INTERVAL_KEY] = null;
|
|
||||||
}
|
|
||||||
const prevStatusInterval = (globalThis as any)[STATUS_INTERVAL_KEY];
|
|
||||||
if (prevStatusInterval) {
|
|
||||||
clearInterval(prevStatusInterval);
|
|
||||||
(globalThis as any)[STATUS_INTERVAL_KEY] = null;
|
|
||||||
}
|
|
||||||
const prevAbort = (globalThis as any)[POLL_ABORT_KEY] as AbortController | undefined;
|
|
||||||
if (prevAbort) prevAbort.abort();
|
|
||||||
(globalThis as any)[POLL_ABORT_KEY] = new AbortController();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getModuleAbortSignal(): AbortSignal {
|
|
||||||
return ((globalThis as any)[POLL_ABORT_KEY] as AbortController).signal;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SubagentParams = Type.Object({
|
const SubagentParams = Type.Object({
|
||||||
agent: Type.String({
|
agent: Type.String({
|
||||||
@@ -634,10 +613,41 @@ interface RunningSubagent {
|
|||||||
* subagent's pane (e.g. planner).
|
* subagent's pane (e.g. planner).
|
||||||
*/
|
*/
|
||||||
interactive: boolean;
|
interactive: boolean;
|
||||||
|
/** Suppress terminal delivery when a real session shutdown cancels this run. */
|
||||||
|
suppressDelivery?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All currently running subagents, keyed by id. */
|
type PendingDelivery = (pi: ExtensionAPI) => void;
|
||||||
const runningSubagents = new Map<string, RunningSubagent>();
|
|
||||||
|
interface SharedSubagentRuntime {
|
||||||
|
runningSubagents: Map<string, RunningSubagent>;
|
||||||
|
latestCtx: ExtensionContext | null;
|
||||||
|
latestPi: ExtensionAPI | null;
|
||||||
|
widgetInterval: ReturnType<typeof setInterval> | null;
|
||||||
|
statusInterval: ReturnType<typeof setInterval> | null;
|
||||||
|
pendingDeliveries: PendingDelivery[];
|
||||||
|
deliveryFlushScheduled: boolean;
|
||||||
|
activeRuntimeToken: symbol | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSharedRuntime(): SharedSubagentRuntime {
|
||||||
|
return {
|
||||||
|
runningSubagents: new Map(),
|
||||||
|
latestCtx: null,
|
||||||
|
latestPi: null,
|
||||||
|
widgetInterval: null,
|
||||||
|
statusInterval: null,
|
||||||
|
pendingDeliveries: [],
|
||||||
|
deliveryFlushScheduled: false,
|
||||||
|
activeRuntimeToken: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedRuntime = ((globalThis as any)[SHARED_RUNTIME_KEY] ??=
|
||||||
|
createSharedRuntime()) as SharedSubagentRuntime;
|
||||||
|
|
||||||
|
/** All currently running subagents, preserved across /reload. */
|
||||||
|
const runningSubagents = sharedRuntime.runningSubagents;
|
||||||
|
|
||||||
// When this extension is loaded inside a subagent that itself spawns children
|
// When this extension is loaded inside a subagent that itself spawns children
|
||||||
// (e.g. a worker delegating to scout/researcher), `subagent-done.ts` runs in the
|
// (e.g. a worker delegating to scout/researcher), `subagent-done.ts` runs in the
|
||||||
@@ -648,18 +658,42 @@ const runningSubagents = new Map<string, RunningSubagent>();
|
|||||||
const RUNNING_CHILDREN_COUNT_KEY = Symbol.for("pi-subagents/running-children-count");
|
const RUNNING_CHILDREN_COUNT_KEY = Symbol.for("pi-subagents/running-children-count");
|
||||||
(globalThis as any)[RUNNING_CHILDREN_COUNT_KEY] = () => runningSubagents.size;
|
(globalThis as any)[RUNNING_CHILDREN_COUNT_KEY] = () => runningSubagents.size;
|
||||||
|
|
||||||
// ── Widget management ──
|
// ── Widget and runtime delivery management ──
|
||||||
|
|
||||||
/** Latest ExtensionContext from session_start, used for widget updates. */
|
function sendWhenRuntimeReady(delivery: PendingDelivery): void {
|
||||||
let latestCtx: ExtensionContext | null = null;
|
const pi = sharedRuntime.latestPi;
|
||||||
/** Latest ExtensionAPI, used to deliver ask_question notifications from the watcher. */
|
if (pi) {
|
||||||
let latestPi: ExtensionAPI | null = null;
|
delivery(pi);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sharedRuntime.pendingDeliveries.push(delivery);
|
||||||
|
}
|
||||||
|
|
||||||
/** Interval timer for widget re-renders. */
|
function flushPendingDeliveries(): void {
|
||||||
let widgetInterval: ReturnType<typeof setInterval> | null = null;
|
sharedRuntime.deliveryFlushScheduled = false;
|
||||||
|
const pi = sharedRuntime.latestPi;
|
||||||
|
if (!pi || sharedRuntime.pendingDeliveries.length === 0) return;
|
||||||
|
|
||||||
/** Interval timer for status transition checks. */
|
const pending = sharedRuntime.pendingDeliveries.splice(0);
|
||||||
let statusInterval: ReturnType<typeof setInterval> | null = null;
|
for (const delivery of pending) delivery(pi);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePendingDeliveryFlush(): void {
|
||||||
|
if (sharedRuntime.deliveryFlushScheduled) return;
|
||||||
|
sharedRuntime.deliveryFlushScheduled = true;
|
||||||
|
setImmediate(flushPendingDeliveries);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRefreshIntervals(): void {
|
||||||
|
if (sharedRuntime.widgetInterval) {
|
||||||
|
clearInterval(sharedRuntime.widgetInterval);
|
||||||
|
sharedRuntime.widgetInterval = null;
|
||||||
|
}
|
||||||
|
if (sharedRuntime.statusInterval) {
|
||||||
|
clearInterval(sharedRuntime.statusInterval);
|
||||||
|
sharedRuntime.statusInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatElapsedMMSS(startTime: number): string {
|
function formatElapsedMMSS(startTime: number): string {
|
||||||
const seconds = Math.floor((Date.now() - startTime) / 1000);
|
const seconds = Math.floor((Date.now() - startTime) / 1000);
|
||||||
@@ -755,19 +789,19 @@ function renderSubagentWidgetLines(agents: RunningSubagent[], width: number): st
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateWidget() {
|
function updateWidget() {
|
||||||
if (!latestCtx?.hasUI) return;
|
const ctx = sharedRuntime.latestCtx;
|
||||||
|
if (!ctx?.hasUI) return;
|
||||||
|
|
||||||
if (runningSubagents.size === 0) {
|
if (runningSubagents.size === 0) {
|
||||||
latestCtx.ui.setWidget("subagent-status", undefined);
|
ctx.ui.setWidget("subagent-status", undefined);
|
||||||
if (widgetInterval) {
|
if (sharedRuntime.widgetInterval) {
|
||||||
clearInterval(widgetInterval);
|
clearInterval(sharedRuntime.widgetInterval);
|
||||||
widgetInterval = null;
|
sharedRuntime.widgetInterval = null;
|
||||||
(globalThis as any)[WIDGET_INTERVAL_KEY] = null;
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
latestCtx.ui.setWidget(
|
ctx.ui.setWidget(
|
||||||
"subagent-status",
|
"subagent-status",
|
||||||
(_tui: any, _theme: any) => {
|
(_tui: any, _theme: any) => {
|
||||||
return {
|
return {
|
||||||
@@ -1066,15 +1100,14 @@ function handleSubagentSteer(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function startStatusRefresh(pi: ExtensionAPI) {
|
function startStatusRefresh() {
|
||||||
if (!statusConfig.enabled || statusInterval) return;
|
if (!statusConfig.enabled || sharedRuntime.statusInterval) return;
|
||||||
|
|
||||||
statusInterval = setInterval(() => {
|
sharedRuntime.statusInterval = setInterval(() => {
|
||||||
if (runningSubagents.size === 0) {
|
if (runningSubagents.size === 0) {
|
||||||
if (statusInterval) {
|
if (sharedRuntime.statusInterval) {
|
||||||
clearInterval(statusInterval);
|
clearInterval(sharedRuntime.statusInterval);
|
||||||
statusInterval = null;
|
sharedRuntime.statusInterval = null;
|
||||||
(globalThis as any)[STATUS_INTERVAL_KEY] = null;
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1104,19 +1137,19 @@ function startStatusRefresh(pi: ExtensionAPI) {
|
|||||||
|
|
||||||
if (transitionLines.length > 0) {
|
if (transitionLines.length > 0) {
|
||||||
const capped = capStatusLines(transitionLines, statusConfig.lineLimit);
|
const capped = capStatusLines(transitionLines, statusConfig.lineLimit);
|
||||||
pi.sendMessage(
|
sendWhenRuntimeReady((pi) => {
|
||||||
{
|
pi.sendMessage(
|
||||||
customType: "subagent_status",
|
{
|
||||||
content: formatStatusAggregate(transitionLines, statusConfig.lineLimit),
|
customType: "subagent_status",
|
||||||
display: true,
|
content: formatStatusAggregate(transitionLines, statusConfig.lineLimit),
|
||||||
details: { lines: capped.visibleLines, overflow: capped.overflow },
|
display: true,
|
||||||
},
|
details: { lines: capped.visibleLines, overflow: capped.overflow },
|
||||||
{ triggerTurn: true, deliverAs: "steer" },
|
},
|
||||||
);
|
{ triggerTurn: true, deliverAs: "steer" },
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
(globalThis as any)[STATUS_INTERVAL_KEY] = statusInterval;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resuming a finished session is always autonomous: the relaunched agent runs
|
// Resuming a finished session is always autonomous: the relaunched agent runs
|
||||||
@@ -1158,15 +1191,17 @@ export const __test__ = {
|
|||||||
contextWindowFor,
|
contextWindowFor,
|
||||||
formatUsageSegments,
|
formatUsageSegments,
|
||||||
widgetIcon,
|
widgetIcon,
|
||||||
|
sendWhenRuntimeReady,
|
||||||
|
flushPendingDeliveries,
|
||||||
|
sharedRuntime,
|
||||||
};
|
};
|
||||||
|
|
||||||
function startWidgetRefresh() {
|
function startWidgetRefresh() {
|
||||||
if (widgetInterval) return;
|
if (sharedRuntime.widgetInterval) return;
|
||||||
updateWidget(); // immediate first render
|
updateWidget(); // immediate first render
|
||||||
widgetInterval = setInterval(() => {
|
sharedRuntime.widgetInterval = setInterval(() => {
|
||||||
updateWidget();
|
updateWidget();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
(globalThis as any)[WIDGET_INTERVAL_KEY] = widgetInterval;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1531,20 +1566,22 @@ function deliverPendingQuestion(running: RunningSubagent): void {
|
|||||||
const elapsed = Math.floor((Date.now() - running.startTime) / 1000);
|
const elapsed = Math.floor((Date.now() - running.startTime) / 1000);
|
||||||
const replyHint = `\n\nReply with subagent_message({ name: "${name}", message: "…" }) — the same name works whether it is still running or has since exited. It stays open until you reply.`;
|
const replyHint = `\n\nReply with subagent_message({ name: "${name}", message: "…" }) — the same name works whether it is still running or has since exited. It stays open until you reply.`;
|
||||||
|
|
||||||
latestPi?.sendMessage(
|
sendWhenRuntimeReady((pi) => {
|
||||||
{
|
pi.sendMessage(
|
||||||
customType: "subagent_question",
|
{
|
||||||
content: `Sub-agent "${name}" asks (${formatElapsed(elapsed)}):\n\n${payload.question}${replyHint}`,
|
customType: "subagent_question",
|
||||||
display: true,
|
content: `Sub-agent "${name}" asks (${formatElapsed(elapsed)}):\n\n${payload.question}${replyHint}`,
|
||||||
details: {
|
display: true,
|
||||||
name,
|
details: {
|
||||||
agent: running.agent,
|
name,
|
||||||
question: payload.question,
|
agent: running.agent,
|
||||||
...(sessionId ? { sessionId } : {}),
|
question: payload.question,
|
||||||
|
...(sessionId ? { sessionId } : {}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
{ triggerTurn: true, deliverAs: "steer" },
|
||||||
{ triggerTurn: true, deliverAs: "steer" },
|
);
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function watchSubagent(
|
async function watchSubagent(
|
||||||
@@ -1554,7 +1591,7 @@ async function watchSubagent(
|
|||||||
const { name, task, surface, startTime, sessionFile } = running;
|
const { name, task, surface, startTime, sessionFile } = running;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await pollForExit(surface, AbortSignal.any([signal, getModuleAbortSignal()]), {
|
const result = await pollForExit(surface, signal, {
|
||||||
interval: 1000,
|
interval: 1000,
|
||||||
sessionFile,
|
sessionFile,
|
||||||
sentinelFile: running.sentinelFile,
|
sentinelFile: running.sentinelFile,
|
||||||
@@ -1668,35 +1705,45 @@ async function watchSubagent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function subagentsExtension(pi: ExtensionAPI) {
|
export default function subagentsExtension(pi: ExtensionAPI) {
|
||||||
latestPi = pi;
|
const runtimeToken = Symbol("pi-subagents/runtime");
|
||||||
// Capture the UI context for widget updates
|
|
||||||
|
// Bind only after Pi has installed the fresh runtime. During /reload the old
|
||||||
|
// session_shutdown handler clears these references first, so watcher results
|
||||||
|
// cannot enqueue a turn through stale extension APIs.
|
||||||
pi.on("session_start", (_event, ctx) => {
|
pi.on("session_start", (_event, ctx) => {
|
||||||
latestCtx = ctx;
|
sharedRuntime.activeRuntimeToken = runtimeToken;
|
||||||
// pi runs multiple sessions in one process. A prior session's shutdown
|
sharedRuntime.latestPi = pi;
|
||||||
// aborts the shared module poll-abort controller; install a fresh one so
|
sharedRuntime.latestCtx = ctx;
|
||||||
// subagents spawned in this session aren't watched against a dead signal.
|
|
||||||
// See https://github.com/HazAT/pi-interactive-subagents/issues/5
|
if (runningSubagents.size > 0) {
|
||||||
const prevAbort = (globalThis as any)[POLL_ABORT_KEY] as AbortController | undefined;
|
startWidgetRefresh();
|
||||||
if (!prevAbort || prevAbort.signal.aborted) {
|
startStatusRefresh();
|
||||||
(globalThis as any)[POLL_ABORT_KEY] = new AbortController();
|
|
||||||
}
|
}
|
||||||
|
// A completion can land in the short gap between old shutdown and this
|
||||||
|
// fresh session_start. Flush on the next macrotask, after reload finishes.
|
||||||
|
schedulePendingDeliveryFlush();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up on session shutdown
|
pi.on("session_shutdown", (event, _ctx) => {
|
||||||
pi.on("session_shutdown", (_event, _ctx) => {
|
// A delayed event from an invalidated extension instance must not tear down
|
||||||
if (widgetInterval) {
|
// state already rebound to the replacement runtime.
|
||||||
clearInterval(widgetInterval);
|
if (sharedRuntime.activeRuntimeToken !== runtimeToken) return;
|
||||||
widgetInterval = null;
|
|
||||||
(globalThis as any)[WIDGET_INTERVAL_KEY] = null;
|
sharedRuntime.activeRuntimeToken = null;
|
||||||
}
|
sharedRuntime.latestPi = null;
|
||||||
if (statusInterval) {
|
sharedRuntime.latestCtx = null;
|
||||||
clearInterval(statusInterval);
|
clearRefreshIntervals();
|
||||||
statusInterval = null;
|
|
||||||
(globalThis as any)[STATUS_INTERVAL_KEY] = null;
|
// /reload keeps the same parent session and terminal surfaces. Existing
|
||||||
}
|
// watchers continue and rebind to the fresh runtime in session_start.
|
||||||
const moduleAbort = (globalThis as any)[POLL_ABORT_KEY] as AbortController | undefined;
|
if (event.reason === "reload") return;
|
||||||
if (moduleAbort) moduleAbort.abort();
|
|
||||||
for (const [_id, agent] of runningSubagents) {
|
// Real session replacement/quit owns a different lifecycle. Cancel those
|
||||||
|
// children and suppress the synthetic cancellation result; there is no
|
||||||
|
// valid destination session to receive it.
|
||||||
|
sharedRuntime.pendingDeliveries.length = 0;
|
||||||
|
for (const agent of runningSubagents.values()) {
|
||||||
|
agent.suppressDelivery = true;
|
||||||
agent.abortController?.abort();
|
agent.abortController?.abort();
|
||||||
}
|
}
|
||||||
runningSubagents.clear();
|
runningSubagents.clear();
|
||||||
@@ -1848,47 +1895,54 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
|
|
||||||
// Start widget refresh and status supervision when the first agent launches
|
// Start widget refresh and status supervision when the first agent launches
|
||||||
startWidgetRefresh();
|
startWidgetRefresh();
|
||||||
startStatusRefresh(pi);
|
startStatusRefresh();
|
||||||
|
|
||||||
// Fire-and-forget: start watching in background
|
// Fire-and-forget: start watching in background. Delivery resolves the
|
||||||
|
// current Pi runtime at completion time so /reload cannot retain a stale
|
||||||
|
// ExtensionAPI closure.
|
||||||
watchSubagent(running, watcherAbort.signal)
|
watchSubagent(running, watcherAbort.signal)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
updateWidget(); // reflect removal from Map immediately
|
updateWidget(); // reflect removal from Map immediately
|
||||||
|
if (running.suppressDelivery) return;
|
||||||
|
|
||||||
const presentation = resolveResultPresentation(result, running.name);
|
const presentation = resolveResultPresentation(result, running.name);
|
||||||
|
sendWhenRuntimeReady((currentPi) => {
|
||||||
pi.sendMessage(
|
currentPi.sendMessage(
|
||||||
{
|
{
|
||||||
customType: "subagent_result",
|
customType: "subagent_result",
|
||||||
content: presentation,
|
content: presentation,
|
||||||
display: true,
|
display: true,
|
||||||
details: {
|
details: {
|
||||||
name: running.name,
|
name: running.name,
|
||||||
task: running.task,
|
task: running.task,
|
||||||
agent: running.agent,
|
agent: running.agent,
|
||||||
exitCode: result.exitCode,
|
exitCode: result.exitCode,
|
||||||
elapsed: result.elapsed,
|
elapsed: result.elapsed,
|
||||||
sessionFile: result.sessionFile,
|
sessionFile: result.sessionFile,
|
||||||
...(result.sessionId ? { sessionId: result.sessionId } : {}),
|
...(result.sessionId ? { sessionId: result.sessionId } : {}),
|
||||||
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
||||||
...(result.claudeSessionId ? { claudeSessionId: result.claudeSessionId } : {}),
|
...(result.claudeSessionId ? { claudeSessionId: result.claudeSessionId } : {}),
|
||||||
...(result.stats ? { stats: result.stats } : {}),
|
...(result.stats ? { stats: result.stats } : {}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
{ triggerTurn: true, deliverAs: "steer" },
|
||||||
{ triggerTurn: true, deliverAs: "steer" },
|
);
|
||||||
);
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
updateWidget();
|
updateWidget();
|
||||||
pi.sendMessage(
|
if (running.suppressDelivery) return;
|
||||||
{
|
sendWhenRuntimeReady((currentPi) => {
|
||||||
customType: "subagent_result",
|
currentPi.sendMessage(
|
||||||
content: `Sub-agent "${running.name}" error: ${err?.message ?? String(err)}`,
|
{
|
||||||
display: true,
|
customType: "subagent_result",
|
||||||
details: { name: running.name, task: running.task, error: err?.message },
|
content: `Sub-agent "${running.name}" error: ${err?.message ?? String(err)}`,
|
||||||
},
|
display: true,
|
||||||
{ triggerTurn: true, deliverAs: "steer" },
|
details: { name: running.name, task: running.task, error: err?.message },
|
||||||
);
|
},
|
||||||
|
{ triggerTurn: true, deliverAs: "steer" },
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return immediately
|
// Return immediately
|
||||||
@@ -2286,7 +2340,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
};
|
};
|
||||||
runningSubagents.set(id, running);
|
runningSubagents.set(id, running);
|
||||||
startWidgetRefresh();
|
startWidgetRefresh();
|
||||||
startStatusRefresh(pi);
|
startStatusRefresh();
|
||||||
|
|
||||||
// Fire-and-forget watcher
|
// Fire-and-forget watcher
|
||||||
const watcherAbort = new AbortController();
|
const watcherAbort = new AbortController();
|
||||||
@@ -2295,6 +2349,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
watchSubagent(running, watcherAbort.signal)
|
watchSubagent(running, watcherAbort.signal)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
updateWidget();
|
updateWidget();
|
||||||
|
if (running.suppressDelivery) return;
|
||||||
|
|
||||||
const allEntries = getNewEntries(sessionPath, entryCountBefore);
|
const allEntries = getNewEntries(sessionPath, entryCountBefore);
|
||||||
const summary = findLastAssistantMessage(allEntries) ??
|
const summary = findLastAssistantMessage(allEntries) ??
|
||||||
@@ -2308,35 +2363,40 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|||||||
name,
|
name,
|
||||||
);
|
);
|
||||||
|
|
||||||
pi.sendMessage(
|
sendWhenRuntimeReady((currentPi) => {
|
||||||
{
|
currentPi.sendMessage(
|
||||||
customType: "subagent_result",
|
{
|
||||||
content: presentation,
|
customType: "subagent_result",
|
||||||
display: true,
|
content: presentation,
|
||||||
details: {
|
display: true,
|
||||||
name,
|
details: {
|
||||||
task: message,
|
name,
|
||||||
exitCode: result.exitCode,
|
task: message,
|
||||||
elapsed: result.elapsed,
|
exitCode: result.exitCode,
|
||||||
sessionFile: sessionPath,
|
elapsed: result.elapsed,
|
||||||
sessionId: resumedSessionId,
|
sessionFile: sessionPath,
|
||||||
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
sessionId: resumedSessionId,
|
||||||
|
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
{ triggerTurn: true, deliverAs: "steer" },
|
||||||
{ triggerTurn: true, deliverAs: "steer" },
|
);
|
||||||
);
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
updateWidget();
|
updateWidget();
|
||||||
pi.sendMessage(
|
if (running.suppressDelivery) return;
|
||||||
{
|
sendWhenRuntimeReady((currentPi) => {
|
||||||
customType: "subagent_result",
|
currentPi.sendMessage(
|
||||||
content: `Resume error: ${err?.message ?? String(err)}`,
|
{
|
||||||
display: true,
|
customType: "subagent_result",
|
||||||
details: { name, error: err?.message },
|
content: `Resume error: ${err?.message ?? String(err)}`,
|
||||||
},
|
display: true,
|
||||||
{ triggerTurn: true, deliverAs: "steer" },
|
details: { name, error: err?.message },
|
||||||
);
|
},
|
||||||
|
{ triggerTurn: true, deliverAs: "steer" },
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
uniqueId,
|
uniqueId,
|
||||||
trackTempFile,
|
trackTempFile,
|
||||||
readScreen,
|
readScreen,
|
||||||
|
sendCommand,
|
||||||
PI_TIMEOUT,
|
PI_TIMEOUT,
|
||||||
type TestEnv,
|
type TestEnv,
|
||||||
} from "./harness.ts";
|
} from "./harness.ts";
|
||||||
@@ -103,6 +104,57 @@ for (const backend of backends) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Reload preservation ──
|
||||||
|
|
||||||
|
it("keeps a running subagent and fresh tool wrappers across parent extension reload", async () => {
|
||||||
|
const id = uniqueId();
|
||||||
|
const startedFile = `/tmp/pi-integ-reload-start-${id}.txt`;
|
||||||
|
const completedFile = `/tmp/pi-integ-reload-done-${id}.txt`;
|
||||||
|
const followupFile = `/tmp/pi-integ-reload-followup-${id}.txt`;
|
||||||
|
trackTempFile(env, startedFile);
|
||||||
|
trackTempFile(env, completedFile);
|
||||||
|
trackTempFile(env, followupFile);
|
||||||
|
|
||||||
|
const surface = createTrackedSurface(env, `reload-${id}`);
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const task = [
|
||||||
|
`Call the subagent tool once with these EXACT parameters:`,
|
||||||
|
` name: "Reload-${id}"`,
|
||||||
|
` agent: "test-echo"`,
|
||||||
|
` task: "Run: echo 'START_${id}' > '${startedFile}'; sleep 10; echo 'DONE_${id}' > '${completedFile}'"`,
|
||||||
|
`Do not run the command yourself.`,
|
||||||
|
`After that subagent result arrives, call the subagent tool once more with these EXACT parameters:`,
|
||||||
|
` name: "Reload-followup-${id}"`,
|
||||||
|
` agent: "test-echo"`,
|
||||||
|
` task: "Write 'FOLLOWUP_${id}' to '${followupFile}'"`,
|
||||||
|
`After the second subagent result arrives, say RELOAD_SURVIVED_${id}.`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
startPi(surface, env.dir, task);
|
||||||
|
await waitForFile(startedFile, PI_TIMEOUT, /START_/);
|
||||||
|
|
||||||
|
sendCommand(surface, "/reload");
|
||||||
|
|
||||||
|
const completed = await waitForFile(completedFile, PI_TIMEOUT, /DONE_/);
|
||||||
|
assert.ok(completed.includes(`DONE_${id}`), "child should finish after parent reload");
|
||||||
|
const followup = await waitForFile(followupFile, PI_TIMEOUT, /FOLLOWUP_/);
|
||||||
|
assert.ok(
|
||||||
|
followup.includes(`FOLLOWUP_${id}`),
|
||||||
|
"the result-triggered turn should spawn through the fresh tool wrapper",
|
||||||
|
);
|
||||||
|
|
||||||
|
const resultMarker = `RELOAD_SURVIVED_${id}`;
|
||||||
|
const responseLine = new RegExp(`^ ${resultMarker}\\s*$`, "m");
|
||||||
|
const screen = await waitForScreen(surface, responseLine, PI_TIMEOUT, 300);
|
||||||
|
assert.equal(
|
||||||
|
screen.match(new RegExp(`^ ${resultMarker}\\s*$`, "gm"))?.length,
|
||||||
|
1,
|
||||||
|
"the fresh runtime should receive the completion exactly once",
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(screen, /Subagent cancelled|ctx is stale after session replacement or reload/i);
|
||||||
|
});
|
||||||
|
|
||||||
// ── In-progress activity snapshots ──
|
// ── In-progress activity snapshots ──
|
||||||
|
|
||||||
it("keeps a long active tool call from surfacing false stalled status", async () => {
|
it("keeps a long active tool call from surfacing false stalled status", async () => {
|
||||||
|
|||||||
+116
-1
@@ -88,16 +88,22 @@ function createMockExtensionApi() {
|
|||||||
const registeredTools: Array<any> = [];
|
const registeredTools: Array<any> = [];
|
||||||
const registeredCommands: Array<any> = [];
|
const registeredCommands: Array<any> = [];
|
||||||
const registeredMessageRenderers: Array<any> = [];
|
const registeredMessageRenderers: Array<any> = [];
|
||||||
|
const eventHandlers = new Map<string, Array<(event: any, ctx: any) => any>>();
|
||||||
const sentUserMessages: string[] = [];
|
const sentUserMessages: string[] = [];
|
||||||
const sentMessages: Array<any> = [];
|
const sentMessages: Array<any> = [];
|
||||||
return {
|
return {
|
||||||
registeredTools,
|
registeredTools,
|
||||||
registeredCommands,
|
registeredCommands,
|
||||||
registeredMessageRenderers,
|
registeredMessageRenderers,
|
||||||
|
eventHandlers,
|
||||||
sentUserMessages,
|
sentUserMessages,
|
||||||
sentMessages,
|
sentMessages,
|
||||||
api: {
|
api: {
|
||||||
on() {},
|
on(event: string, handler: (event: any, ctx: any) => any) {
|
||||||
|
const handlers = eventHandlers.get(event) ?? [];
|
||||||
|
handlers.push(handler);
|
||||||
|
eventHandlers.set(event, handlers);
|
||||||
|
},
|
||||||
registerTool(tool: any) {
|
registerTool(tool: any) {
|
||||||
registeredTools.push(tool);
|
registeredTools.push(tool);
|
||||||
},
|
},
|
||||||
@@ -117,10 +123,24 @@ function createMockExtensionApi() {
|
|||||||
getAllTools() {
|
getAllTools() {
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
|
getActiveTools() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
} as any,
|
} as any,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function emitMockEvent(
|
||||||
|
mock: ReturnType<typeof createMockExtensionApi>,
|
||||||
|
event: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
ctx: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
for (const handler of mock.eventHandlers.get(event) ?? []) {
|
||||||
|
await handler({ type: event, ...payload }, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function restoreEnvVar(name: string, value: string | undefined) {
|
function restoreEnvVar(name: string, value: string | undefined) {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
delete process.env[name];
|
delete process.env[name];
|
||||||
@@ -1838,6 +1858,101 @@ describe("commands", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("reload lifecycle", () => {
|
||||||
|
const testApi = (subagentsModule as any).__test__;
|
||||||
|
const shared = testApi.sharedRuntime as {
|
||||||
|
runningSubagents: Map<string, any>;
|
||||||
|
latestCtx: any;
|
||||||
|
latestPi: any;
|
||||||
|
widgetInterval: ReturnType<typeof setInterval> | null;
|
||||||
|
statusInterval: ReturnType<typeof setInterval> | null;
|
||||||
|
pendingDeliveries: Array<(pi: any) => void>;
|
||||||
|
deliveryFlushScheduled: boolean;
|
||||||
|
activeRuntimeToken: symbol | null;
|
||||||
|
};
|
||||||
|
const ctx = { hasUI: false, ui: { setWidget() {} } };
|
||||||
|
|
||||||
|
function resetSharedRuntime() {
|
||||||
|
if (shared.widgetInterval) clearInterval(shared.widgetInterval);
|
||||||
|
if (shared.statusInterval) clearInterval(shared.statusInterval);
|
||||||
|
shared.widgetInterval = null;
|
||||||
|
shared.statusInterval = null;
|
||||||
|
shared.runningSubagents.clear();
|
||||||
|
shared.latestCtx = null;
|
||||||
|
shared.latestPi = null;
|
||||||
|
shared.pendingDeliveries.length = 0;
|
||||||
|
shared.deliveryFlushScheduled = false;
|
||||||
|
shared.activeRuntimeToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(resetSharedRuntime);
|
||||||
|
after(resetSharedRuntime);
|
||||||
|
|
||||||
|
it("preserves a live subagent and delivers its queued result through the fresh runtime", async () => {
|
||||||
|
const oldRuntime = createMockExtensionApi();
|
||||||
|
(subagentsModule as any).default(oldRuntime.api);
|
||||||
|
await emitMockEvent(oldRuntime, "session_start", { reason: "startup" }, ctx);
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const running: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
task: string;
|
||||||
|
surface: string;
|
||||||
|
startTime: number;
|
||||||
|
sessionFile: string;
|
||||||
|
statusState: ReturnType<typeof createStatusState>;
|
||||||
|
interactive: boolean;
|
||||||
|
abortController: AbortController;
|
||||||
|
suppressDelivery?: boolean;
|
||||||
|
} = {
|
||||||
|
id: "reload-child",
|
||||||
|
name: "reload-child",
|
||||||
|
task: "keep running",
|
||||||
|
surface: "surface-1",
|
||||||
|
startTime: Date.now(),
|
||||||
|
sessionFile: "/tmp/reload-child.jsonl",
|
||||||
|
statusState: createStatusState({ source: "pi", startTimeMs: Date.now() }),
|
||||||
|
interactive: false,
|
||||||
|
abortController,
|
||||||
|
};
|
||||||
|
shared.runningSubagents.set(running.id, running);
|
||||||
|
|
||||||
|
await emitMockEvent(oldRuntime, "session_shutdown", { reason: "reload" }, ctx);
|
||||||
|
assert.equal(abortController.signal.aborted, false, "reload must not cancel the child");
|
||||||
|
assert.equal(shared.runningSubagents.get(running.id), running);
|
||||||
|
|
||||||
|
testApi.sendWhenRuntimeReady((pi: any) => {
|
||||||
|
pi.sendMessage({ customType: "subagent_result", content: "done", display: true });
|
||||||
|
});
|
||||||
|
assert.equal(oldRuntime.sentMessages.length, 0, "the stale runtime must not receive completion");
|
||||||
|
assert.equal(shared.pendingDeliveries.length, 1);
|
||||||
|
|
||||||
|
const freshRuntime = createMockExtensionApi();
|
||||||
|
(subagentsModule as any).default(freshRuntime.api);
|
||||||
|
await emitMockEvent(freshRuntime, "session_start", { reason: "reload" }, ctx);
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.equal(freshRuntime.sentMessages.length, 1);
|
||||||
|
assert.equal(freshRuntime.sentMessages[0].message.customType, "subagent_result");
|
||||||
|
assert.equal(oldRuntime.sentMessages.length, 0);
|
||||||
|
assert.equal(shared.pendingDeliveries.length, 0);
|
||||||
|
assert.equal(shared.runningSubagents.get(running.id), running);
|
||||||
|
|
||||||
|
testApi.flushPendingDeliveries();
|
||||||
|
assert.equal(freshRuntime.sentMessages.length, 1, "a queued completion must flush exactly once");
|
||||||
|
|
||||||
|
await emitMockEvent(oldRuntime, "session_shutdown", { reason: "quit" }, ctx);
|
||||||
|
assert.equal(abortController.signal.aborted, false, "a stale runtime cannot cancel rebound state");
|
||||||
|
assert.equal(shared.runningSubagents.get(running.id), running);
|
||||||
|
|
||||||
|
await emitMockEvent(freshRuntime, "session_shutdown", { reason: "quit" }, ctx);
|
||||||
|
assert.equal(abortController.signal.aborted, true, "real shutdown still cancels the child");
|
||||||
|
assert.equal(running.suppressDelivery, true);
|
||||||
|
assert.equal(shared.runningSubagents.size, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("tool registration", () => {
|
describe("tool registration", () => {
|
||||||
it("always resumes subagents as autonomous (auto-exit, non-interactive tracking)", () => {
|
it("always resumes subagents as autonomous (auto-exit, non-interactive tracking)", () => {
|
||||||
const testApi = (subagentsModule as any).__test__;
|
const testApi = (subagentsModule as any).__test__;
|
||||||
|
|||||||
Reference in New Issue
Block a user