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:
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user