import { describe, expect, test } from "bun:test"; import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async/job-manager"; describe("AsyncJobManager", () => { test("forwards progress updates and delivers completion", async () => { const progressEvents: Array<{ text: string; details?: Record }> = []; const completions: Array<{ jobId: string; text: string }> = []; const manager = new AsyncJobManager({ onJobComplete: async (jobId, text) => { completions.push({ jobId, text }); }, }); const jobId = manager.register( "bash", "echo hi", async ({ reportProgress }) => { await reportProgress("running step", { async: { state: "running" } }); return "final output"; }, { onProgress: async (text, details) => { progressEvents.push({ text, details }); }, }, ); await manager.waitForAll(); await manager.drainDeliveries({ timeoutMs: 2_000 }); expect(progressEvents).toEqual([{ text: "running step", details: { async: { state: "running" } } }]); expect(completions).toEqual([{ jobId, text: "final output" }]); expect(manager.getJob(jobId)?.status).toBe("completed"); }); test("swallows progress callback errors without failing the job", async () => { const completions: Array<{ jobId: string; text: string }> = []; const manager = new AsyncJobManager({ onJobComplete: async (jobId, text) => { completions.push({ jobId, text }); }, }); const jobId = manager.register( "task", "agent task", async ({ reportProgress }) => { await reportProgress("subagent started"); return "task done"; }, { onProgress: async () => { throw new Error("progress renderer exploded"); }, }, ); await manager.waitForAll(); await manager.drainDeliveries({ timeoutMs: 2_000 }); expect(completions).toEqual([{ jobId, text: "task done" }]); expect(manager.getJob(jobId)?.status).toBe("completed"); }); test("delivers error text when run fails", async () => { const completions: Array<{ jobId: string; text: string }> = []; const manager = new AsyncJobManager({ onJobComplete: async (jobId, text) => { completions.push({ jobId, text }); }, }); const jobId = manager.register("bash", "bad command", async () => { throw new Error("command failed"); }); await manager.waitForAll(); await manager.drainDeliveries({ timeoutMs: 2_000 }); expect(completions).toEqual([{ jobId, text: "command failed" }]); expect(manager.getJob(jobId)?.status).toBe("failed"); expect(manager.getJob(jobId)?.errorText).toBe("command failed"); }); test("cancels a running job by id", async () => { const completions: Array<{ jobId: string; text: string }> = []; const manager = new AsyncJobManager({ onJobComplete: async (jobId, text) => { completions.push({ jobId, text }); }, }); const jobId = manager.register("bash", "sleep", async ({ signal }) => { await new Promise((_resolve, reject) => { signal.addEventListener( "abort", () => { reject(new Error("aborted")); }, { once: true }, ); }); throw new Error("unreachable"); }); expect(manager.cancel(jobId)).toBe(true); expect(manager.cancel(jobId)).toBe(false); await manager.waitForAll(); await manager.drainDeliveries({ timeoutMs: 2_000 }); expect(manager.getJob(jobId)?.status).toBe("cancelled"); expect(completions).toHaveLength(0); }); test("bounds owner-job reap while preserving late settlement", async () => { const manager = new AsyncJobManager({ onJobComplete: async () => {} }); const release = Promise.withResolvers(); const jobId = manager.register( "task", "ignores abort", async () => { await release.promise; return "late result"; }, { ownerId: "owner" }, ); const reap = await manager.cancelAndReapOwnerJobs("owner", Date.now()); expect(reap.settled).toBe(false); expect(reap.pendingJobIds).toEqual([jobId]); expect(manager.getJob(jobId)?.status).toBe("cancelled"); release.resolve(); await reap.completion; expect(manager.getJob(jobId)?.resultText).toBe("late result"); }); test("enforces maxRunningJobs cap", () => { const manager = new AsyncJobManager({ maxRunningJobs: 1, onJobComplete: async () => {}, }); const firstJobId = manager.register("bash", "first", async ({ signal }) => { await new Promise(resolve => { signal.addEventListener("abort", () => resolve(), { once: true }); }); return "done"; }); expect(() => manager.register("bash", "second", async () => { return "second"; }), ).toThrow(/Background job limit reached/); manager.cancel(firstJobId); }); test("queued jobs do not count toward the cap until markRunning", async () => { const manager = new AsyncJobManager({ maxRunningJobs: 1, onJobComplete: async () => {}, }); const gate = Promise.withResolvers(); const started = Promise.withResolvers(); const release = Promise.withResolvers(); const queuedJobId = manager.register( "task", "queued", async ({ markRunning }) => { await gate.promise; markRunning(); started.resolve(); await release.promise; return "queued done"; }, { queued: true }, ); // Queued job holds no slot: another job registers fine at cap 1. const runningJobId = manager.register("bash", "running", async ({ signal }) => { await new Promise(resolve => { signal.addEventListener("abort", () => resolve(), { once: true }); }); return "done"; }); // Free the slot, then let the queued job start: it now occupies the slot. manager.cancel(runningJobId); gate.resolve(); await started.promise; expect(() => manager.register("bash", "third", async () => "third")).toThrow(/Background job limit reached/); release.resolve(); await manager.waitForAll(); expect(manager.getJob(queuedJobId)?.status).toBe("completed"); }); test("evicts completed jobs after retention period", async () => { const manager = new AsyncJobManager({ retentionMs: 25, onJobComplete: async () => {}, }); const jobId = manager.register("task", "short", async () => "done"); await manager.waitForAll(); await manager.drainDeliveries({ timeoutMs: 2_000 }); expect(manager.getJob(jobId)?.status).toBe("completed"); await Bun.sleep(60); expect(manager.getJob(jobId)).toBeUndefined(); }); test("cancelAll does not clear retention timers for already completed jobs", async () => { const manager = new AsyncJobManager({ retentionMs: 30, onJobComplete: async () => {}, }); const completedJobId = manager.register("task", "completed", async () => "done"); const runningJobId = manager.register("bash", "running", async ({ signal }) => { await new Promise(resolve => { signal.addEventListener("abort", () => resolve(), { once: true }); }); throw new Error("aborted"); }); const completedDeadline = Date.now() + 2_000; while (manager.getJob(completedJobId)?.status === "running") { if (Date.now() >= completedDeadline) throw new Error("Timed out waiting for completed job"); await Bun.sleep(5); } manager.cancelAll(); await manager.waitForAll(); await manager.drainDeliveries({ timeoutMs: 2_000 }); expect(manager.getJob(completedJobId)?.status).toBe("completed"); expect(manager.getJob(runningJobId)?.status).toBe("cancelled"); await Bun.sleep(80); expect(manager.getJob(completedJobId)).toBeUndefined(); expect(manager.getJob(runningJobId)).toBeUndefined(); }); test("acknowledgeDeliveries suppresses pending retries for completed jobs", async () => { let attempts = 0; const manager = new AsyncJobManager({ onJobComplete: async () => { attempts += 1; throw new Error("delivery failed"); }, }); const jobId = manager.register("task", "awaited-job", async () => "done"); await manager.waitForAll(); const firstAttemptDeadline = Date.now() + 2_000; while (attempts === 0) { if (Date.now() >= firstAttemptDeadline) throw new Error("Timed out waiting for first delivery attempt"); await Bun.sleep(5); } expect(manager.hasPendingDeliveries()).toBe(true); const removed = manager.acknowledgeDeliveries([jobId]); expect(removed).toBeGreaterThanOrEqual(1); const drained = await manager.drainDeliveries({ timeoutMs: 200 }); expect(drained).toBe(true); expect(manager.hasPendingDeliveries()).toBe(false); const attemptsAfterAck = attempts; await Bun.sleep(700); expect(attempts).toBe(attemptsAfterAck); }); test("dispose clears jobs and pending deliveries", async () => { const manager = new AsyncJobManager({ onJobComplete: async () => { throw new Error("delivery failed"); }, }); manager.register("bash", "will-complete", async () => "output"); await manager.waitForAll(); expect(manager.hasPendingDeliveries()).toBe(true); const drained = await manager.dispose({ timeoutMs: 25 }); expect(drained).toBe(false); expect(manager.getAllJobs()).toHaveLength(0); expect(manager.hasPendingDeliveries()).toBe(false); }); test("dispose honors timeout when a cancelled job never settles", async () => { const manager = new AsyncJobManager({ onJobComplete: async () => {}, }); manager.register("bash", "ignores-abort", async () => { await Promise.withResolvers().promise; return "unreachable"; }); const startedAt = Date.now(); const result = await Promise.race([ manager.dispose({ timeoutMs: 25 }).then(drained => ({ drained, settled: true })), Bun.sleep(150).then(() => ({ drained: true, settled: false })), ]); expect(result.settled).toBe(true); expect(result.drained).toBe(false); expect(Date.now() - startedAt).toBeLessThan(150); expect(manager.getAllJobs()).toHaveLength(0); }); test("scoped delivery drain returns once matching owner deliveries finish", async () => { let mainJobId = ""; let releaseMainDelivery = (): void => {}; let notifyMainDeliveryStarted = (): void => {}; const mainDeliveryStarted = new Promise(resolve => { notifyMainDeliveryStarted = resolve; }); const mainDeliveryReleased = new Promise(resolve => { releaseMainDelivery = resolve; }); const subagentCompletions: Array<{ jobId: string; text: string }> = []; const manager = new AsyncJobManager({ retentionMs: 0 }); manager.registerDeliverySink("0-Main", async () => { notifyMainDeliveryStarted(); await mainDeliveryReleased; }); manager.registerDeliverySink("3-AuthLoader", (jobId, text) => { subagentCompletions.push({ jobId, text }); }); mainJobId = manager.register("task", "main job", async () => "main result", { ownerId: "0-Main" }); const targetJobId = manager.register("task", "subagent job", async () => "subagent result", { ownerId: "3-AuthLoader", }); await manager.waitForAll(); await mainDeliveryStarted; expect(manager.hasPendingDeliveries({ ownerId: "0-Main" })).toBe(true); const drained = await manager.drainDeliveries({ timeoutMs: 50, filter: { ownerId: "3-AuthLoader" } }); expect(drained).toBe(true); expect(subagentCompletions).toEqual([{ jobId: targetJobId, text: "subagent result" }]); expect(manager.hasPendingDeliveries({ ownerId: "3-AuthLoader" })).toBe(false); expect(manager.acknowledgeDeliveries([mainJobId])).toBe(0); expect(manager.hasPendingDeliveries({ ownerId: "0-Main" })).toBe(false); releaseMainDelivery(); await Bun.sleep(0); }); test("scoped delivery drain times out while a matching delivery callback is in flight", async () => { let targetJobId = ""; let releaseMainDelivery = (): void => {}; let notifyMainDeliveryStarted = (): void => {}; let releaseTargetDelivery = (): void => {}; let notifyTargetDeliveryStarted = (): void => {}; const mainDeliveryStarted = new Promise(resolve => { notifyMainDeliveryStarted = resolve; }); const mainDeliveryReleased = new Promise(resolve => { releaseMainDelivery = resolve; }); const targetDeliveryStarted = new Promise(resolve => { notifyTargetDeliveryStarted = resolve; }); const targetDeliveryReleased = new Promise(resolve => { releaseTargetDelivery = resolve; }); const completions: string[] = []; const manager = new AsyncJobManager({}); manager.registerDeliverySink("0-Main", async () => { notifyMainDeliveryStarted(); await mainDeliveryReleased; }); manager.registerDeliverySink("3-AuthLoader", async jobId => { notifyTargetDeliveryStarted(); await targetDeliveryReleased; completions.push(jobId); }); manager.register("task", "main job", async () => "main result", { ownerId: "0-Main" }); targetJobId = manager.register("task", "subagent job", async () => "subagent result", { ownerId: "3-AuthLoader", }); await manager.waitForAll(); await mainDeliveryStarted; const timedOut = await manager.drainDeliveries({ timeoutMs: 10, filter: { ownerId: "3-AuthLoader" } }); await targetDeliveryStarted; expect(timedOut).toBe(false); expect(manager.hasPendingDeliveries({ ownerId: "3-AuthLoader" })).toBe(true); expect(completions).toEqual([]); releaseTargetDelivery(); const drained = await manager.drainDeliveries({ timeoutMs: 200, filter: { ownerId: "3-AuthLoader" } }); expect(drained).toBe(true); expect(completions).toEqual([targetJobId]); releaseMainDelivery(); expect(await manager.drainDeliveries({ timeoutMs: 200 })).toBe(true); }); test("cancelAll with ownerId only cancels matching jobs", async () => { const manager = new AsyncJobManager({ onJobComplete: async () => {}, }); const hold = (signal: AbortSignal) => new Promise(resolve => { signal.addEventListener("abort", () => resolve(), { once: true }); }); const parentJobId = manager.register( "bash", "parent-job", async ({ signal }) => { await hold(signal); return "parent-cancelled"; }, { ownerId: "0-Main" }, ); const subagentJobId = manager.register( "bash", "subagent-job", async ({ signal }) => { await hold(signal); return "subagent-cancelled"; }, { ownerId: "3-AuthLoader" }, ); manager.cancelAll({ ownerId: "3-AuthLoader" }); expect(manager.getJob(parentJobId)?.status).toBe("running"); expect(manager.getJob(subagentJobId)?.status).toBe("cancelled"); // Filtered query mirrors filtered cancel. expect(manager.getRunningJobs({ ownerId: "0-Main" }).map(j => j.id)).toEqual([parentJobId]); expect(manager.getRunningJobs({ ownerId: "3-AuthLoader" })).toEqual([]); expect(manager.getAllJobs({ ownerId: "0-Main" }).map(j => j.id)).toEqual([parentJobId]); // Unscoped cancelAll still cleans up everything. manager.cancelAll(); await manager.waitForAll(); expect(manager.getJob(parentJobId)?.status).toBe("cancelled"); }); test("routes owned deliveries to the owner's registered sink only", async () => { const mainDeliveries: string[] = []; const defaultDeliveries: string[] = []; const manager = new AsyncJobManager({ onJobComplete: async jobId => { defaultDeliveries.push(jobId); }, }); manager.registerDeliverySink("Main", jobId => { mainDeliveries.push(jobId); }); manager.register("bash", "owned", async () => "ok", { id: "owned-1", ownerId: "Main" }); manager.register("bash", "unowned", async () => "ok", { id: "unowned-1" }); await manager.waitForAll(); await manager.drainDeliveries({ timeoutMs: 500 }); expect(mainDeliveries).toEqual(["owned-1"]); expect(defaultDeliveries).toEqual(["unowned-1"]); }); test("dead-letters an owned delivery when its owner has no live sink", async () => { const defaultDeliveries: string[] = []; const manager = new AsyncJobManager({ onJobComplete: async jobId => { defaultDeliveries.push(jobId); }, }); const unregister = manager.registerDeliverySink("Sub", () => {}); unregister(); manager.register("bash", "orphan", async () => "orphan result", { id: "orphan-1", ownerId: "Sub" }); await manager.waitForAll(); const drained = await manager.drainDeliveries({ timeoutMs: 500 }); // Dead-letter drops the delivery (drain settles) without misrouting it // into the default sink; the outcome stays readable on the job row. expect(drained).toBe(true); expect(defaultDeliveries).toEqual([]); expect(manager.getJob("orphan-1")?.resultText).toBe("orphan result"); }); test("waitForOwnerJobs settles cancelled jobs and skips suppressed ones on request", async () => { const manager = new AsyncJobManager({}); manager.register( "bash", "hung", async ({ signal }) => { await new Promise(resolve => { if (signal.aborted) return resolve(); signal.addEventListener("abort", () => resolve(), { once: true }); }); return "stopped"; }, { id: "hung-1", ownerId: "Sub" }, ); // Quiescence-barrier contract: a watched (suppressed) job can never // re-wake a run, so the filtered wait treats it as settled. manager.watchJobs(["hung-1"]); await expect(manager.waitForOwnerJobs("Sub", { excludeSuppressed: true })).resolves.toBe(true); // Teardown-reap contract: the unfiltered wait blocks until the // cancelled job's body actually finishes. const reap = manager.waitForOwnerJobs("Sub", { timeoutMs: 1_000 }); manager.cancelAll({ ownerId: "Sub" }); await expect(reap).resolves.toBe(true); expect(manager.getJob("hung-1")?.status).toBe("cancelled"); }); }); describe("AsyncJobManager smart poll-wait escalation", () => { const newManager = () => new AsyncJobManager({ onJobComplete: async () => {} }); test("first poll waits the ladder floor", () => { const m = newManager(); expect(m.nextPollWaitMs("Main", 1_000)).toBe(5_000); // A fresh owner also starts at the floor. expect(m.nextPollWaitMs("Other", 1_000)).toBe(5_000); }); test("back-to-back polls climb the ladder to the top rung", () => { const m = newManager(); const owner = "Main"; const t = 1_000; const waits: number[] = []; for (let i = 0; i < 6; i++) { // Same timestamp every time → zero gap → always escalates. waits.push(m.nextPollWaitMs(owner, t)); m.recordPollWaitEnd(owner, t); } // Climbs the rungs, then saturates at the top. expect(waits).toEqual([5_000, 10_000, 30_000, 60_000, 300_000, 300_000]); }); test("a quiet gap of a minute resets back to the floor", () => { const m = newManager(); const owner = "Main"; expect(m.nextPollWaitMs(owner, 0)).toBe(5_000); m.recordPollWaitEnd(owner, 0); // Still within the reset window (just under a minute) → keeps climbing. expect(m.nextPollWaitMs(owner, 59_999)).toBe(10_000); m.recordPollWaitEnd(owner, 60_000); // A full minute without polling resets the climb to the floor. expect(m.nextPollWaitMs(owner, 120_000)).toBe(5_000); }); test("escalation is tracked independently per owner", () => { const m = newManager(); const t = 1_000; m.nextPollWaitMs("A", t); m.recordPollWaitEnd("A", t); m.nextPollWaitMs("A", t); m.recordPollWaitEnd("A", t); // A fresh owner starts at the floor regardless of A's escalation. expect(m.nextPollWaitMs("B", t)).toBe(5_000); // A keeps climbing from where it left off. expect(m.nextPollWaitMs("A", t)).toBe(30_000); }); });