feat(FN-3231): preserve merge-active state on verification bounce and board
This merge lands FN-3231 across two steps: it preserves a merge-active fix when verification bounces occur (step 1) and ensures the fix is retained during board routing transitions (step 2). Changes span the dashboard Board routing logic and the engine executor, with corresponding test coverage adde Fusion-Task-Id: FN-3231
This commit is contained in:
@@ -86,8 +86,8 @@ function sortTasksForColumn(tasks: Task[], column: ColumnType): Task[] {
|
|||||||
return [...tasks].sort((a, b) => {
|
return [...tasks].sort((a, b) => {
|
||||||
// In the in-review column, merging tasks stay pinned above non-merging tasks.
|
// In the in-review column, merging tasks stay pinned above non-merging tasks.
|
||||||
if (column === "in-review") {
|
if (column === "in-review") {
|
||||||
const aIsMerging = a.status === "merging" || a.status === "merging-pr";
|
const aIsMerging = a.status === "merging" || a.status === "merging-pr" || a.status === "merging-fix";
|
||||||
const bIsMerging = b.status === "merging" || b.status === "merging-pr";
|
const bIsMerging = b.status === "merging" || b.status === "merging-pr" || b.status === "merging-fix";
|
||||||
if (aIsMerging !== bIsMerging) {
|
if (aIsMerging !== bIsMerging) {
|
||||||
return aIsMerging ? -1 : 1;
|
return aIsMerging ? -1 : 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,14 @@ const COLUMN_COLOR_MAP: Record<Column, string> = {
|
|||||||
archived: "var(--text-secondary)",
|
archived: "var(--text-secondary)",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging"]);
|
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
|
||||||
|
|
||||||
type SortField = "id" | "title" | "status" | "column";
|
type SortField = "id" | "title" | "status" | "column";
|
||||||
|
|
||||||
|
function getTaskStatusLabel(status: string): string {
|
||||||
|
if (status === "merging-fix") return "Merging fixes…";
|
||||||
|
return status;
|
||||||
|
}
|
||||||
type SortDirection = "asc" | "desc";
|
type SortDirection = "asc" | "desc";
|
||||||
|
|
||||||
// Column visibility types
|
// Column visibility types
|
||||||
@@ -1263,7 +1268,7 @@ export function ListView({
|
|||||||
<span className="list-status-badge stuck">Stuck</span>
|
<span className="list-status-badge stuck">Stuck</span>
|
||||||
) : hasStatus ? (
|
) : hasStatus ? (
|
||||||
<span className={`list-status-badge list-status-badge--${task.column}${isFailed ? " failed" : ""}${isAgentActive ? " pulsing" : ""}`}>
|
<span className={`list-status-badge list-status-badge--${task.column}${isFailed ? " failed" : ""}${isAgentActive ? " pulsing" : ""}`}>
|
||||||
{task.status}
|
{getTaskStatusLabel(task.status ?? "")}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -1465,7 +1470,7 @@ export function ListView({
|
|||||||
isAgentActive ? " pulsing" : ""
|
isAgentActive ? " pulsing" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{task.status}
|
{getTaskStatusLabel(task.status ?? "")}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="list-status-badge">-</span>
|
<span className="list-status-badge">-</span>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function getRoutingPolicyLabel(policy: RoutingSettings["unavailableNodePolicy"]
|
|||||||
return "Not configured";
|
return "Not configured";
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging"]);
|
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
|
||||||
|
|
||||||
function isUnhealthy(status: NodeInfo["status"] | undefined): boolean {
|
function isUnhealthy(status: NodeInfo["status"] | undefined): boolean {
|
||||||
return status !== undefined && status !== "online";
|
return status !== undefined && status !== "online";
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ function abbreviateBadge(text: string, max: number): string {
|
|||||||
|
|
||||||
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
|
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
|
||||||
|
|
||||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging"]);
|
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
|
||||||
const ACTIVE_MERGE_STATUSES = new Set(["merging", "merging-pr"]);
|
const ACTIVE_MERGE_STATUSES = new Set(["merging", "merging-pr", "merging-fix"]);
|
||||||
|
|
||||||
const COLUMN_PROGRESS_COLOR_MAP: Record<Column, string> = {
|
const COLUMN_PROGRESS_COLOR_MAP: Record<Column, string> = {
|
||||||
triage: "var(--triage)",
|
triage: "var(--triage)",
|
||||||
@@ -102,6 +102,11 @@ const TIME_INDICATOR_COLUMNS = new Set<Column>([
|
|||||||
]);
|
]);
|
||||||
const LIVE_TIME_INDICATOR_POLL_MS = 30_000;
|
const LIVE_TIME_INDICATOR_POLL_MS = 30_000;
|
||||||
|
|
||||||
|
function getTaskStatusLabel(status: string): string {
|
||||||
|
if (status === "merging-fix") return "Merging fixes…";
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
function parseTimestampToMs(value?: string): number | null {
|
function parseTimestampToMs(value?: string): number | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const parsed = Date.parse(value);
|
const parsed = Date.parse(value);
|
||||||
@@ -1323,7 +1328,7 @@ function TaskCardComponent({
|
|||||||
<span
|
<span
|
||||||
className={`card-status-badge card-status-badge--${task.column}${isAwaitingApproval ? " awaiting-approval" : ""}${ACTIVE_STATUSES.has(task.status) ? " pulsing" : ""}${isFailed ? " failed" : ""}${isStuck ? " stuck" : ""}`}
|
className={`card-status-badge card-status-badge--${task.column}${isAwaitingApproval ? " awaiting-approval" : ""}${ACTIVE_STATUSES.has(task.status) ? " pulsing" : ""}${isFailed ? " failed" : ""}${isStuck ? " stuck" : ""}`}
|
||||||
>
|
>
|
||||||
{isStuck ? "Stuck" : isAwaitingApproval ? "Awaiting Approval" : task.status}
|
{isStuck ? "Stuck" : isAwaitingApproval ? "Awaiting Approval" : getTaskStatusLabel(task.status)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isStuck && (isPaused || !task.status || task.status === "queued") && (
|
{isStuck && (isPaused || !task.status || task.status === "queued") && (
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ interface ModelSelection {
|
|||||||
modelId?: string;
|
modelId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging"]);
|
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1555,6 +1555,7 @@ export function TaskDetailContent({
|
|||||||
"creating-pr": "Creating PR…",
|
"creating-pr": "Creating PR…",
|
||||||
"awaiting-pr-checks": "Awaiting PR checks",
|
"awaiting-pr-checks": "Awaiting PR checks",
|
||||||
"merging-pr": "Merging PR…",
|
"merging-pr": "Merging PR…",
|
||||||
|
"merging-fix": "Merging fixes…",
|
||||||
};
|
};
|
||||||
const prAutomationLabel = task.status ? prAutomationStatusLabels[task.status] : undefined;
|
const prAutomationLabel = task.status ? prAutomationStatusLabels[task.status] : undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -403,6 +403,28 @@ describe("Board", () => {
|
|||||||
expect(inReviewTasks.map((task) => task.id)).toEqual(["FN-020", "FN-021"]);
|
expect(inReviewTasks.map((task) => task.id)).toEqual(["FN-020", "FN-021"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("pins merging-fix tasks to top of in-review even when newer non-merging tasks exist", () => {
|
||||||
|
const tasks: Task[] = [
|
||||||
|
createTask({
|
||||||
|
id: "FN-060",
|
||||||
|
column: "in-review",
|
||||||
|
status: "merging-fix",
|
||||||
|
columnMovedAt: "2024-01-01T10:00:00.000Z",
|
||||||
|
}),
|
||||||
|
createTask({
|
||||||
|
id: "FN-061",
|
||||||
|
column: "in-review",
|
||||||
|
status: "review-ready",
|
||||||
|
columnMovedAt: "2024-01-01T13:00:00.000Z",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
renderBoard({ tasks });
|
||||||
|
|
||||||
|
const inReviewTasks = JSON.parse(screen.getByTestId("column-in-review").getAttribute("data-tasks") || "[]") as Task[];
|
||||||
|
expect(inReviewTasks.map((task) => task.id)).toEqual(["FN-060", "FN-061"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("sorts multiple merging tasks by priority then task ID within the pinned group", () => {
|
it("sorts multiple merging tasks by priority then task ID within the pinned group", () => {
|
||||||
const tasks: Task[] = [
|
const tasks: Task[] = [
|
||||||
createTask({
|
createTask({
|
||||||
|
|||||||
@@ -620,12 +620,15 @@ describe("ListView", () => {
|
|||||||
expect(row?.className).toContain("paused");
|
expect(row?.className).toContain("paused");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders agent-active tasks with glow styling", () => {
|
it.each([
|
||||||
|
{ status: "executing", column: "in-progress" as const, label: "executing" },
|
||||||
|
{ status: "merging-fix", column: "in-review" as const, label: "Merging fixes…" },
|
||||||
|
])("renders agent-active tasks with glow styling for $status", ({ status, column, label }) => {
|
||||||
const tasks = [
|
const tasks = [
|
||||||
createMockTask({
|
createMockTask({
|
||||||
id: "FN-001",
|
id: "FN-001",
|
||||||
status: "executing",
|
status,
|
||||||
column: "in-progress",
|
column,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -633,6 +636,7 @@ describe("ListView", () => {
|
|||||||
|
|
||||||
const row = screen.getByText("FN-001").closest("tr");
|
const row = screen.getByText("FN-001").closest("tr");
|
||||||
expect(row?.className).toContain("agent-active");
|
expect(row?.className).toContain("agent-active");
|
||||||
|
expect(screen.getByText(label)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not render agent-active when globalPaused is true", () => {
|
it("does not render agent-active when globalPaused is true", () => {
|
||||||
@@ -2845,15 +2849,18 @@ describe("ListView - Bulk Selection", () => {
|
|||||||
expect((screen.getByLabelText("Select FN-002") as HTMLInputElement).checked).toBe(true);
|
expect((screen.getByLabelText("Select FN-002") as HTMLInputElement).checked).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies agent-active class to mobile cards when task is in-progress and not paused/failed", () => {
|
it.each([
|
||||||
|
{ status: "executing", column: "in-progress" as const },
|
||||||
|
{ status: "merging-fix", column: "in-review" as const },
|
||||||
|
])("applies agent-active class to mobile cards for active states (%s)", ({ status, column }) => {
|
||||||
mockMobileViewport();
|
mockMobileViewport();
|
||||||
|
|
||||||
const { container } = renderListView({
|
const { container } = renderListView({
|
||||||
tasks: [
|
tasks: [
|
||||||
createMockTask({
|
createMockTask({
|
||||||
id: "FN-001",
|
id: "FN-001",
|
||||||
status: "executing",
|
status,
|
||||||
column: "in-progress",
|
column,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
globalPaused: false,
|
globalPaused: false,
|
||||||
|
|||||||
@@ -224,8 +224,8 @@ describe("RoutingTab", () => {
|
|||||||
expect(screen.getByText("Node override cannot be changed while the task is active.")).toBeInTheDocument();
|
expect(screen.getByText("Node override cannot be changed while the task is active.")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables node selector for active task statuses", async () => {
|
it.each(["executing", "merging-fix"] as const)("disables node selector for active task status %s", async (status) => {
|
||||||
render(<RoutingTab task={makeTask({ column: "todo", status: "executing" })} settings={makeSettings()} addToast={addToast} />);
|
render(<RoutingTab task={makeTask({ column: "todo", status })} settings={makeSettings()} addToast={addToast} />);
|
||||||
|
|
||||||
const selector = await screen.findByLabelText("Select execution node");
|
const selector = await screen.findByLabelText("Select execution node");
|
||||||
expect(selector).toBeDisabled();
|
expect(selector).toBeDisabled();
|
||||||
|
|||||||
@@ -70,6 +70,20 @@ describe("TaskCard", () => {
|
|||||||
expect(screen.getByText("executing")).toBeDefined();
|
expect(screen.getByText("executing")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders merge-remediation status as merge-active for in-review tasks", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({ column: "in-review", status: "merging-fix" })}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Merging fixes…")).toBeDefined();
|
||||||
|
const badge = container.querySelector(".card-status-badge");
|
||||||
|
expect(badge?.className).toContain("pulsing");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the status badge after the card ID in DOM order", () => {
|
it("renders the status badge after the card ID in DOM order", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<TaskCard
|
<TaskCard
|
||||||
@@ -856,7 +870,7 @@ describe("TaskCard", () => {
|
|||||||
expect(container.querySelector(".card-time-indicator")?.getAttribute("title")).toBe("Execution time 35m");
|
expect(container.querySelector(".card-time-indicator")?.getAttribute("title")).toBe("Execution time 35m");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows live merge elapsed in timer chip while task.status is merging", () => {
|
it.each(["merging", "merging-fix"] as const)("shows live merge elapsed in timer chip while task.status is %s", (status) => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date("2026-04-25T13:45:00.000Z"));
|
vi.setSystemTime(new Date("2026-04-25T13:45:00.000Z"));
|
||||||
|
|
||||||
@@ -865,7 +879,7 @@ describe("TaskCard", () => {
|
|||||||
<TaskCard
|
<TaskCard
|
||||||
task={makeTask({
|
task={makeTask({
|
||||||
column: "in-review",
|
column: "in-review",
|
||||||
status: "merging",
|
status,
|
||||||
executionStartedAt: "2026-04-25T13:00:00.000Z",
|
executionStartedAt: "2026-04-25T13:00:00.000Z",
|
||||||
updatedAt: "2026-04-25T13:44:30.000Z",
|
updatedAt: "2026-04-25T13:44:30.000Z",
|
||||||
workflowStepResults: [
|
workflowStepResults: [
|
||||||
|
|||||||
@@ -10801,7 +10801,7 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
|
|||||||
log: [],
|
log: [],
|
||||||
mergeDetails: { strategy: "manual" } as any,
|
mergeDetails: { strategy: "manual" } as any,
|
||||||
mergeRetries: 2,
|
mergeRetries: 2,
|
||||||
verificationFailureCount: 1,
|
verificationFailureCount: 0,
|
||||||
workflowStepResults: [{ id: "wf-1", status: "passed" }],
|
workflowStepResults: [{ id: "wf-1", status: "passed" }],
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
@@ -10847,7 +10847,7 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
|
|||||||
log: [],
|
log: [],
|
||||||
mergeDetails: { strategy: "ours" } as any,
|
mergeDetails: { strategy: "ours" } as any,
|
||||||
mergeRetries: 1,
|
mergeRetries: 1,
|
||||||
verificationFailureCount: 2,
|
verificationFailureCount: 0,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -10871,6 +10871,40 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves verificationFailureCount for merge remediation cycles even if status was cleared", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test");
|
||||||
|
vi.spyOn(executor, "execute").mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const movedTask = {
|
||||||
|
id: "FN-2883-D",
|
||||||
|
title: "Verification remediation",
|
||||||
|
description: "desc",
|
||||||
|
column: "in-progress" as const,
|
||||||
|
dependencies: [],
|
||||||
|
steps: [{ name: "Step 2: Testing & Verification", status: "done" }],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
mergeDetails: { strategy: "manual" } as any,
|
||||||
|
mergeRetries: 0,
|
||||||
|
status: null,
|
||||||
|
verificationFailureCount: 2,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
store.getTask.mockResolvedValue(movedTask);
|
||||||
|
store._trigger("task:moved", { task: movedTask, from: "in-review", to: "in-progress" });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith("FN-2883-D", expect.objectContaining({
|
||||||
|
mergeDetails: null,
|
||||||
|
mergeRetries: 0,
|
||||||
|
verificationFailureCount: 2,
|
||||||
|
workflowStepResults: [],
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it("does not reset merge state on todo → in-progress move", async () => {
|
it("does not reset merge state on todo → in-progress move", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
const executor = new TaskExecutor(store, "/tmp/test");
|
||||||
@@ -13711,12 +13745,16 @@ describe("Executor verification gate (FN-3345)", () => {
|
|||||||
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
|
||||||
// Task should NOT move to in-review
|
// Task should NOT move to in-review
|
||||||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-3345", "in-review");
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-3345", "in-review");
|
||||||
// Task should have been sent back for fix
|
// Task should have been sent back for merge remediation with active merge status
|
||||||
expect(store.addTaskComment).toHaveBeenCalledWith(
|
expect(store.addTaskComment).toHaveBeenCalledWith(
|
||||||
"FN-3345",
|
"FN-3345",
|
||||||
expect.stringContaining("Deterministic verification failed"),
|
expect.stringContaining("Deterministic verification failed"),
|
||||||
"agent",
|
"agent",
|
||||||
);
|
);
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith(
|
||||||
|
"FN-3345",
|
||||||
|
expect.objectContaining({ status: "merging-fix" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("test fails then fix succeeds → re-verification runs both test AND build", async () => {
|
it("test fails then fix succeeds → re-verification runs both test AND build", async () => {
|
||||||
@@ -13850,11 +13888,15 @@ describe("Executor verification gate (FN-3345)", () => {
|
|||||||
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
|
||||||
// Task should NOT move to in-review
|
// Task should NOT move to in-review
|
||||||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-3345", "in-review");
|
expect(store.moveTask).not.toHaveBeenCalledWith("FN-3345", "in-review");
|
||||||
// Task should have been sent back for fix
|
// Task should have been sent back for merge remediation with active merge status
|
||||||
expect(store.addTaskComment).toHaveBeenCalledWith(
|
expect(store.addTaskComment).toHaveBeenCalledWith(
|
||||||
"FN-3345",
|
"FN-3345",
|
||||||
expect.stringContaining("Deterministic verification failed"),
|
expect.stringContaining("Deterministic verification failed"),
|
||||||
"agent",
|
"agent",
|
||||||
);
|
);
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith(
|
||||||
|
"FN-3345",
|
||||||
|
expect.objectContaining({ status: "merging-fix" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ describe("ProjectEngine merge error recovery", () => {
|
|||||||
expect(hasErrorLog(errorSpy, "persist failed")).toBe(true);
|
expect(hasErrorLog(errorSpy, "persist failed")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("moves task back to in-progress on verification errors", async () => {
|
it("moves task back to in-progress with merge-remediation status on verification errors", async () => {
|
||||||
const verificationError = new Error("Deterministic test verification failed");
|
const verificationError = new Error("Deterministic test verification failed");
|
||||||
verificationError.name = "VerificationError";
|
verificationError.name = "VerificationError";
|
||||||
vi.mocked(aiMergeTask).mockRejectedValueOnce(verificationError);
|
vi.mocked(aiMergeTask).mockRejectedValueOnce(verificationError);
|
||||||
@@ -309,7 +309,7 @@ describe("ProjectEngine merge error recovery", () => {
|
|||||||
"agent",
|
"agent",
|
||||||
);
|
);
|
||||||
expect(store.updateTask).toHaveBeenCalledWith(TASK_ID, {
|
expect(store.updateTask).toHaveBeenCalledWith(TASK_ID, {
|
||||||
status: null,
|
status: "merging-fix",
|
||||||
mergeRetries: 0,
|
mergeRetries: 0,
|
||||||
error: null,
|
error: null,
|
||||||
verificationFailureCount: 1,
|
verificationFailureCount: 1,
|
||||||
@@ -317,13 +317,34 @@ describe("ProjectEngine merge error recovery", () => {
|
|||||||
expect(store.moveTask).toHaveBeenCalledWith(TASK_ID, "in-progress");
|
expect(store.moveTask).toHaveBeenCalledWith(TASK_ID, "in-progress");
|
||||||
expect(store.logEntry).toHaveBeenCalledWith(
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
TASK_ID,
|
TASK_ID,
|
||||||
"Deterministic test verification failed (1/3) — moved back to in-progress for remediation",
|
"Deterministic test verification failed (1/3) — moved back to in-progress with status=merging-fix for remediation",
|
||||||
);
|
);
|
||||||
expect(logSpy).toHaveBeenCalledWith(
|
expect(logSpy).toHaveBeenCalledWith(
|
||||||
`Auto-merge: ${TASK_ID} deterministic test verification failed (1/3) — moved to in-progress`,
|
`Auto-merge: ${TASK_ID} deterministic test verification failed (1/3) — moved to in-progress with status=merging-fix`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("increments verificationFailureCount across consecutive verification bounces", async () => {
|
||||||
|
const verificationError = new Error("Deterministic test verification failed");
|
||||||
|
verificationError.name = "VerificationError";
|
||||||
|
vi.mocked(aiMergeTask).mockRejectedValueOnce(verificationError);
|
||||||
|
|
||||||
|
const store = makeStore({
|
||||||
|
tasks: [makeTask({ verificationFailureCount: 1, status: "merging-fix" })],
|
||||||
|
});
|
||||||
|
const engine = createEngine(store);
|
||||||
|
|
||||||
|
await runMergeCycle(engine);
|
||||||
|
|
||||||
|
expect(store.updateTask).toHaveBeenCalledWith(TASK_ID, {
|
||||||
|
status: "merging-fix",
|
||||||
|
mergeRetries: 0,
|
||||||
|
error: null,
|
||||||
|
verificationFailureCount: 2,
|
||||||
|
});
|
||||||
|
expect(store.moveTask).toHaveBeenCalledWith(TASK_ID, "in-progress");
|
||||||
|
});
|
||||||
|
|
||||||
it("caps verification-failure bounces and creates a follow-up task", async () => {
|
it("caps verification-failure bounces and creates a follow-up task", async () => {
|
||||||
const verificationError = new Error("Deterministic test verification failed");
|
const verificationError = new Error("Deterministic test verification failed");
|
||||||
verificationError.name = "VerificationError";
|
verificationError.name = "VerificationError";
|
||||||
|
|||||||
@@ -1174,7 +1174,8 @@ export class TaskExecutor {
|
|||||||
|| (task.mergeRetries ?? 0) > 0
|
|| (task.mergeRetries ?? 0) > 0
|
||||||
|| (task.verificationFailureCount ?? 0) > 0
|
|| (task.verificationFailureCount ?? 0) > 0
|
||||||
|| task.status === "merging"
|
|| task.status === "merging"
|
||||||
|| task.status === "merging-pr";
|
|| task.status === "merging-pr"
|
||||||
|
|| task.status === "merging-fix";
|
||||||
|
|
||||||
if (!hasMergeEvidence) {
|
if (!hasMergeEvidence) {
|
||||||
return task;
|
return task;
|
||||||
@@ -1183,14 +1184,24 @@ export class TaskExecutor {
|
|||||||
return this.cleanupMergeStateForReverification(
|
return this.cleanupMergeStateForReverification(
|
||||||
task,
|
task,
|
||||||
`Task returned to in-progress from ${from} column — resetting verification steps and merge state for re-verification`,
|
`Task returned to in-progress from ${from} column — resetting verification steps and merge state for re-verification`,
|
||||||
|
{
|
||||||
|
// Keep deterministic merge-verification bounce budget across remediation
|
||||||
|
// cycles. Status may be cleared by intermediate paths, so the counter is
|
||||||
|
// the canonical signal once a bounce has started.
|
||||||
|
preserveVerificationFailureCount: (task.verificationFailureCount ?? 0) > 0,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cleanupMergeStateForReverification(task: Task, logMessage: string): Promise<Task> {
|
private async cleanupMergeStateForReverification(
|
||||||
|
task: Task,
|
||||||
|
logMessage: string,
|
||||||
|
options?: { preserveVerificationFailureCount?: boolean },
|
||||||
|
): Promise<Task> {
|
||||||
await this.store.updateTask(task.id, {
|
await this.store.updateTask(task.id, {
|
||||||
mergeDetails: null,
|
mergeDetails: null,
|
||||||
mergeRetries: 0,
|
mergeRetries: 0,
|
||||||
verificationFailureCount: 0,
|
verificationFailureCount: options?.preserveVerificationFailureCount ? task.verificationFailureCount ?? 0 : 0,
|
||||||
workflowStepResults: [],
|
workflowStepResults: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1990,7 +2001,7 @@ export class TaskExecutor {
|
|||||||
// Skip for tasks that are already in-progress, in-review, merging, or done —
|
// Skip for tasks that are already in-progress, in-review, merging, or done —
|
||||||
// these should not be interrupted and sent back to triage for re-planning.
|
// these should not be interrupted and sent back to triage for re-planning.
|
||||||
const activeColumns = new Set(["in-progress", "in-review", "done"]);
|
const activeColumns = new Set(["in-progress", "in-review", "done"]);
|
||||||
const activeMergeStatuses = new Set(["merging", "merging-pr"]);
|
const activeMergeStatuses = new Set(["merging", "merging-pr", "merging-fix"]);
|
||||||
const isActiveTask = activeColumns.has(task.column) || activeMergeStatuses.has(task.status ?? "");
|
const isActiveTask = activeColumns.has(task.column) || activeMergeStatuses.has(task.status ?? "");
|
||||||
if (!isActiveTask) {
|
if (!isActiveTask) {
|
||||||
const tasksDir = join(this.store.getFusionDir(), "tasks");
|
const tasksDir = join(this.store.getFusionDir(), "tasks");
|
||||||
@@ -2440,6 +2451,8 @@ export class TaskExecutor {
|
|||||||
`${failedType} command \`${failedCommand}\` failed (exit ${failedResult.exitCode}):\n${summary}`,
|
`${failedType} command \`${failedCommand}\` failed (exit ${failedResult.exitCode}):\n${summary}`,
|
||||||
`Verification (${failedType})`,
|
`Verification (${failedType})`,
|
||||||
`Deterministic verification failed (${failedType})`,
|
`Deterministic verification failed (${failedType})`,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2485,6 +2498,8 @@ export class TaskExecutor {
|
|||||||
`${failedType} command \`${failedCommand}\` failed (exit ${failedResult.exitCode}) after ${maxFixRetries} fix attempts:\n${summary}`,
|
`${failedType} command \`${failedCommand}\` failed (exit ${failedResult.exitCode}) after ${maxFixRetries} fix attempts:\n${summary}`,
|
||||||
`Verification (${failedType})`,
|
`Verification (${failedType})`,
|
||||||
`Deterministic verification failed after ${maxFixRetries} fix attempts`,
|
`Deterministic verification failed after ${maxFixRetries} fix attempts`,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4604,6 +4619,7 @@ ${failureContext.output.slice(0, VERIFICATION_LOG_MAX_CHARS)}
|
|||||||
stepName: string,
|
stepName: string,
|
||||||
reason: string,
|
reason: string,
|
||||||
preserveResumeState: boolean = true,
|
preserveResumeState: boolean = true,
|
||||||
|
mergeVerificationFailure: boolean = false,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const taskId = task.id;
|
const taskId = task.id;
|
||||||
this.clearCompletedTaskWatchdog(taskId);
|
this.clearCompletedTaskWatchdog(taskId);
|
||||||
@@ -4634,7 +4650,7 @@ ${failureContext.output.slice(0, VERIFICATION_LOG_MAX_CHARS)}
|
|||||||
|
|
||||||
// 5. Clear error/status/session fields and reset workflow step retries
|
// 5. Clear error/status/session fields and reset workflow step retries
|
||||||
await this.store.updateTask(taskId, {
|
await this.store.updateTask(taskId, {
|
||||||
status: null,
|
status: mergeVerificationFailure ? "merging-fix" : null,
|
||||||
error: null,
|
error: null,
|
||||||
sessionFile: null,
|
sessionFile: null,
|
||||||
workflowStepRetries: 0,
|
workflowStepRetries: 0,
|
||||||
|
|||||||
@@ -1404,7 +1404,7 @@ export class ProjectEngine {
|
|||||||
"agent",
|
"agent",
|
||||||
);
|
);
|
||||||
await store.updateTask(taskId, {
|
await store.updateTask(taskId, {
|
||||||
status: null,
|
status: "merging-fix",
|
||||||
mergeRetries: 0,
|
mergeRetries: 0,
|
||||||
error: null,
|
error: null,
|
||||||
verificationFailureCount: nextBounces,
|
verificationFailureCount: nextBounces,
|
||||||
@@ -1412,10 +1412,10 @@ export class ProjectEngine {
|
|||||||
await store.moveTask(taskId, "in-progress");
|
await store.moveTask(taskId, "in-progress");
|
||||||
await store.logEntry(
|
await store.logEntry(
|
||||||
taskId,
|
taskId,
|
||||||
`Deterministic ${failedKind} verification failed (${nextBounces}/${cap}) — moved back to in-progress for remediation`,
|
`Deterministic ${failedKind} verification failed (${nextBounces}/${cap}) — moved back to in-progress with status=merging-fix for remediation`,
|
||||||
);
|
);
|
||||||
runtimeLog.log(
|
runtimeLog.log(
|
||||||
`Auto-merge: ${taskId} deterministic ${failedKind} verification failed (${nextBounces}/${cap}) — moved to in-progress`,
|
`Auto-merge: ${taskId} deterministic ${failedKind} verification failed (${nextBounces}/${cap}) — moved to in-progress with status=merging-fix`,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
runtimeLog.error(
|
runtimeLog.error(
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export interface SelfHealingOptions {
|
|||||||
|
|
||||||
const APPROVED_TRIAGE_RECOVERY_GRACE_MS = 60_000;
|
const APPROVED_TRIAGE_RECOVERY_GRACE_MS = 60_000;
|
||||||
const ORPHANED_EXECUTION_RECOVERY_GRACE_MS = 60_000;
|
const ORPHANED_EXECUTION_RECOVERY_GRACE_MS = 60_000;
|
||||||
const ACTIVE_MERGE_STATUSES = new Set(["merging", "merging-pr"]);
|
const ACTIVE_MERGE_STATUSES = new Set(["merging", "merging-pr", "merging-fix"]);
|
||||||
const NON_TERMINAL_STEP_STATUSES = new Set(["pending", "in-progress"]);
|
const NON_TERMINAL_STEP_STATUSES = new Set(["pending", "in-progress"]);
|
||||||
/** Statuses that represent an explicit human-handoff or active merge —
|
/** Statuses that represent an explicit human-handoff or active merge —
|
||||||
* the ghost-review fallback must not disturb tasks parked in these states. */
|
* the ghost-review fallback must not disturb tasks parked in these states. */
|
||||||
@@ -89,6 +89,7 @@ const GHOST_REVIEW_PRESERVED_STATUSES = new Set([
|
|||||||
"awaiting-approval",
|
"awaiting-approval",
|
||||||
"merging",
|
"merging",
|
||||||
"merging-pr",
|
"merging-pr",
|
||||||
|
"merging-fix",
|
||||||
]);
|
]);
|
||||||
/**
|
/**
|
||||||
* Longer grace period for tasks that still have a worktree on disk.
|
* Longer grace period for tasks that still have a worktree on disk.
|
||||||
@@ -1040,7 +1041,7 @@ export class SelfHealingManager {
|
|||||||
*
|
*
|
||||||
* Preserved statuses (skipped):
|
* Preserved statuses (skipped):
|
||||||
* - `awaiting-user-review`, `awaiting-approval`: explicit human handoff
|
* - `awaiting-user-review`, `awaiting-approval`: explicit human handoff
|
||||||
* - `merging`, `merging-pr`: handled by `recoverInterruptedMergingTasks`
|
* - `merging`, `merging-pr`, `merging-fix`: handled by `recoverInterruptedMergingTasks`
|
||||||
*
|
*
|
||||||
* Rate-limiting comes from the `updatedAt >= taskStuckTimeoutMs` gate —
|
* Rate-limiting comes from the `updatedAt >= taskStuckTimeoutMs` gate —
|
||||||
* each kick refreshes `updatedAt`, so a task that re-enters review and gets
|
* each kick refreshes `updatedAt`, so a task that re-enters review and gets
|
||||||
|
|||||||
Reference in New Issue
Block a user