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:
Fusion
2026-05-04 06:55:59 -07:00
committed by gsxdsm
parent 004a0ad904
commit 1bd32a542d
14 changed files with 172 additions and 38 deletions

View File

@@ -86,8 +86,8 @@ function sortTasksForColumn(tasks: Task[], column: ColumnType): Task[] {
return [...tasks].sort((a, b) => {
// In the in-review column, merging tasks stay pinned above non-merging tasks.
if (column === "in-review") {
const aIsMerging = a.status === "merging" || a.status === "merging-pr";
const bIsMerging = b.status === "merging" || b.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" || b.status === "merging-fix";
if (aIsMerging !== bIsMerging) {
return aIsMerging ? -1 : 1;
}

View File

@@ -25,9 +25,14 @@ const COLUMN_COLOR_MAP: Record<Column, string> = {
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";
function getTaskStatusLabel(status: string): string {
if (status === "merging-fix") return "Merging fixes…";
return status;
}
type SortDirection = "asc" | "desc";
// Column visibility types
@@ -1263,7 +1268,7 @@ export function ListView({
<span className="list-status-badge stuck">Stuck</span>
) : hasStatus ? (
<span className={`list-status-badge list-status-badge--${task.column}${isFailed ? " failed" : ""}${isAgentActive ? " pulsing" : ""}`}>
{task.status}
{getTaskStatusLabel(task.status ?? "")}
</span>
) : null}
</div>
@@ -1465,7 +1470,7 @@ export function ListView({
isAgentActive ? " pulsing" : ""
}`}
>
{task.status}
{getTaskStatusLabel(task.status ?? "")}
</span>
) : (
<span className="list-status-badge">-</span>

View File

@@ -25,7 +25,7 @@ function getRoutingPolicyLabel(policy: RoutingSettings["unavailableNodePolicy"]
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 {
return status !== undefined && status !== "online";

View File

@@ -83,8 +83,8 @@ function abbreviateBadge(text: string, max: number): string {
const EDITABLE_COLUMNS: Set<Column> = new Set(["triage", "todo"]);
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging"]);
const ACTIVE_MERGE_STATUSES = new Set(["merging", "merging-pr"]);
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
const ACTIVE_MERGE_STATUSES = new Set(["merging", "merging-pr", "merging-fix"]);
const COLUMN_PROGRESS_COLOR_MAP: Record<Column, string> = {
triage: "var(--triage)",
@@ -102,6 +102,11 @@ const TIME_INDICATOR_COLUMNS = new Set<Column>([
]);
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 {
if (!value) return null;
const parsed = Date.parse(value);
@@ -1323,7 +1328,7 @@ function TaskCardComponent({
<span
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>
)}
{isStuck && (isPaused || !task.status || task.status === "queued") && (

View File

@@ -44,7 +44,7 @@ interface ModelSelection {
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…",
"awaiting-pr-checks": "Awaiting PR checks",
"merging-pr": "Merging PR…",
"merging-fix": "Merging fixes…",
};
const prAutomationLabel = task.status ? prAutomationStatusLabels[task.status] : undefined;

View File

@@ -403,6 +403,28 @@ describe("Board", () => {
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", () => {
const tasks: Task[] = [
createTask({

View File

@@ -620,12 +620,15 @@ describe("ListView", () => {
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 = [
createMockTask({
id: "FN-001",
status: "executing",
column: "in-progress",
status,
column,
}),
];
@@ -633,6 +636,7 @@ describe("ListView", () => {
const row = screen.getByText("FN-001").closest("tr");
expect(row?.className).toContain("agent-active");
expect(screen.getByText(label)).toBeInTheDocument();
});
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);
});
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();
const { container } = renderListView({
tasks: [
createMockTask({
id: "FN-001",
status: "executing",
column: "in-progress",
status,
column,
}),
],
globalPaused: false,

View File

@@ -224,8 +224,8 @@ describe("RoutingTab", () => {
expect(screen.getByText("Node override cannot be changed while the task is active.")).toBeInTheDocument();
});
it("disables node selector for active task statuses", async () => {
render(<RoutingTab task={makeTask({ column: "todo", status: "executing" })} settings={makeSettings()} addToast={addToast} />);
it.each(["executing", "merging-fix"] as const)("disables node selector for active task status %s", async (status) => {
render(<RoutingTab task={makeTask({ column: "todo", status })} settings={makeSettings()} addToast={addToast} />);
const selector = await screen.findByLabelText("Select execution node");
expect(selector).toBeDisabled();

View File

@@ -70,6 +70,20 @@ describe("TaskCard", () => {
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", () => {
const { container } = render(
<TaskCard
@@ -856,7 +870,7 @@ describe("TaskCard", () => {
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.setSystemTime(new Date("2026-04-25T13:45:00.000Z"));
@@ -865,7 +879,7 @@ describe("TaskCard", () => {
<TaskCard
task={makeTask({
column: "in-review",
status: "merging",
status,
executionStartedAt: "2026-04-25T13:00:00.000Z",
updatedAt: "2026-04-25T13:44:30.000Z",
workflowStepResults: [