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 75fe39d051
commit a136dd340c
12 changed files with 260 additions and 37 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Stale `blockedBy` markers no longer prevent `fn_task_done`, and self-healing now repairs stale blockers on active `in-progress` and `in-review` tasks as well as `todo` rows.

View File

@@ -558,7 +558,7 @@ See [Memory Plugin Contract](./memory-plugin-contract.md) for the full plan.
### 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.
- `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/FN-4091): the field is only durable when it references a current unresolved explicit dependency (or, for dependency-free tasks, an active overlap blocker). Completion gating now validates `blockedBy` through live task resolution: missing blockers and blockers already in `done`/`archived` are treated as stale, while only still-active blockers continue to prevent `fn_task_done`. 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.
@@ -609,7 +609,7 @@ Runtime action-gate flow (v1):
- `recoverMergeableReviewTasks()` only re-enqueues truly eligible tasks; retry-exhausted review tasks are skipped to avoid re-enqueue/no-op loops that keep refreshing `updatedAt`.
- `recoverAlreadyMergedReviewTasks()` auto-finalizes retry-exhausted `in-review` tasks when self-healing can prove their work already landed on the merge target, but it still honors `getTaskMergeBlocker()` before `in-review``done`. If a blocker remains (for example, incomplete steps), the task is surfaced in stable `in-review/failed` state with a blocker error instead of entering an auto-finalize loop.
- No-`fn_task_done` recovery classification is normalized across executor, restart recovery, and self-healing: detection keys on executor-emitted `"without calling fn_task_done"` strings (while still tolerating legacy `task_done` wording), then applies the bounded ladder deterministically (in-session retries → bounded todo requeues with preserved progress when appropriate → terminal surfaced failure when budget is exhausted).
- `clearStaleBlockedBy()` clears `blockedBy` (and transient `status`) on todo tasks when their blocker is missing, done, archived, paused in-review, or failed in-review with merge retries exhausted. FN-3924 extends this with a dependency-integrity guard: if a task has explicit dependencies and `blockedBy` is not one of the currently unresolved deps, the stale marker is cleared. This repairs rows corrupted by historical overlap re-stamping and lets scheduler re-evaluate from live dependency state.
- `clearStaleBlockedBy()` clears `blockedBy` (and transient `status`) on todo tasks when their blocker is missing, done, archived, paused in-review, or failed in-review with merge retries exhausted. FN-3924 extends this with a dependency-integrity guard: if a task has explicit dependencies and `blockedBy` is not one of the currently unresolved deps, the stale marker is cleared. FN-4091 broadens the sweep to active `in-progress` and un-paused `in-review` tasks as well, but those repairs only null `blockedBy` (they do not rewrite scheduler-owned queued state). This repairs rows corrupted by historical overlap re-stamping and lets scheduler re-evaluate from live dependency state. The ad-hoc `scripts/recover-stale-blocked-by.mjs` remains a manual backstop for filesystem/db audits, not the primary repair path.
- Together, `recoverAlreadyMergedReviewTasks()`, `clearStaleBlockedBy()`, and paused-aware in-review scheduling prevent merge-deadlock loops by finalizing already-landed work, clearing stale dependency blockers, and avoiding paused review cards re-blocking overlap dispatch.
- Merge commit attribution is ownership-aware: a `mergeDetails.commitSha` is trusted only when reachable from `HEAD` **and** attributable to the task via `Fusion-Task-Id` trailer or task-ID-bearing subject. Reachable-but-unowned SHAs are rejected to prevent sibling done tasks from sharing misleading merge metadata.
- `ProjectEngine` settings lifecycle handlers (`project-engine.ts`) treat `enginePaused` as a soft pause: clearing it dispatches runtime resume and, when `autoMerge` is enabled, performs an `in-review` eligibility sweep to requeue mergeable review tasks.

View File

@@ -305,11 +305,38 @@ describe("getTaskCompletionBlocker", () => {
await expect(getTaskCompletionBlocker(baseCompletionTask)).resolves.toBeUndefined();
});
it("returns a reason when task has blockedBy", async () => {
it("returns a reason when task has blockedBy without resolveTask", async () => {
await expect(getTaskCompletionBlocker({ ...baseCompletionTask, blockedBy: "FN-123" }))
.resolves.toBe("task is blocked by FN-123");
});
it("ignores blockedBy when resolveTask reports the blocker missing", async () => {
const resolveTask = async () => null;
await expect(getTaskCompletionBlocker({
...baseCompletionTask,
blockedBy: "FN-4054",
}, { resolveTask })).resolves.toBeUndefined();
});
it.each(["done", "archived"] as const)("ignores blockedBy when resolveTask reports the blocker is %s", async (column) => {
const resolveTask = async () => ({ id: "FN-4054", column });
await expect(getTaskCompletionBlocker({
...baseCompletionTask,
blockedBy: "FN-4054",
}, { resolveTask })).resolves.toBeUndefined();
});
it.each(["todo", "in-progress", "in-review"] as const)("returns a reason when resolveTask reports an active blocker in %s", async (column) => {
const resolveTask = async () => ({ id: "FN-123", column });
await expect(getTaskCompletionBlocker({
...baseCompletionTask,
blockedBy: "FN-123",
}, { resolveTask })).resolves.toBe("task is blocked by FN-123");
});
it("returns a reason when a dependency is unresolved", async () => {
const resolveTask = async (taskId: string) => {
if (taskId === "FN-001") {

View File

@@ -123,6 +123,11 @@ export function isTaskReadyForMerge(
}
export interface TaskCompletionBlockerOptions {
/**
* Resolves a task reference so completion gating can distinguish live blockers
* from stale `blockedBy` markers. Missing tasks and blockers already in
* `done`/`archived` are treated as non-blocking.
*/
resolveTask?: (taskId: string) => Promise<Pick<Task, "id" | "column"> | null | undefined>;
}
@@ -138,8 +143,16 @@ export async function getTaskCompletionBlocker(
task: Pick<Task, "blockedBy" | "dependencies">,
options: TaskCompletionBlockerOptions = {},
): Promise<string | undefined> {
if (task.blockedBy?.trim()) {
return `task is blocked by ${task.blockedBy.trim()}`;
const blockedBy = task.blockedBy?.trim();
if (blockedBy) {
if (!options.resolveTask) {
return `task is blocked by ${blockedBy}`;
}
const blocker = await options.resolveTask(blockedBy);
if (blocker && blocker.column !== "done" && blocker.column !== "archived") {
return `task is blocked by ${blockedBy}`;
}
}
const dependencies = task.dependencies ?? [];

View File

@@ -128,7 +128,7 @@ function LiveAgentLogOutput({
container.scrollTop = container.scrollHeight;
}, [stepEntries.length]);
if (entries.length === 0) {
if (stepEntries.length === 0) {
return (
<div className="workflow-live-log" data-testid={`workflow-live-log-${stepId}`}>
<div className="workflow-live-log-empty">Waiting for agent output</div>

View File

@@ -2,14 +2,20 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { WorkflowResultsTab } from "../WorkflowResultsTab";
import { fetchWorkflowSteps } from "../../api";
import { useAgentLogs } from "../../hooks/useAgentLogs";
import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture";
import type { WorkflowStep, WorkflowStepResult } from "@fusion/core";
import type { AgentLogEntry, WorkflowStep, WorkflowStepResult } from "@fusion/core";
vi.mock("../../api", () => ({
fetchWorkflowSteps: vi.fn(),
}));
vi.mock("../../hooks/useAgentLogs", () => ({
useAgentLogs: vi.fn(),
}));
const mockedFetchWorkflowSteps = vi.mocked(fetchWorkflowSteps);
const mockedUseAgentLogs = vi.mocked(useAgentLogs);
describe("WorkflowResultsTab", () => {
const mockWorkflowSteps: WorkflowStep[] = [
@@ -52,6 +58,16 @@ describe("WorkflowResultsTab", () => {
beforeEach(() => {
mockedFetchWorkflowSteps.mockReset();
mockedFetchWorkflowSteps.mockResolvedValue(mockWorkflowSteps);
mockedUseAgentLogs.mockReset();
mockedUseAgentLogs.mockReturnValue({
entries: [],
loading: false,
clear: vi.fn(),
loadMore: vi.fn(),
hasMore: false,
total: 0,
loadingMore: false,
});
});
const mockResults: WorkflowStepResult[] = [
@@ -131,6 +147,33 @@ describe("WorkflowResultsTab", () => {
expect(pendingBadge).toHaveClass("workflow-result-badge--pending");
});
it("shows the waiting placeholder when pending-step logs have not started yet", () => {
const historicalEntries: AgentLogEntry[] = [
{
timestamp: "2026-03-31T10:03:00Z",
taskId: "FN-001",
text: "Earlier workflow output",
type: "text",
},
];
mockedUseAgentLogs.mockReturnValue({
entries: historicalEntries,
loading: false,
clear: vi.fn(),
loadMore: vi.fn(),
hasMore: false,
total: historicalEntries.length,
loadingMore: false,
});
render(
<WorkflowResultsTab taskId="FN-001" results={mockResults} isTaskInProgress />,
);
expect(screen.getByText("Waiting for agent output…")).toBeInTheDocument();
expect(screen.queryByText("Earlier workflow output")).not.toBeInTheDocument();
});
it("shows output content when toggle is clicked to expand", () => {
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);

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);