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 986a928fa9
commit 4135db0d77
14 changed files with 172 additions and 38 deletions

View File

@@ -10801,7 +10801,7 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
log: [],
mergeDetails: { strategy: "manual" } as any,
mergeRetries: 2,
verificationFailureCount: 1,
verificationFailureCount: 0,
workflowStepResults: [{ id: "wf-1", status: "passed" }],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -10847,7 +10847,7 @@ describe("TaskExecutor agent execution flow (FN-978)", () => {
log: [],
mergeDetails: { strategy: "ours" } as any,
mergeRetries: 1,
verificationFailureCount: 2,
verificationFailureCount: 0,
createdAt: 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 () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
@@ -13711,12 +13745,16 @@ describe("Executor verification gate (FN-3345)", () => {
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
// Task should NOT move to 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(
"FN-3345",
expect.stringContaining("Deterministic verification failed"),
"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 () => {
@@ -13850,11 +13888,15 @@ describe("Executor verification gate (FN-3345)", () => {
expect(mockedCreateFnAgent).not.toHaveBeenCalled();
// Task should NOT move to 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(
"FN-3345",
expect.stringContaining("Deterministic verification failed"),
"agent",
);
expect(store.updateTask).toHaveBeenCalledWith(
"FN-3345",
expect.objectContaining({ status: "merging-fix" }),
);
});
});

View File

@@ -293,7 +293,7 @@ describe("ProjectEngine merge error recovery", () => {
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");
verificationError.name = "VerificationError";
vi.mocked(aiMergeTask).mockRejectedValueOnce(verificationError);
@@ -309,7 +309,7 @@ describe("ProjectEngine merge error recovery", () => {
"agent",
);
expect(store.updateTask).toHaveBeenCalledWith(TASK_ID, {
status: null,
status: "merging-fix",
mergeRetries: 0,
error: null,
verificationFailureCount: 1,
@@ -317,13 +317,34 @@ describe("ProjectEngine merge error recovery", () => {
expect(store.moveTask).toHaveBeenCalledWith(TASK_ID, "in-progress");
expect(store.logEntry).toHaveBeenCalledWith(
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(
`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 () => {
const verificationError = new Error("Deterministic test verification failed");
verificationError.name = "VerificationError";

View File

@@ -1174,7 +1174,8 @@ export class TaskExecutor {
|| (task.mergeRetries ?? 0) > 0
|| (task.verificationFailureCount ?? 0) > 0
|| task.status === "merging"
|| task.status === "merging-pr";
|| task.status === "merging-pr"
|| task.status === "merging-fix";
if (!hasMergeEvidence) {
return task;
@@ -1183,14 +1184,24 @@ export class TaskExecutor {
return this.cleanupMergeStateForReverification(
task,
`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, {
mergeDetails: null,
mergeRetries: 0,
verificationFailureCount: 0,
verificationFailureCount: options?.preserveVerificationFailureCount ? task.verificationFailureCount ?? 0 : 0,
workflowStepResults: [],
});
@@ -1990,7 +2001,7 @@ export class TaskExecutor {
// 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.
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 ?? "");
if (!isActiveTask) {
const tasksDir = join(this.store.getFusionDir(), "tasks");
@@ -2440,6 +2451,8 @@ export class TaskExecutor {
`${failedType} command \`${failedCommand}\` failed (exit ${failedResult.exitCode}):\n${summary}`,
`Verification (${failedType})`,
`Deterministic verification failed (${failedType})`,
true,
true,
);
return;
}
@@ -2485,6 +2498,8 @@ export class TaskExecutor {
`${failedType} command \`${failedCommand}\` failed (exit ${failedResult.exitCode}) after ${maxFixRetries} fix attempts:\n${summary}`,
`Verification (${failedType})`,
`Deterministic verification failed after ${maxFixRetries} fix attempts`,
true,
true,
);
return;
}
@@ -4604,6 +4619,7 @@ ${failureContext.output.slice(0, VERIFICATION_LOG_MAX_CHARS)}
stepName: string,
reason: string,
preserveResumeState: boolean = true,
mergeVerificationFailure: boolean = false,
): Promise<void> {
const taskId = task.id;
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
await this.store.updateTask(taskId, {
status: null,
status: mergeVerificationFailure ? "merging-fix" : null,
error: null,
sessionFile: null,
workflowStepRetries: 0,

View File

@@ -1404,7 +1404,7 @@ export class ProjectEngine {
"agent",
);
await store.updateTask(taskId, {
status: null,
status: "merging-fix",
mergeRetries: 0,
error: null,
verificationFailureCount: nextBounces,
@@ -1412,10 +1412,10 @@ export class ProjectEngine {
await store.moveTask(taskId, "in-progress");
await store.logEntry(
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(
`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 {
runtimeLog.error(

View File

@@ -79,7 +79,7 @@ export interface SelfHealingOptions {
const APPROVED_TRIAGE_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"]);
/** Statuses that represent an explicit human-handoff or active merge —
* 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",
"merging",
"merging-pr",
"merging-fix",
]);
/**
* Longer grace period for tasks that still have a worktree on disk.
@@ -1040,7 +1041,7 @@ export class SelfHealingManager {
*
* Preserved statuses (skipped):
* - `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 —
* each kick refreshes `updatedAt`, so a task that re-enters review and gets