- 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
460 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|