Files
fusion/packages/engine/src/concurrency.test.ts
gsxdsm 1b98f167a0 feat(FN-978): add diagnostic logging, semaphore resilience, and executor tests
- Add structured diagnostic logging to executor, stuck-task-detector, and pi.ts with subsystem prefixes
- Add defensive guards to AgentSemaphore (limit minimum 1, invalid limit handling)
- Add comprehensive integration tests for agent execution flow (executor.test.ts)
- Add unit tests for semaphore resilience (concurrency.test.ts) and stuck-task-detector (stuck-task-detector.test.ts)
- Fix TypeScript errors in test task objects and duplicate execution test
- Document engine diagnostic logging points in AGENTS.md
2026-04-05 13:37:21 -07:00

460 lines
14 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { AgentSemaphore, PRIORITY_MERGE, PRIORITY_EXECUTE, PRIORITY_SPECIFY } from "./concurrency.js";
describe("AgentSemaphore", () => {
it("allows immediate acquire when under limit", async () => {
const sem = new AgentSemaphore(2);
await sem.acquire();
expect(sem.activeCount).toBe(1);
expect(sem.availableCount).toBe(1);
sem.release();
expect(sem.activeCount).toBe(0);
expect(sem.availableCount).toBe(2);
});
it("queues waiters when at capacity and unblocks FIFO", async () => {
const sem = new AgentSemaphore(1);
await sem.acquire(); // slot taken
const order: number[] = [];
const p1 = sem.acquire().then(() => order.push(1));
const p2 = sem.acquire().then(() => order.push(2));
// Both should be waiting
expect(sem.activeCount).toBe(1);
// Release — first waiter should be unblocked
sem.release();
await p1;
expect(order).toEqual([1]);
expect(sem.activeCount).toBe(1);
// Release again — second waiter
sem.release();
await p2;
expect(order).toEqual([1, 2]);
expect(sem.activeCount).toBe(1);
sem.release();
expect(sem.activeCount).toBe(0);
});
it("run() releases on success", async () => {
const sem = new AgentSemaphore(1);
const result = await sem.run(async () => {
expect(sem.activeCount).toBe(1);
return 42;
});
expect(result).toBe(42);
expect(sem.activeCount).toBe(0);
});
it("run() releases on error", async () => {
const sem = new AgentSemaphore(1);
await expect(
sem.run(async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
expect(sem.activeCount).toBe(0);
});
it("respects dynamic limit changes on next acquire", async () => {
let limit = 2;
const sem = new AgentSemaphore(() => limit);
await sem.acquire();
await sem.acquire();
expect(sem.activeCount).toBe(2);
expect(sem.availableCount).toBe(0);
// Increase the limit — next acquire should succeed immediately
limit = 3;
expect(sem.availableCount).toBe(1);
await sem.acquire();
expect(sem.activeCount).toBe(3);
sem.release();
sem.release();
sem.release();
});
it("blocks new acquires when limit is reduced below activeCount", async () => {
let limit = 3;
const sem = new AgentSemaphore(() => limit);
await sem.acquire();
await sem.acquire();
expect(sem.activeCount).toBe(2);
// Reduce limit below current active count
limit = 1;
expect(sem.availableCount).toBe(0);
let acquired = false;
const p = sem.acquire().then(() => {
acquired = true;
});
// Should not have acquired yet
await Promise.resolve(); // tick
expect(acquired).toBe(false);
// Release one slot — active goes from 2 to 1, still >= limit (1), so still blocked
sem.release();
await Promise.resolve();
expect(acquired).toBe(false);
// Release again — active drops to 0, which is < limit (1), so waiter unblocks
sem.release();
await p;
expect(acquired).toBe(true);
expect(sem.activeCount).toBe(1);
sem.release();
});
it("activeCount and availableCount are accurate under load", async () => {
const sem = new AgentSemaphore(3);
expect(sem.activeCount).toBe(0);
expect(sem.availableCount).toBe(3);
expect(sem.limit).toBe(3);
await sem.acquire();
expect(sem.activeCount).toBe(1);
expect(sem.availableCount).toBe(2);
await sem.acquire();
await sem.acquire();
expect(sem.activeCount).toBe(3);
expect(sem.availableCount).toBe(0);
sem.release();
expect(sem.activeCount).toBe(2);
expect(sem.availableCount).toBe(1);
sem.release();
sem.release();
expect(sem.activeCount).toBe(0);
expect(sem.availableCount).toBe(3);
});
it("run() gates concurrent calls", async () => {
const sem = new AgentSemaphore(2);
let concurrent = 0;
let maxConcurrent = 0;
const task = () =>
sem.run(async () => {
concurrent++;
maxConcurrent = Math.max(maxConcurrent, concurrent);
// Yield to allow other tasks to attempt to run
await new Promise((r) => setTimeout(r, 10));
concurrent--;
});
await Promise.all([task(), task(), task(), task(), task()]);
expect(maxConcurrent).toBe(2);
expect(sem.activeCount).toBe(0);
});
it("integration: simulates triage-like usage with semaphore.run()", async () => {
const sem = new AgentSemaphore(1);
let concurrent = 0;
let maxConcurrent = 0;
// Simulate two specifyTask-like calls that would normally run in parallel
const specifyTask = async () => {
const agentWork = async () => {
concurrent++;
maxConcurrent = Math.max(maxConcurrent, concurrent);
await new Promise((r) => setTimeout(r, 10));
concurrent--;
};
await sem.run(agentWork);
};
await Promise.all([specifyTask(), specifyTask(), specifyTask()]);
expect(maxConcurrent).toBe(1);
expect(sem.activeCount).toBe(0);
});
it("integration: simulates merge-like usage with semaphore.run()", async () => {
const sem = new AgentSemaphore(1);
let concurrent = 0;
let maxConcurrent = 0;
// Simulate serialized merge queue where each merge also goes through semaphore
const rawMerge = async () => {
concurrent++;
maxConcurrent = Math.max(maxConcurrent, concurrent);
await new Promise((r) => setTimeout(r, 10));
concurrent--;
};
const onMerge = () => sem.run(rawMerge);
await Promise.all([onMerge(), onMerge(), onMerge()]);
expect(maxConcurrent).toBe(1);
expect(sem.activeCount).toBe(0);
});
it("integration: shared semaphore limits triage + execution + merge together", async () => {
const sem = new AgentSemaphore(2);
let concurrent = 0;
let maxConcurrent = 0;
const simulateAgent = () =>
sem.run(async () => {
concurrent++;
maxConcurrent = Math.max(maxConcurrent, concurrent);
await new Promise((r) => setTimeout(r, 10));
concurrent--;
});
// Simulate mixed activity: 2 triage + 2 execution + 2 merge = 6 total
await Promise.all([
simulateAgent(), // triage
simulateAgent(), // triage
simulateAgent(), // execution
simulateAgent(), // execution
simulateAgent(), // merge
simulateAgent(), // merge
]);
// Should never exceed 2 concurrent despite 6 tasks
expect(maxConcurrent).toBe(2);
expect(sem.activeCount).toBe(0);
});
it("integration: semaphore is optional (no-op when absent)", async () => {
const opts: { semaphore?: AgentSemaphore } = {};
let ran = false;
const agentWork = async () => {
ran = true;
};
if (opts.semaphore) {
await opts.semaphore.run(agentWork);
} else {
await agentWork();
}
expect(ran).toBe(true);
});
// ── Priority scheduling tests ──────────────────────────────────────
it("priority: highest-priority waiter is served first when slot is released", async () => {
const sem = new AgentSemaphore(1);
await sem.acquire(); // fill the single slot
const order: string[] = [];
// Queue three waiters in non-priority order: specify, merge, execute
const pSpecify = sem.acquire(PRIORITY_SPECIFY).then(() => order.push("specify"));
const pMerge = sem.acquire(PRIORITY_MERGE).then(() => order.push("merge"));
const pExecute = sem.acquire(PRIORITY_EXECUTE).then(() => order.push("execute"));
// Release slots one at a time and observe drain order
sem.release();
await pMerge;
expect(order).toEqual(["merge"]);
sem.release();
await pExecute;
expect(order).toEqual(["merge", "execute"]);
sem.release();
await pSpecify;
expect(order).toEqual(["merge", "execute", "specify"]);
sem.release(); // cleanup
expect(sem.activeCount).toBe(0);
});
it("priority: FIFO order is preserved among equal-priority waiters", async () => {
const sem = new AgentSemaphore(1);
await sem.acquire(); // fill the slot
const order: number[] = [];
const p1 = sem.acquire(PRIORITY_EXECUTE).then(() => order.push(1));
const p2 = sem.acquire(PRIORITY_EXECUTE).then(() => order.push(2));
const p3 = sem.acquire(PRIORITY_EXECUTE).then(() => order.push(3));
sem.release();
await p1;
sem.release();
await p2;
sem.release();
await p3;
expect(order).toEqual([1, 2, 3]);
sem.release();
});
it("priority: run() forwards priority to acquire()", async () => {
const sem = new AgentSemaphore(1);
await sem.acquire(); // fill the slot
const order: string[] = [];
const pLow = sem.run(async () => { order.push("low"); }, PRIORITY_SPECIFY);
const pHigh = sem.run(async () => { order.push("high"); }, PRIORITY_MERGE);
// Release — high priority should go first, then its run() releases the
// slot automatically, allowing the low-priority waiter to proceed.
sem.release();
await pHigh;
await pLow;
expect(order).toEqual(["high", "low"]);
expect(sem.activeCount).toBe(0);
});
it("priority: mixed-priority integration — 1 slot, arbitrary enqueue order, correct drain", async () => {
const sem = new AgentSemaphore(1);
await sem.acquire(); // hold the single slot
const order: string[] = [];
// Enqueue in a scrambled order: execute, specify, merge, specify, execute, merge
const promises = [
sem.acquire(PRIORITY_EXECUTE).then(() => { order.push("execute-1"); }),
sem.acquire(PRIORITY_SPECIFY).then(() => { order.push("specify-1"); }),
sem.acquire(PRIORITY_MERGE).then(() => { order.push("merge-1"); }),
sem.acquire(PRIORITY_SPECIFY).then(() => { order.push("specify-2"); }),
sem.acquire(PRIORITY_EXECUTE).then(() => { order.push("execute-2"); }),
sem.acquire(PRIORITY_MERGE).then(() => { order.push("merge-2"); }),
];
// Expected drain order:
// merge-1, merge-2 (highest, FIFO within),
// execute-1, execute-2 (middle, FIFO within),
// specify-1, specify-2 (lowest, FIFO within)
for (let i = 0; i < 6; i++) {
sem.release();
// Wait for the next promise to settle
await Promise.resolve();
await Promise.resolve();
}
await Promise.all(promises);
expect(order).toEqual([
"merge-1", "merge-2",
"execute-1", "execute-2",
"specify-1", "specify-2",
]);
sem.release(); // cleanup
expect(sem.activeCount).toBe(0);
});
it("priority constants have correct values", () => {
expect(PRIORITY_MERGE).toBe(2);
expect(PRIORITY_EXECUTE).toBe(1);
expect(PRIORITY_SPECIFY).toBe(0);
expect(PRIORITY_MERGE).toBeGreaterThan(PRIORITY_EXECUTE);
expect(PRIORITY_EXECUTE).toBeGreaterThan(PRIORITY_SPECIFY);
});
it("priority: default priority (no argument) behaves as PRIORITY_SPECIFY (0)", async () => {
const sem = new AgentSemaphore(1);
await sem.acquire(); // fill the slot
const order: string[] = [];
// acquire() with no priority arg — should be treated as 0
const pDefault = sem.acquire().then(() => order.push("default"));
const pMerge = sem.acquire(PRIORITY_MERGE).then(() => order.push("merge"));
sem.release();
await pMerge;
expect(order).toEqual(["merge"]);
sem.release();
await pDefault;
expect(order).toEqual(["merge", "default"]);
sem.release();
});
});
// ─── Semaphore Resilience Tests (FN-978) ─────────────────────────────────────
describe("AgentSemaphore resilience (FN-978)", () => {
it("defaults to limit=1 when getter returns undefined", () => {
const sem = new AgentSemaphore(() => undefined as any);
// Should use minimum limit of 1
expect(sem.limit).toBe(1);
// availableCount returns 0 for invalid limits (defensive)
expect(sem.availableCount).toBe(0);
});
it("defaults to limit=1 when getter returns 0", () => {
const sem = new AgentSemaphore(0);
expect(sem.limit).toBe(1);
expect(sem.availableCount).toBe(0);
});
it("defaults to limit=1 when getter returns negative", () => {
const sem = new AgentSemaphore(-1);
expect(sem.limit).toBe(1);
expect(sem.availableCount).toBe(0);
});
it("defaults to limit=1 when getter returns NaN", () => {
const sem = new AgentSemaphore(() => NaN);
expect(sem.limit).toBe(1);
expect(sem.availableCount).toBe(0);
});
it("allows acquire even when limit getter returns undefined", async () => {
const sem = new AgentSemaphore(() => undefined as any);
// Should not block indefinitely
await sem.acquire();
expect(sem.activeCount).toBe(1);
sem.release();
expect(sem.activeCount).toBe(0);
});
it("drains waiters correctly when limit changes from invalid to valid", async () => {
let limit = 0;
const sem = new AgentSemaphore(() => limit);
// With limit=0, availableCount should be 0 (raw limit is invalid)
expect(sem.limit).toBe(1); // guarded getter returns min 1
expect(sem.availableCount).toBe(0); // raw limit is 0, so 0
// But acquire uses the guarded limit (1), so it should work
await sem.acquire();
expect(sem.activeCount).toBe(1);
sem.release();
expect(sem.activeCount).toBe(0);
});
it("handles limit changing dynamically", async () => {
let limit = 2;
const sem = new AgentSemaphore(() => limit);
// Acquire 2 slots
await sem.acquire();
await sem.acquire();
expect(sem.activeCount).toBe(2);
// Reduce limit to 1
limit = 1;
// Available should be 0 (1-2, clamped to 0)
expect(sem.availableCount).toBe(0);
// Release one — active goes from 2 to 1, drain checks limit=1, active=1 → no more drain
sem.release();
expect(sem.activeCount).toBe(1);
// Release the second one
sem.release();
expect(sem.activeCount).toBe(0);
});
});