Files
upstream-atomic-crm/.claude/hooks/completion-invariant.mjs
upstream-mirror 93ecc1a6f8
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
snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
2026-09-18 01:48:50 +05:00

143 lines
4.8 KiB
JavaScript
Executable File

#!/usr/bin/env node
// SubagentStop(orchestrator) - completion invariant. INSURANCE for the handoff
// stall: forcing foreground dispatch is the real fix (foreground blocks, verified),
// but if the orchestrator ever stops with APPROVED dev work NOT merged into the session
// branch, that is an orphaned / incomplete pipeline. Reject the stop a few times (keep
// it alive to finish the merges FOREGROUND); if it still stops orphaned, drop a
// `needs-recovery` marker so the LAUNCHING SURFACE (main thread / chat-service, which IS
// re-invokable) re-runs <intent>recovery</intent>. Fail-open: any doubt or error accepts
// the stop, so this never wedges a legitimate completion.
import {
existsSync,
mkdirSync,
readFileSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { createHookContext } from "./lib/context.mjs";
import { readAgentMeta } from "./lib/agent-meta.mjs";
import { getUnmergedTaskBranches, git } from "./lib/git.mjs";
import { sessionBranch, simpleBranch } from "./lib/topology.mjs";
const REJECT_LIMIT = 2; // reject at most twice, then allow + mark for recovery
let ctx;
try {
const raw = readFileSync(0, "utf8");
ctx = createHookContext(raw, "completion-invariant");
const payload = JSON.parse(raw);
// Only the orchestrator's stop is our concern. SubagentStop matchers do not reliably
// filter (see cleanup-worktree) and agent_type in the payload is empty, so identify
// via the sibling meta.json. Any doubt -> accept (never block a non-orchestrator stop).
const meta = readAgentMeta(payload);
if (!meta || meta.agentType !== "orchestrator") {
ctx.accept(`not orchestrator (${meta?.agentType || "unknown"})`);
}
// No session branch yet -> nothing could be orphaned.
const sessionRef = sessionBranch(ctx);
if (
git(["show-ref", "--verify", "--quiet", `refs/heads/${sessionRef}`])
.status !== 0
) {
ctx.accept("no session branch");
}
// Committed dev work not merged into the session branch. Reuse the promotion-safety
// helper (single source of truth, fail-closed). Exclude <short>/simple (it promotes
// straight to base, never into the session branch).
const unmerged = getUnmergedTaskBranches(ctx.sessionShort, sessionRef, [
simpleBranch(ctx),
]);
// Only an APPROVED-but-unmerged branch is "orphaned by a stall". A branch with no
// APPROVED review flag is a FAILED or still-in-review ticket, not our concern -
// flagging it would be a false positive on every failed-ticket run.
const orphaned = unmerged
.map((u) => u.branch)
.filter((br) => {
const taskId = br.split("/").pop(); // <short>/TASK-XXX -> TASK-XXX
return existsSync(
join(ctx.ticketsDir, "reviews", `${taskId}-quality-reviewer`),
);
});
if (orphaned.length === 0) {
clearRejects();
ctx.accept("no approved-but-unmerged work");
}
const list = orphaned.join(", ");
const rejects = readRejects();
if (rejects < REJECT_LIMIT) {
writeRejects(rejects + 1);
ctx.fail(
`Completion invariant: APPROVED work on [${list}] is NOT merged into ${sessionRef}. ` +
`You are a nested subagent and are never re-invoked by a background child, so finish ` +
`the pipeline NOW in this turn: dispatch the merger for these FOREGROUND ` +
`(run_in_background:false). Do not end your turn. (attempt ${rejects + 1}/${REJECT_LIMIT})`,
{ log: `reject ${rejects + 1}/${REJECT_LIMIT} orphaned=[${list}]` },
);
}
// Still orphaned after REJECT_LIMIT tries -> stop looping. Hand off to the launching
// surface: write a recovery marker and allow the stop.
writeRecoveryMarker(orphaned);
clearRejects();
ctx.accept(`needs-recovery written; orphaned=[${list}]`);
} catch (e) {
// Fail-open: our own error must never wedge a stop.
if (ctx) ctx.log(`error, accepting: ${String(e).slice(0, 140)}`);
process.exit(0);
}
// --- markers -----------------------------------------------------------------
function breakerFile() {
const dir = join(ctx.sessionDir, "breaker");
try {
mkdirSync(dir, { recursive: true });
} catch {
/* best effort */
}
return join(dir, "completion-invariant-rejects");
}
function readRejects() {
try {
return parseInt(readFileSync(breakerFile(), "utf8"), 10) || 0;
} catch {
return 0;
}
}
function writeRejects(n) {
try {
writeFileSync(breakerFile(), String(n));
} catch {
/* best effort */
}
}
function clearRejects() {
try {
unlinkSync(breakerFile());
} catch {
/* absent - fine */
}
}
function writeRecoveryMarker(branches) {
try {
writeFileSync(
join(ctx.sessionDir, "needs-recovery"),
JSON.stringify({
reason: "orphaned-task-branches",
branches,
at: new Date().toISOString(),
}) + "\n",
);
} catch {
/* best effort */
}
}