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:
7
.changeset/remove-pr-claim-blocking.md
Normal file
7
.changeset/remove-pr-claim-blocking.md
Normal 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.
|
||||||
32
AGENTS.md
32
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`.
|
`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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" }),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
Reference in New Issue
Block a user