feat(FN-4091): fix stale blocker in WorkflowResultsTab

The branch delivers a fix for stale blockers in the WorkflowResultsTab component, with the primary logic change in the component file and comprehensive test coverage added in the companion test suite.

Fusion-Task-Id: FN-4091
This commit is contained in:
Fusion
2026-05-12 17:20:54 -07:00
committed by gsxdsm
parent bddcc3a931
commit ab66e5b60a
12 changed files with 260 additions and 37 deletions

View File

@@ -1536,7 +1536,7 @@ describe("TaskExecutor fn_task_done blockers", () => {
}
return {
id: taskId,
column: "done",
column: taskId === "FN-DEP-1" ? "in-progress" : "done",
};
});

View File

@@ -2851,11 +2851,16 @@ describe("Scheduler", () => {
it("does not mark a feature done when the completed task is blocked", async () => {
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(createMockTask({
id: "FN-001",
blockedBy: "FN-000",
column: "done",
})),
getTask: vi.fn(async (taskId: string) => {
if (taskId === "FN-001") {
return createMockTask({
id: "FN-001",
blockedBy: "FN-000",
column: "done",
}) as TaskDetail;
}
return createMockTask({ id: taskId, column: "in-progress" }) as TaskDetail;
}) as TaskStore["getTask"],
});
const mockAutopilot = {
setScheduler: vi.fn(),

View File

@@ -4361,6 +4361,28 @@ describe("clearStaleBlockedBy", () => {
});
}
function mockSweepTasks(
store: ReturnType<typeof createRunningStore>,
{
todo = [],
inProgress = [],
inReview = [],
all = [...todo, ...inProgress, ...inReview],
}: {
todo?: Record<string, unknown>[];
inProgress?: Record<string, unknown>[];
inReview?: Record<string, unknown>[];
all?: Record<string, unknown>[];
},
) {
(store.listTasks as ReturnType<typeof vi.fn>).mockImplementation(async (options?: { column?: string }) => {
if (options?.column === "todo") return todo;
if (options?.column === "in-progress") return inProgress;
if (options?.column === "in-review") return inReview;
return all;
});
}
function createTask(id: string, overrides: Record<string, unknown> = {}) {
return {
id,
@@ -4376,7 +4398,7 @@ describe("clearStaleBlockedBy", () => {
it("clears stale blockedBy when blocker is missing", async () => {
const store = createRunningStore();
const taskA = createTask("A", { blockedBy: "FN-MISSING" });
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([taskA]);
mockSweepTasks(store, { todo: [taskA] });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const recovered = await manager.clearStaleBlockedBy();
@@ -4393,7 +4415,7 @@ describe("clearStaleBlockedBy", () => {
const blockerId = "FN-100";
const taskA = createTask("A", { blockedBy: blockerId });
const taskB = createTask(blockerId, { column });
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([taskA, taskB]);
mockSweepTasks(store, { todo: [taskA], all: [taskA, taskB] });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const recovered = await manager.clearStaleBlockedBy();
@@ -4410,7 +4432,7 @@ describe("clearStaleBlockedBy", () => {
const blockerId = "FN-200";
const taskA = createTask("A", { blockedBy: blockerId });
const taskB = createTask(blockerId, { column: "in-review", paused: true });
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([taskA, taskB]);
mockSweepTasks(store, { todo: [taskA], inReview: [taskB], all: [taskA, taskB] });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const recovered = await manager.clearStaleBlockedBy();
@@ -4427,7 +4449,7 @@ describe("clearStaleBlockedBy", () => {
const blockerId = "FN-300";
const taskA = createTask("A", { blockedBy: blockerId });
const taskB = createTask(blockerId, { column: "in-review", status: "failed", mergeRetries: 3 });
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([taskA, taskB]);
mockSweepTasks(store, { todo: [taskA], inReview: [taskB], all: [taskA, taskB] });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const recovered = await manager.clearStaleBlockedBy();
@@ -4443,7 +4465,7 @@ describe("clearStaleBlockedBy", () => {
const store = createRunningStore();
const taskA = createTask("A", { blockedBy: "FN-400" });
const taskB = createTask("FN-400", { column: "in-progress" });
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([taskA, taskB]);
mockSweepTasks(store, { todo: [taskA], inProgress: [taskB], all: [taskA, taskB] });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const recovered = await manager.clearStaleBlockedBy();
@@ -4458,7 +4480,7 @@ describe("clearStaleBlockedBy", () => {
const taskA = createTask("A", { blockedBy: "FN-400", dependencies: ["FN-DEP"] });
const overlapBlocker = createTask("FN-400", { column: "in-progress" });
const dependency = createTask("FN-DEP", { column: "done" });
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([taskA, overlapBlocker, dependency]);
mockSweepTasks(store, { todo: [taskA], inProgress: [overlapBlocker], all: [taskA, overlapBlocker, dependency] });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const recovered = await manager.clearStaleBlockedBy();
@@ -4473,7 +4495,7 @@ describe("clearStaleBlockedBy", () => {
const store = createRunningStore();
const taskA = createTask("A", { blockedBy: "FN-500" });
const taskB = createTask("FN-500", { column: "in-review", paused: false, mergeRetries: 0 });
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([taskA, taskB]);
mockSweepTasks(store, { todo: [taskA], inReview: [taskB], all: [taskA, taskB] });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const recovered = await manager.clearStaleBlockedBy();
@@ -4487,7 +4509,7 @@ describe("clearStaleBlockedBy", () => {
const store = createRunningStore();
const taskA = createTask("A", { blockedBy: "FN-600" });
const taskB = createTask("FN-600", { column: "in-review", status: "failed", mergeRetries: 1 });
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([taskA, taskB]);
mockSweepTasks(store, { todo: [taskA], inReview: [taskB], all: [taskA, taskB] });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const recovered = await manager.clearStaleBlockedBy();
@@ -4507,7 +4529,7 @@ describe("clearStaleBlockedBy", () => {
error: "Refusing to start coding agent in missing worktree: /Users/eclipxe/Projects/kb/.worktrees/bright-wren",
steps: [{ status: "done" }, { status: "pending" }] as any,
});
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([taskA, taskB]);
mockSweepTasks(store, { todo: [taskA], inReview: [taskB], all: [taskA, taskB] });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const recovered = await manager.clearStaleBlockedBy();
@@ -4531,7 +4553,7 @@ describe("clearStaleBlockedBy", () => {
...settings,
} as unknown as Settings);
const taskA = createTask("A", { blockedBy: "FN-700" });
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([taskA]);
mockSweepTasks(store, { todo: [taskA] });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const recovered = await manager.clearStaleBlockedBy();
@@ -4550,9 +4572,14 @@ describe("clearStaleBlockedBy", () => {
const recoveredState = createTask("A", { blockedBy: null });
(store.listTasks as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce([blocked, blocker])
.mockResolvedValueOnce([blocked, blocker])
.mockResolvedValueOnce([recoveredState, blocker]);
.mockImplementationOnce(async (options?: { column?: string }) => options?.column === "todo" ? [blocked] : options?.column === "in-progress" ? [] : options?.column === "in-review" ? [] : [blocked, blocker])
.mockImplementationOnce(async (options?: { column?: string }) => options?.column === "todo" ? [blocked] : options?.column === "in-progress" ? [] : options?.column === "in-review" ? [] : [blocked, blocker])
.mockImplementationOnce(async (options?: { column?: string }) => options?.column === "todo" ? [recoveredState] : options?.column === "in-progress" ? [] : options?.column === "in-review" ? [] : [recoveredState, blocker])
.mockImplementationOnce(async (options?: { column?: string }) => options?.column === "todo" ? [recoveredState] : options?.column === "in-progress" ? [] : options?.column === "in-review" ? [] : [recoveredState, blocker])
.mockImplementationOnce(async (options?: { column?: string }) => options?.column === "todo" ? [recoveredState] : options?.column === "in-progress" ? [] : options?.column === "in-review" ? [] : [recoveredState, blocker])
.mockImplementationOnce(async (options?: { column?: string }) => options?.column === "todo" ? [recoveredState] : options?.column === "in-progress" ? [] : options?.column === "in-review" ? [] : [recoveredState, blocker])
.mockImplementationOnce(async () => [recoveredState, blocker])
.mockImplementationOnce(async () => [recoveredState, blocker]);
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const first = await manager.clearStaleBlockedBy();
@@ -4565,6 +4592,66 @@ describe("clearStaleBlockedBy", () => {
manager.stop();
});
it("clears stale blockedBy on an in-progress task when blocker is missing", async () => {
const store = createRunningStore();
const taskA = createTask("FN-4076", { column: "in-progress", blockedBy: "FN-MISSING" });
mockSweepTasks(store, { inProgress: [taskA], all: [taskA] });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const recovered = await manager.clearStaleBlockedBy();
expect(recovered).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("FN-4076", { blockedBy: null });
expect(store.logEntry).toHaveBeenCalledWith("FN-4076", expect.stringContaining("FN-4091"));
expect(store.logEntry).toHaveBeenCalledWith("FN-4076", expect.stringContaining("FN-MISSING"));
manager.stop();
});
it("clears stale blockedBy on an unpaused in-review task when blocker is done", async () => {
const store = createRunningStore();
const blockerId = "FN-4100";
const taskA = createTask("FN-4076", { column: "in-review", blockedBy: blockerId, paused: false });
const blocker = createTask(blockerId, { column: "done" });
mockSweepTasks(store, { inReview: [taskA], all: [taskA, blocker] });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const recovered = await manager.clearStaleBlockedBy();
expect(recovered).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("FN-4076", { blockedBy: null });
expect(store.logEntry).toHaveBeenCalledWith("FN-4076", expect.stringContaining("FN-4091"));
expect(store.logEntry).toHaveBeenCalledWith("FN-4076", expect.stringContaining("done"));
manager.stop();
});
it("does not clear blockedBy on an in-progress task when blocker is still active", async () => {
const store = createRunningStore();
const blockerId = "FN-4101";
const taskA = createTask("FN-4076", { column: "in-progress", blockedBy: blockerId });
const blocker = createTask(blockerId, { column: "todo" });
mockSweepTasks(store, { inProgress: [taskA], all: [taskA, blocker] });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const recovered = await manager.clearStaleBlockedBy();
expect(recovered).toBe(0);
expect(store.updateTask).not.toHaveBeenCalled();
manager.stop();
});
it("does not clear blockedBy on a paused in-review task even when the blocker is stale", async () => {
const store = createRunningStore();
const taskA = createTask("FN-4076", { column: "in-review", paused: true, blockedBy: "FN-MISSING" });
mockSweepTasks(store, { inReview: [taskA], all: [taskA] });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
const recovered = await manager.clearStaleBlockedBy();
expect(recovered).toBe(0);
expect(store.updateTask).not.toHaveBeenCalled();
manager.stop();
});
it("FN-3908: clears stale queued status when all dependencies are already satisfied", async () => {
const store = createRunningStore();
const queuedTask = createTask("FN-3170", {

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { TaskDetail } from "@fusion/core";
import type { TaskDetail, TaskStore } from "@fusion/core";
import { getTaskCompletionBlockerForStore } from "../task-completion.js";
function createTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
@@ -20,6 +20,33 @@ function createTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
}
describe("getTaskCompletionBlockerForStore", () => {
it("ignores blockedBy when the blocker task is missing", async () => {
const getTask = vi.fn(async (taskId: string) => {
if (taskId === "FN-MISSING") {
return null;
}
return createTask({ id: taskId, column: "done" });
});
await expect(getTaskCompletionBlockerForStore(
{ getTask } as Pick<TaskStore, "getTask">,
createTask({ blockedBy: "FN-MISSING" }),
)).resolves.toBeUndefined();
expect(getTask).toHaveBeenCalledWith("FN-MISSING");
});
it("ignores blockedBy when the blocker task is done", async () => {
const getTask = vi.fn(async (taskId: string) => createTask({ id: taskId, column: "done" }));
await expect(getTaskCompletionBlockerForStore(
{ getTask } as Pick<TaskStore, "getTask">,
createTask({ blockedBy: "FN-DONE" }),
)).resolves.toBeUndefined();
expect(getTask).toHaveBeenCalledWith("FN-DONE");
});
it("treats dependency lookup failures as unresolved dependencies", async () => {
const getTask = vi.fn(async (taskId: string) => {
if (taskId === "FN-DONE") {
@@ -29,7 +56,7 @@ describe("getTaskCompletionBlockerForStore", () => {
});
await expect(getTaskCompletionBlockerForStore(
{ getTask },
{ getTask } as Pick<TaskStore, "getTask">,
createTask({ dependencies: ["FN-DONE", "FN-MISSING"] }),
)).resolves.toBe("task has unresolved dependencies: FN-MISSING");

View File

@@ -1200,9 +1200,13 @@ export class SelfHealingManager {
if (settings.globalPause || settings.enginePaused) return 0;
const todoTasks = await this.store.listTasks({ column: "todo", slim: true });
const blockedTasks = todoTasks.filter(
(task) => typeof task.blockedBy === "string" && task.blockedBy.trim().length > 0,
);
const inProgressTasks = await this.store.listTasks({ column: "in-progress", slim: true });
const inReviewTasks = await this.store.listTasks({ column: "in-review", slim: true });
const blockedTasks = [
...todoTasks,
...inProgressTasks,
...inReviewTasks.filter((task) => !task.paused),
].filter((task) => typeof task.blockedBy === "string" && task.blockedBy.trim().length > 0);
const queuedDependencyTasks = todoTasks.filter(
(task) => task.status === "queued" && task.dependencies.length > 0,
);
@@ -1213,6 +1217,7 @@ export class SelfHealingManager {
const taskById = new Map(allTasks.map((task) => [task.id, task]));
let recovered = 0;
const todoTaskIds = new Set(todoTasks.map((task) => task.id));
const blockedTaskIds = new Set(blockedTasks.map((task) => task.id));
const queuedDependencyTaskIds = new Set(queuedDependencyTasks.map((task) => task.id));
const candidates = new Map<string, typeof todoTasks[number]>();
@@ -1259,13 +1264,18 @@ export class SelfHealingManager {
if (reason) {
try {
if (unresolvedDeps.length > 0) {
const nextBlocker = unresolvedDeps[0]!;
await this.store.updateTask(task.id, { blockedBy: nextBlocker, status: "queued" });
await this.store.logEntry(task.id, `Auto-recovered: refreshed stale blockedBy — ${reason}; now blocked by ${nextBlocker}`);
if (todoTaskIds.has(task.id)) {
if (unresolvedDeps.length > 0) {
const nextBlocker = unresolvedDeps[0]!;
await this.store.updateTask(task.id, { blockedBy: nextBlocker, status: "queued" });
await this.store.logEntry(task.id, `Auto-recovered: refreshed stale blockedBy — ${reason}; now blocked by ${nextBlocker}`);
} else {
await this.store.updateTask(task.id, { blockedBy: null, status: null });
await this.store.logEntry(task.id, `Auto-recovered: cleared stale blockedBy — ${reason}`);
}
} else {
await this.store.updateTask(task.id, { blockedBy: null, status: null });
await this.store.logEntry(task.id, `Auto-recovered: cleared stale blockedBy — ${reason}`);
await this.store.updateTask(task.id, { blockedBy: null });
await this.store.logEntry(task.id, `Auto-recovered (FN-4091): cleared stale blockedBy — ${reason}`);
}
recovered++;
} catch (err: unknown) {
@@ -1274,6 +1284,10 @@ export class SelfHealingManager {
}
continue;
}
if (!todoTaskIds.has(task.id)) {
continue;
}
}
if (unresolvedDeps.length === 0) {

View File

@@ -5,6 +5,8 @@ export async function getTaskCompletionBlockerForStore(
task: Task,
): Promise<string | undefined> {
return getTaskCompletionBlocker(task, {
// FN-4091: return full task state from the store so completion gating can
// ignore stale blockedBy markers when the blocker is missing or terminal.
resolveTask: async (dependencyId) => {
try {
return await store.getTask(dependencyId);