fix(agent): let shutdown supersede a prior budget abort
requestAbort's abortSent branch only upgraded incoming signal reasons, so a shutdown landing after a soft-budget hard-abort was discarded and abortKind() stayed budget. finalizeSubagentLifecycle then followed the budget-resumable path (idle + adopt) even though AgentLifecycleManager.dispose() had run, leaking the subagent session into SDK/process reuse. - Upgrade a prior budget abort to shutdown so the run takes the shutdown release path; genuine kills (signal/timeout/terminate) stay terminal and shutdown is never downgraded to signal. - Cover the shutdown-races-budget-hard-abort case. Fixes #8216
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async";
|
||||
import { ASYNC_JOB_MANAGER_SHUTDOWN_REASON, AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async";
|
||||
import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import type { LoadExtensionsResult } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types";
|
||||
@@ -344,6 +344,52 @@ describe("runSubprocess soft request budget", () => {
|
||||
rpcRegistry.dispose();
|
||||
});
|
||||
|
||||
it("a shutdown racing a budget hard-abort follows the shutdown release path", async () => {
|
||||
// Regression: a process shutdown that lands right after the soft-budget
|
||||
// grace hard-aborts must supersede the budget reason, so the subagent is
|
||||
// released (disposed + unregistered, restorable as parked) instead of
|
||||
// being left adopted and alive past AgentLifecycleManager.dispose().
|
||||
const id = "RacedScout";
|
||||
const rootSessionFile = `${tempDir.path()}/main.jsonl`;
|
||||
const workerSessionFile = `${tempDir.path()}/main/${id}.jsonl`;
|
||||
await Bun.write(rootSessionFile, "");
|
||||
await Bun.write(workerSessionFile, "");
|
||||
const controller = new AbortController();
|
||||
// abort #1 = budget soft-stop (abortSent still false); abort #2 =
|
||||
// budget hard-abort's abortActiveSession (abortReason already "budget").
|
||||
// Fire the shutdown only on #2 so it must supersede the budget reason.
|
||||
let abortInvocations = 0;
|
||||
const handle = createMockSession(
|
||||
({ promptIndex, emit, pushMessage }) => {
|
||||
if (promptIndex !== 1) return;
|
||||
// Never yields: budget 2 → stop at 3, grace exhausted at 8.
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
const message = assistantText(`burning request ${i}`);
|
||||
pushMessage(message);
|
||||
emit({ type: "message_end", message } as unknown as AgentSessionEvent);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
abortInvocations += 1;
|
||||
if (abortInvocations >= 2 && !controller.signal.aborted) {
|
||||
controller.abort(ASYNC_JOB_MANAGER_SHUTDOWN_REASON);
|
||||
}
|
||||
},
|
||||
);
|
||||
mockCreateAgentSession(handle.session);
|
||||
registerRunning(id, handle.session, workerSessionFile);
|
||||
|
||||
const result = await runSubprocess({ ...baseOptions(id), signal: controller.signal });
|
||||
|
||||
expect(result.aborted).toBe(true);
|
||||
expect(AgentRegistry.global().get(id)).toBeUndefined();
|
||||
expect(handle.disposeCalls()).toBeGreaterThanOrEqual(1);
|
||||
expect(await Bun.file(`${workerSessionFile}.tombstone`).exists()).toBe(false);
|
||||
const restored = new AgentRegistry();
|
||||
await registerPersistedSubagents(restored, rootSessionFile);
|
||||
expect(restored.get(id)?.status).toBe("parked");
|
||||
});
|
||||
|
||||
it("manager shutdown restores a running kept-alive agent as parked without a tombstone", async () => {
|
||||
const id = "ShutdownScout";
|
||||
const rootSessionFile = `${tempDir.path()}/main.jsonl`;
|
||||
|
||||
Reference in New Issue
Block a user