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:
@@ -1536,7 +1536,7 @@ describe("TaskExecutor fn_task_done blockers", () => {
|
||||
}
|
||||
return {
|
||||
id: taskId,
|
||||
column: "done",
|
||||
column: taskId === "FN-DEP-1" ? "in-progress" : "done",
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user