snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
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
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
This commit is contained in:
180
.claude/hooks/test/block-duplicate-dispatch.test.mjs
Normal file
180
.claude/hooks/test/block-duplicate-dispatch.test.mjs
Normal file
@@ -0,0 +1,180 @@
|
||||
// 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
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user