fix: stop blocking tasks on open-PR file claims — board tasks are the only blockers

Remove the FN-8700 PR/file-claim blocking mechanism end to end (operator
decision after FN-8728 parked on unrelated PR #2398):

- Drop the AGENTS.md claim-check rule and scripts/check-file-claimed.mjs
- Executor prompt + fn_task_done no longer accept pr:N refs or treat open
  PRs as blocked-exit reasons
- execution-block-classifier classifies on Fusion task dependencies only;
  legacy pr refs are discarded, reason prose never makes a block durable
- Remove the session-log BLOCKED promotion and the gh-backed
  reconcile-external-pr-blockers self-healing sweep
- Legacy file-claim parks are no longer honored, so previously PR-blocked
  rows recover via normal paths

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-02 17:06:38 -07:00
parent 4c0ead498a
commit 1e7f510ee2
9 changed files with 139 additions and 718 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Tasks no longer park blocked on open GitHub PRs touching their files; blockers are board tasks only.
category: fix
dev: Removes the FN-8700 PR/file-claim blocking mechanism — the AGENTS.md claim-check rule, `scripts/check-file-claimed.mjs`, `pr:N` blockedBy refs, file-claim classification in `execution-block-classifier.ts`, the session-log BLOCKED promotion, and the `reconcile-external-pr-blockers` self-healing sweep. Legacy file-claim parks are no longer honored by `isDurableBlockedTask`, so previously PR-blocked rows recover via normal paths.

View File

@@ -143,34 +143,12 @@ pnpm verify:workspace # deep opt-in verification (lint -> test:full -> build);
`pnpm verify:fast` is the recommended **test-free verification** path: bootstrap missing/stale workspace dist artifacts, typecheck + build scoped to the changed packages (it reuses `pnpm test`'s changed-package resolution), an always-on `@runfusion/fusion` CLI build required by the source-checkout boot smoke, plus the boot smoke once, with **no test run**. It is deterministic and flake-free, suitable as a project `testCommand`/verification command when you want non-test verification; the full suite stays available and runs non-blocking. It is additive and does not change `pnpm test`, the gate, or CI. See `docs/testing.md`. `pnpm verify:fast` is the recommended **test-free verification** path: bootstrap missing/stale workspace dist artifacts, typecheck + build scoped to the changed packages (it reuses `pnpm test`'s changed-package resolution), an always-on `@runfusion/fusion` CLI build required by the source-checkout boot smoke, plus the boot smoke once, with **no test run**. It is deterministic and flake-free, suitable as a project `testCommand`/verification command when you want non-test verification; the full suite stays available and runs non-blocking. It is additive and does not change `pnpm test`, the gate, or CI. See `docs/testing.md`.
### Check whether a file is claimed before converting it
Every fleet worker pushes as the same GitHub account, so `gh pr list --author "@me"` returns EVERY open
PR and cannot distinguish your work from a teammate's. Before starting a conversion, ask:
```bash
node scripts/check-file-claimed.mjs packages/engine/src/self-healing.ts
```
It lists the open PRs touching that path and exits non-zero if any do, so it can gate work directly.
It narrows the collision window rather than closing it — it cannot see unpushed work in progress.
Measured cost of not having it: four PRs in one session were superseded by teammates landing the same
conversion first, each time with both implementations correct and independently identical.
<!-- <!--
FNXC:FleetClaims 2026-07-31-21:15: WHY THIS IS A RULE AND NOT A SUGGESTION. FNXC:FleetClaims 2026-08-02-23:59: The "Check whether a file is claimed before converting it" rule
(scripts/check-file-claimed.mjs, added 2026-07-31 for the lifecycle-migration fleet) is REMOVED.
Every worker ranks work from the same census output, so without a published claim they independently It caused board tasks to park blocked on unrelated open PRs (FN-8728 vs PR #2398). Operator decision:
pick the same top file. In one fleet phase that produced three parallel conversions of file-scope conflict detection must only consider Fusion's own board (file-scope leases, dependencies),
`self-healing.ts` (two left unmergeable after the first landed), two workers marking the same two never open GitHub PRs. The fleet phase that motivated the rule is complete.
files, and two independent versions of the same `task:moved` emitter fix — five collisions, all with
both sides correct.
The check is cheap because the claim is a pushed branch: `git ls-remote` is authoritative the moment
work starts, whereas a claim announced anywhere else is invisible until the duplicate work exists.
That asymmetry is the whole point — the first signal of a collision used to be a failed checkout or a
conflicting PR, i.e. after the cost was already paid.
--> -->
### Standing Rule: Flaky Tests Are Quarantined on Sight (Deletion Ratchet) ### Standing Rule: Flaky Tests Are Quarantined on Sight (Deletion Ratchet)

View File

@@ -3,83 +3,96 @@ import {
BLOCKED_THRASH_LIMIT, BLOCKED_THRASH_LIMIT,
classifyBlockedExit, classifyBlockedExit,
countBlockedThrashHits, countBlockedThrashHits,
isDurableBlockedError,
isDurableBlockedTask, isDurableBlockedTask,
isFileClaimBlockedReason,
partitionBlockedByRefs, partitionBlockedByRefs,
} from "../execution-block-classifier.js"; } from "../execution-block-classifier.js";
/*
FNXC:HonestBlockedExit 2026-08-02-23:59 (operator decision — FN-8728 vs PR #2398):
Blocked exits classify on Fusion task dependencies ONLY. PR refs and file-claim
reason language must never produce a durable park — open PRs are not blockers.
*/
describe("partitionBlockedByRefs", () => { describe("partitionBlockedByRefs", () => {
it("splits task ids from pr refs", () => { it("keeps task ids and discards legacy pr refs and junk", () => {
expect(partitionBlockedByRefs(["FN-8145", "pr:2398", "#2400", "PR-12", " "])).toEqual({ expect(partitionBlockedByRefs(["FN-8145", "pr:2398", "#2400", "PR-12", " ", "fn-8145"])).toEqual({
taskIds: ["FN-8145"], taskIds: ["FN-8145"],
prNumbers: [2398, 2400, 12],
}); });
}); });
}); });
describe("classifyBlockedExit", () => { describe("classifyBlockedExit", () => {
it("allows auto-replan only for plan defects with empty blockers", () => { it("allows auto-replan for empty blockers regardless of reason prose", () => {
const c = classifyBlockedExit("requirements contradict each other", []); const c = classifyBlockedExit("requirements contradict each other", []);
expect(c.allowAutoReplan).toBe(true); expect(c.allowAutoReplan).toBe(true);
expect(c.class).toBe("plan-defect"); expect(c.class).toBe("plan-defect");
}); });
it("rejects auto-replan for file-claim / PR language (FN-8700)", () => { it("ignores file-claim / PR language — reason prose never makes a block durable", () => {
const reason = const reason =
"Required SQL finding packages/core/src/task-store/reads.ts:619 is actively claimed by PR #2398. " + "Required SQL finding packages/core/src/task-store/reads.ts:619 is actively claimed by PR #2398. " +
"check-file-claimed reports collision policy."; "check-file-claimed reports collision policy.";
const c = classifyBlockedExit(reason, []); const c = classifyBlockedExit(reason, []);
expect(c.allowAutoReplan).toBe(false); expect(c.allowAutoReplan).toBe(true);
expect(c.class).toBe("file-claim"); expect(c.class).toBe("plan-defect");
expect(c.prNumbers).toContain(2398);
expect(c.externalBlockers.some((b) => b.kind === "github-pr" && b.number === 2398)).toBe(true);
}); });
it("rejects auto-replan when blockedBy carries task deps", () => { it("rejects auto-replan when blockedBy carries task deps", () => {
const c = classifyBlockedExit("waiting on upstream", ["FN-8145"]); const c = classifyBlockedExit("waiting on upstream", ["FN-8145"]);
expect(c.allowAutoReplan).toBe(false); expect(c.allowAutoReplan).toBe(false);
expect(c.class).toBe("external"); expect(c.class).toBe("external");
expect(c.thrashSignature).toBe("tasks:FN-8145");
}); });
it("accepts pr: refs in blockedBy without treating as plan defect", () => { it("discards pr: refs in blockedBy — a PR-only block is a plan defect", () => {
const c = classifyBlockedExit("files claimed", ["pr:2398"]); const c = classifyBlockedExit("files claimed", ["pr:2398"]);
expect(c.allowAutoReplan).toBe(false); expect(c.allowAutoReplan).toBe(true);
expect(c.prNumbers).toContain(2398); expect(c.class).toBe("plan-defect");
}); });
}); });
describe("isFileClaimBlockedReason", () => { describe("isDurableBlockedTask", () => {
it("matches claim policy language", () => { it("honors only metadata-classed external (task-dependency) parks", () => {
expect(isFileClaimBlockedReason("actively claimed by PR #2398")).toBe(true); expect(
expect(isFileClaimBlockedReason("check-file-claimed.mjs reports open PR")).toBe(true); isDurableBlockedTask({
expect(isFileClaimBlockedReason("requirements contradict")).toBe(false); status: "failed",
}); error: "BLOCKED: waiting on FN-8145",
}); sourceMetadata: { blockedClass: "external" },
}),
describe("isDurableBlockedError / task", () => { ).toBe(true);
it("treats claim BLOCKED errors as durable", () => { // Legacy FN-8700 file-claim parks are deliberately NOT durable anymore.
expect(isDurableBlockedError("BLOCKED: path actively claimed by PR #1")).toBe(true);
expect(isDurableBlockedError("BLOCKED: requirements contradict each other")).toBe(false);
expect( expect(
isDurableBlockedTask({ isDurableBlockedTask({
status: "failed", status: "failed",
error: "BLOCKED: actively claimed by PR #2398", error: "BLOCKED: actively claimed by PR #2398",
sourceMetadata: {
blockedClass: "file-claim",
externalBlockers: [{ kind: "github-pr", number: 2398 }],
},
}), }),
).toBe(true); ).toBe(false);
expect(
isDurableBlockedTask({ status: "failed", error: "BLOCKED: actively claimed by PR #2398" }),
).toBe(false);
}); });
}); });
describe("countBlockedThrashHits", () => { describe("countBlockedThrashHits", () => {
it("counts recent claim BLOCKED log rows toward the thrash limit", () => { it("counts recent BLOCKED log rows matching the task-dependency signature", () => {
const now = Date.parse("2026-08-02T01:30:00.000Z"); const now = Date.parse("2026-08-02T01:30:00.000Z");
const log = [ const log = [
{ action: "BLOCKED: actively claimed by PR #2398", timestamp: "2026-08-02T01:00:00.000Z" }, { action: "BLOCKED: waiting on FN-8145", timestamp: "2026-08-02T01:00:00.000Z" },
{ action: "BLOCKED: check-file-claimed collision on reads.ts PR #2398", timestamp: "2026-08-02T01:10:00.000Z" }, { action: "BLOCKED: still waiting on FN-8145", timestamp: "2026-08-02T01:10:00.000Z" },
{ action: "BLOCKED: actively claimed by PR #2398", timestamp: "2026-08-02T01:20:00.000Z" }, { action: "BLOCKED: waiting on FN-8145", timestamp: "2026-08-02T01:20:00.000Z" },
{ action: "unrelated progress", timestamp: "2026-08-02T01:25:00.000Z" }, { action: "unrelated progress", timestamp: "2026-08-02T01:25:00.000Z" },
]; ];
const sig = classifyBlockedExit("actively claimed by PR #2398", []).thrashSignature; const sig = classifyBlockedExit("waiting on upstream", ["FN-8145"]).thrashSignature;
expect(countBlockedThrashHits(log, sig, now)).toBeGreaterThanOrEqual(BLOCKED_THRASH_LIMIT); expect(countBlockedThrashHits(log, sig, now)).toBeGreaterThanOrEqual(BLOCKED_THRASH_LIMIT);
}); });
it("never counts hits for the plan-defect signature", () => {
const now = Date.parse("2026-08-02T01:30:00.000Z");
const log = [{ action: "BLOCKED: anything", timestamp: "2026-08-02T01:20:00.000Z" }];
expect(countBlockedThrashHits(log, "plan-defect", now)).toBe(0);
});
}); });

View File

@@ -183,10 +183,12 @@ describe("FN-8141 fn_task_done honest blocked exit", () => {
); );
}); });
it("parks file-claim / open-PR blocks as durable failed even with empty task blockedBy (FN-8700)", async () => { it("ignores file-claim / open-PR blocks — PR refs are discarded and the exit auto-replans (board-only blockers)", async () => {
/* /*
FNXC:HonestBlockedExit 2026-08-02-01:30: FNXC:HonestBlockedExit 2026-08-02-23:59 (operator decision — FN-8728 vs PR #2398):
Empty blockedBy + claim language must NOT auto-replan — that re-ran claimed paths forever. Open PRs are never blockers. A blocked exit citing only a PR claim carries no real
task dependency, so it parks needs-replan (auto-replan) instead of the removed
FN-8700 durable file-claim park, and no PR data lands in dependencies or metadata.
*/ */
const { store, tool } = await setup(); const { store, tool } = await setup();
@@ -196,20 +198,16 @@ describe("FN-8141 fn_task_done honest blocked exit", () => {
blockedBy: ["pr:2398"], blockedBy: ["pr:2398"],
}); });
const patch = store.updateTask.mock.calls.find(([, p]: [string, Record<string, unknown>]) => p?.status === "failed")?.[1] as Record<string, unknown>; const patch = store.updateTask.mock.calls.find(([, p]: [string, Record<string, unknown>]) => "status" in p)?.[1] as Record<string, unknown>;
expect(patch).toBeDefined(); expect(patch.status).toBe("needs-replan");
expect(String(patch.error)).toMatch(/^BLOCKED:/); expect(patch.error).toBeNull();
expect(String(patch.error)).toContain("2398"); // The discarded PR ref must not become a dependency edge or metadata blocker.
expect(patch.sourceMetadataPatch).toEqual( const depCall = store.updateTask.mock.calls.find(([, p]: [string, Record<string, unknown>]) => Array.isArray(p?.dependencies));
expect.objectContaining({ expect(depCall).toBeUndefined();
blockedClass: "file-claim",
externalBlockers: expect.arrayContaining([expect.objectContaining({ kind: "github-pr", number: 2398 })]),
}),
);
expect(store.recordRunAuditEvent).toHaveBeenCalledWith( expect(store.recordRunAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
mutationType: "task:execution-blocked-parked", mutationType: "task:execution-blocked-parked",
metadata: expect.objectContaining({ parkedAs: "failed", blockedClass: "file-claim" }), metadata: expect.objectContaining({ parkedAs: "auto-replan", blockedBy: [], blockedClass: "plan-defect" }),
}), }),
); );
}); });

View File

@@ -1,166 +1,81 @@
/** /**
* Classify honest-blocked exits so the engine does not auto-replan (or thrash * Classify honest-blocked exits so the engine does not auto-replan (or thrash
* re-execute) work that is blocked by an external claim or open PR. * re-execute) work that is blocked behind other Fusion board tasks.
* *
* FNXC:HonestBlockedExit 2026-08-02-01:30: * FNXC:HonestBlockedExit 2026-08-02-23:59 (operator decision — FN-8728 vs PR #2398):
* FN-8700 looped forever: agent correctly parked on `check-file-claimed` / PR #2398 * FN-8700 previously treated "file claim / open PR" language as a durable external
* with empty blockedBy (no FN-#### dependency). Empty blockedBy was treated as a plan * block: agents were instructed to check open GitHub PRs for files they were about to
* defect → needs-replan → re-execute → same claim → BLOCKED. File-claim and open-PR * touch and park blocked on collisions, and self-healing cleared the park when the PR
* blocks are durable external waits, not plan defects. Classify them so: * merged/closed. That made board tasks wait on unrelated PRs. File-scope conflicts are
* (A) empty blockedBy does NOT auto-replan for claim/PR classes * arbitrated ONLY by Fusion's own board (file-scope leases, task dependencies) — an
* (B) PR numbers from reason/blockedBy refs are stored as externalBlockers metadata * open PR is never a claim on a task's file scope. All PR/file-claim classification,
* (C) graph-resume and thrash detectors can leave durable parks alone * pr:N blockedBy refs, and the gh-backed PR-clear sweep are removed. Blocked exits now
* classify on task dependencies alone: task deps → durable external park (requeues
* when the deps complete); no deps → plan defect → auto-replan (FN-8634).
*/ */
export type ExternalBlocker = export type BlockedExitClass = "plan-defect" | "external";
| { kind: "github-pr"; number: number }
| { kind: "file-claim"; prNumber?: number };
export type BlockedExitClass =
| "plan-defect"
| "file-claim"
| "external"
| "unknown-external";
export type BlockedExitClassification = { export type BlockedExitClassification = {
/** Only plan defects may use the empty-blockedBy → needs-replan path. */ /** Only plan defects may use the empty-blockedBy → needs-replan path. */
allowAutoReplan: boolean; allowAutoReplan: boolean;
class: BlockedExitClass; class: BlockedExitClass;
externalBlockers: ExternalBlocker[];
/** Compact signature for thrash detection (ids/outcomes only — no free prose). */ /** Compact signature for thrash detection (ids/outcomes only — no free prose). */
thrashSignature: string; thrashSignature: string;
/** PR numbers extracted from reason and blockedBy refs. */
prNumbers: number[];
}; };
const TASK_ID_RE = /^[A-Z][A-Z0-9]*-\d+$/i; const TASK_ID_RE = /^[A-Z][A-Z0-9]*-\d+$/i;
/** /**
* FNXC:ExecutionBlockClassification 2026-08-01-18:45: * FNXC:HonestBlockedExit 2026-08-02-23:59:
* Bare PR blockers accept pr:2398, pr#2398, pr-2398, #2398, and PR-2398 while excluding FN-#### task IDs. Keep the hyphen last in the character class so ESLint does not require an unnecessary escape. * Legacy PR refs (pr:2398, pr#2398, pr-2398, #2398, PR-2398) are recognized only to be
* DISCARDED — without this filter "PR-12" would match TASK_ID_RE and become a dependency
* edge on a nonexistent "PR-12" task row, wedging the card forever.
*/ */
const PR_REF_RE = /^(?:pr[:#-]|#|PR-)(\d+)$/i; const LEGACY_PR_REF_RE = /^(?:pr[:#-]|#|PR-)(\d+)$/i;
const PR_IN_TEXT_RE = /\bPR\s*#?\s*(\d+)\b/gi;
const CLAIM_REASON_RE =
/check-file-claimed|actively claimed|file[- ]claim|claimed by (?:open )?pr|open pr\s*#?\s*\d+|collision policy|claimed paths?|file claims?/i;
/** /**
* Split blockedBy entries into real task IDs vs external refs (pr:2398, #2398, PR-2398). * Extract Fusion task IDs from blockedBy entries. Non-task tokens — including legacy
* pr:N / #N PR refs — are ignored: open PRs are not valid blockers.
*/ */
export function partitionBlockedByRefs(blockedBy: readonly string[]): { export function partitionBlockedByRefs(blockedBy: readonly string[]): {
taskIds: string[]; taskIds: string[];
prNumbers: number[];
} { } {
const taskIds: string[] = []; const taskIds: string[] = [];
const prNumbers: number[] = [];
for (const raw of blockedBy) { for (const raw of blockedBy) {
const id = raw.trim(); const id = raw.trim();
if (!id) continue; if (!id) continue;
const prMatch = id.match(PR_REF_RE); if (LEGACY_PR_REF_RE.test(id)) continue;
// Prefer PR refs before task-id matching (PR-12 would otherwise match TASK_ID_RE).
if (prMatch) {
prNumbers.push(Number(prMatch[1]));
continue;
}
if (TASK_ID_RE.test(id)) { if (TASK_ID_RE.test(id)) {
const m = id.toUpperCase().match(/^([A-Z][A-Z0-9]*)-(\d+)$/); const m = id.toUpperCase().match(/^([A-Z][A-Z0-9]*)-(\d+)$/);
taskIds.push(m ? `${m[1]}-${m[2]}` : id.toUpperCase()); taskIds.push(m ? `${m[1]}-${m[2]}` : id.toUpperCase());
continue;
} }
// Ignore unknown tokens for dependency edges // Ignore other unknown tokens for dependency edges
} }
return { return { taskIds: [...new Set(taskIds)] };
taskIds: [...new Set(taskIds)],
prNumbers: [...new Set(prNumbers.filter((n) => Number.isFinite(n) && n > 0))],
};
}
export function extractPrNumbersFromText(text: string): number[] {
const found: number[] = [];
for (const match of text.matchAll(PR_IN_TEXT_RE)) {
const n = Number(match[1]);
if (Number.isFinite(n) && n > 0) found.push(n);
}
return [...new Set(found)];
}
export function isFileClaimBlockedReason(reason: string): boolean {
return CLAIM_REASON_RE.test(reason);
} }
/** /**
* Classify a blocked exit for parking policy. * Classify a blocked exit for parking policy. Reason prose never affects the
* classification — only real task dependencies make a block durable.
*/ */
export function classifyBlockedExit( export function classifyBlockedExit(
reason: string, _reason: string,
blockedBy: readonly string[] = [], blockedBy: readonly string[] = [],
): BlockedExitClassification { ): BlockedExitClassification {
const trimmed = reason.trim(); const { taskIds } = partitionBlockedByRefs(blockedBy);
const partitioned = partitionBlockedByRefs(blockedBy); if (taskIds.length > 0) {
const prFromText = extractPrNumbersFromText(trimmed);
const prNumbers = [...new Set([...partitioned.prNumbers, ...prFromText])];
const isClaim = isFileClaimBlockedReason(trimmed) || prNumbers.length > 0 && /claim/i.test(trimmed);
if (isClaim || prNumbers.length > 0 && isFileClaimBlockedReason(trimmed)) {
const externalBlockers: ExternalBlocker[] = [
...prNumbers.map((number) => ({ kind: "github-pr" as const, number })),
];
if (externalBlockers.length === 0 && isClaim) {
externalBlockers.push({
kind: "file-claim",
prNumber: prNumbers[0],
});
} else if (isClaim) {
// Also record a file-claim tag when claim language is present
for (const n of prNumbers) {
if (!externalBlockers.some((b) => b.kind === "github-pr" && b.number === n)) {
externalBlockers.push({ kind: "github-pr", number: n });
}
}
if (prNumbers.length === 0) {
externalBlockers.push({ kind: "file-claim" });
}
}
const thrashSignature = [
"file-claim",
...prNumbers.map((n) => `pr:${n}`).sort(),
...partitioned.taskIds.slice().sort(),
].join("|");
return {
allowAutoReplan: false,
class: "file-claim",
externalBlockers,
thrashSignature: thrashSignature || "file-claim",
prNumbers,
};
}
if (partitioned.taskIds.length > 0) {
return { return {
allowAutoReplan: false, allowAutoReplan: false,
class: "external", class: "external",
externalBlockers: [], thrashSignature: `tasks:${taskIds.slice().sort().join(",")}`,
thrashSignature: `tasks:${partitioned.taskIds.slice().sort().join(",")}`,
prNumbers,
}; };
} }
if (prNumbers.length > 0) { // Empty blockedBy → plan defect, auto-replan is OK
return {
allowAutoReplan: false,
class: "external",
externalBlockers: prNumbers.map((number) => ({ kind: "github-pr" as const, number })),
thrashSignature: prNumbers.map((n) => `pr:${n}`).sort().join("|"),
prNumbers,
};
}
// Empty blockedBy + no claim language → plan defect, auto-replan is OK
return { return {
allowAutoReplan: true, allowAutoReplan: true,
class: "plan-defect", class: "plan-defect",
externalBlockers: [],
thrashSignature: "plan-defect", thrashSignature: "plan-defect",
prNumbers: [],
}; };
} }
@@ -173,7 +88,7 @@ export const BLOCKED_THRASH_WINDOW_MS = 60 * 60 * 1000;
export type TaskLogLike = { action?: string; timestamp?: string }; export type TaskLogLike = { action?: string; timestamp?: string };
/** /**
* Count recent log rows that match a durable block signature or BLOCKED: claim text. * Count recent log rows that match a durable block signature.
*/ */
export function countBlockedThrashHits( export function countBlockedThrashHits(
log: readonly TaskLogLike[] | undefined, log: readonly TaskLogLike[] | undefined,
@@ -182,6 +97,7 @@ export function countBlockedThrashHits(
windowMs: number = BLOCKED_THRASH_WINDOW_MS, windowMs: number = BLOCKED_THRASH_WINDOW_MS,
): number { ): number {
if (!log?.length) return 0; if (!log?.length) return 0;
if (signature === "plan-defect") return 0;
const cutoff = nowMs - windowMs; const cutoff = nowMs - windowMs;
let count = 0; let count = 0;
for (const entry of log) { for (const entry of log) {
@@ -194,19 +110,7 @@ export function countBlockedThrashHits(
continue; continue;
} }
} }
// Signature match via pr:N tokens or claim class
if (signature === "plan-defect") continue;
if (signature.includes("file-claim") || signature.startsWith("pr:") || signature.includes("|pr:")) {
if (isFileClaimBlockedReason(action) || /PR\s*#?\s*\d+/i.test(action) || action.includes("durable external block")) {
count += 1;
continue;
}
}
for (const part of signature.split("|")) { for (const part of signature.split("|")) {
if (part.startsWith("pr:") && action.includes(`PR #${part.slice(3)}`)) {
count += 1;
break;
}
if (part.startsWith("tasks:") && part.slice(6).split(",").some((id) => id && action.includes(id))) { if (part.startsWith("tasks:") && part.slice(6).split(",").some((id) => id && action.includes(id))) {
count += 1; count += 1;
break; break;
@@ -216,30 +120,27 @@ export function countBlockedThrashHits(
return count; return count;
} }
export function isDurableBlockedError(error: string | null | undefined): boolean {
if (!error?.startsWith("BLOCKED:")) return false;
const reason = error.slice("BLOCKED:".length).trim();
const classification = classifyBlockedExit(reason, []);
return !classification.allowAutoReplan;
}
export function isDurableBlockedTask(task: { export function isDurableBlockedTask(task: {
status?: string | null; status?: string | null;
error?: string | null; error?: string | null;
sourceMetadata?: Record<string, unknown> | null; sourceMetadata?: Record<string, unknown> | null;
}): boolean { }): boolean {
if (task.status === "failed" && isDurableBlockedError(task.error)) return true; /*
FNXC:HonestBlockedExit 2026-08-02-23:59:
Only metadata-classed "external" (task-dependency) parks are durable. Legacy
"file-claim" parks and externalBlockers metadata from the removed FN-8700 PR-claim
path are deliberately NOT honored, so previously PR-blocked rows become recoverable
by normal graph-resume/scheduler paths instead of waiting on a merged/closed PR sweep.
*/
const meta = task.sourceMetadata; const meta = task.sourceMetadata;
if (!meta || typeof meta !== "object") return false; if (!meta || typeof meta !== "object") return false;
if (meta.blockedClass === "file-claim" || meta.blockedClass === "external") { if (meta.blockedClass !== "external") return false;
if (task.status === "failed" || task.status === "needs-replan") return true; return task.status === "failed" || task.status === "needs-replan";
}
const blockers = meta.externalBlockers;
return Array.isArray(blockers) && blockers.length > 0 && task.status === "failed";
} }
/** /**
* Build sourceMetadata patch for a durable external block park. * Build sourceMetadata patch for a durable external block park.
* `externalBlockers` is always cleared — the PR-claim blocker list is removed.
*/ */
export function buildExternalBlockMetadataPatch( export function buildExternalBlockMetadataPatch(
classification: BlockedExitClassification, classification: BlockedExitClassification,
@@ -249,6 +150,6 @@ export function buildExternalBlockMetadataPatch(
blockedClass: classification.class, blockedClass: classification.class,
blockedThrashSignature: classification.thrashSignature, blockedThrashSignature: classification.thrashSignature,
blockedThrashCount: thrashCount, blockedThrashCount: thrashCount,
externalBlockers: classification.externalBlockers, externalBlockers: [],
}; };
} }

View File

@@ -22,7 +22,6 @@ import {
buildExternalBlockMetadataPatch, buildExternalBlockMetadataPatch,
classifyBlockedExit, classifyBlockedExit,
countBlockedThrashHits, countBlockedThrashHits,
isDurableBlockedError,
isDurableBlockedTask, isDurableBlockedTask,
partitionBlockedByRefs, partitionBlockedByRefs,
} from "./execution-block-classifier.js"; } from "./execution-block-classifier.js";
@@ -1415,7 +1414,7 @@ If you have just finished a step's work, immediately call \`fn_task_update\` to
The user is not watching this conversation in real-time. They will read the final result. Asking permission wastes a full retry cycle and may orphan committed work. The user is not watching this conversation in real-time. They will read the final result. Asking permission wastes a full retry cycle and may orphan committed work.
**Cannot proceed — the honest blocked exit.** If the work genuinely cannot be finished (an upstream API break, a missing prerequisite task, an unresolvable external error, or a **file claim / open PR collision**), call \`fn_task_done(outcome="blocked", reason="<concrete blocker + what would unblock it>", blockedBy=["FN-XXXX"] or ["pr:2398"])\`. File-claim reasons must name the PR (e.g. "actively claimed by PR #2398" + \`blockedBy:["pr:2398"]\`). That parks durable failed WITHOUT auto-replan so the engine does not thrash; task IDs requeue when those tasks complete; PR refs clear when the PR merges/closes. Do NOT skip remaining steps to fake completion. **Cannot proceed — the honest blocked exit.** If the work genuinely cannot be finished (an upstream API break, a missing prerequisite task, or an unresolvable external error), call \`fn_task_done(outcome="blocked", reason="<concrete blocker + what would unblock it>", blockedBy=["FN-XXXX"])\`. That parks durable failed WITHOUT auto-replan so the engine does not thrash; task IDs requeue when those tasks complete. Blockers must be Fusion board tasks — do NOT treat open GitHub PRs touching the same files as blockers; other PRs are not claims on your file scope. Do NOT skip remaining steps to fake completion.
This is THE correct action when you are stuck — do NOT instead mark the remaining steps \`skipped\` and call \`fn_task_done\` to make the task look finished. Skipping steps to escape a blocker launders a failure into \`done\` and is never the right move. (\`skipped\` remains valid only for the stale-premise path below, when the requested work is already present on HEAD.) Never write the blocker as plain prose. This is THE correct action when you are stuck — do NOT instead mark the remaining steps \`skipped\` and call \`fn_task_done\` to make the task look finished. Skipping steps to escape a blocker launders a failure into \`done\` and is never the right move. (\`skipped\` remains valid only for the stale-premise path below, when the requested work is already present on HEAD.) Never write the blocker as plain prose.
## How to work ## How to work
@@ -12288,70 +12287,6 @@ export class TaskExecutor {
} }
} }
/*
FNXC:HonestBlockedExit 2026-08-02-01:30:
Agents often log `BLOCKED: … actively claimed by PR #N` without calling fn_task_done.
Without promotion, incomplete-step graph resume requeues the card and thrash restarts
(FN-8700). Promote claim/external blocks to a durable failed park here.
*/
private async parkDurableBlockedFromRecentLog(live: Task): Promise<boolean> {
const recent = [...(live.log ?? [])].reverse().find((entry) => {
const action = entry.action ?? "";
return action.startsWith("BLOCKED:") || action.includes("durable external block");
});
if (!recent?.action) return false;
const reason = recent.action.startsWith("BLOCKED:")
? recent.action.slice("BLOCKED:".length).trim()
: recent.action;
const classification = classifyBlockedExit(reason, []);
if (classification.allowAutoReplan) return false;
const thrashCount = countBlockedThrashHits(live.log, classification.thrashSignature) + 1;
const thrashExhausted = thrashCount >= BLOCKED_THRASH_LIMIT;
const parkError = thrashExhausted
? `BLOCKED: ${reason} [thrash-exhausted after ${thrashCount} identical durable blocks]`
: `BLOCKED: ${reason}`;
const metaPatch = buildExternalBlockMetadataPatch(classification, thrashCount);
await this.store.updateTask(live.id, {
status: "failed",
error: parkError,
paused: false,
pausedByAgentId: null,
sourceMetadataPatch: metaPatch,
}, this.getRunContextFor(live.id));
await this.store.logEntry(
live.id,
thrashExhausted
? `${parkError} — promoted from session log; thrash-exhausted, no auto-requeue`
: `${parkError} — promoted from session log to durable external block park (class=${classification.class})`,
undefined,
this.getRunContextFor(live.id),
);
await this.store.recordRunAuditEvent?.({
taskId: live.id,
agentId: "executor",
runId: generateSyntheticRunId("execution-blocked-log-promote", live.id),
domain: "database",
mutationType: "task:execution-blocked-parked",
target: live.id,
metadata: {
taskId: live.id,
blockedBy: [],
hasReason: true,
parkedAs: "failed",
blockedClass: classification.class,
thrashCount,
thrashExhausted,
prNumbers: classification.prNumbers,
source: "session-log-promote",
},
});
executorLog.warn(
`${live.id}: promoted durable BLOCKED from session log (class=${classification.class}; thrash=${thrashCount})`,
);
return true;
}
private async routeGraphFailureToExecutionResume( private async routeGraphFailureToExecutionResume(
live: TaskDetail, live: TaskDetail,
failedNode: string, failedNode: string,
@@ -12373,20 +12308,18 @@ export class TaskExecutor {
if (live.paused || live.userPaused === true) return false; if (live.paused || live.userPaused === true) return false;
if ((await resolveTerminalColumnsFor(this.store, live.id)).includes(live.column)) return false; if ((await resolveTerminalColumnsFor(this.store, live.id)).includes(live.column)) return false;
/* /*
FNXC:HonestBlockedExit 2026-08-02-01:30: FNXC:HonestBlockedExit 2026-08-02-23:59:
Durable file-claim / external BLOCKED parks must NOT bounce to todo for execution resume Durable external (task-dependency) BLOCKED parks must NOT bounce to todo for execution
(FN-8700). Incomplete steps after a claim block are expected — re-running re-hits the claim. resume — the scheduler requeues them when the blocking tasks complete. PR/file-claim
parks and the session-log BLOCKED promotion are removed (operator decision, FN-8728):
open PRs are never blockers, so only metadata-classed task-dependency parks are honored.
*/ */
if (isDurableBlockedTask(live) || isDurableBlockedError(live.error)) { if (isDurableBlockedTask(live)) {
executorLog.log( executorLog.log(
`${live.id}: graph failure resume skipped — durable BLOCKED park honored (class from error/metadata)`, `${live.id}: graph failure resume skipped — durable BLOCKED park honored (task-dependency block)`,
); );
return false; return false;
} }
// Agent often logs BLOCKED: without fn_task_done; promote to durable park and do not requeue.
if (await this.parkDurableBlockedFromRecentLog(live)) {
return false;
}
/* /*
* FNXC:WorkflowCompletion 2026-07-01-16:26: * FNXC:WorkflowCompletion 2026-07-01-16:26:
* Backstop for issue #1863. The advisory completion-summary node must never * Backstop for issue #1863. The advisory completion-summary node must never
@@ -17412,10 +17345,10 @@ export class TaskExecutor {
{ description: "\"completed\" (default) finishes the task; \"blocked\" honestly parks it as failed because the work cannot proceed. Use \"blocked\" instead of skipping steps + completing when you are stuck." }, { description: "\"completed\" (default) finishes the task; \"blocked\" honestly parks it as failed because the work cannot proceed. Use \"blocked\" instead of skipping steps + completing when you are stuck." },
)), )),
blockedBy: Type.Optional(Type.Array(Type.String(), { blockedBy: Type.Optional(Type.Array(Type.String(), {
description: "When outcome=\"blocked\": task IDs (e.g. [\"FN-8145\"]) and/or PR refs (e.g. [\"pr:2398\"]) that must clear before this task can proceed. Task IDs become real dependency edges; PR refs are stored as external blockers (durable park until the PR merges/closes).", description: "When outcome=\"blocked\": Fusion task IDs (e.g. [\"FN-8145\"]) that must complete before this task can proceed. Task IDs become real dependency edges. Open GitHub PRs are not valid blockers.",
})), })),
reason: Type.Optional(Type.String({ reason: Type.Optional(Type.String({
description: "Required when outcome=\"blocked\": concrete explanation of what is blocking the work and what is needed to unblock it. File-claim / open-PR collisions should name the PR (e.g. \"actively claimed by PR #2398\").", description: "Required when outcome=\"blocked\": concrete explanation of what is blocking the work and what is needed to unblock it.",
})), })),
}), }),
execute: async (_id: string, params: { summary?: string; outcome?: "completed" | "blocked"; blockedBy?: string[]; reason?: string }) => { execute: async (_id: string, params: { summary?: string; outcome?: "completed" | "blocked"; blockedBy?: string[]; reason?: string }) => {
@@ -17441,14 +17374,14 @@ export class TaskExecutor {
new Set((params.blockedBy ?? []).map((id) => id.trim()).filter((id) => id.length > 0)), new Set((params.blockedBy ?? []).map((id) => id.trim()).filter((id) => id.length > 0)),
); );
/* /*
FNXC:HonestBlockedExit 2026-08-02-01:30: FNXC:HonestBlockedExit 2026-08-02-23:59 (operator decision — FN-8728 vs PR #2398):
FN-8700: empty blockedBy + file-claim/PR reason is NOT a plan defect. Auto-replan re-ran the Blocked exits classify on Fusion task dependencies ONLY. The FN-8700 file-claim/open-PR
same claimed paths forever. Classify the reason (and pr:N refs) so claim/external blocks classification is removed: open PRs are never blockers, legacy pr:N refs are discarded,
park durable failed; only pure plan defects keep the needs-replan path (FN-8634). and reason prose never makes a block durable. Task deps → durable failed park (requeues
when deps complete); no deps → plan defect → needs-replan (FN-8634).
*/ */
const classification = classifyBlockedExit(reason, rawBlockedBy); const classification = classifyBlockedExit(reason, rawBlockedBy);
const { taskIds: blockedByIds } = partitionBlockedByRefs(rawBlockedBy); const { taskIds: blockedByIds } = partitionBlockedByRefs(rawBlockedBy);
// Prefer task IDs from blockedBy; classification may only carry PRs from reason text.
const thrashCount = countBlockedThrashHits( const thrashCount = countBlockedThrashHits(
blockedTask.log, blockedTask.log,
classification.thrashSignature, classification.thrashSignature,
@@ -17458,16 +17391,14 @@ export class TaskExecutor {
const parkError = thrashExhausted const parkError = thrashExhausted
? `BLOCKED: ${reason} [thrash-exhausted after ${thrashCount} identical durable blocks]` ? `BLOCKED: ${reason} [thrash-exhausted after ${thrashCount} identical durable blocks]`
: `BLOCKED: ${reason}`; : `BLOCKED: ${reason}`;
// Record blockedBy TASK ids as real dependency edges (union with existing). PR refs stay in // Record blockedBy TASK ids as real dependency edges (union with existing).
// sourceMetadata.externalBlockers — they are not task rows and cannot go through assertTaskExists.
const mergedDependencies = blockedByIds.length > 0 const mergedDependencies = blockedByIds.length > 0
? Array.from(new Set([...(blockedTask.dependencies ?? []), ...blockedByIds])) ? Array.from(new Set([...(blockedTask.dependencies ?? []), ...blockedByIds]))
: undefined; : undefined;
/* /*
FNXC:HonestBlockedExit 2026-08-01-01:40 (operator: FN-8634 "shouldn't show a failed badge"): FNXC:HonestBlockedExit 2026-08-01-01:40 (operator: FN-8634 "shouldn't show a failed badge"):
When `blockedBy` is EMPTY AND the reason is a plan defect, park needs-replan (auto-replan). When `blockedBy` is EMPTY, park needs-replan (auto-replan) — nothing external to wait for.
Durable external/file-claim blocks always park failed — even with empty task deps — so the Task-dependency blocks park failed so the scheduler leaves the card alone until deps complete.
scheduler and graph-resume paths leave the card alone until an operator or PR-clear sweep acts.
*/ */
const autoReplanPark = classification.allowAutoReplan && blockedByIds.length === 0 && !thrashExhausted; const autoReplanPark = classification.allowAutoReplan && blockedByIds.length === 0 && !thrashExhausted;
const metaPatch = !autoReplanPark const metaPatch = !autoReplanPark
@@ -17507,9 +17438,7 @@ export class TaskExecutor {
taskId, taskId,
thrashExhausted thrashExhausted
? `${parkError} — durable external block thrash-exhausted (signature=${classification.thrashSignature}); parked failed, no auto-requeue` ? `${parkError} — durable external block thrash-exhausted (signature=${classification.thrashSignature}); parked failed, no auto-requeue`
: classification.externalBlockers.length > 0 : `${parkError} — recorded dependencies: ${blockedByIds.join(", ")} — parked failed (honest blocked exit; steps preserved)`,
? `${parkError} — durable external block (${classification.class}; pr=${classification.prNumbers.join(",") || "none"}; tasks=${blockedByIds.join(",") || "none"}) — parked failed (honest blocked exit; steps preserved)`
: `${parkError} — recorded dependencies: ${blockedByIds.join(", ")} — parked failed (honest blocked exit; steps preserved)`,
undefined, undefined,
this.getRunContextFor(taskId), this.getRunContextFor(taskId),
); );
@@ -17529,7 +17458,6 @@ export class TaskExecutor {
blockedClass: classification.class, blockedClass: classification.class,
thrashCount, thrashCount,
thrashExhausted, thrashExhausted,
prNumbers: classification.prNumbers,
}, },
}); });
await this.persistTokenUsage(taskId); await this.persistTokenUsage(taskId);
@@ -17539,7 +17467,7 @@ export class TaskExecutor {
? "parked for automatic replan via blocked exit (plan defect, no dependencies)" ? "parked for automatic replan via blocked exit (plan defect, no dependencies)"
: thrashExhausted : thrashExhausted
? `parked failed via blocked thrash-exhaustion (class=${classification.class})` ? `parked failed via blocked thrash-exhaustion (class=${classification.class})`
: `parked failed via durable blocked exit (class=${classification.class}; blockedBy tasks: ${blockedByIds.join(", ") || "none"}; pr: ${classification.prNumbers.join(",") || "none"})` : `parked failed via durable blocked exit (class=${classification.class}; blockedBy tasks: ${blockedByIds.join(", ") || "none"})`
}`, }`,
); );
@@ -17547,14 +17475,10 @@ export class TaskExecutor {
content: [{ content: [{
type: "text" as const, type: "text" as const,
text: autoReplanPark text: autoReplanPark
? "Task parked as blocked with no external/file-claim blocker — queued for automatic replan so the plan can resolve the conflict. Steps left in their true statuses; no completion recorded." ? "Task parked as blocked with no blocking task dependencies — queued for automatic replan so the plan can resolve the conflict. Steps left in their true statuses; no completion recorded."
: thrashExhausted : thrashExhausted
? "Task parked as blocked (failed) after repeated identical durable blocks — no further automatic retries. Resolve the external claim/PR or replan manually." ? "Task parked as blocked (failed) after repeated identical durable blocks — no further automatic retries. Resolve the blocking tasks or replan manually."
: blockedByIds.length > 0 : `Task parked as blocked (failed). Recorded ${blockedByIds.length} blocking task dependency(ies); it will requeue once they complete. Steps left in their true statuses; no completion recorded.`,
? `Task parked as blocked (failed). Recorded ${blockedByIds.length} blocking task dependency(ies); it will requeue once they complete. Steps left in their true statuses; no completion recorded.`
: classification.prNumbers.length > 0
? `Task parked as blocked (failed) on open PR #${classification.prNumbers.join(", #")}. It will not auto-replan or re-execute until that claim clears or an operator retries. Steps preserved.`
: "Task parked as blocked (failed) on a durable external/file-claim blocker. No automatic replan. Steps preserved.",
}], }],
details: {}, details: {},
}; };

View File

@@ -36,8 +36,6 @@ import { type TaskMoveLanes, resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PR
resolveProjectColumnsForRoles, resolveProjectColumnsForRoles,
REVIEW_ROLES, REVIEW_ROLES,
pruneTaskLifecycleEvents, pruneTaskLifecycleEvents,
isGhAvailable,
runGhJsonAsync,
} from "@fusion/core"; } from "@fusion/core";
import { finalizePlanningSegment } from "@fusion/core"; import { finalizePlanningSegment } from "@fusion/core";
import type { MeshLeaseManager } from "./mesh-lease-manager.js"; import type { MeshLeaseManager } from "./mesh-lease-manager.js";
@@ -2772,7 +2770,6 @@ export class SelfHealingManager extends SelfHealingGitEvidence {
{ name: "reconcile-done-task-integrity", fn: () => this.reconcileDoneTaskIntegrity() }, { name: "reconcile-done-task-integrity", fn: () => this.reconcileDoneTaskIntegrity() },
{ name: "reconcile-stale-merger-status", fn: () => this.reconcileStaleMergerStatus() }, { name: "reconcile-stale-merger-status", fn: () => this.reconcileStaleMergerStatus() },
{ name: "reconcile-stale-duplicate-decision", fn: () => this.reconcileStaleDuplicateDecisionPause() }, { name: "reconcile-stale-duplicate-decision", fn: () => this.reconcileStaleDuplicateDecisionPause() },
{ name: "reconcile-external-pr-blockers", fn: () => this.reconcileExternalPrBlockers() },
// FNXC:OrphanedPendingSteps 2026-07-22-16:35 (FN-8492 review follow-up): also // FNXC:OrphanedPendingSteps 2026-07-22-16:35 (FN-8492 review follow-up): also
// steady-state — a step session can die without an engine restart, and startup-only // steady-state — a step session can die without an engine restart, and startup-only
// cadence left that case riding the 3×30-min stall escalator to a deadlock park. // cadence left that case riding the 3×30-min stall escalator to a deadlock park.
@@ -14401,77 +14398,12 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
} }
/* /*
FNXC:HonestBlockedExit 2026-08-02-01:30: FNXC:HonestBlockedExit 2026-08-02-23:59 (operator decision — FN-8728 vs PR #2398):
Durable parks store github-pr externalBlockers (FN-8700 file-claim). When every blocking The FN-8700 `reconcile-external-pr-blockers` sweep (gh-backed clearing of PR-claim parks)
PR is MERGED or CLOSED, clear the failed park so the scheduler can re-dispatch. Fail-soft is REMOVED with the whole PR/file-claim blocking mechanism. Open PRs are never blockers;
when gh is unavailable — leave the park for the operator. file-scope conflicts are arbitrated only by Fusion's own board. Legacy PR-claim parks are
no longer honored by isDurableBlockedTask, so normal recovery paths reclaim them.
*/ */
async reconcileExternalPrBlockers(): Promise<number> {
try {
if (!(await isGhAvailable())) {
log.debug("reconcile-external-pr-blockers skipped — gh unavailable");
return 0;
}
const tasks = await this.store.listTasks({ slim: true, includeArchived: false, limit: 500 });
let cleared = 0;
for (const task of tasks.slice(0, 80)) {
if (task.status !== "failed" || !task.error?.startsWith("BLOCKED:")) continue;
const meta = task.sourceMetadata;
const blockers = meta?.externalBlockers;
if (!Array.isArray(blockers) || blockers.length === 0) continue;
const prNumbers = blockers
.map((b) => (b && typeof b === "object" && (b as { kind?: string }).kind === "github-pr"
? Number((b as { number?: unknown }).number)
: NaN))
.filter((n) => Number.isFinite(n) && n > 0);
if (prNumbers.length === 0) continue;
let allResolved = true;
for (const n of prNumbers) {
try {
const pr = await runGhJsonAsync<{ state?: string; mergedAt?: string | null }>(
["pr", "view", String(n), "--json", "state,mergedAt"],
{ timeoutMs: 15_000 },
);
const state = String(pr?.state ?? "").toUpperCase();
const merged = Boolean(pr?.mergedAt) || state === "MERGED";
const closed = state === "CLOSED" || state === "MERGED";
if (!merged && !closed) {
allResolved = false;
break;
}
} catch {
allResolved = false;
break;
}
}
if (!allResolved) continue;
await this.store.updateTask(task.id, {
status: null,
error: null,
sourceMetadataPatch: {
externalBlockers: [],
blockedClass: null,
blockedThrashSignature: null,
blockedThrashCount: null,
externalPrBlockersClearedAt: new Date().toISOString(),
externalPrBlockersCleared: prNumbers,
},
});
await this.store.logEntry(
task.id,
`Auto-recovered: external PR blocker(s) ${prNumbers.map((n) => `#${n}`).join(", ")} merged/closed — cleared durable BLOCKED park for re-dispatch`,
);
log.log(`Cleared durable PR block for ${task.id} (prs=${prNumbers.join(",")})`);
cleared += 1;
}
return cleared;
} catch (error) {
log.warn(`reconcile-external-pr-blockers failed: ${error instanceof Error ? error.message : String(error)}`);
return 0;
}
}
async resolveExplicitDuplicateMarkerTasks(): Promise<number> { async resolveExplicitDuplicateMarkerTasks(): Promise<number> {
try { try {

View File

@@ -1,186 +0,0 @@
/*
FNXC:FleetClaims 2026-08-01-16:09:
The claim checker is a collision-prevention gate, so its fake `gh` records exact argument arrays and
models paginated API pages without network access. These tests keep the key invariant explicit: only a
count-reconciled complete scan can prove a claim or an unclaimed path, and any incomplete PR overrides
otherwise-known claims.
*/
import assert from "node:assert/strict";
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
const repoRoot = resolve(import.meta.dirname, "../..");
const script = join(repoRoot, "scripts/check-file-claimed.mjs");
function files(count, prefix = "generated") {
return Array.from({ length: count }, (_, index) => ({ filename: `${prefix}/file-${index + 1}.ts` }));
}
function runClaimCheck({ open = [], pages = {}, targets = ["needle"], fail = [] }) {
const dir = mkdtempSync(join(tmpdir(), "fusion-claim-check-"));
const fixturePath = join(dir, "fixture.json");
const callsPath = join(dir, "calls.jsonl");
const ghPath = join(dir, "gh");
writeFileSync(fixturePath, JSON.stringify({ open, pages, fail }));
writeFileSync(ghPath, `#!/usr/bin/env node
const { appendFileSync, readFileSync } = require("node:fs");
const args = process.argv.slice(2);
const fixture = JSON.parse(readFileSync(process.env.CLAIM_FIXTURE, "utf8"));
appendFileSync(process.env.CLAIM_CALLS, JSON.stringify(args) + "\\n");
if (fixture.fail.some((prefix) => args.join(" ").startsWith(prefix))) process.exit(1);
if (args[0] === "pr" && args[1] === "list") process.stdout.write(JSON.stringify(fixture.open));
else if (args[0] === "api") {
const match = args[1]?.match(/pulls\\/(\\d+)\\/files\\?per_page=100&page=(\\d+)/);
if (!match) process.exit(1);
const response = fixture.pages[match[1] + ":" + match[2]];
if (response === undefined) process.stdout.write("[]");
else process.stdout.write(typeof response === "string" ? response : JSON.stringify(response));
} else process.exit(1);
`);
chmodSync(ghPath, 0o755);
try {
const result = spawnSync(process.execPath, [script, ...targets], {
cwd: repoRoot,
encoding: "utf8",
env: {
...process.env,
PATH: `${dir}:${process.env.PATH}`,
CLAIM_FIXTURE: fixturePath,
CLAIM_CALLS: callsPath,
},
});
const calls = readFileSync(callsPath, "utf8").trim().split("\n").filter(Boolean).map(JSON.parse);
return { ...result, calls };
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
function apiPages(prNumber, count, targetIndex) {
const result = {};
for (let page = 1; page <= Math.ceil(count / 100); page += 1) {
const start = (page - 1) * 100;
const entries = files(Math.min(100, count - start), `pr-${prNumber}`);
if (targetIndex !== undefined && targetIndex >= start && targetIndex < start + entries.length) {
entries[targetIndex - start].filename = "src/after-300/needle.ts";
}
result[`${prNumber}:${page}`] = entries;
}
return result;
}
test("finds a target after file 300 through every expected API page", () => {
const result = runClaimCheck({
open: [{ number: 17, title: "large claim", changedFiles: 350 }],
pages: apiPages(17, 350, 325),
});
assert.equal(result.status, 1);
assert.match(result.stdout, /CLAIMED {4}needle/);
assert.deepEqual(result.calls.filter((args) => args[0] === "api").map((args) => args[1]), [
"repos/{owner}/{repo}/pulls/17/files?per_page=100&page=1",
"repos/{owner}/{repo}/pulls/17/files?per_page=100&page=2",
"repos/{owner}/{repo}/pulls/17/files?per_page=100&page=3",
"repos/{owner}/{repo}/pulls/17/files?per_page=100&page=4",
]);
assert.equal(result.calls.some((args) => args[0] === "pr" && args[1] === "diff"), false);
});
test("accepts the exact 3000-file API ceiling and retains substring multi-target matches", () => {
const result = runClaimCheck({
open: [{ number: 18, title: "ceiling claim", changedFiles: 3000 }],
pages: apiPages(18, 3000, 2999),
targets: ["needle", "file-1"],
});
assert.equal(result.status, 1);
assert.match(result.stdout, /CLAIMED {4}needle/);
assert.match(result.stdout, /CLAIMED {4}file-1/);
assert.equal(result.calls.filter((args) => args[0] === "api").length, 30);
});
test("preserves complete zero-open, zero-file, one-page, multi-match, and no-match verdicts", () => {
const empty = runClaimCheck({ targets: ["none"] });
assert.equal(empty.status, 0);
assert.equal(empty.stdout, "UNCLAIMED none\n");
const complete = runClaimCheck({
open: [
{ number: 1, title: "zero", changedFiles: 0 },
{ number: 2, title: "one page", changedFiles: 1 },
{ number: 3, title: "first match", changedFiles: 101 },
{ number: 4, title: "second match", changedFiles: 1 },
],
pages: {
"2:1": [{ filename: "src/no-match.ts" }],
...apiPages(3, 101, 100),
"4:1": [{ filename: "src/another-needle.ts" }],
},
targets: ["needle", "absent"],
});
assert.equal(complete.status, 1);
assert.match(complete.stdout, /#3 {2}first match/);
assert.match(complete.stdout, /#4 {2}second match/);
assert.match(complete.stdout, /UNCLAIMED {2}absent/);
});
test("fails closed for counts above the API ceiling and count mismatches", () => {
const above = runClaimCheck({ open: [{ number: 4, title: "too big", changedFiles: 3001 }] });
assert.equal(above.status, 2);
assert.match(above.stdout, /UNKNOWN {4}needle/);
assert.match(above.stderr, /above the 3000-file API ceiling/);
const mismatch = runClaimCheck({
open: [{ number: 5, title: "short page", changedFiles: 101 }],
pages: { "5:1": files(100), "5:2": [] },
});
assert.equal(mismatch.status, 2);
assert.match(mismatch.stderr, /returned 100 files but reports 101/);
});
test("fails closed for malformed responses, API failures, and malformed counts", () => {
const malformed = runClaimCheck({
open: [{ number: 6, title: "bad json", changedFiles: 1 }],
pages: { "6:1": "not-json" },
});
assert.equal(malformed.status, 2);
assert.match(malformed.stderr, /malformed page 1/);
const empty = runClaimCheck({
open: [{ number: 7, title: "empty output", changedFiles: 1 }],
pages: { "7:1": "" },
});
assert.equal(empty.status, 2);
assert.match(empty.stderr, /malformed page 1/);
const failed = runClaimCheck({
open: [{ number: 8, title: "api error", changedFiles: 1 }],
pages: { "8:1": files(1) },
fail: ["api repos/{owner}/{repo}/pulls/8/files"],
});
assert.equal(failed.status, 2);
assert.match(failed.stderr, /files API failed/);
const missingCount = runClaimCheck({ open: [{ number: 9, title: "no count" }] });
assert.equal(missingCount.status, 2);
assert.match(missingCount.stderr, /no valid changed-file count/);
});
test("incomplete data outranks a known claim after every PR is evaluated", () => {
const result = runClaimCheck({
open: [
{ number: 9, title: "known claim", changedFiles: 1 },
{ number: 10, title: "unknown claim state", changedFiles: 1 },
],
pages: { "9:1": [{ filename: "src/needle.ts" }], "10:1": "{}" },
});
assert.equal(result.status, 2);
assert.equal(result.stdout, "UNKNOWN needle\n");
assert.match(result.stderr, /PR #10 files API returned malformed page 1/);
assert.equal(result.calls.filter((args) => args[0] === "api").length, 2);
});

View File

@@ -1,146 +0,0 @@
#!/usr/bin/env node
/*
FNXC:FleetCoordination 2026-07-31-06:10 (fleet):
WHY THIS EXISTS. Every fleet worker pushes as the SAME GitHub account, so `gh pr list --author "@me"`
returns all 17 open PRs and no worker can tell their own from a teammate's. There is no way to ask "is
this file already being converted?" short of fetching every open PR's file list by hand — 25+ API calls
that a worker will not make before starting, and I did not make either.
MEASURED COST, not a hypothetical: four of my PRs were superseded by teammates landing the same work
first (#3096, #3116, #3140 shrank to tests; #3125 to nothing and was closed). In every case both
implementations were correct and independently reached the same design. The fleet is not making
mistakes — it is doing correct work twice, and finding coverage gaps only by accident when the rebases
collide.
WHAT THIS DOES. One command, one answer:
node scripts/check-file-claimed.mjs packages/engine/src/self-healing.ts
It prints the open PRs touching that path, so "claimed?" is answerable before the work starts rather
than at rebase time.
WHAT IT DOES NOT DO, deliberately. It cannot see work that is in progress and unpushed, so it narrows
the window rather than closing it. Closing it needs distinguishable authorship — a per-worker
`Co-Authored-By` or a title prefix — which is a coordination decision, not a script. This is the part
that can be fixed from inside the repo.
*/
import { execFileSync } from "node:child_process";
const targets = process.argv.slice(2).filter((a) => !a.startsWith("-"));
if (targets.length === 0) {
console.error("usage: node scripts/check-file-claimed.mjs <path> [<path>...]");
console.error(" paths are matched as substrings of each PR's changed-file list");
process.exit(2);
}
const PR_LIMIT = 300;
const PR_FILE_PAGE_SIZE = 100;
const PR_FILE_API_CEILING = 3000;
function gh(args) {
return execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
}
function unknown(message) {
console.error(`claim-check: ${message}`);
console.error("claim-check: cannot prove a file is unclaimed from incomplete data. Treat as UNKNOWN, not free.");
for (const target of targets) console.log(`UNKNOWN ${target}`);
process.exit(2);
}
function readOpenPullRequests() {
let output;
try {
output = gh(["pr", "list", "--state", "open", "--limit", String(PR_LIMIT), "--json", "number,title,changedFiles"]);
} catch (error) {
unknown(`gh failed while listing open PRs — ${error?.message ?? error}`);
}
try {
const open = JSON.parse(output);
if (!Array.isArray(open)) throw new TypeError("expected an array");
if (open.length >= PR_LIMIT) unknown(`${open.length} open PRs hit the --limit ${PR_LIMIT} cap, so the list may be truncated.`);
return open;
} catch (error) {
unknown(`open PR list was malformed — ${error?.message ?? error}`);
}
}
/*
FNXC:FleetClaims 2026-08-01-16:09:
GitHub refuses PR diffs over 300 files, but its pull-request-files API exposes a paginated list up to
its documented 3,000-file ceiling. Reconcile every validated filename record with the authoritative
`changedFiles` count before using a PR as claim evidence; missing, malformed, failed, mismatched, or
above-ceiling data stays UNKNOWN. Evaluate every PR before deciding so incomplete data always outranks
a known claim and workers never treat a partial scan as permission to overlap.
*/
function filesForPullRequest(pr) {
if (!Number.isInteger(pr?.number) || !Number.isInteger(pr?.changedFiles) || pr.changedFiles < 0) {
return { complete: false, reason: `PR #${pr?.number ?? "unknown"} has no valid changed-file count` };
}
if (pr.changedFiles > PR_FILE_API_CEILING) {
return { complete: false, reason: `PR #${pr.number} changes ${pr.changedFiles} files, above the ${PR_FILE_API_CEILING}-file API ceiling` };
}
const files = [];
const pageCount = Math.ceil(pr.changedFiles / PR_FILE_PAGE_SIZE);
for (let page = 1; page <= pageCount; page += 1) {
let output;
try {
output = gh([
"api",
`repos/{owner}/{repo}/pulls/${pr.number}/files?per_page=${PR_FILE_PAGE_SIZE}&page=${page}`,
]);
} catch (error) {
return { complete: false, reason: `PR #${pr.number} files API failed on page ${page} — ${error?.message ?? error}` };
}
try {
const records = JSON.parse(output);
if (!Array.isArray(records) || records.some((record) => typeof record?.filename !== "string" || record.filename.length === 0)) {
throw new TypeError("expected an array of filename records");
}
files.push(...records.map((record) => record.filename));
} catch (error) {
return { complete: false, reason: `PR #${pr.number} files API returned malformed page ${page} — ${error?.message ?? error}` };
}
}
if (files.length !== pr.changedFiles) {
return { complete: false, reason: `PR #${pr.number} returned ${files.length} files but reports ${pr.changedFiles}` };
}
return { complete: true, files };
}
const open = readOpenPullRequests();
const hits = new Map(targets.map((target) => [target, []]));
const incomplete = [];
for (const pr of open) {
const result = filesForPullRequest(pr);
if (!result.complete) {
incomplete.push(result.reason);
continue;
}
for (const target of targets) {
if (result.files.some((file) => file.includes(target))) hits.get(target).push(pr);
}
}
if (incomplete.length > 0) unknown(incomplete.join("; "));
let claimed = false;
for (const target of targets) {
const prs = hits.get(target);
if (prs.length === 0) {
console.log(`UNCLAIMED ${target}`);
continue;
}
claimed = true;
console.log(`CLAIMED ${target}`);
for (const pr of prs) console.log(` #${pr.number} ${pr.title}`);
}
/* Exit 1 when anything is claimed, so a worker can gate on it: `... && start-work`. */
process.exit(claimed ? 1 : 0);