feat(FN-3939): audit and patch PR #59 residual defects, add retry parity an

Completes FN-3939 audit work for PR #59 residual defects by refactoring the mission execution loop in the engine (halving its complexity), adding self-healing test coverage, and wiring in task workflow route improvements. Retry and validation behavior updates are documented in the changeset and skil

Fusion-Task-Id: FN-3939
This commit is contained in:
Fusion
2026-05-10 16:19:04 -07:00
committed by gsxdsm
parent 60d16aa267
commit 76c00fbadd
15 changed files with 308 additions and 94 deletions

View File

@@ -852,7 +852,7 @@
height: 100%;
background: var(--color-success);
border-radius: 2px;
transition: width 0.3s ease;
transition: width var(--transition-slow);
}
.mission-health-badge {
@@ -1485,7 +1485,7 @@
}
.mission-plan-state-indicator--not-started {
background: color-mix(in srgb, var(--text-secondary) 30%, transparent);
background: color-mix(in srgb, var(--text-muted) 30%, transparent);
}
.mission-plan-state-indicator--planned {
@@ -1512,15 +1512,15 @@
.mission-triage-preview {
margin-top: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-raised);
background: var(--surface);
}
.mission-triage-preview__header {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
color: var(--text-muted);
margin-bottom: var(--space-xs);
text-transform: uppercase;
letter-spacing: 0.5px;
@@ -1530,7 +1530,7 @@
white-space: pre-wrap;
font-size: 12px;
font-family: var(--font-mono, monospace);
color: var(--text-secondary);
color: var(--text-muted);
max-height: 200px;
overflow-y: auto;
padding: var(--space-xs);
@@ -1601,7 +1601,7 @@
.mission-assertions__title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
@@ -1621,7 +1621,7 @@
/* ── Single Assertion ── */
.mission-assertion {
background: var(--surface-raised);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: var(--space-sm);
@@ -1653,7 +1653,7 @@
.mission-assertion__text {
font-size: 12px;
color: var(--text-secondary);
color: var(--text-muted);
white-space: pre-wrap;
line-height: 1.5;
margin-bottom: var(--space-sm);
@@ -1662,7 +1662,7 @@
.mission-assertion__linked-count {
font-size: 11px;
color: var(--text-dim);
background: var(--surface-raised);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 1px 6px;
@@ -1700,7 +1700,7 @@
left: 0;
right: 0;
z-index: 100;
background: var(--surface-raised);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-md);
@@ -1730,7 +1730,7 @@
border: none;
cursor: pointer;
text-align: left;
transition: background-color 0.1s;
transition: background-color var(--transition-instant);
}
.mission-assertion__feature-picker-item:hover:not(:disabled) {
@@ -1762,7 +1762,7 @@
align-items: center;
gap: var(--space-sm);
padding: var(--space-xs) 0;
border-bottom: 1px solid var(--border-subtle);
border-bottom: 1px solid var(--border);
}
.mission-assertion__linked-feature:last-child {
@@ -2064,7 +2064,7 @@
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-raised);
background: var(--surface);
display: flex;
flex-direction: column;
gap: var(--space-sm);

View File

@@ -429,7 +429,7 @@ describe("POST /tasks/:id/retry", () => {
mergeRetries: 0,
});
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Retry requested from dashboard (in-review retry, mergeRetries reset)");
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Retry requested from dashboard (in-review merge retry, mergeRetries reset)");
});
it("retries a stuck-killed in-review task without moving columns", async () => {
@@ -451,7 +451,7 @@ describe("POST /tasks/:id/retry", () => {
mergeRetries: 0,
});
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Retry requested from dashboard (in-review retry, mergeRetries reset)");
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Retry requested from dashboard (in-review merge retry, mergeRetries reset)");
});
it("preserves worktree/branch when retrying in-review task", async () => {
@@ -482,6 +482,102 @@ describe("POST /tasks/:id/retry", () => {
expect(updateCall).not.toHaveProperty("nextRecoveryAt");
});
it("retries execution-failed in-review task by moving to todo with progress preserved", async () => {
const executionFailedTask = {
...FAKE_TASK_DETAIL,
column: "in-review" as const,
status: "failed",
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "in-progress" },
{ name: "Step 2", status: "pending" },
],
};
const movedTask = { ...executionFailedTask, column: "todo" as const, status: undefined };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(executionFailedTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(executionFailedTask);
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
status: null,
error: null,
stuckKillCount: 0,
});
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true });
expect(store.logEntry).toHaveBeenCalledWith(
"KB-001",
"Retry requested from dashboard (execution failure in-review → todo, preserving progress)",
);
});
it("retries merge-failed in-review task by staying in-review with mergeRetries reset", async () => {
const mergeFailedTask = {
...FAKE_TASK_DETAIL,
column: "in-review" as const,
status: "failed",
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "done" },
{ name: "Step 2", status: "done" },
],
};
(store.getTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce(mergeFailedTask)
.mockResolvedValueOnce(mergeFailedTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(mergeFailedTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
status: null,
error: null,
stuckKillCount: 0,
mergeRetries: 0,
});
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.logEntry).toHaveBeenCalledWith(
"KB-001",
"Retry requested from dashboard (in-review merge retry, mergeRetries reset)",
);
});
it("retries stuck-killed in-review task with incomplete steps moves to todo", async () => {
const stuckTask = {
...FAKE_TASK_DETAIL,
column: "in-review" as const,
status: "stuck-killed",
steps: [
{ name: "Step 0", status: "done" },
{ name: "Step 1", status: "pending" },
],
};
const movedTask = { ...stuckTask, column: "todo" as const, status: undefined };
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValueOnce(stuckTask);
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(stuckTask);
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo", { preserveProgress: true });
expect(store.logEntry).toHaveBeenCalledWith(
"KB-001",
"Retry requested from dashboard (execution failure in-review → todo, preserving progress)",
);
const updateCall = (store.updateTask as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(updateCall).not.toHaveProperty("mergeRetries");
});
it("retries a stranded planning triage task in triage and removes stale prompt", async () => {
const tempRoot = mkdtempSync(join(tmpdir(), "kb-task-retry-spec-"));
const taskDir = join(tempRoot, ".fusion", "tasks", "FN-001");

View File

@@ -528,16 +528,35 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
throw badRequest(`Task is not in a retryable state (current status: ${task.status || 'none'})`);
}
// In-review retry: keep the task in in-review, clear only error/retry state
// so the auto-merge system re-attempts on its next sweep.
// In-review retry: distinguish between execution failures (incomplete steps)
// and merge failures (all steps done).
if (isInReviewRetry) {
const hasIncompleteSteps =
task.steps.length > 0 &&
task.steps.some((s: { status: string }) => s.status === "pending" || s.status === "in-progress");
if (hasIncompleteSteps) {
await scopedStore.updateTask(req.params.id, {
status: null,
error: null,
stuckKillCount: 0,
});
await scopedStore.logEntry(
req.params.id,
"Retry requested from dashboard (execution failure in-review → todo, preserving progress)",
);
const updated = await scopedStore.moveTask(req.params.id, "todo", { preserveProgress: true });
res.json(updated);
return;
}
await scopedStore.updateTask(req.params.id, {
status: null,
error: null,
stuckKillCount: 0,
mergeRetries: 0,
});
await scopedStore.logEntry(req.params.id, "Retry requested from dashboard (in-review retry, mergeRetries reset)");
await scopedStore.logEntry(req.params.id, "Retry requested from dashboard (in-review merge retry, mergeRetries reset)");
const updated = await scopedStore.getTask(req.params.id);
res.json(updated);
return;