Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
181 lines
6.9 KiB
JavaScript
181 lines
6.9 KiB
JavaScript
// Tests for block-duplicate-dispatch.mjs — the PreToolUse(Agent) gate that
|
|
// stops accidental duplicate dispatches. Two concerns:
|
|
// 1. planner: at most one per (caller, TICKETS_DIR).
|
|
// 2. developer/quality-reviewer/merger: debounce identical re-dispatches
|
|
// inside a short window (the async-runtime "Async agent launched" trap).
|
|
// A block is emitted via decisionBlock: exit 0 with {"decision":"block"} on
|
|
// stdout (NOT exit 2). An allowed dispatch exits 0 with no decision JSON.
|
|
|
|
import {
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
rmSync,
|
|
utimesSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { spawnSync } from "node:child_process";
|
|
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
|
import { sanitizePath } from "../lib/paths.mjs";
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const HOOK = join(HERE, "..", "block-duplicate-dispatch.mjs");
|
|
const SESSION_ID = "ab12cd34-1111-2222-3333-444455556666";
|
|
const CALLER = "orchestrator-agent-1";
|
|
|
|
let TMP;
|
|
let APP_DIR;
|
|
let breakerDir;
|
|
let env;
|
|
|
|
// Returns { blocked: boolean } — blocked when the decisionBlock JSON is printed.
|
|
// runInBackground defaults to false: in the real harness (post force-foreground)
|
|
// every pipeline dispatch carries run_in_background:false, and block-duplicate only
|
|
// debounces those. Pass `undefined` to simulate the malformed (absent) dispatch that
|
|
// force-foreground denies — block-duplicate must skip it (no marker).
|
|
const run = (
|
|
prompt,
|
|
subagentType,
|
|
agentId = CALLER,
|
|
runInBackground = false,
|
|
) => {
|
|
const tool_input = { subagent_type: subagentType, prompt };
|
|
// Pass the sentinel "absent" to OMIT the field (a default param can't express this:
|
|
// passing `undefined` still triggers the default). "absent" simulates the malformed
|
|
// dispatch force-foreground denies.
|
|
if (runInBackground !== "absent")
|
|
tool_input.run_in_background = runInBackground;
|
|
const r = spawnSync("node", [HOOK], {
|
|
input: JSON.stringify({
|
|
session_id: SESSION_ID,
|
|
agent_id: agentId,
|
|
tool_input,
|
|
}),
|
|
env,
|
|
encoding: "utf8",
|
|
});
|
|
return {
|
|
blocked: /"decision":"block"/.test(r.stdout || ""),
|
|
status: r.status,
|
|
};
|
|
};
|
|
|
|
// Backdate every marker so the next dispatch sees it as outside the debounce
|
|
// window (and a planner marker as stale).
|
|
const ageMarkers = (secondsAgo) => {
|
|
const when = Date.now() / 1000 - secondsAgo;
|
|
for (const f of readdirSync(breakerDir)) {
|
|
utimesSync(join(breakerDir, f), when, when);
|
|
}
|
|
};
|
|
|
|
beforeAll(() => {
|
|
TMP = mkdtempSync(join(tmpdir(), "block-dup-dispatch-test-"));
|
|
APP_DIR = join(TMP, "app");
|
|
const CRM_TMP_ROOT = join(TMP, "scratch");
|
|
breakerDir = join(CRM_TMP_ROOT, sanitizePath(APP_DIR), SESSION_ID, "breaker");
|
|
mkdirSync(breakerDir, { recursive: true });
|
|
env = { ...process.env, APP_DIR, CRM_TMP_ROOT };
|
|
});
|
|
|
|
afterAll(() => rmSync(TMP, { recursive: true, force: true }));
|
|
|
|
describe("block-duplicate-dispatch — planner", () => {
|
|
test("allows the first planner, blocks the second for the same TICKETS_DIR", () => {
|
|
const p = `TICKETS_DIR=${join(TMP, "tickets")}`;
|
|
expect(run(p, "planner").blocked).toBe(false);
|
|
expect(run(p, "planner").blocked).toBe(true);
|
|
});
|
|
|
|
test("allows a planner again once the marker is stale (>1h)", () => {
|
|
ageMarkers(2 * 60 * 60); // 2h
|
|
const p = `TICKETS_DIR=${join(TMP, "tickets")}`;
|
|
expect(run(p, "planner").blocked).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("block-duplicate-dispatch — reviewer/developer/merger debounce", () => {
|
|
test("blocks a second quality-reviewer for the same ticket within the window", () => {
|
|
const p = `ROLE: quality-reviewer\nTASK_ID: TASK-100\nWORKTREE_PATH: /wt`;
|
|
expect(run(p, "quality-reviewer").blocked).toBe(false);
|
|
expect(run(p, "quality-reviewer").blocked).toBe(true);
|
|
});
|
|
|
|
test("allows a reviewer for a different ticket", () => {
|
|
expect(
|
|
run(
|
|
`ROLE: quality-reviewer\nTASK_ID: TASK-200\nWORKTREE_PATH: /wt`,
|
|
"quality-reviewer",
|
|
).blocked,
|
|
).toBe(false);
|
|
});
|
|
|
|
test("allows the same reviewer again once outside the debounce window", () => {
|
|
ageMarkers(5 * 60); // 5 min — well past the 90s window
|
|
expect(
|
|
run(
|
|
`ROLE: quality-reviewer\nTASK_ID: TASK-100\nWORKTREE_PATH: /wt`,
|
|
"quality-reviewer",
|
|
).blocked,
|
|
).toBe(false);
|
|
});
|
|
|
|
test("debounces by ticket, not role: a merger for a fresh ticket is allowed", () => {
|
|
expect(
|
|
run(`ROLE: merger\nTASK_ID: TASK-300\nBRANCH_NAME: x`, "merger").blocked,
|
|
).toBe(false);
|
|
});
|
|
|
|
test("a different caller is not debounced against the first caller's marker", () => {
|
|
const p = `ROLE: developer\nTASK_ID: TASK-400\nWORKTREE_PATH: /wt`;
|
|
expect(run(p, "developer", "orchestrator-A").blocked).toBe(false);
|
|
expect(run(p, "developer", "orchestrator-B").blocked).toBe(false);
|
|
});
|
|
|
|
test("falls back to a prompt hash when there is no TASK_ID (SIMPLE flow)", () => {
|
|
const p = `ROLE: developer\nCHANGE_REQUEST: rename the button\nWORKTREE_PATH: /wt`;
|
|
expect(run(p, "developer").blocked).toBe(false);
|
|
expect(run(p, "developer").blocked).toBe(true); // identical prompt → duplicate
|
|
// A different change request is a distinct dispatch, not a duplicate.
|
|
expect(
|
|
run(
|
|
`ROLE: developer\nCHANGE_REQUEST: hide the export\nWORKTREE_PATH: /wt`,
|
|
"developer",
|
|
).blocked,
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("block-duplicate-dispatch — pass-through", () => {
|
|
test("ignores roles outside the debounce set (documentator)", () => {
|
|
const p = `ROLE: documentator`;
|
|
expect(run(p, "documentator").blocked).toBe(false);
|
|
expect(run(p, "documentator").blocked).toBe(false); // never debounced
|
|
});
|
|
|
|
test("allows when no caller agent_id is present (can't scope a marker)", () => {
|
|
const r = run(
|
|
`ROLE: quality-reviewer\nTASK_ID: TASK-100\nWORKTREE_PATH: /wt`,
|
|
"quality-reviewer",
|
|
"",
|
|
);
|
|
expect(r.blocked).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("block-duplicate-dispatch — force-foreground retry (regression 8468cc06)", () => {
|
|
// A pipeline dispatch without run_in_background:false is DENIED by
|
|
// force-foreground-orchestrator-dispatch and re-issued. block-duplicate must NOT
|
|
// record a marker for the denied attempt, or the corrective retry would be wrongly
|
|
// rejected as a duplicate (this wedged planning for 60 min in a real run).
|
|
test("a rib-absent pipeline dispatch is skipped, so the rib:false retry is not a duplicate", () => {
|
|
const p = `TICKETS_DIR=${join(TMP, "tickets-ff")}`;
|
|
const FF = "ff-orch"; // unique caller: no marker collision with earlier tests
|
|
expect(run(p, "planner", FF, "absent").blocked).toBe(false); // denied form -> skipped, no marker
|
|
expect(run(p, "planner", FF, false).blocked).toBe(false); // the retry proceeds
|
|
expect(run(p, "planner", FF, false).blocked).toBe(true); // a genuine 2nd planner is still blocked
|
|
});
|
|
});
|