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
|
### Scheduling and execution
|
||||||
- `Scheduler` (`scheduler.ts`) — dependency-aware task scheduling that dispatches eligible todo tasks by priority first, then FIFO (`createdAt` ascending) within each priority tier.
|
- `Scheduler` (`scheduler.ts`) — dependency-aware task scheduling that dispatches eligible todo tasks by priority first, then FIFO (`createdAt` ascending) within each priority tier.
|
||||||
- `blockedBy` invariant (FN-3924): the field is only durable when it references a current unresolved explicit dependency (or, for dependency-free tasks, an active overlap blocker). If no current blocker remains, scheduler/event reconciliation clears `blockedBy` to `null` and re-evaluates from live task state.
|
- `blockedBy` invariant (FN-3924/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
|
#### 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.
|
- 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`.
|
- `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.
|
- `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).
|
- 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.
|
- 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.
|
- 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.
|
- `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();
|
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" }))
|
await expect(getTaskCompletionBlocker({ ...baseCompletionTask, blockedBy: "FN-123" }))
|
||||||
.resolves.toBe("task is blocked by 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 () => {
|
it("returns a reason when a dependency is unresolved", async () => {
|
||||||
const resolveTask = async (taskId: string) => {
|
const resolveTask = async (taskId: string) => {
|
||||||
if (taskId === "FN-001") {
|
if (taskId === "FN-001") {
|
||||||
|
|||||||
@@ -123,6 +123,11 @@ export function isTaskReadyForMerge(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskCompletionBlockerOptions {
|
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>;
|
resolveTask?: (taskId: string) => Promise<Pick<Task, "id" | "column"> | null | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,8 +143,16 @@ export async function getTaskCompletionBlocker(
|
|||||||
task: Pick<Task, "blockedBy" | "dependencies">,
|
task: Pick<Task, "blockedBy" | "dependencies">,
|
||||||
options: TaskCompletionBlockerOptions = {},
|
options: TaskCompletionBlockerOptions = {},
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
if (task.blockedBy?.trim()) {
|
const blockedBy = task.blockedBy?.trim();
|
||||||
return `task is blocked by ${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 ?? [];
|
const dependencies = task.dependencies ?? [];
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ function LiveAgentLogOutput({
|
|||||||
container.scrollTop = container.scrollHeight;
|
container.scrollTop = container.scrollHeight;
|
||||||
}, [stepEntries.length]);
|
}, [stepEntries.length]);
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (stepEntries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="workflow-live-log" data-testid={`workflow-live-log-${stepId}`}>
|
<div className="workflow-live-log" data-testid={`workflow-live-log-${stepId}`}>
|
||||||
<div className="workflow-live-log-empty">Waiting for agent output…</div>
|
<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 { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||||
import { WorkflowResultsTab } from "../WorkflowResultsTab";
|
import { WorkflowResultsTab } from "../WorkflowResultsTab";
|
||||||
import { fetchWorkflowSteps } from "../../api";
|
import { fetchWorkflowSteps } from "../../api";
|
||||||
|
import { useAgentLogs } from "../../hooks/useAgentLogs";
|
||||||
import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture";
|
import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture";
|
||||||
import type { WorkflowStep, WorkflowStepResult } from "@fusion/core";
|
import type { AgentLogEntry, WorkflowStep, WorkflowStepResult } from "@fusion/core";
|
||||||
|
|
||||||
vi.mock("../../api", () => ({
|
vi.mock("../../api", () => ({
|
||||||
fetchWorkflowSteps: vi.fn(),
|
fetchWorkflowSteps: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useAgentLogs", () => ({
|
||||||
|
useAgentLogs: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockedFetchWorkflowSteps = vi.mocked(fetchWorkflowSteps);
|
const mockedFetchWorkflowSteps = vi.mocked(fetchWorkflowSteps);
|
||||||
|
const mockedUseAgentLogs = vi.mocked(useAgentLogs);
|
||||||
|
|
||||||
describe("WorkflowResultsTab", () => {
|
describe("WorkflowResultsTab", () => {
|
||||||
const mockWorkflowSteps: WorkflowStep[] = [
|
const mockWorkflowSteps: WorkflowStep[] = [
|
||||||
@@ -52,6 +58,16 @@ describe("WorkflowResultsTab", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockedFetchWorkflowSteps.mockReset();
|
mockedFetchWorkflowSteps.mockReset();
|
||||||
mockedFetchWorkflowSteps.mockResolvedValue(mockWorkflowSteps);
|
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[] = [
|
const mockResults: WorkflowStepResult[] = [
|
||||||
@@ -131,6 +147,33 @@ describe("WorkflowResultsTab", () => {
|
|||||||
expect(pendingBadge).toHaveClass("workflow-result-badge--pending");
|
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", () => {
|
it("shows output content when toggle is clicked to expand", () => {
|
||||||
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);
|
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);
|
||||||
|
|
||||||
|
|||||||
@@ -1536,7 +1536,7 @@ describe("TaskExecutor fn_task_done blockers", () => {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: taskId,
|
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 () => {
|
it("does not mark a feature done when the completed task is blocked", async () => {
|
||||||
const store = createMockStore({
|
const store = createMockStore({
|
||||||
getTask: vi.fn().mockResolvedValue(createMockTask({
|
getTask: vi.fn(async (taskId: string) => {
|
||||||
id: "FN-001",
|
if (taskId === "FN-001") {
|
||||||
blockedBy: "FN-000",
|
return createMockTask({
|
||||||
column: "done",
|
id: "FN-001",
|
||||||
})),
|
blockedBy: "FN-000",
|
||||||
|
column: "done",
|
||||||
|
}) as TaskDetail;
|
||||||
|
}
|
||||||
|
return createMockTask({ id: taskId, column: "in-progress" }) as TaskDetail;
|
||||||
|
}) as TaskStore["getTask"],
|
||||||
});
|
});
|
||||||
const mockAutopilot = {
|
const mockAutopilot = {
|
||||||
setScheduler: vi.fn(),
|
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> = {}) {
|
function createTask(id: string, overrides: Record<string, unknown> = {}) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -4376,7 +4398,7 @@ describe("clearStaleBlockedBy", () => {
|
|||||||
it("clears stale blockedBy when blocker is missing", async () => {
|
it("clears stale blockedBy when blocker is missing", async () => {
|
||||||
const store = createRunningStore();
|
const store = createRunningStore();
|
||||||
const taskA = createTask("A", { blockedBy: "FN-MISSING" });
|
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 manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||||
const recovered = await manager.clearStaleBlockedBy();
|
const recovered = await manager.clearStaleBlockedBy();
|
||||||
@@ -4393,7 +4415,7 @@ describe("clearStaleBlockedBy", () => {
|
|||||||
const blockerId = "FN-100";
|
const blockerId = "FN-100";
|
||||||
const taskA = createTask("A", { blockedBy: blockerId });
|
const taskA = createTask("A", { blockedBy: blockerId });
|
||||||
const taskB = createTask(blockerId, { column });
|
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 manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||||
const recovered = await manager.clearStaleBlockedBy();
|
const recovered = await manager.clearStaleBlockedBy();
|
||||||
@@ -4410,7 +4432,7 @@ describe("clearStaleBlockedBy", () => {
|
|||||||
const blockerId = "FN-200";
|
const blockerId = "FN-200";
|
||||||
const taskA = createTask("A", { blockedBy: blockerId });
|
const taskA = createTask("A", { blockedBy: blockerId });
|
||||||
const taskB = createTask(blockerId, { column: "in-review", paused: true });
|
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 manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||||
const recovered = await manager.clearStaleBlockedBy();
|
const recovered = await manager.clearStaleBlockedBy();
|
||||||
@@ -4427,7 +4449,7 @@ describe("clearStaleBlockedBy", () => {
|
|||||||
const blockerId = "FN-300";
|
const blockerId = "FN-300";
|
||||||
const taskA = createTask("A", { blockedBy: blockerId });
|
const taskA = createTask("A", { blockedBy: blockerId });
|
||||||
const taskB = createTask(blockerId, { column: "in-review", status: "failed", mergeRetries: 3 });
|
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 manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||||
const recovered = await manager.clearStaleBlockedBy();
|
const recovered = await manager.clearStaleBlockedBy();
|
||||||
@@ -4443,7 +4465,7 @@ describe("clearStaleBlockedBy", () => {
|
|||||||
const store = createRunningStore();
|
const store = createRunningStore();
|
||||||
const taskA = createTask("A", { blockedBy: "FN-400" });
|
const taskA = createTask("A", { blockedBy: "FN-400" });
|
||||||
const taskB = createTask("FN-400", { column: "in-progress" });
|
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 manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||||
const recovered = await manager.clearStaleBlockedBy();
|
const recovered = await manager.clearStaleBlockedBy();
|
||||||
@@ -4458,7 +4480,7 @@ describe("clearStaleBlockedBy", () => {
|
|||||||
const taskA = createTask("A", { blockedBy: "FN-400", dependencies: ["FN-DEP"] });
|
const taskA = createTask("A", { blockedBy: "FN-400", dependencies: ["FN-DEP"] });
|
||||||
const overlapBlocker = createTask("FN-400", { column: "in-progress" });
|
const overlapBlocker = createTask("FN-400", { column: "in-progress" });
|
||||||
const dependency = createTask("FN-DEP", { column: "done" });
|
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 manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||||
const recovered = await manager.clearStaleBlockedBy();
|
const recovered = await manager.clearStaleBlockedBy();
|
||||||
@@ -4473,7 +4495,7 @@ describe("clearStaleBlockedBy", () => {
|
|||||||
const store = createRunningStore();
|
const store = createRunningStore();
|
||||||
const taskA = createTask("A", { blockedBy: "FN-500" });
|
const taskA = createTask("A", { blockedBy: "FN-500" });
|
||||||
const taskB = createTask("FN-500", { column: "in-review", paused: false, mergeRetries: 0 });
|
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 manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||||
const recovered = await manager.clearStaleBlockedBy();
|
const recovered = await manager.clearStaleBlockedBy();
|
||||||
@@ -4487,7 +4509,7 @@ describe("clearStaleBlockedBy", () => {
|
|||||||
const store = createRunningStore();
|
const store = createRunningStore();
|
||||||
const taskA = createTask("A", { blockedBy: "FN-600" });
|
const taskA = createTask("A", { blockedBy: "FN-600" });
|
||||||
const taskB = createTask("FN-600", { column: "in-review", status: "failed", mergeRetries: 1 });
|
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 manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||||
const recovered = await manager.clearStaleBlockedBy();
|
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",
|
error: "Refusing to start coding agent in missing worktree: /Users/eclipxe/Projects/kb/.worktrees/bright-wren",
|
||||||
steps: [{ status: "done" }, { status: "pending" }] as any,
|
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 manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||||
const recovered = await manager.clearStaleBlockedBy();
|
const recovered = await manager.clearStaleBlockedBy();
|
||||||
@@ -4531,7 +4553,7 @@ describe("clearStaleBlockedBy", () => {
|
|||||||
...settings,
|
...settings,
|
||||||
} as unknown as Settings);
|
} as unknown as Settings);
|
||||||
const taskA = createTask("A", { blockedBy: "FN-700" });
|
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 manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||||
const recovered = await manager.clearStaleBlockedBy();
|
const recovered = await manager.clearStaleBlockedBy();
|
||||||
@@ -4550,9 +4572,14 @@ describe("clearStaleBlockedBy", () => {
|
|||||||
const recoveredState = createTask("A", { blockedBy: null });
|
const recoveredState = createTask("A", { blockedBy: null });
|
||||||
|
|
||||||
(store.listTasks as ReturnType<typeof vi.fn>)
|
(store.listTasks as ReturnType<typeof vi.fn>)
|
||||||
.mockResolvedValueOnce([blocked, blocker])
|
.mockImplementationOnce(async (options?: { column?: string }) => options?.column === "todo" ? [blocked] : options?.column === "in-progress" ? [] : options?.column === "in-review" ? [] : [blocked, blocker])
|
||||||
.mockResolvedValueOnce([blocked, blocker])
|
.mockImplementationOnce(async (options?: { column?: string }) => options?.column === "todo" ? [blocked] : options?.column === "in-progress" ? [] : options?.column === "in-review" ? [] : [blocked, blocker])
|
||||||
.mockResolvedValueOnce([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 (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 manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||||
const first = await manager.clearStaleBlockedBy();
|
const first = await manager.clearStaleBlockedBy();
|
||||||
@@ -4565,6 +4592,66 @@ describe("clearStaleBlockedBy", () => {
|
|||||||
manager.stop();
|
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 () => {
|
it("FN-3908: clears stale queued status when all dependencies are already satisfied", async () => {
|
||||||
const store = createRunningStore();
|
const store = createRunningStore();
|
||||||
const queuedTask = createTask("FN-3170", {
|
const queuedTask = createTask("FN-3170", {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
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";
|
import { getTaskCompletionBlockerForStore } from "../task-completion.js";
|
||||||
|
|
||||||
function createTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
function createTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||||
@@ -20,6 +20,33 @@ function createTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("getTaskCompletionBlockerForStore", () => {
|
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 () => {
|
it("treats dependency lookup failures as unresolved dependencies", async () => {
|
||||||
const getTask = vi.fn(async (taskId: string) => {
|
const getTask = vi.fn(async (taskId: string) => {
|
||||||
if (taskId === "FN-DONE") {
|
if (taskId === "FN-DONE") {
|
||||||
@@ -29,7 +56,7 @@ describe("getTaskCompletionBlockerForStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(getTaskCompletionBlockerForStore(
|
await expect(getTaskCompletionBlockerForStore(
|
||||||
{ getTask },
|
{ getTask } as Pick<TaskStore, "getTask">,
|
||||||
createTask({ dependencies: ["FN-DONE", "FN-MISSING"] }),
|
createTask({ dependencies: ["FN-DONE", "FN-MISSING"] }),
|
||||||
)).resolves.toBe("task has unresolved dependencies: FN-MISSING");
|
)).resolves.toBe("task has unresolved dependencies: FN-MISSING");
|
||||||
|
|
||||||
|
|||||||
@@ -1200,9 +1200,13 @@ export class SelfHealingManager {
|
|||||||
if (settings.globalPause || settings.enginePaused) return 0;
|
if (settings.globalPause || settings.enginePaused) return 0;
|
||||||
|
|
||||||
const todoTasks = await this.store.listTasks({ column: "todo", slim: true });
|
const todoTasks = await this.store.listTasks({ column: "todo", slim: true });
|
||||||
const blockedTasks = todoTasks.filter(
|
const inProgressTasks = await this.store.listTasks({ column: "in-progress", slim: true });
|
||||||
(task) => typeof task.blockedBy === "string" && task.blockedBy.trim().length > 0,
|
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(
|
const queuedDependencyTasks = todoTasks.filter(
|
||||||
(task) => task.status === "queued" && task.dependencies.length > 0,
|
(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]));
|
const taskById = new Map(allTasks.map((task) => [task.id, task]));
|
||||||
|
|
||||||
let recovered = 0;
|
let recovered = 0;
|
||||||
|
const todoTaskIds = new Set(todoTasks.map((task) => task.id));
|
||||||
const blockedTaskIds = new Set(blockedTasks.map((task) => task.id));
|
const blockedTaskIds = new Set(blockedTasks.map((task) => task.id));
|
||||||
const queuedDependencyTaskIds = new Set(queuedDependencyTasks.map((task) => task.id));
|
const queuedDependencyTaskIds = new Set(queuedDependencyTasks.map((task) => task.id));
|
||||||
const candidates = new Map<string, typeof todoTasks[number]>();
|
const candidates = new Map<string, typeof todoTasks[number]>();
|
||||||
@@ -1259,13 +1264,18 @@ export class SelfHealingManager {
|
|||||||
|
|
||||||
if (reason) {
|
if (reason) {
|
||||||
try {
|
try {
|
||||||
if (unresolvedDeps.length > 0) {
|
if (todoTaskIds.has(task.id)) {
|
||||||
const nextBlocker = unresolvedDeps[0]!;
|
if (unresolvedDeps.length > 0) {
|
||||||
await this.store.updateTask(task.id, { blockedBy: nextBlocker, status: "queued" });
|
const nextBlocker = unresolvedDeps[0]!;
|
||||||
await this.store.logEntry(task.id, `Auto-recovered: refreshed stale blockedBy — ${reason}; now blocked by ${nextBlocker}`);
|
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 {
|
} else {
|
||||||
await this.store.updateTask(task.id, { blockedBy: null, status: null });
|
await this.store.updateTask(task.id, { blockedBy: null });
|
||||||
await this.store.logEntry(task.id, `Auto-recovered: cleared stale blockedBy — ${reason}`);
|
await this.store.logEntry(task.id, `Auto-recovered (FN-4091): cleared stale blockedBy — ${reason}`);
|
||||||
}
|
}
|
||||||
recovered++;
|
recovered++;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -1274,6 +1284,10 @@ export class SelfHealingManager {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!todoTaskIds.has(task.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unresolvedDeps.length === 0) {
|
if (unresolvedDeps.length === 0) {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export async function getTaskCompletionBlockerForStore(
|
|||||||
task: Task,
|
task: Task,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
return getTaskCompletionBlocker(task, {
|
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) => {
|
resolveTask: async (dependencyId) => {
|
||||||
try {
|
try {
|
||||||
return await store.getTask(dependencyId);
|
return await store.getTask(dependencyId);
|
||||||
|
|||||||
Reference in New Issue
Block a user