diff --git a/.changeset/remove-pr-claim-blocking.md b/.changeset/remove-pr-claim-blocking.md new file mode 100644 index 0000000000..0d664b37dc --- /dev/null +++ b/.changeset/remove-pr-claim-blocking.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index e4d0b6472f..7d7153b084 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. -### 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. - ### Standing Rule: Flaky Tests Are Quarantined on Sight (Deletion Ratchet) diff --git a/packages/engine/src/__tests__/execution-block-classifier.test.ts b/packages/engine/src/__tests__/execution-block-classifier.test.ts index 7d015e5af2..54e868d640 100644 --- a/packages/engine/src/__tests__/execution-block-classifier.test.ts +++ b/packages/engine/src/__tests__/execution-block-classifier.test.ts @@ -3,83 +3,96 @@ import { BLOCKED_THRASH_LIMIT, classifyBlockedExit, countBlockedThrashHits, - isDurableBlockedError, isDurableBlockedTask, - isFileClaimBlockedReason, partitionBlockedByRefs, } 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", () => { - it("splits task ids from pr refs", () => { - expect(partitionBlockedByRefs(["FN-8145", "pr:2398", "#2400", "PR-12", " "])).toEqual({ + it("keeps task ids and discards legacy pr refs and junk", () => { + expect(partitionBlockedByRefs(["FN-8145", "pr:2398", "#2400", "PR-12", " ", "fn-8145"])).toEqual({ taskIds: ["FN-8145"], - prNumbers: [2398, 2400, 12], }); }); }); 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", []); expect(c.allowAutoReplan).toBe(true); 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 = "Required SQL finding packages/core/src/task-store/reads.ts:619 is actively claimed by PR #2398. " + "check-file-claimed reports collision policy."; const c = classifyBlockedExit(reason, []); - expect(c.allowAutoReplan).toBe(false); - expect(c.class).toBe("file-claim"); - expect(c.prNumbers).toContain(2398); - expect(c.externalBlockers.some((b) => b.kind === "github-pr" && b.number === 2398)).toBe(true); + expect(c.allowAutoReplan).toBe(true); + expect(c.class).toBe("plan-defect"); }); it("rejects auto-replan when blockedBy carries task deps", () => { const c = classifyBlockedExit("waiting on upstream", ["FN-8145"]); expect(c.allowAutoReplan).toBe(false); 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"]); - expect(c.allowAutoReplan).toBe(false); - expect(c.prNumbers).toContain(2398); + expect(c.allowAutoReplan).toBe(true); + expect(c.class).toBe("plan-defect"); }); }); -describe("isFileClaimBlockedReason", () => { - it("matches claim policy language", () => { - expect(isFileClaimBlockedReason("actively claimed by PR #2398")).toBe(true); - expect(isFileClaimBlockedReason("check-file-claimed.mjs reports open PR")).toBe(true); - expect(isFileClaimBlockedReason("requirements contradict")).toBe(false); - }); -}); - -describe("isDurableBlockedError / task", () => { - it("treats claim BLOCKED errors as durable", () => { - expect(isDurableBlockedError("BLOCKED: path actively claimed by PR #1")).toBe(true); - expect(isDurableBlockedError("BLOCKED: requirements contradict each other")).toBe(false); +describe("isDurableBlockedTask", () => { + it("honors only metadata-classed external (task-dependency) parks", () => { + expect( + isDurableBlockedTask({ + status: "failed", + error: "BLOCKED: waiting on FN-8145", + sourceMetadata: { blockedClass: "external" }, + }), + ).toBe(true); + // Legacy FN-8700 file-claim parks are deliberately NOT durable anymore. expect( isDurableBlockedTask({ status: "failed", 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", () => { - 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 log = [ - { action: "BLOCKED: actively claimed by PR #2398", 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: actively claimed by PR #2398", timestamp: "2026-08-02T01:20:00.000Z" }, + { action: "BLOCKED: waiting on FN-8145", timestamp: "2026-08-02T01:00:00.000Z" }, + { action: "BLOCKED: still waiting on FN-8145", timestamp: "2026-08-02T01:10: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" }, ]; - 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); }); + + 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); + }); }); diff --git a/packages/engine/src/__tests__/executor-task-done-blocked.test.ts b/packages/engine/src/__tests__/executor-task-done-blocked.test.ts index 6e8340bba1..cbbd446b04 100644 --- a/packages/engine/src/__tests__/executor-task-done-blocked.test.ts +++ b/packages/engine/src/__tests__/executor-task-done-blocked.test.ts @@ -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: - Empty blockedBy + claim language must NOT auto-replan — that re-ran claimed paths forever. + FNXC:HonestBlockedExit 2026-08-02-23:59 (operator decision — FN-8728 vs PR #2398): + 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(); @@ -196,20 +198,16 @@ describe("FN-8141 fn_task_done honest blocked exit", () => { blockedBy: ["pr:2398"], }); - const patch = store.updateTask.mock.calls.find(([, p]: [string, Record]) => p?.status === "failed")?.[1] as Record; - expect(patch).toBeDefined(); - expect(String(patch.error)).toMatch(/^BLOCKED:/); - expect(String(patch.error)).toContain("2398"); - expect(patch.sourceMetadataPatch).toEqual( - expect.objectContaining({ - blockedClass: "file-claim", - externalBlockers: expect.arrayContaining([expect.objectContaining({ kind: "github-pr", number: 2398 })]), - }), - ); + const patch = store.updateTask.mock.calls.find(([, p]: [string, Record]) => "status" in p)?.[1] as Record; + expect(patch.status).toBe("needs-replan"); + expect(patch.error).toBeNull(); + // The discarded PR ref must not become a dependency edge or metadata blocker. + const depCall = store.updateTask.mock.calls.find(([, p]: [string, Record]) => Array.isArray(p?.dependencies)); + expect(depCall).toBeUndefined(); expect(store.recordRunAuditEvent).toHaveBeenCalledWith( expect.objectContaining({ mutationType: "task:execution-blocked-parked", - metadata: expect.objectContaining({ parkedAs: "failed", blockedClass: "file-claim" }), + metadata: expect.objectContaining({ parkedAs: "auto-replan", blockedBy: [], blockedClass: "plan-defect" }), }), ); }); diff --git a/packages/engine/src/execution-block-classifier.ts b/packages/engine/src/execution-block-classifier.ts index db006b6c73..a87e8b9967 100644 --- a/packages/engine/src/execution-block-classifier.ts +++ b/packages/engine/src/execution-block-classifier.ts @@ -1,166 +1,81 @@ /** * 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: - * FN-8700 looped forever: agent correctly parked on `check-file-claimed` / PR #2398 - * with empty blockedBy (no FN-#### dependency). Empty blockedBy was treated as a plan - * defect → needs-replan → re-execute → same claim → BLOCKED. File-claim and open-PR - * blocks are durable external waits, not plan defects. Classify them so: - * (A) empty blockedBy does NOT auto-replan for claim/PR classes - * (B) PR numbers from reason/blockedBy refs are stored as externalBlockers metadata - * (C) graph-resume and thrash detectors can leave durable parks alone + * FNXC:HonestBlockedExit 2026-08-02-23:59 (operator decision — FN-8728 vs PR #2398): + * FN-8700 previously treated "file claim / open PR" language as a durable external + * block: agents were instructed to check open GitHub PRs for files they were about to + * touch and park blocked on collisions, and self-healing cleared the park when the PR + * merged/closed. That made board tasks wait on unrelated PRs. File-scope conflicts are + * arbitrated ONLY by Fusion's own board (file-scope leases, task dependencies) — an + * open PR is never a claim on a task's file scope. All PR/file-claim classification, + * 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 = - | { kind: "github-pr"; number: number } - | { kind: "file-claim"; prNumber?: number }; - -export type BlockedExitClass = - | "plan-defect" - | "file-claim" - | "external" - | "unknown-external"; +export type BlockedExitClass = "plan-defect" | "external"; export type BlockedExitClassification = { /** Only plan defects may use the empty-blockedBy → needs-replan path. */ allowAutoReplan: boolean; class: BlockedExitClass; - externalBlockers: ExternalBlocker[]; /** Compact signature for thrash detection (ids/outcomes only — no free prose). */ thrashSignature: string; - /** PR numbers extracted from reason and blockedBy refs. */ - prNumbers: number[]; }; const TASK_ID_RE = /^[A-Z][A-Z0-9]*-\d+$/i; /** - * FNXC:ExecutionBlockClassification 2026-08-01-18:45: - * 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. + * FNXC:HonestBlockedExit 2026-08-02-23:59: + * 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 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; +const LEGACY_PR_REF_RE = /^(?:pr[:#-]|#|PR-)(\d+)$/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[]): { taskIds: string[]; - prNumbers: number[]; } { const taskIds: string[] = []; - const prNumbers: number[] = []; for (const raw of blockedBy) { const id = raw.trim(); if (!id) continue; - const prMatch = id.match(PR_REF_RE); - // Prefer PR refs before task-id matching (PR-12 would otherwise match TASK_ID_RE). - if (prMatch) { - prNumbers.push(Number(prMatch[1])); - continue; - } + if (LEGACY_PR_REF_RE.test(id)) continue; if (TASK_ID_RE.test(id)) { const m = id.toUpperCase().match(/^([A-Z][A-Z0-9]*)-(\d+)$/); taskIds.push(m ? `${m[1]}-${m[2]}` : id.toUpperCase()); - continue; } - // Ignore unknown tokens for dependency edges + // Ignore other unknown tokens for dependency edges } - return { - 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); + return { taskIds: [...new Set(taskIds)] }; } /** - * 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( - reason: string, + _reason: string, blockedBy: readonly string[] = [], ): BlockedExitClassification { - const trimmed = reason.trim(); - const partitioned = partitionBlockedByRefs(blockedBy); - 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) { + const { taskIds } = partitionBlockedByRefs(blockedBy); + if (taskIds.length > 0) { return { allowAutoReplan: false, class: "external", - externalBlockers: [], - thrashSignature: `tasks:${partitioned.taskIds.slice().sort().join(",")}`, - prNumbers, + thrashSignature: `tasks:${taskIds.slice().sort().join(",")}`, }; } - if (prNumbers.length > 0) { - 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 + // Empty blockedBy → plan defect, auto-replan is OK return { allowAutoReplan: true, class: "plan-defect", - externalBlockers: [], thrashSignature: "plan-defect", - prNumbers: [], }; } @@ -173,7 +88,7 @@ export const BLOCKED_THRASH_WINDOW_MS = 60 * 60 * 1000; 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( log: readonly TaskLogLike[] | undefined, @@ -182,6 +97,7 @@ export function countBlockedThrashHits( windowMs: number = BLOCKED_THRASH_WINDOW_MS, ): number { if (!log?.length) return 0; + if (signature === "plan-defect") return 0; const cutoff = nowMs - windowMs; let count = 0; for (const entry of log) { @@ -194,19 +110,7 @@ export function countBlockedThrashHits( 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("|")) { - 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))) { count += 1; break; @@ -216,30 +120,27 @@ export function countBlockedThrashHits( 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: { status?: string | null; error?: string | null; sourceMetadata?: Record | null; }): 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; if (!meta || typeof meta !== "object") return false; - if (meta.blockedClass === "file-claim" || meta.blockedClass === "external") { - if (task.status === "failed" || task.status === "needs-replan") return true; - } - const blockers = meta.externalBlockers; - return Array.isArray(blockers) && blockers.length > 0 && task.status === "failed"; + if (meta.blockedClass !== "external") return false; + return task.status === "failed" || task.status === "needs-replan"; } /** * Build sourceMetadata patch for a durable external block park. + * `externalBlockers` is always cleared — the PR-claim blocker list is removed. */ export function buildExternalBlockMetadataPatch( classification: BlockedExitClassification, @@ -249,6 +150,6 @@ export function buildExternalBlockMetadataPatch( blockedClass: classification.class, blockedThrashSignature: classification.thrashSignature, blockedThrashCount: thrashCount, - externalBlockers: classification.externalBlockers, + externalBlockers: [], }; } diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index f59adcb8e1..4f31ee972a 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -22,7 +22,6 @@ import { buildExternalBlockMetadataPatch, classifyBlockedExit, countBlockedThrashHits, - isDurableBlockedError, isDurableBlockedTask, partitionBlockedByRefs, } 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. -**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="", 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="", 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. ## 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 { - 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( live: TaskDetail, failedNode: string, @@ -12373,20 +12308,18 @@ export class TaskExecutor { if (live.paused || live.userPaused === true) return false; if ((await resolveTerminalColumnsFor(this.store, live.id)).includes(live.column)) return false; /* - FNXC:HonestBlockedExit 2026-08-02-01:30: - Durable file-claim / external BLOCKED parks must NOT bounce to todo for execution resume - (FN-8700). Incomplete steps after a claim block are expected — re-running re-hits the claim. + FNXC:HonestBlockedExit 2026-08-02-23:59: + Durable external (task-dependency) BLOCKED parks must NOT bounce to todo for execution + 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( - `${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; } - // 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: * 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." }, )), 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({ - 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 }) => { @@ -17441,14 +17374,14 @@ export class TaskExecutor { new Set((params.blockedBy ?? []).map((id) => id.trim()).filter((id) => id.length > 0)), ); /* - FNXC:HonestBlockedExit 2026-08-02-01:30: - FN-8700: empty blockedBy + file-claim/PR reason is NOT a plan defect. Auto-replan re-ran the - same claimed paths forever. Classify the reason (and pr:N refs) so claim/external blocks - park durable failed; only pure plan defects keep the needs-replan path (FN-8634). + FNXC:HonestBlockedExit 2026-08-02-23:59 (operator decision — FN-8728 vs PR #2398): + Blocked exits classify on Fusion task dependencies ONLY. The FN-8700 file-claim/open-PR + classification is removed: open PRs are never blockers, legacy pr:N refs are discarded, + 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 { taskIds: blockedByIds } = partitionBlockedByRefs(rawBlockedBy); - // Prefer task IDs from blockedBy; classification may only carry PRs from reason text. const thrashCount = countBlockedThrashHits( blockedTask.log, classification.thrashSignature, @@ -17458,16 +17391,14 @@ export class TaskExecutor { const parkError = thrashExhausted ? `BLOCKED: ${reason} [thrash-exhausted after ${thrashCount} identical durable blocks]` : `BLOCKED: ${reason}`; - // Record blockedBy TASK ids as real dependency edges (union with existing). PR refs stay in - // sourceMetadata.externalBlockers — they are not task rows and cannot go through assertTaskExists. + // Record blockedBy TASK ids as real dependency edges (union with existing). const mergedDependencies = blockedByIds.length > 0 ? Array.from(new Set([...(blockedTask.dependencies ?? []), ...blockedByIds])) : undefined; /* 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). - Durable external/file-claim blocks always park failed — even with empty task deps — so the - scheduler and graph-resume paths leave the card alone until an operator or PR-clear sweep acts. + When `blockedBy` is EMPTY, park needs-replan (auto-replan) — nothing external to wait for. + Task-dependency blocks park failed so the scheduler leaves the card alone until deps complete. */ const autoReplanPark = classification.allowAutoReplan && blockedByIds.length === 0 && !thrashExhausted; const metaPatch = !autoReplanPark @@ -17507,9 +17438,7 @@ export class TaskExecutor { taskId, thrashExhausted ? `${parkError} — durable external block thrash-exhausted (signature=${classification.thrashSignature}); parked failed, no auto-requeue` - : classification.externalBlockers.length > 0 - ? `${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)`, + : `${parkError} — recorded dependencies: ${blockedByIds.join(", ")} — parked failed (honest blocked exit; steps preserved)`, undefined, this.getRunContextFor(taskId), ); @@ -17529,7 +17458,6 @@ export class TaskExecutor { blockedClass: classification.class, thrashCount, thrashExhausted, - prNumbers: classification.prNumbers, }, }); await this.persistTokenUsage(taskId); @@ -17539,7 +17467,7 @@ export class TaskExecutor { ? "parked for automatic replan via blocked exit (plan defect, no dependencies)" : thrashExhausted ? `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: [{ type: "text" as const, 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 - ? "Task parked as blocked (failed) after repeated identical durable blocks — no further automatic retries. Resolve the external claim/PR 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.` - : 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.", + ? "Task parked as blocked (failed) after repeated identical durable blocks — no further automatic retries. Resolve the blocking tasks or replan manually." + : `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.`, }], details: {}, }; diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 8f59ac6098..43a60dacd4 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -36,8 +36,6 @@ import { type TaskMoveLanes, resolveColumnFlags, IN_REVIEW_STALL_DEADLOCK_LOG_PR resolveProjectColumnsForRoles, REVIEW_ROLES, pruneTaskLifecycleEvents, - isGhAvailable, - runGhJsonAsync, } from "@fusion/core"; import { finalizePlanningSegment } from "@fusion/core"; 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-stale-merger-status", fn: () => this.reconcileStaleMergerStatus() }, { 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 // 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. @@ -14401,77 +14398,12 @@ const movedTask = await this.store.moveTask(task.id, completeLane); } /* - FNXC:HonestBlockedExit 2026-08-02-01:30: - Durable parks store github-pr externalBlockers (FN-8700 file-claim). When every blocking - PR is MERGED or CLOSED, clear the failed park so the scheduler can re-dispatch. Fail-soft - when gh is unavailable — leave the park for the operator. + FNXC:HonestBlockedExit 2026-08-02-23:59 (operator decision — FN-8728 vs PR #2398): + The FN-8700 `reconcile-external-pr-blockers` sweep (gh-backed clearing of PR-claim parks) + is REMOVED with the whole PR/file-claim blocking mechanism. Open PRs are never blockers; + 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 { - 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 { try { diff --git a/scripts/__tests__/check-file-claimed.test.mjs b/scripts/__tests__/check-file-claimed.test.mjs deleted file mode 100644 index ea888444a2..0000000000 --- a/scripts/__tests__/check-file-claimed.test.mjs +++ /dev/null @@ -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); -}); diff --git a/scripts/check-file-claimed.mjs b/scripts/check-file-claimed.mjs deleted file mode 100644 index aacc80ac8a..0000000000 --- a/scripts/check-file-claimed.mjs +++ /dev/null @@ -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 [...]"); - 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);