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:
5
.changeset/fn-3939-retry-review-audit.md
Normal file
5
.changeset/fn-3939-retry-review-audit.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Align `fn_task_retry` retry classification for `in-review` failures across dashboard and CLI surfaces. Execution-failed review tasks (incomplete steps) now retry back to `todo` with preserved progress, while merge-only failures (all steps done) stay in `in-review` with merge retry state reset. Also removes visible mission validation board-task creation in favor of internal validator runs.
|
||||
@@ -216,6 +216,8 @@ On task completion, the scheduler calls `MissionExecutionLoop.processTaskOutcome
|
||||
3. Fire AI validator agent against contract assertions
|
||||
4. Record `MissionValidatorRun` with per-assertion results
|
||||
|
||||
Validation runs are internal mission-loop operations: Fusion does **not** create visible `🔍 Validate:` board tasks for single-feature validation.
|
||||
|
||||
```typescript
|
||||
interface MissionValidatorRun {
|
||||
id: string;
|
||||
|
||||
@@ -92,6 +92,7 @@ Fusion task columns:
|
||||
3. **in-progress** — executor active in isolated worktree
|
||||
4. **in-review** — implementation complete; awaiting finalization
|
||||
- If merge/finalization hits a terminal error, tasks can remain in `in-review` with `status: "failed"` for explicit follow-up. This state is intentionally preserved by recovery (not auto-bounced to `todo`).
|
||||
- Retry behavior splits by step completion: `in-review` tasks with incomplete steps (`pending`/`in-progress`) are treated as execution failures and retried back to `todo` with `preserveProgress: true`; `in-review` tasks with all steps `done` are treated as merge/finalization failures and stay in `in-review` with merge retry state reset.
|
||||
- Self-healing can still auto-finalize retry-exhausted failed review tasks when it can prove their branch content already landed on the merge target, so already-merged work does not deadlock in `in-review`.
|
||||
5. **done** — merged/finalized
|
||||
6. **archived** — preserved history, optionally cleaned from filesystem
|
||||
|
||||
@@ -75,7 +75,7 @@ Unpause a task — resumes automated agent and scheduler interaction.
|
||||
|
||||
### fn_task_retry
|
||||
|
||||
Retry a failed task — clears the error state. Tasks in other columns move to todo; tasks in in-review stay in-place for auto-merge retry.
|
||||
Retry a failed task — clears the error state. Non-review failures move to todo; in-review execution failures move to todo preserving progress; in-review merge failures stay in-place for auto-merge retry.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
|
||||
@@ -19,7 +19,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
|
||||
| `fn_task_attach` | Attach a file to a task. Supports images (png, jpg, gif, webp) and text files (txt, log, json, yaml, yml, toml, csv, xml). |
|
||||
| `fn_task_pause` | Pause a task — stops all automated agent and scheduler interaction for this task. |
|
||||
| `fn_task_unpause` | Unpause a task — resumes automated agent and scheduler interaction. |
|
||||
| `fn_task_retry` | Retry a failed task — clears the error state. Tasks in other columns move to todo; tasks in in-review stay in-place for auto-merge retry. |
|
||||
| `fn_task_retry` | Retry a failed task — clears the error state. Non-review failures move to todo; in-review execution failures move to todo preserving progress; in-review merge failures stay in-place for auto-merge retry. |
|
||||
| `fn_task_duplicate` | Duplicate an existing task, creating a fresh copy in planning. Copies the title and description but resets all execution state. The AI planning agent will replan the new task. |
|
||||
| `fn_task_refine` | Request a refinement of a completed or in-review task. Creates a new follow-up task in planning that references the original task as a dependency. Use this when a done or in-review task needs additional work, improvements, or follow-up changes. |
|
||||
| `fn_task_archive` | Archive a done task (move from done → archived). Archived tasks are preserved for historical reference but moved out of the main board view. |
|
||||
|
||||
@@ -1693,6 +1693,73 @@ describe("fn pi extension (runnable structured-output regression slice)", () =>
|
||||
});
|
||||
});
|
||||
|
||||
describe("fn_task_retry", () => {
|
||||
it("moves execution-failed in-review task (incomplete steps) to todo preserving progress", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
|
||||
const task = await store.createTask({
|
||||
title: "execution-failed task",
|
||||
description: "test",
|
||||
column: "todo",
|
||||
});
|
||||
await store.updateTask(task.id, {
|
||||
steps: [
|
||||
{ name: "Step 0", status: "done" },
|
||||
{ name: "Step 1", status: "in-progress" },
|
||||
{ name: "Step 2", status: "pending" },
|
||||
],
|
||||
});
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.updateTask(task.id, { status: "failed", error: "429 rate limited" });
|
||||
|
||||
const retryTool = api.tools.get("fn_task_retry")!;
|
||||
const result = await retryTool.execute("retry-exec", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.details.newColumn).toBe("todo");
|
||||
|
||||
const updated = await store.getTask(task.id);
|
||||
expect(updated?.column).toBe("todo");
|
||||
expect(updated?.status).toBeFalsy();
|
||||
expect(updated?.error).toBeFalsy();
|
||||
expect(updated?.steps[1].status).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("keeps merge-failed in-review task (all steps done) in in-review and resets merge state", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
|
||||
const task = await store.createTask({
|
||||
title: "merge-failed task",
|
||||
description: "test",
|
||||
column: "todo",
|
||||
});
|
||||
await store.updateTask(task.id, {
|
||||
steps: [
|
||||
{ name: "Step 0", status: "done" },
|
||||
{ name: "Step 1", status: "done" },
|
||||
],
|
||||
});
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "in-review");
|
||||
await store.updateTask(task.id, { status: "failed", error: "merge conflict", mergeRetries: 3 });
|
||||
|
||||
const retryTool = api.tools.get("fn_task_retry")!;
|
||||
const result = await retryTool.execute("retry-merge", { id: task.id }, undefined, undefined, makeCtx(tmpDir));
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.details.newColumn).toBe("in-review");
|
||||
|
||||
const updated = await store.getTask(task.id);
|
||||
expect(updated?.column).toBe("in-review");
|
||||
expect(updated?.status).toBeFalsy();
|
||||
expect(updated?.error).toBeFalsy();
|
||||
expect(updated?.mergeRetries).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fn_list_agents", () => {
|
||||
it("returns agent list", async () => {
|
||||
await seedAgent(tmpDir, { name: "alpha-agent" });
|
||||
|
||||
@@ -826,12 +826,13 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
name: "fn_task_retry",
|
||||
label: "fn: Retry Task",
|
||||
description:
|
||||
"Retry a failed task — clears the error state. Tasks in other columns move to todo; tasks in in-review stay in-place for auto-merge retry.",
|
||||
"Retry a failed task — clears the error state. Non-review failures move to todo; in-review execution failures move to todo preserving progress; in-review merge failures stay in-place for auto-merge retry.",
|
||||
promptSnippet: "Retry a failed Fusion task (clears error, moves to todo or stays in in-review)",
|
||||
promptGuidelines: [
|
||||
"Use when a task has failed and needs to be retried",
|
||||
"Only tasks in 'failed' or 'stuck-killed' state can be retried",
|
||||
"Tasks in 'in-review' stay in in-review — only the error/retry state is cleared, and the auto-merge system re-attempts",
|
||||
"In-review tasks with incomplete steps (pending/in-progress) move to todo with preserveProgress so execution can resume",
|
||||
"In-review tasks with all steps done stay in in-review and reset merge retry state for auto-merge re-attempt",
|
||||
"Tasks in other columns are moved to the todo column with error state cleared",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
@@ -862,12 +863,26 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
};
|
||||
}
|
||||
|
||||
// In-review retry: keep the task in in-review, clear only error/retry state
|
||||
// In-review retry: distinguish between execution failures and merge failures.
|
||||
if (task.column === 'in-review') {
|
||||
const hasIncompleteSteps =
|
||||
task.steps.length > 0 &&
|
||||
task.steps.some((s: { status: string }) => s.status === "pending" || s.status === "in-progress");
|
||||
|
||||
if (hasIncompleteSteps) {
|
||||
await store.updateTask(params.id, { status: null, error: null, stuckKillCount: 0 });
|
||||
await store.logEntry(params.id, "Retry requested via Fusion extension (execution failure in-review → todo, preserving progress)");
|
||||
await store.moveTask(params.id, "todo", { preserveProgress: true });
|
||||
return {
|
||||
content: [{ type: "text", text: `Retried ${params.id} → todo (execution failure, preserving step progress)` }],
|
||||
details: { taskId: params.id, newColumn: 'todo' },
|
||||
};
|
||||
}
|
||||
|
||||
await store.updateTask(params.id, { status: null, error: null, stuckKillCount: 0, mergeRetries: 0 });
|
||||
await store.logEntry(params.id, "Retry requested via Fusion extension (in-review retry, mergeRetries reset)");
|
||||
await store.logEntry(params.id, "Retry requested via Fusion extension (in-review merge retry, mergeRetries reset)");
|
||||
return {
|
||||
content: [{ type: "text", text: `Retried ${params.id} → in-review (merge retry state cleared, task stays in in-review)` }],
|
||||
content: [{ type: "text", text: `Retried ${params.id} → in-review (merge retry state cleared)` }],
|
||||
details: { taskId: params.id, newColumn: 'in-review' },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -472,7 +472,7 @@ describe("MissionExecutionLoop", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("creates validation board task when feature has assertions", async () => {
|
||||
it("does NOT create a board task for single-feature validation", async () => {
|
||||
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001", sliceId: "SL-001" });
|
||||
missionStore._setFeature(feature);
|
||||
taskStore._setTask({ id: "FN-001", title: "Test", description: "Test task", log: [] });
|
||||
@@ -490,17 +490,11 @@ describe("MissionExecutionLoop", () => {
|
||||
|
||||
await loop.processTaskOutcome("FN-001");
|
||||
|
||||
// Should create a validation board task
|
||||
expect(taskStore.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: expect.stringContaining("Validate:"),
|
||||
column: "in-progress",
|
||||
sliceId: "SL-001",
|
||||
}),
|
||||
);
|
||||
expect(taskStore.createTask).toHaveBeenCalledTimes(0);
|
||||
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion");
|
||||
});
|
||||
|
||||
it("sets validation task status to mission-validation", async () => {
|
||||
it("does NOT set mission-validation status on any task", async () => {
|
||||
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001", sliceId: "SL-001" });
|
||||
missionStore._setFeature(feature);
|
||||
taskStore._setTask({ id: "FN-001", title: "Test", description: "Test task", log: [] });
|
||||
@@ -518,14 +512,13 @@ describe("MissionExecutionLoop", () => {
|
||||
|
||||
await loop.processTaskOutcome("FN-001");
|
||||
|
||||
// Should update the task status to mission-validation
|
||||
expect(taskStore.updateTask).toHaveBeenCalledWith(
|
||||
expect(taskStore.updateTask).not.toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ status: "mission-validation" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes taskId to startValidatorRun", async () => {
|
||||
it("calls startValidatorRun without a board task ID", async () => {
|
||||
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001", sliceId: "SL-001" });
|
||||
missionStore._setFeature(feature);
|
||||
taskStore._setTask({ id: "FN-001", title: "Test", description: "Test task", log: [] });
|
||||
@@ -533,8 +526,6 @@ describe("MissionExecutionLoop", () => {
|
||||
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue([
|
||||
{ id: "CA-1", milestoneId: "MS-001", title: "Test assertion", assertion: "Should work", status: "pending" as const, orderIndex: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
]);
|
||||
// Make createTask return a predictable ID
|
||||
taskStore.createTask = vi.fn().mockResolvedValue({ id: "KB-999" });
|
||||
|
||||
loop = new MissionExecutionLoop({
|
||||
taskStore: taskStore as any,
|
||||
@@ -545,11 +536,9 @@ describe("MissionExecutionLoop", () => {
|
||||
|
||||
await loop.processTaskOutcome("FN-001");
|
||||
|
||||
// Should pass the created task ID to startValidatorRun
|
||||
expect(missionStore.startValidatorRun).toHaveBeenCalledWith(
|
||||
"F-001",
|
||||
"task_completion",
|
||||
"KB-999",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2310,6 +2310,65 @@ describe("SelfHealingManager", () => {
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("detects stale in-review task using columnMovedAt when available", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
});
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ taskStuckTimeoutMs: 60_000 });
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-407-test-1",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
status: null,
|
||||
columnMovedAt: new Date(Date.now() - 120_000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 5_000).toISOString(),
|
||||
steps: [
|
||||
{ name: "Step 0", status: "done" },
|
||||
{ name: "Step 1", status: "in-progress" },
|
||||
],
|
||||
workflowStepResults: [],
|
||||
mergeDetails: undefined,
|
||||
log: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await managerWithRecovery.recoverStaleIncompleteReviewTasks();
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-407-test-1", "todo", { preserveProgress: true });
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("falls back to updatedAt for staleness when columnMovedAt is null (legacy tasks)", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
});
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ taskStuckTimeoutMs: 60_000 });
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
id: "FN-407-test-2",
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
status: null,
|
||||
columnMovedAt: null,
|
||||
updatedAt: new Date(Date.now() - 120_000).toISOString(),
|
||||
steps: [{ name: "Step 0", status: "in-progress" }],
|
||||
workflowStepResults: [],
|
||||
mergeDetails: undefined,
|
||||
log: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await managerWithRecovery.recoverStaleIncompleteReviewTasks();
|
||||
|
||||
expect(result).toBe(1);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-407-test-2", "todo", { preserveProgress: true });
|
||||
|
||||
managerWithRecovery.stop();
|
||||
});
|
||||
|
||||
it("moves merged in-review tasks to done and clears transient merge state", async () => {
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/test-project",
|
||||
|
||||
@@ -224,8 +224,6 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
|
||||
loopLog.log(`Processing task outcome for ${taskId}`);
|
||||
|
||||
// Track the validation board task ID (created later if there are assertions)
|
||||
let validationTaskId: string | undefined;
|
||||
|
||||
try {
|
||||
// Find the feature linked to this task
|
||||
@@ -254,35 +252,10 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
this.activeValidations.add(feature.id);
|
||||
|
||||
try {
|
||||
// Resolve mission context for creating the validation board task
|
||||
const featureSlice = this.missionStore.getSlice(feature.sliceId);
|
||||
const featureMilestone = featureSlice ? this.missionStore.getMilestone(featureSlice.milestoneId) : undefined;
|
||||
const missionId = featureMilestone?.missionId;
|
||||
loopLog.log(`Running internal validation for feature ${feature.id} — no board task created (policy: docs/task-authoring-standards.md §5)`);
|
||||
|
||||
// Create a visible board task for this validation run
|
||||
const validationTask = await this.taskStore.createTask({
|
||||
title: `🔍 Validate: ${feature.title}`,
|
||||
description: `Validating implementation for feature "${feature.title}" against ${assertions.length} contract assertion(s).\n\nFeature: ${feature.id}\nSlice: ${feature.sliceId}\nAssertions: ${assertions.map(a => a.title).join(", ")}`,
|
||||
column: "in-progress",
|
||||
missionId,
|
||||
sliceId: feature.sliceId,
|
||||
source: {
|
||||
sourceType: "automation",
|
||||
sourceMetadata: {
|
||||
missionId,
|
||||
featureId: feature.id,
|
||||
sliceId: feature.sliceId,
|
||||
},
|
||||
},
|
||||
});
|
||||
validationTaskId = validationTask.id;
|
||||
|
||||
// Mark as validation task so scheduler/stuck-detector skip it
|
||||
await this.taskStore.updateTask(validationTaskId, { status: "mission-validation" });
|
||||
loopLog.log(`Created validation board task ${validationTaskId} for feature ${feature.id}`);
|
||||
|
||||
// Start the validator run, linked to the board task
|
||||
const run = this.missionStore.startValidatorRun(feature.id, "task_completion", validationTaskId);
|
||||
// Start the validator run (no board task per docs/task-authoring-standards.md §5)
|
||||
const run = this.missionStore.startValidatorRun(feature.id, "task_completion");
|
||||
loopLog.log(`Started validator run ${run.id} for feature ${feature.id}`);
|
||||
|
||||
// Run the validation
|
||||
@@ -290,31 +263,19 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
|
||||
// Handle the result
|
||||
if (result.status === "pass") {
|
||||
await this.handleValidationPass(feature.id, run.id, result.summary, validationTaskId);
|
||||
await this.handleValidationPass(feature.id, run.id, result.summary, undefined);
|
||||
} else if (result.status === "fail") {
|
||||
await this.handleValidationFail(feature.id, run.id, result, validationTaskId);
|
||||
await this.handleValidationFail(feature.id, run.id, result, undefined);
|
||||
} else if (result.status === "blocked") {
|
||||
await this.handleValidationBlocked(feature.id, run.id, result.blockedReason, validationTaskId);
|
||||
await this.handleValidationBlocked(feature.id, run.id, result.blockedReason, undefined);
|
||||
} else if (result.status === "error") {
|
||||
await this.handleValidationError(feature.id, run.id, result.summary, validationTaskId);
|
||||
await this.handleValidationError(feature.id, run.id, result.summary, undefined);
|
||||
}
|
||||
} finally {
|
||||
this.activeValidations.delete(feature.id);
|
||||
}
|
||||
} catch (err) {
|
||||
loopLog.error(`Error processing task outcome for ${taskId}:`, err);
|
||||
// Move the validation task to in-review if it exists
|
||||
if (validationTaskId) {
|
||||
try {
|
||||
await this.taskStore.updateTask(validationTaskId, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
summary: "Validation failed unexpectedly",
|
||||
});
|
||||
await this.taskStore.moveTask(validationTaskId, "in-review");
|
||||
} catch (moveErr) {
|
||||
loopLog.error(`Failed to move validation task ${validationTaskId} on error:`, moveErr);
|
||||
}
|
||||
}
|
||||
// Don't crash the loop - log and continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1351,7 +1351,7 @@ export class SelfHealingManager {
|
||||
!task.status &&
|
||||
task.steps.length > 0 &&
|
||||
task.steps.some((step) => NON_TERMINAL_STEP_STATUSES.has(step.status)) &&
|
||||
now - new Date(task.updatedAt).getTime() >= timeoutMs
|
||||
now - new Date(task.columnMovedAt ?? task.updatedAt).getTime() >= timeoutMs
|
||||
);
|
||||
|
||||
if (staleIncomplete.length === 0) return 0;
|
||||
@@ -1422,7 +1422,7 @@ export class SelfHealingManager {
|
||||
!(task.status && GHOST_REVIEW_PRESERVED_STATUSES.has(task.status)) &&
|
||||
// Confirmed merges belong in `done` (handled by `recoverMergedReviewTasks`).
|
||||
task.mergeDetails?.mergeConfirmed !== true &&
|
||||
now - new Date(task.updatedAt).getTime() >= timeoutMs
|
||||
now - new Date(task.columnMovedAt ?? task.updatedAt).getTime() >= timeoutMs
|
||||
);
|
||||
|
||||
if (ghosts.length === 0) return 0;
|
||||
|
||||
@@ -23,14 +23,14 @@
|
||||
.reports-detail-body { display: flex; flex-direction: column; gap: var(--space-sm); }
|
||||
.reports-detail-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
font-size: calc(var(--space-sm) + (var(--space-xs) * 1.5));
|
||||
}
|
||||
.reports-detail-sections {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.reports-detail iframe, .reports-compare iframe { width: 100%; min-height: 20rem; border: var(--btn-border-width) solid var(--border); border-radius: var(--radius-md); background: var(--surface); }
|
||||
.reports-detail iframe, .reports-compare iframe { width: 100%; min-height: calc(var(--space-2xl) * 10); border: var(--btn-border-width) solid var(--border); border-radius: var(--radius-md); background: var(--surface); }
|
||||
.reports-empty {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -45,7 +45,7 @@
|
||||
margin: 0;
|
||||
}
|
||||
.reports-compare {
|
||||
width: min(100%, 64rem);
|
||||
width: min(100%, calc(var(--space-2xl) * 32));
|
||||
max-height: min(100%, calc(100dvh - var(--space-2xl)));
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user