fix(coding-agent): scoped python kernel cleanup to sessions

scoped retained-kernel ownership to agent sessions and cleaned it up on session disposal

cleaned up warmed python owners on session startup failure and rejected new direct and tool-based python starts during disposal, including async hook, preflight, and warmup races
This commit is contained in:
Carl
2026-04-12 13:29:17 +02:00
parent f96422d911
commit 379064a0c9
18 changed files with 3593 additions and 1156 deletions
@@ -339,6 +339,97 @@ describe("PythonKernel gateway lifecycle", () => {
);
});
it("treats a retry against an already-missing kernel as confirmed shutdown", async () => {
using _runtime = stubKernelRuntime();
vi.spyOn(gatewayCoordinator, "acquireSharedGateway").mockResolvedValue({
url: "http://127.0.0.1:9999",
isShared: true,
});
let deleteCalls = 0;
using _hook = hookFetch((input, init) => {
const url = String(input);
env.fetchCalls.push({ url, init });
if (url.endsWith("/api/kernels") && init?.method === "POST") {
return createResponse({ ok: true, json: { id: "kernel-retry-delete" } }) as unknown as Response;
}
if (url.endsWith("/api/kernels/kernel-retry-delete") && init?.method === "DELETE") {
deleteCalls += 1;
if (deleteCalls === 1) {
return createResponse({ ok: false, status: 503, text: "not yet" }) as unknown as Response;
}
return createResponse({ ok: false, status: 404, text: "gone" }) as unknown as Response;
}
return createResponse({ ok: true }) as unknown as Response;
});
const kernel = await PythonKernel.start({ cwd: tempDir.path() });
await expect(kernel.shutdown()).resolves.toEqual({ confirmed: false });
expect(kernel.isAlive()).toBe(false);
expect(FakeWebSocket.instances.at(-1)?.readyState).toBe(FakeWebSocket.CLOSED);
await expect(kernel.shutdown()).resolves.toEqual({ confirmed: true });
await expect(kernel.shutdown()).resolves.toEqual({ confirmed: true });
expect(deleteCalls).toBe(2);
});
it("returns unconfirmed when shutdown times out and can confirm on retry", async () => {
using _runtime = stubKernelRuntime();
vi.spyOn(gatewayCoordinator, "acquireSharedGateway").mockResolvedValue({
url: "http://127.0.0.1:9999",
isShared: true,
});
let deleteCalls = 0;
const firstDeleteStarted = Promise.withResolvers<void>();
const firstDeleteAborted = Promise.withResolvers<void>();
using _hook = hookFetch((input, init) => {
const url = String(input);
env.fetchCalls.push({ url, init });
if (url.endsWith("/api/kernels") && init?.method === "POST") {
return createResponse({ ok: true, json: { id: "kernel-shutdown-timeout" } }) as unknown as Response;
}
if (url.endsWith("/api/kernels/kernel-shutdown-timeout") && init?.method === "DELETE") {
deleteCalls += 1;
if (deleteCalls === 1) {
firstDeleteStarted.resolve();
return new Promise<Response>((_, reject) => {
const waitForAbort = () => {
if (init.signal?.aborted) {
firstDeleteAborted.resolve();
const reason = init.signal.reason;
reject(reason instanceof Error ? reason : new Error("Python kernel shutdown timed out"));
return;
}
const poll = setTimeout(waitForAbort, 5);
poll.unref?.();
};
waitForAbort();
});
}
return createResponse({ ok: false, status: 404, text: "gone" }) as unknown as Response;
}
return createResponse({ ok: true }) as unknown as Response;
});
const kernel = await PythonKernel.start({ cwd: tempDir.path() });
const shutdownPromise = kernel.shutdown({ timeoutMs: 25 });
await firstDeleteStarted.promise;
const pending = Symbol("pending");
const settled = await Promise.race([
shutdownPromise,
new Promise<typeof pending>(resolve => {
const timer = setTimeout(() => resolve(pending), 250);
timer.unref?.();
}),
]);
await firstDeleteAborted.promise;
expect(settled).not.toBe(pending);
expect(settled).toEqual({ confirmed: false });
expect(kernel.isAlive()).toBe(false);
expect(FakeWebSocket.instances.at(-1)?.readyState).toBe(FakeWebSocket.CLOSED);
await expect(kernel.shutdown()).resolves.toEqual({ confirmed: true });
expect(deleteCalls).toBe(2);
});
it("does not throw when shutdown API fails", async () => {
using _runtime = stubKernelRuntime();
vi.spyOn(gatewayCoordinator, "acquireSharedGateway").mockResolvedValue({
@@ -360,6 +451,6 @@ describe("PythonKernel gateway lifecycle", () => {
const kernel = await PythonKernel.start({ cwd: tempDir.path() });
await expect(kernel.shutdown()).resolves.toBeUndefined();
await expect(kernel.shutdown()).resolves.toEqual({ confirmed: false });
});
});