mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
324 lines
13 KiB
TypeScript
324 lines
13 KiB
TypeScript
// The 10-minute record GC (AgentManager.cleanup) has never run in a test: the
|
|
// main agent-manager suite uses real timers throughout, and nothing calls the
|
|
// private method. Its two guards are load-bearing in opposite directions —
|
|
// inverting the cutoff disposes results the LLM hasn't read yet, and dropping
|
|
// the running/queued skip disposes a LIVE agent's session mid-run.
|
|
//
|
|
// It lives in its own file because vi.useFakeTimers() has to be installed
|
|
// BEFORE `new AgentManager()` (the constructor starts the interval), and fake
|
|
// timers are hostile to the promise-settling style of the main suite.
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { AgentManager } from "../src/agent-manager.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(),
|
|
}));
|
|
|
|
import { runAgent } from "../src/agent-runner.js";
|
|
|
|
const mockPi = {} as any;
|
|
const mockCtx = { cwd: "/tmp" } as any;
|
|
|
|
const TEN_MINUTES = 10 * 60_000;
|
|
const TICK = 60_000;
|
|
|
|
describe("AgentManager — record GC", () => {
|
|
let manager: AgentManager;
|
|
|
|
beforeEach(() => {
|
|
// Before construction: the cleanup interval is started in the constructor.
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
manager?.dispose();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
/** Spawn a background agent and settle it, returning its id and record. */
|
|
async function settled(prompt: string) {
|
|
vi.mocked(runAgent).mockResolvedValue({
|
|
responseText: "done",
|
|
session: { dispose: vi.fn() } as any,
|
|
aborted: false,
|
|
steered: false,
|
|
} as any);
|
|
manager ??= new AgentManager();
|
|
const id = manager.spawn(mockPi, mockCtx, "X", prompt, { description: prompt, isBackground: true });
|
|
await manager.getRecord(id)!.promise;
|
|
return { id, record: manager.getRecord(id)! };
|
|
}
|
|
|
|
it("keeps a record that completed inside the retention window", async () => {
|
|
manager = new AgentManager();
|
|
const { id, record } = await settled("recent");
|
|
// Age at sweep time is (TEN_MINUTES - 2*TICK) + TICK — just inside the window.
|
|
// Advancing the timers moves Date.now() too, so the margin has to outlast it.
|
|
record.completedAt = Date.now() - (TEN_MINUTES - 2 * TICK);
|
|
|
|
await vi.advanceTimersByTimeAsync(TICK);
|
|
|
|
expect(manager.getRecord(id)).toBeDefined();
|
|
});
|
|
|
|
it("evicts a record that completed before the cutoff and disposes its session", async () => {
|
|
manager = new AgentManager();
|
|
const { id, record } = await settled("stale");
|
|
const dispose = vi.fn();
|
|
record.session = { dispose } as any;
|
|
record.completedAt = Date.now() - (TEN_MINUTES + 30_000);
|
|
|
|
await vi.advanceTimersByTimeAsync(TICK);
|
|
|
|
expect(manager.getRecord(id)).toBeUndefined();
|
|
expect(manager.listAgents().map(a => a.id)).not.toContain(id);
|
|
expect(dispose).toHaveBeenCalled();
|
|
});
|
|
|
|
it("closes the evicted session's extension lifecycle before disposing it (#242)", async () => {
|
|
// The reported crash, on its own path: this sweep is what fires ~10 min after a
|
|
// subagent finishes. Disposing only invalidates the ExtensionRunner, so whatever
|
|
// an extension armed in `session_start` stayed armed — and its next tick threw
|
|
// `assertActive()` from a bare timer callback, killing interactive pi.
|
|
manager = new AgentManager();
|
|
const { id, record } = await settled("stale");
|
|
const emit = vi.fn(async () => {});
|
|
const dispose = vi.fn();
|
|
record.session = {
|
|
dispose,
|
|
extensionRunner: { hasHandlers: (event: string) => event === "session_shutdown", emit },
|
|
} as any;
|
|
record.completedAt = Date.now() - (TEN_MINUTES + 30_000);
|
|
|
|
await vi.advanceTimersByTimeAsync(TICK);
|
|
|
|
expect(manager.getRecord(id)).toBeUndefined();
|
|
expect(emit).toHaveBeenCalledWith({ type: "session_shutdown", reason: "quit" });
|
|
// After dispose() the runner is invalidated and every ctx getter throws, so an
|
|
// emit that landed afterwards would be worse than none.
|
|
expect(emit.mock.invocationCallOrder[0]).toBeLessThan(dispose.mock.invocationCallOrder[0]);
|
|
});
|
|
|
|
it("never evicts a running agent, however old its timestamp looks", async () => {
|
|
// A live agent's session being disposed mid-run is the worst failure this
|
|
// guard prevents, and `completedAt` on a running record is meaningless.
|
|
vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}));
|
|
manager = new AgentManager();
|
|
const id = manager.spawn(mockPi, mockCtx, "X", "live", { description: "live", isBackground: true });
|
|
const record = manager.getRecord(id)!;
|
|
expect(record.status).toBe("running");
|
|
record.completedAt = Date.now() - 10 * TEN_MINUTES;
|
|
const dispose = vi.fn();
|
|
record.session = { dispose } as any;
|
|
|
|
await vi.advanceTimersByTimeAsync(TICK * 5);
|
|
|
|
expect(manager.getRecord(id)).toBeDefined();
|
|
expect(dispose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("never evicts a queued agent", async () => {
|
|
vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}));
|
|
manager = new AgentManager(undefined, 1);
|
|
manager.spawn(mockPi, mockCtx, "X", "holder", { description: "holder", isBackground: true });
|
|
const queuedId = manager.spawn(mockPi, mockCtx, "X", "waiter", { description: "waiter", isBackground: true });
|
|
const queued = manager.getRecord(queuedId)!;
|
|
expect(queued.status).toBe("queued");
|
|
queued.completedAt = Date.now() - 10 * TEN_MINUTES;
|
|
|
|
await vi.advanceTimersByTimeAsync(TICK * 5);
|
|
|
|
expect(manager.getRecord(queuedId)?.status).toBe("queued");
|
|
});
|
|
|
|
it("sweeps repeatedly, not just once", async () => {
|
|
// The interval must keep firing: a record that ages past the cutoff on a
|
|
// later tick has to be collected too.
|
|
manager = new AgentManager();
|
|
const { id, record } = await settled("ages-out");
|
|
record.completedAt = Date.now() - (TEN_MINUTES - 3 * TICK);
|
|
|
|
await vi.advanceTimersByTimeAsync(TICK);
|
|
expect(manager.getRecord(id)).toBeDefined(); // still inside the window
|
|
|
|
await vi.advanceTimersByTimeAsync(TICK * 4);
|
|
expect(manager.getRecord(id)).toBeUndefined(); // aged out on a later tick
|
|
});
|
|
});
|
|
|
|
// Eviction is exactly the moment a handle would otherwise stop working. These
|
|
// cover what outlives it: enough to find the agent's session on disk and
|
|
// reopen the conversation, and the name held so nothing else claims it.
|
|
describe("AgentManager — tombstones outliving the GC", () => {
|
|
let manager: AgentManager;
|
|
|
|
beforeEach(() => vi.useFakeTimers());
|
|
afterEach(() => {
|
|
manager?.dispose();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
/** Spawn, settle, and age past the cutoff so the next tick evicts it. */
|
|
async function evictable(type: string, prompt: string, sessionFile?: string) {
|
|
vi.mocked(runAgent).mockResolvedValue({
|
|
responseText: "done",
|
|
session: { dispose: vi.fn() } as any,
|
|
aborted: false,
|
|
steered: false,
|
|
} as any);
|
|
manager ??= new AgentManager();
|
|
const id = manager.spawn(mockPi, mockCtx, type, prompt, { description: prompt, isBackground: true });
|
|
const record = manager.getRecord(id)!;
|
|
await record.promise;
|
|
record.sessionFile = sessionFile;
|
|
record.completedAt = Date.now() - (TEN_MINUTES + 30_000);
|
|
return { id, record };
|
|
}
|
|
|
|
it("keeps an evicted agent reachable by name when its session is on disk", async () => {
|
|
manager = new AgentManager();
|
|
await evictable("Explore", "audit the RPC path", "/sessions/explore.jsonl");
|
|
|
|
await vi.advanceTimersByTimeAsync(TICK);
|
|
|
|
const resolved = manager.resolveMention("explore");
|
|
expect(resolved?.kind).toBe("tombstone");
|
|
expect(resolved).toMatchObject({
|
|
entry: { handle: "explore", type: "Explore", description: "audit the RPC path", sessionFile: "/sessions/explore.jsonl" },
|
|
});
|
|
});
|
|
|
|
it("leaves nothing behind when the session was only ever in memory", async () => {
|
|
// Without a file there is no conversation to reopen, so promising a resume
|
|
// would be a lie — the mention has to fall through to starting a new agent.
|
|
manager = new AgentManager();
|
|
await evictable("Explore", "ephemeral", undefined);
|
|
|
|
await vi.advanceTimersByTimeAsync(TICK);
|
|
|
|
expect(manager.resolveMention("explore")).toBeUndefined();
|
|
expect(manager.listTombstones()).toHaveLength(0);
|
|
});
|
|
|
|
it("holds the evicted handle so a later agent of the same type can't shadow it", async () => {
|
|
manager = new AgentManager();
|
|
await evictable("Explore", "first", "/sessions/first.jsonl");
|
|
await vi.advanceTimersByTimeAsync(TICK);
|
|
|
|
const id = manager.spawn(mockPi, mockCtx, "Explore", "second", { description: "second", isBackground: true });
|
|
|
|
expect(manager.getRecord(id)!.handle).toBe("explore-2");
|
|
// ...and `@explore` still means the conversation you can resume.
|
|
expect(manager.resolveMention("explore")?.kind).toBe("tombstone");
|
|
});
|
|
|
|
it("prefers a live agent over a tombstone holding the same name", async () => {
|
|
manager = new AgentManager();
|
|
await evictable("Explore", "old", "/sessions/old.jsonl");
|
|
await vi.advanceTimersByTimeAsync(TICK);
|
|
// Give the live agent the tombstone's name directly: the collision this
|
|
// guards against is resolution order, not allocation.
|
|
vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}));
|
|
const id = manager.spawn(mockPi, mockCtx, "Explore", "live", { description: "live", isBackground: true });
|
|
manager.getRecord(id)!.handle = "explore";
|
|
|
|
const resolved = manager.resolveMention("explore");
|
|
expect(resolved?.kind).toBe("live");
|
|
expect(resolved).toMatchObject({ record: { id } });
|
|
});
|
|
|
|
it("drops the oldest once the cap is reached, keeping the most recent", async () => {
|
|
manager = new AgentManager();
|
|
for (let i = 0; i < 101; i++) {
|
|
const { record } = await evictable("Explore", `run-${i}`, `/sessions/${i}.jsonl`);
|
|
// Distinct ages so "oldest" is well defined; run-0 is the oldest. Set on
|
|
// the record we just made — under fake timers every startedAt is equal,
|
|
// so listAgents() has no meaningful order to index into.
|
|
record.completedAt = Date.now() - (TEN_MINUTES + 101_000 - i * 1000);
|
|
}
|
|
await vi.advanceTimersByTimeAsync(TICK);
|
|
|
|
expect(manager.listTombstones()).toHaveLength(100);
|
|
expect(manager.resolveMention("explore")).toBeUndefined(); // run-0's handle
|
|
expect(manager.resolveMention("explore-101")?.kind).toBe("tombstone");
|
|
});
|
|
|
|
it("hands a reclaimed name straight back, numbering nothing", async () => {
|
|
// The resume path's whole contract: `handleBase(type)` cannot reproduce a
|
|
// numbered handle, so the spawn takes the tombstone's names verbatim.
|
|
manager = new AgentManager();
|
|
await evictable("Explore", "first", "/sessions/first.jsonl");
|
|
manager.spawn(mockPi, mockCtx, "Explore", "second", { description: "second", isBackground: true });
|
|
await vi.advanceTimersByTimeAsync(TICK);
|
|
|
|
vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}));
|
|
const id = manager.spawn(mockPi, mockCtx, "Explore", "resumed", {
|
|
description: "first",
|
|
isBackground: true,
|
|
reclaim: { handle: "explore", alias: "auth-audit" },
|
|
} as any);
|
|
|
|
expect(manager.getRecord(id)).toMatchObject({ handle: "explore", alias: "auth-audit" });
|
|
});
|
|
|
|
it("ignores a reclaim on a nested child, which has no name to hold", async () => {
|
|
manager = new AgentManager();
|
|
vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}));
|
|
const id = manager.spawn(mockPi, mockCtx, "Explore", "child", {
|
|
description: "child",
|
|
parentAgentId: "parent-id",
|
|
reclaim: { handle: "explore", alias: "auth-audit" },
|
|
} as any);
|
|
|
|
expect(manager.getRecord(id)).toMatchObject({ handle: undefined, alias: undefined });
|
|
});
|
|
|
|
it("stops answering a name once its tombstone is dropped", async () => {
|
|
manager = new AgentManager();
|
|
await evictable("Explore", "done", "/sessions/done.jsonl");
|
|
await vi.advanceTimersByTimeAsync(TICK);
|
|
expect(manager.resolveMention("explore")?.kind).toBe("tombstone");
|
|
|
|
manager.dropTombstone("explore");
|
|
|
|
expect(manager.resolveMention("explore")).toBeUndefined();
|
|
expect(manager.listTombstones()).toHaveLength(0);
|
|
});
|
|
|
|
it("frees a dropped name for the next agent of that type", async () => {
|
|
// Otherwise a resumed agent's own handle stays reserved by the corpse and
|
|
// every later spawn climbs: explore-2, explore-3, …
|
|
manager = new AgentManager();
|
|
await evictable("Explore", "done", "/sessions/done.jsonl");
|
|
await vi.advanceTimersByTimeAsync(TICK);
|
|
manager.dropTombstone("explore");
|
|
|
|
vi.mocked(runAgent).mockImplementation(() => new Promise(() => {}));
|
|
const id = manager.spawn(mockPi, mockCtx, "Explore", "next", { description: "next", isBackground: true });
|
|
|
|
expect(manager.getRecord(id)!.handle).toBe("explore");
|
|
});
|
|
|
|
it("forgets every name when the session ends", async () => {
|
|
// A handle from a conversation the user has left must not resolve, or
|
|
// `@explore` reaches an agent they have no memory of starting.
|
|
manager = new AgentManager();
|
|
await evictable("Explore", "prior session", "/sessions/prior.jsonl");
|
|
await vi.advanceTimersByTimeAsync(TICK);
|
|
expect(manager.listTombstones()).toHaveLength(1);
|
|
|
|
manager.clearCompleted(true);
|
|
|
|
expect(manager.listTombstones()).toHaveLength(0);
|
|
expect(manager.resolveMention("explore")).toBeUndefined();
|
|
});
|
|
});
|