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

@@ -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.

View File

@@ -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;

View File

@@ -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

View File

@@ -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 |
|-----------|------|----------|-------------|

View File

@@ -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. |

View File

@@ -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" });

View File

@@ -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' },
};
}

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;

View File

@@ -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",
);
});
});

View File

@@ -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",

View File

@@ -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
}
}

View File

@@ -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;

View File

@@ -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;