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:
5
.changeset/FN-3899-stable-blocked-by.md
Normal file
5
.changeset/FN-3899-stable-blocked-by.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix scheduler overwriting `blockedBy` on queued todo tasks every tick, which caused unrelated work to converge on a single broad-scope in-progress task. Stamping is now sticky-when-still-valid with deterministic tiebreak.
|
||||||
@@ -552,6 +552,13 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan.
|
|||||||
### Scheduling and execution
|
### Scheduling and execution
|
||||||
- `Scheduler` (`scheduler.ts`) — dependency-aware task scheduling that dispatches eligible todo tasks by priority first, then FIFO (`createdAt` ascending) within each priority tier.
|
- `Scheduler` (`scheduler.ts`) — dependency-aware task scheduling that dispatches eligible todo tasks by priority first, then FIFO (`createdAt` ascending) within each priority tier.
|
||||||
- `blockedBy` invariant (FN-3924): the field is only durable when it references a current unresolved explicit dependency (or, for dependency-free tasks, an active overlap blocker). If no current blocker remains, scheduler/event reconciliation clears `blockedBy` to `null` and re-evaluates from live task state.
|
- `blockedBy` invariant (FN-3924): the field is only durable when it references a current unresolved explicit dependency (or, for dependency-free tasks, an active overlap blocker). If no current blocker remains, scheduler/event reconciliation clears `blockedBy` to `null` and re-evaluates from live task state.
|
||||||
|
|
||||||
|
#### BlockedBy stamping invariants
|
||||||
|
- Scheduler writes overlap-based `blockedBy` only when overlap gating is active and there is a live overlapping active scope; otherwise overlap logic does not stamp blockers.
|
||||||
|
- Stamping is sticky when valid (FN-3899): if a todo task is already `queued` behind a blocker that is still active and still overlaps, the scheduler preserves that blocker and skips rewrites.
|
||||||
|
- When the blocker must change, selection is deterministic: active overlap candidates are ordered by task ID and the first overlapping task is chosen, removing tick-order churn.
|
||||||
|
- Writes are idempotent: scheduler updates `status/blockedBy` only when values change, reducing per-tick churn and audit noise.
|
||||||
|
- Self-healing remains responsible for terminal/missing blocker cleanup (`clearStaleBlockedBy()`), while scheduler overlap stamping now focuses on stable active-overlap attribution.
|
||||||
- `StepSessionExecutor` (`step-session-executor.ts`) — per-step sessions + parallel wave execution
|
- `StepSessionExecutor` (`step-session-executor.ts`) — per-step sessions + parallel wave execution
|
||||||
- `TaskCompletion` (`task-completion.ts`) — completion gate helpers
|
- `TaskCompletion` (`task-completion.ts`) — completion gate helpers
|
||||||
- `SpecStaleness` (`spec-staleness.ts`) — stale spec detection utilities
|
- `SpecStaleness` (`spec-staleness.ts`) — stale spec detection utilities
|
||||||
|
|||||||
@@ -1052,6 +1052,125 @@ describe("Scheduler", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("blockedBy stability — FN-3899", () => {
|
||||||
|
it("preserves a still-valid queued blocker instead of repointing to another active task", async () => {
|
||||||
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
|
||||||
|
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "FN-A", column: "in-progress" }),
|
||||||
|
createMockTask({ id: "FN-B", column: "in-progress" }),
|
||||||
|
createMockTask({ id: "FN-T", column: "todo", status: "queued", blockedBy: "FN-B" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const parseScopeMock = vi.fn(async (taskId: string): Promise<string[]> => {
|
||||||
|
if (taskId === "FN-A") return ["packages/engine/src/merger.ts", "packages/dashboard/app/components/Header.tsx"];
|
||||||
|
if (taskId === "FN-B") return ["packages/dashboard/app/App.tsx"];
|
||||||
|
if (taskId === "FN-T") return ["packages/dashboard/app/App.tsx"];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTask = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const moveTask = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const store = createMockStore({
|
||||||
|
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||||
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
|
maxConcurrent: 10,
|
||||||
|
maxWorktrees: 10,
|
||||||
|
groupOverlappingFiles: true,
|
||||||
|
}),
|
||||||
|
parseFileScopeFromPrompt: parseScopeMock,
|
||||||
|
updateTask,
|
||||||
|
moveTask,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scheduler = new Scheduler(store);
|
||||||
|
(scheduler as any).running = true;
|
||||||
|
await scheduler.schedule();
|
||||||
|
|
||||||
|
expect(updateTask).not.toHaveBeenCalledWith("FN-T", { status: "queued", blockedBy: "FN-A" });
|
||||||
|
expect(moveTask).not.toHaveBeenCalledWith("FN-T", "in-progress", expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recomputes stale queued blockers when the recorded blocker is no longer overlapping", async () => {
|
||||||
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
|
||||||
|
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "FN-A", column: "in-progress" }),
|
||||||
|
createMockTask({ id: "FN-B", column: "in-progress" }),
|
||||||
|
createMockTask({ id: "FN-T", column: "todo", status: "queued", blockedBy: "FN-A" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const parseScopeMock = vi.fn(async (taskId: string): Promise<string[]> => {
|
||||||
|
if (taskId === "FN-A") return ["packages/engine/src/merger.ts"];
|
||||||
|
if (taskId === "FN-B") return ["packages/dashboard/app/App.tsx"];
|
||||||
|
if (taskId === "FN-T") return ["packages/dashboard/app/App.tsx"];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTask = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const store = createMockStore({
|
||||||
|
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||||
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
|
maxConcurrent: 10,
|
||||||
|
maxWorktrees: 10,
|
||||||
|
groupOverlappingFiles: true,
|
||||||
|
}),
|
||||||
|
parseFileScopeFromPrompt: parseScopeMock,
|
||||||
|
updateTask,
|
||||||
|
moveTask: vi.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const scheduler = new Scheduler(store);
|
||||||
|
(scheduler as any).running = true;
|
||||||
|
await scheduler.schedule();
|
||||||
|
|
||||||
|
expect(updateTask).toHaveBeenCalledWith("FN-T", { status: "queued", blockedBy: "FN-B" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not stamp blockedBy for todos without overlap, including empty scopes", async () => {
|
||||||
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
|
||||||
|
|
||||||
|
const tasks = [
|
||||||
|
createMockTask({ id: "FN-A", column: "in-progress" }),
|
||||||
|
createMockTask({ id: "FN-T1", column: "todo" }),
|
||||||
|
createMockTask({ id: "FN-T2", column: "todo" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const parseScopeMock = vi.fn(async (taskId: string): Promise<string[]> => {
|
||||||
|
if (taskId === "FN-A") return ["packages/engine/src/merger.ts"];
|
||||||
|
if (taskId === "FN-T1") return ["packages/dashboard/app/App.tsx"];
|
||||||
|
if (taskId === "FN-T2") return [];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTask = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const moveTask = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const store = createMockStore({
|
||||||
|
listTasks: vi.fn().mockResolvedValue(tasks),
|
||||||
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
|
maxConcurrent: 10,
|
||||||
|
maxWorktrees: 10,
|
||||||
|
groupOverlappingFiles: true,
|
||||||
|
}),
|
||||||
|
parseFileScopeFromPrompt: parseScopeMock,
|
||||||
|
updateTask,
|
||||||
|
moveTask,
|
||||||
|
});
|
||||||
|
|
||||||
|
const scheduler = new Scheduler(store);
|
||||||
|
(scheduler as any).running = true;
|
||||||
|
await scheduler.schedule();
|
||||||
|
|
||||||
|
expect(updateTask).not.toHaveBeenCalledWith("FN-T1", { status: "queued", blockedBy: "FN-A" });
|
||||||
|
expect(updateTask).not.toHaveBeenCalledWith("FN-T2", { status: "queued", blockedBy: "FN-A" });
|
||||||
|
expect(moveTask).toHaveBeenCalledWith("FN-T1", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
|
||||||
|
expect(moveTask).toHaveBeenCalledWith("FN-T2", "in-progress", expect.objectContaining({ allocateWorktree: expect.any(Function) }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("worktree reservation", () => {
|
describe("worktree reservation", () => {
|
||||||
it("assigns a planned worktree path before moving a task to in-progress", async () => {
|
it("assigns a planned worktree path before moving a task to in-progress", async () => {
|
||||||
vi.mocked(existsSync).mockReturnValue(true);
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
|
|||||||
@@ -792,24 +792,32 @@ export class Scheduler {
|
|||||||
overlapIgnorePaths,
|
overlapIgnorePaths,
|
||||||
);
|
);
|
||||||
if (taskScope.length > 0) {
|
if (taskScope.length > 0) {
|
||||||
let overlappingTaskId: string | null = null;
|
const activeScopeEntries = Array.from(activeScopes.entries()).sort(([aId], [bId]) => aId.localeCompare(bId));
|
||||||
for (const [ipId, ipScope] of activeScopes) {
|
const currentBlockerScope = task.blockedBy ? activeScopes.get(task.blockedBy) : undefined;
|
||||||
if (this.pathsOverlap(taskScope, ipScope)) {
|
const hasValidCurrentBlocker =
|
||||||
overlappingTaskId = ipId;
|
Boolean(task.blockedBy)
|
||||||
break;
|
&& Boolean(currentBlockerScope)
|
||||||
}
|
&& this.pathsOverlap(taskScope, currentBlockerScope!);
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* blockedBy stamping invariants:
|
||||||
|
* - sticky when still valid: preserve an existing active overlapping blocker
|
||||||
|
* - deterministic when changing: pick the first overlapping active task by sorted taskId
|
||||||
|
* - idempotent writes only: update DB only when blockedBy/status must change
|
||||||
|
*/
|
||||||
|
const overlappingTaskId = hasValidCurrentBlocker
|
||||||
|
? task.blockedBy
|
||||||
|
: activeScopeEntries.find(([, ipScope]) => this.pathsOverlap(taskScope, ipScope))?.[0] ?? null;
|
||||||
|
|
||||||
if (overlappingTaskId) {
|
if (overlappingTaskId) {
|
||||||
// Keep blockedBy tied to explicit unresolved dependencies when a task has
|
// Keep blockedBy tied to explicit unresolved dependencies when a task has
|
||||||
// dependency edges; avoid repointing dependency-unblocked tasks to unrelated
|
// dependency edges; avoid repointing dependency-unblocked tasks to unrelated
|
||||||
// overlap ids (FN-3924). For dependency-free tasks, blockedBy may reference
|
// overlap ids (FN-3924). For dependency-free tasks, blockedBy may reference
|
||||||
// the active overlap blocker.
|
// the active overlap blocker.
|
||||||
await this.store.updateTask(
|
const targetBlockedBy = task.dependencies.length > 0 ? null : overlappingTaskId;
|
||||||
task.id,
|
if (task.status !== "queued" || task.blockedBy !== targetBlockedBy) {
|
||||||
task.dependencies.length > 0
|
await this.store.updateTask(task.id, { status: "queued", blockedBy: targetBlockedBy });
|
||||||
? { status: "queued", blockedBy: null }
|
}
|
||||||
: { status: "queued", blockedBy: overlappingTaskId },
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
scripts/__tests__/recover-stale-blocked-by.test.mjs
Normal file
102
scripts/__tests__/recover-stale-blocked-by.test.mjs
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
152
scripts/recover-stale-blocked-by.mjs
Normal file
152
scripts/recover-stale-blocked-by.mjs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user