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:
5
.changeset/fn-4091-stale-overlap-blockers.md
Normal file
5
.changeset/fn-4091-stale-overlap-blockers.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
|
||||
@@ -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