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
139 lines
3.9 KiB
JavaScript
Executable File
139 lines
3.9 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// PreToolUse(Bash) - per-subagent circuit breaker. Counts Bash calls per subagent
|
|
// (keyed on agent_id, present only for subagents) and blocks once a subagent
|
|
// exceeds the loop limit, so a stuck agent can't spin forever. The main session
|
|
// (no agent_id) is never throttled - interactive sessions legitimately make many
|
|
// Bash calls.
|
|
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
writeFileSync,
|
|
statSync,
|
|
unlinkSync,
|
|
} from "node:fs";
|
|
import { join } from "node:path";
|
|
import { createHash } from "node:crypto";
|
|
import { createHookContext } from "./lib/context.mjs";
|
|
import { TMP_ROOT } from "./lib/paths.mjs";
|
|
|
|
// Per-subagent budget of WORK calls. Read-only exploration and git plumbing/commit
|
|
// are free (see isFreeCommand), so this counts only "real work" (builds, scripts,
|
|
// migrations, rebases). 45 work calls is comfortable and catches infinite loops
|
|
// (which hit 100+ fast). Before this exclusion, devs burned the whole budget on
|
|
// grep-based symbol search (0 LSP / 209 grep in one session) and had none left to
|
|
// commit.
|
|
const ITERATION_LIMIT = 45;
|
|
|
|
// A Bash call is "free" (never counted) when its primary command is read-only
|
|
// exploration or git plumbing/commit. Strips a leading `cd <path> &&` (the worktree
|
|
// convention) and inspects the first token of the first pipeline stage.
|
|
function isFreeCommand(raw) {
|
|
let c = String(raw || "").trim();
|
|
c = c.replace(/^cd\s+\S+\s*(?:&&|;)\s*/, "").trim();
|
|
const first = c.split(/[|&;]/)[0].trim().split(/\s+/)[0] || "";
|
|
const READONLY = new Set([
|
|
"grep",
|
|
"rg",
|
|
"egrep",
|
|
"fgrep",
|
|
"find",
|
|
"ls",
|
|
"cat",
|
|
"wc",
|
|
"head",
|
|
"tail",
|
|
"pwd",
|
|
"tree",
|
|
"stat",
|
|
"file",
|
|
"which",
|
|
"echo",
|
|
"dirname",
|
|
"basename",
|
|
"realpath",
|
|
]);
|
|
if (READONLY.has(first)) return true;
|
|
const gm = c.match(/^git\s+(\S+)/);
|
|
if (gm) {
|
|
const GIT_FREE = new Set([
|
|
"add",
|
|
"commit",
|
|
"status",
|
|
"diff",
|
|
"log",
|
|
"show",
|
|
"branch",
|
|
"rev-parse",
|
|
"rev-list",
|
|
"stash",
|
|
"worktree",
|
|
"ls-files",
|
|
"merge-base",
|
|
]);
|
|
if (GIT_FREE.has(gm[1])) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const input = JSON.parse(readFileSync(0, "utf8"));
|
|
const ctx = createHookContext(input, "circuit-breaker");
|
|
|
|
// Per-subagent breaker only. The main session (no agent_id) is never throttled.
|
|
if (!ctx.agentId) process.exit(0);
|
|
|
|
// Read-only exploration (grep/find/ls/cat/...) and git plumbing (add/commit/status)
|
|
// do NOT count against the budget. The breaker exists to catch stuck WORK loops, not
|
|
// to ration symbol search or starve the final commit.
|
|
if (isFreeCommand(input.tool_input?.command)) {
|
|
ctx.log(`free: ${String(input.tool_input?.command ?? "").slice(0, 80)}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
const key = `sub-${ctx.agentId}`;
|
|
const keyHash = createHash("sha1").update(key).digest("hex").slice(0, 16);
|
|
|
|
let counterDir = join(ctx.sessionDir, "breaker");
|
|
try {
|
|
mkdirSync(counterDir, { recursive: true });
|
|
} catch {
|
|
counterDir = TMP_ROOT;
|
|
}
|
|
const counterFile = join(counterDir, `bash-count-${keyHash}`);
|
|
|
|
// Auto-reset if the counter file is older than 1 hour (stale subagent).
|
|
if (existsSync(counterFile)) {
|
|
try {
|
|
const ageMs = Date.now() - statSync(counterFile).mtimeMs;
|
|
if (ageMs > 60 * 60 * 1000) unlinkSync(counterFile);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
let count = 0;
|
|
try {
|
|
count = parseInt(readFileSync(counterFile, "utf8"), 10) || 0;
|
|
} catch {
|
|
count = 0;
|
|
}
|
|
count += 1;
|
|
try {
|
|
writeFileSync(counterFile, String(count));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
// Observability: record the keyed count so per-agent budgets can be audited.
|
|
ctx.log(`key=${key} hash=${keyHash} count=${count}`);
|
|
|
|
if (count > ITERATION_LIMIT) {
|
|
ctx.block({
|
|
reason: `Circuit breaker: this subagent has made ${count} Bash calls - likely stuck in a loop. Stop, and report where you are blocked so the orchestrator can re-dispatch with a fresh context.`,
|
|
log: `BLOCK count=${count}`,
|
|
});
|
|
}
|
|
|
|
process.exit(0);
|