mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
188 lines
6.8 KiB
TypeScript
188 lines
6.8 KiB
TypeScript
/**
|
|
* #242 — `runAgent` calls `session.bindExtensions()` so `session_start` fires and
|
|
* extensions can set up per-session state, but nothing ever closed that lifecycle:
|
|
* both eviction (`removeRecord`) and quit (`dispose`) called `session.dispose()`,
|
|
* which in pi only calls `ExtensionRunner.invalidate()` — it does NOT emit
|
|
* `session_shutdown`. So anything an extension armed in `session_start` (timers, fs
|
|
* watchers, sockets) leaked once per spawn, and its next tick threw `assertActive()`
|
|
* from a bare `Timeout._onTimeout` — an uncaughtException that killed interactive pi.
|
|
*
|
|
* pi emits the event itself in `AgentSessionRuntime.dispose()` before disposing; these
|
|
* tests pin that we do the same, that quit actually waits for the handlers, and that a
|
|
* hung handler can't strand the user at a dead terminal.
|
|
*/
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { AgentManager } from "../src/agent-manager.js";
|
|
import { registerPermissionChildSession } from "../src/permission-system-bridge.js";
|
|
|
|
vi.mock("../src/agent-runner.js", () => ({
|
|
runAgent: vi.fn(),
|
|
resumeAgent: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../src/worktree.js", () => ({
|
|
createWorktree: vi.fn(),
|
|
cleanupWorktree: vi.fn(() => ({ hasChanges: false })),
|
|
pruneWorktrees: vi.fn(),
|
|
isWorktreeIsolationEnabled: vi.fn(() => true),
|
|
}));
|
|
|
|
import { runAgent } from "../src/agent-runner.js";
|
|
import { pruneWorktrees } from "../src/worktree.js";
|
|
|
|
const mockPi = {} as any;
|
|
const mockCtx = { cwd: "/tmp" } as any;
|
|
|
|
/** A child session as `runAgent` leaves it: extensions bound, so a runner with handlers. */
|
|
function boundSession(emit: (...args: any[]) => any = vi.fn(async () => {})) {
|
|
return {
|
|
dispose: vi.fn(),
|
|
extensionRunner: {
|
|
hasHandlers: vi.fn((event: string) => event === "session_shutdown"),
|
|
emit: vi.fn(emit),
|
|
},
|
|
} as any;
|
|
}
|
|
|
|
/** Spawn one background agent that resolves with `session`, and wait for it to complete. */
|
|
async function spawnCompleted(manager: AgentManager, session: any) {
|
|
vi.mocked(runAgent).mockResolvedValue({
|
|
responseText: "done",
|
|
session,
|
|
aborted: false,
|
|
steered: false,
|
|
} as any);
|
|
const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", {
|
|
description: "test",
|
|
isBackground: true,
|
|
});
|
|
await manager.getRecord(id)!.promise;
|
|
return id;
|
|
}
|
|
|
|
describe("child session shutdown (#242)", () => {
|
|
let manager: AgentManager;
|
|
|
|
afterEach(async () => {
|
|
await manager?.dispose();
|
|
vi.useRealTimers();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("emits session_shutdown before disposing an evicted session", async () => {
|
|
manager = new AgentManager();
|
|
const session = boundSession();
|
|
await spawnCompleted(manager, session);
|
|
|
|
manager.clearCompleted();
|
|
await vi.waitFor(() => expect(session.dispose).toHaveBeenCalled());
|
|
|
|
expect(session.extensionRunner.emit).toHaveBeenCalledWith({
|
|
type: "session_shutdown",
|
|
reason: "quit",
|
|
});
|
|
// Order is the whole point: after `dispose()` the runner is invalidated and
|
|
// every `ctx` getter throws, so a handler emitted afterwards is useless.
|
|
expect(session.extensionRunner.emit.mock.invocationCallOrder[0])
|
|
.toBeLessThan(session.dispose.mock.invocationCallOrder[0]);
|
|
});
|
|
|
|
it("publishes child disposal only after the session is closed", async () => {
|
|
manager = new AgentManager();
|
|
const order: string[] = [];
|
|
const pi = {
|
|
events: { emit: vi.fn((channel: string) => { order.push(channel); }) },
|
|
} as any;
|
|
const session = boundSession();
|
|
session.sessionManager = { getSessionId: () => "child-session" };
|
|
session.dispose.mockImplementation(() => { order.push("session-disposed"); });
|
|
registerPermissionChildSession(pi, session, "root-session");
|
|
await spawnCompleted(manager, session);
|
|
|
|
manager.clearCompleted();
|
|
await vi.waitFor(() => expect(session.dispose).toHaveBeenCalled());
|
|
|
|
expect(order).toEqual([
|
|
"subagents:child:session-created",
|
|
"session-disposed",
|
|
"subagents:child:disposed",
|
|
]);
|
|
});
|
|
|
|
it("quit waits for the child's shutdown handlers to finish", async () => {
|
|
manager = new AgentManager();
|
|
let releaseHandler!: () => void;
|
|
const session = boundSession(() => new Promise<void>(r => { releaseHandler = r; }));
|
|
await spawnCompleted(manager, session);
|
|
|
|
const disposed = manager.dispose();
|
|
let settled = false;
|
|
void disposed.then(() => { settled = true; });
|
|
// Several microtask turns: enough for a fire-and-forget implementation to have
|
|
// resolved, not enough for a correctly awaited one.
|
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
|
|
expect(settled).toBe(false);
|
|
expect(session.dispose).not.toHaveBeenCalled();
|
|
|
|
releaseHandler();
|
|
await disposed;
|
|
expect(settled).toBe(true);
|
|
expect(session.dispose).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("quit is not hostage to a handler that never resolves", async () => {
|
|
manager = new AgentManager();
|
|
const session = boundSession(() => new Promise<void>(() => {}));
|
|
await spawnCompleted(manager, session);
|
|
|
|
vi.useFakeTimers();
|
|
const disposed = manager.dispose();
|
|
// Past the internal ceiling. Without it the TUI is already torn down and the
|
|
// user is left at a dead terminal with only Ctrl-C.
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
await disposed;
|
|
|
|
expect(session.dispose).toHaveBeenCalledOnce();
|
|
// Teardown continues past the timeout rather than unwinding.
|
|
expect(pruneWorktrees).toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips the emit when no extension handles session_shutdown", async () => {
|
|
manager = new AgentManager();
|
|
const session = boundSession();
|
|
session.extensionRunner.hasHandlers = vi.fn(() => false);
|
|
await spawnCompleted(manager, session);
|
|
|
|
manager.clearCompleted();
|
|
await vi.waitFor(() => expect(session.dispose).toHaveBeenCalled());
|
|
|
|
expect(session.extensionRunner.emit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("degrades on a stubbed session instead of throwing", async () => {
|
|
const rejections: unknown[] = [];
|
|
const onRejection = (err: unknown) => rejections.push(err);
|
|
process.on("unhandledRejection", onRejection);
|
|
try {
|
|
manager = new AgentManager();
|
|
// No extensionRunner at all — an older pi, or a partial `onSessionCreated` stub.
|
|
const noRunner = { dispose: vi.fn() } as any;
|
|
await spawnCompleted(manager, noRunner);
|
|
manager.clearCompleted();
|
|
await vi.waitFor(() => expect(noRunner.dispose).toHaveBeenCalled());
|
|
|
|
// Nothing at all — not even dispose.
|
|
const empty = {} as any;
|
|
await spawnCompleted(manager, empty);
|
|
manager.clearCompleted();
|
|
await expect(manager.dispose()).resolves.toBeUndefined();
|
|
|
|
await new Promise(r => setImmediate(r));
|
|
expect(rejections).toEqual([]);
|
|
} finally {
|
|
process.off("unhandledRejection", onRejection);
|
|
}
|
|
});
|
|
});
|