feat(FN-3899): stabilize blockedBy overlap stamping and add recovery utilit

Stabilizes blocked-by overlap stamping in the scheduler and adds a standalone `recover-stale-blocked-by` utility script to clear stale upstream blockers, with corresponding scheduler tests and architecture documentation.

Fusion-Task-Id: FN-3899
This commit is contained in:
Fusion
2026-05-11 10:30:24 -07:00
committed by gsxdsm
parent 7d2a98d2b7
commit 39a5668057
6 changed files with 406 additions and 13 deletions

View File

@@ -0,0 +1,102 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { recoverBlockedBy } from "../recover-stale-blocked-by.mjs";
function setupFixture() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fn-3899-"));
const tasksDir = path.join(dir, "tasks");
fs.mkdirSync(tasksDir, { recursive: true });
const dbPath = path.join(dir, "fusion.db");
const db = new DatabaseSync(dbPath);
db.exec(`
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
"column" TEXT,
blockedBy TEXT,
worktree TEXT,
paused INTEGER,
log TEXT,
updatedAt TEXT
);
`);
return { dir, tasksDir, db };
}
function writePrompt(tasksDir, taskId, scopeLines) {
const taskDir = path.join(tasksDir, taskId);
fs.mkdirSync(taskDir, { recursive: true });
const bullets = scopeLines.map((line) => `- \`${line}\``).join("\n");
fs.writeFileSync(path.join(taskDir, "PROMPT.md"), `# Task\n\n## File Scope\n${bullets}\n`);
}
function insertTask(db, row) {
db.prepare(`INSERT INTO tasks (id, "column", blockedBy, worktree, paused, log, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?)`)
.run(row.id, row.column, row.blockedBy ?? null, row.worktree ?? null, row.paused ?? 0, row.log ?? "[]", row.updatedAt ?? new Date().toISOString());
}
test("clears stale blocker when blocker is terminal", () => {
const { dir, tasksDir, db } = setupFixture();
try {
writePrompt(tasksDir, "FN-BLOCKED", ["packages/dashboard/app/App.tsx"]);
writePrompt(tasksDir, "FN-DONE", ["packages/dashboard/app/App.tsx"]);
insertTask(db, { id: "FN-DONE", column: "done" });
insertTask(db, { id: "FN-BLOCKED", column: "todo", blockedBy: "FN-DONE" });
const findings = recoverBlockedBy({ db, tasksDir, dryRun: false });
const blocked = db.prepare("SELECT blockedBy, log FROM tasks WHERE id = ?").get("FN-BLOCKED");
assert.equal(findings.find((f) => f.taskId === "FN-BLOCKED")?.reason, "blocker-terminal:done");
assert.equal(blocked.blockedBy, null);
assert.match(blocked.log, /FN-3899 recovery/);
} finally {
db.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("preserves valid blocker when overlap remains active", () => {
const { dir, tasksDir, db } = setupFixture();
try {
writePrompt(tasksDir, "FN-ACTIVE", ["packages/dashboard/app/App.tsx"]);
writePrompt(tasksDir, "FN-BLOCKED", ["packages/dashboard/app/App.tsx"]);
insertTask(db, { id: "FN-ACTIVE", column: "in-progress" });
insertTask(db, { id: "FN-BLOCKED", column: "todo", blockedBy: "FN-ACTIVE" });
recoverBlockedBy({ db, tasksDir, dryRun: false });
const blocked = db.prepare("SELECT blockedBy FROM tasks WHERE id = ?").get("FN-BLOCKED");
assert.equal(blocked.blockedBy, "FN-ACTIVE");
} finally {
db.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("dry-run reports repairs without writing", () => {
const { dir, tasksDir, db } = setupFixture();
try {
writePrompt(tasksDir, "FN-BLOCKED", ["packages/dashboard/app/App.tsx"]);
writePrompt(tasksDir, "FN-MISSING-SCOPE", ["packages/engine/src/scheduler.ts"]);
insertTask(db, { id: "FN-MISSING-SCOPE", column: "in-review", worktree: null });
insertTask(db, { id: "FN-BLOCKED", column: "todo", blockedBy: "FN-MISSING-SCOPE" });
const findings = recoverBlockedBy({ db, tasksDir, dryRun: true });
const blocked = db.prepare("SELECT blockedBy, log FROM tasks WHERE id = ?").get("FN-BLOCKED");
assert.equal(findings.find((f) => f.taskId === "FN-BLOCKED")?.reason, "blocker-in-review-without-worktree");
assert.equal(blocked.blockedBy, "FN-MISSING-SCOPE");
assert.equal(blocked.log, "[]");
} finally {
db.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,152 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { execSync } from "node:child_process";
import { DatabaseSync } from "node:sqlite";
function parseArgs(argv) {
const flags = new Set(argv.slice(2));
return {
dryRun: !flags.has("--apply"),
apply: flags.has("--apply"),
};
}
export function parseFileScopeFromPromptText(promptText) {
const headerMatch = promptText.match(/^##\s+File Scope\s*$/m);
if (!headerMatch || headerMatch.index === undefined) return [];
const start = headerMatch.index + headerMatch[0].length;
const rest = promptText.slice(start);
const nextHeader = rest.search(/^##\s+/m);
const section = nextHeader >= 0 ? rest.slice(0, nextHeader) : rest;
const paths = [];
const regex = /`([^`]+)`/g;
let match;
while ((match = regex.exec(section)) !== null) {
const value = match[1].trim();
if (value) paths.push(value);
}
return [...new Set(paths)];
}
export function pathsOverlap(a, b) {
for (const pa of a) {
const prefixA = pa.endsWith("/*") ? pa.slice(0, -1) : null;
for (const pb of b) {
const prefixB = pb.endsWith("/*") ? pb.slice(0, -1) : null;
const cleanA = prefixA ? pa.slice(0, -2) : pa;
const cleanB = prefixB ? pb.slice(0, -2) : pb;
if (cleanA === cleanB) return true;
if (prefixA && pb.startsWith(prefixA)) return true;
if (prefixB && pa.startsWith(prefixB)) return true;
if (prefixA && prefixB && (prefixA.startsWith(prefixB) || prefixB.startsWith(prefixA))) return true;
if (pa === pb) return true;
}
}
return false;
}
function loadScope(tasksDir, taskId) {
const promptPath = path.join(tasksDir, taskId, "PROMPT.md");
if (!fs.existsSync(promptPath)) return [];
return parseFileScopeFromPromptText(fs.readFileSync(promptPath, "utf8"));
}
function isTerminalColumn(column) {
return column === "done" || column === "archived";
}
export function recoverBlockedBy({ db, tasksDir, dryRun = true }) {
const rows = db.prepare("SELECT id, \"column\", blockedBy, worktree, paused, log FROM tasks").all();
const byId = new Map(rows.map((row) => [row.id, row]));
const activeScopes = new Map();
for (const row of rows) {
const isActive = row.column === "in-progress" || (row.column === "in-review" && row.worktree && !row.paused);
if (!isActive) continue;
const scope = loadScope(tasksDir, row.id);
if (scope.length > 0) activeScopes.set(row.id, scope);
}
const findings = [];
const now = new Date().toISOString();
for (const row of rows) {
if (row.column !== "todo" || !row.blockedBy) continue;
const blocker = byId.get(row.blockedBy);
const taskScope = loadScope(tasksDir, row.id);
let reason = null;
if (!blocker) {
reason = "blocker-missing";
} else if (isTerminalColumn(blocker.column)) {
reason = `blocker-terminal:${blocker.column}`;
} else if (blocker.column === "in-review" && !blocker.worktree) {
reason = "blocker-in-review-without-worktree";
} else {
const blockerScope = activeScopes.get(blocker.id) ?? [];
if (taskScope.length === 0 || blockerScope.length === 0 || !pathsOverlap(taskScope, blockerScope)) {
reason = "scope-no-overlap";
}
}
if (!reason) {
findings.push({ taskId: row.id, oldBlocker: row.blockedBy, newBlocker: row.blockedBy, reason: "unchanged" });
continue;
}
findings.push({ taskId: row.id, oldBlocker: row.blockedBy, newBlocker: null, reason });
if (!dryRun) {
let log = [];
try {
log = row.log ? JSON.parse(row.log) : [];
if (!Array.isArray(log)) log = [];
} catch {
log = [];
}
log.push({
at: now,
message: "Recovered: cleared stale blockedBy via FN-3899 recovery",
outcome: `Recovered: cleared stale blockedBy via FN-3899 recovery (reason: ${reason})`,
});
db.prepare("UPDATE tasks SET blockedBy = NULL, log = ?, updatedAt = ? WHERE id = ?").run(JSON.stringify(log), now, row.id);
}
}
return findings;
}
function resolveProjectRoot() {
const commonDir = execSync("git rev-parse --git-common-dir", { encoding: "utf8" }).trim();
return path.resolve(commonDir, "..");
}
function printFindings(findings, dryRun) {
const changed = findings.filter((row) => row.oldBlocker !== row.newBlocker);
console.log(dryRun ? "Mode: DRY RUN" : "Mode: APPLY");
console.log("taskId\toldBlocker\tnewBlocker\treason");
for (const row of findings) {
if (row.oldBlocker === row.newBlocker) continue;
console.log(`${row.taskId}\t${row.oldBlocker}\t${row.newBlocker ?? "NULL"}\t${row.reason}`);
}
console.log(`Repairs: ${changed.length}`);
}
if (import.meta.url === `file://${process.argv[1]}`) {
const { dryRun } = parseArgs(process.argv);
const projectRoot = resolveProjectRoot();
const dbPath = path.join(projectRoot, ".fusion", "fusion.db");
const tasksDir = path.join(projectRoot, ".fusion", "tasks");
const db = new DatabaseSync(dbPath);
try {
const findings = recoverBlockedBy({ db, tasksDir, dryRun });
printFindings(findings, dryRun);
} finally {
db.close();
}
}