feat(FN-5448): add completed-task oscillation backstop and guard
Adds an oscillation backstop and guard mechanism for completed tasks to the self-healing system, including a dedicated 145-line test suite for completed-task oscillation behavior and a small update to the existing self-healing module. Fusion-Task-Id: FN-5448
This commit is contained in:
committed by
gsxdsm
parent
1bffa22ca9
commit
324cdeb12b
@@ -154,6 +154,10 @@ When `settings.autoMerge: false`, `in-review` is terminal-until-merged by a huma
|
||||
|
||||
`testMode?: boolean` is now available in both project and global settings. If project `testMode === true` (or the resolved default provider is `"mock"` at any tier), every AI lane is forced to `mock/scripted`, overriding per-task and per-lane model selections. The dashboard exposes this via the Settings Modal "Enable test mode" toggle and a persistent "Test mode — no real AI calls" banner.
|
||||
|
||||
### Reliability Mechanism Coverage
|
||||
|
||||
- FN-5448: composition backstop for stranded-completed todo recovery × ghost-review fallback at `packages/engine/src/__tests__/reliability-interactions/completed-task-oscillation.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Reference docs (deeper detail)
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// FN-5448 accepted branch (a): exempted — ghost-review must not demote
|
||||
// recently stranded-completed promotions when all implementation steps are done.
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Task, TaskStore } from "@fusion/core";
|
||||
import { SelfHealingManager } from "../../self-healing.js";
|
||||
|
||||
function createTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-5448-RI",
|
||||
title: "completed-task oscillation fixture",
|
||||
description: "regression harness",
|
||||
column: "todo",
|
||||
paused: false,
|
||||
status: null,
|
||||
error: null,
|
||||
branch: "fusion/fn-5448-fixture",
|
||||
worktree: null,
|
||||
steps: [{ name: "Implement", status: "done" as const }],
|
||||
workflowStepResults: [],
|
||||
dependencies: [],
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
columnMovedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function createStore(task: Task, settings: Record<string, unknown> = {}): TaskStore & EventEmitter {
|
||||
const emitter = new EventEmitter() as TaskStore & EventEmitter;
|
||||
const audits: any[] = [];
|
||||
(emitter as any).__audits = audits;
|
||||
|
||||
(emitter as any).getSettings = vi.fn().mockResolvedValue({
|
||||
autoMerge: true,
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
taskStuckTimeoutMs: 600_000,
|
||||
...settings,
|
||||
});
|
||||
(emitter as any).listTasks = vi.fn().mockImplementation(async ({ column }: { column?: string } = {}) => {
|
||||
if (!column || task.column === column) return [task];
|
||||
return [];
|
||||
});
|
||||
(emitter as any).logEntry = vi.fn().mockImplementation(async (_taskId: string, action: string) => {
|
||||
task.log = task.log ?? [];
|
||||
task.log.push({ timestamp: new Date(Date.now()).toISOString(), action } as any);
|
||||
});
|
||||
(emitter as any).updateTask = vi.fn().mockImplementation(async (_taskId: string, patch: Partial<Task>) => {
|
||||
Object.assign(task, patch, { updatedAt: new Date(Date.now()).toISOString() });
|
||||
return task;
|
||||
});
|
||||
(emitter as any).moveTask = vi.fn().mockImplementation(async (_taskId: string, column: string) => {
|
||||
task.column = column as any;
|
||||
const now = new Date(Date.now()).toISOString();
|
||||
task.updatedAt = now;
|
||||
task.columnMovedAt = now;
|
||||
return task;
|
||||
});
|
||||
(emitter as any).recordRunAuditEvent = vi.fn().mockImplementation(async (event: any) => {
|
||||
audits.push(event);
|
||||
});
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
describe("FN-5448 reliability interactions: completed-task oscillation", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("branch (a) exempted: ghost-review does not demote recently recovered completed todo tasks", async () => {
|
||||
const task = createTask();
|
||||
const store = createStore(task, { taskStuckTimeoutMs: 600_000, autoMerge: true });
|
||||
const recoverCompletedTask = vi.fn().mockImplementation(async (strandedTask: Task) => {
|
||||
const now = new Date(Date.now()).toISOString();
|
||||
Object.assign(strandedTask, {
|
||||
column: "in-review",
|
||||
status: null,
|
||||
error: null,
|
||||
columnMovedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
const manager = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/repo",
|
||||
recoverCompletedTask,
|
||||
getExecutingTaskIds: () => new Set<string>(),
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
expect(await manager.recoverStrandedCompletedTodoTasks()).toBe(1);
|
||||
expect(recoverCompletedTask).toHaveBeenCalledTimes(1);
|
||||
expect(task.column).toBe("in-review");
|
||||
|
||||
vi.advanceTimersByTime(600_001);
|
||||
const ghostRecovered = await manager.recoverGhostReviewTasks();
|
||||
|
||||
expect(ghostRecovered).toBe(0);
|
||||
expect(task.column).toBe("in-review");
|
||||
expect((store.moveTask as any).mock.calls).toEqual([]);
|
||||
|
||||
manager.stop();
|
||||
});
|
||||
|
||||
it("FN-5147: autoMerge false keeps in-review task unchanged after timeout", async () => {
|
||||
const task = createTask();
|
||||
const store = createStore(task, { taskStuckTimeoutMs: 600_000, autoMerge: false });
|
||||
const recoverCompletedTask = vi.fn().mockImplementation(async (strandedTask: Task) => {
|
||||
const now = new Date(Date.now()).toISOString();
|
||||
Object.assign(strandedTask, {
|
||||
column: "in-review",
|
||||
status: null,
|
||||
error: null,
|
||||
columnMovedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
const manager = new SelfHealingManager(store, {
|
||||
rootDir: "/tmp/repo",
|
||||
recoverCompletedTask,
|
||||
getExecutingTaskIds: () => new Set<string>(),
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
expect(await manager.recoverStrandedCompletedTodoTasks()).toBe(1);
|
||||
expect(task.column).toBe("in-review");
|
||||
|
||||
vi.advanceTimersByTime(600_001);
|
||||
expect(await manager.recoverGhostReviewTasks()).toBe(0);
|
||||
expect(task.column).toBe("in-review");
|
||||
expect((store.moveTask as any).mock.calls).toEqual([]);
|
||||
|
||||
manager.stop();
|
||||
});
|
||||
});
|
||||
@@ -7698,7 +7698,7 @@ describe("FN-5335 triple-proof no-action unit coverage", () => {
|
||||
it("emits ghost-review no-action when triple proof fails", async () => {
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, autoMerge: true, taskStuckTimeoutMs: 1_000 } as any),
|
||||
listTasks: vi.fn().mockResolvedValue([{ id: "FN-GHOST", column: "in-review", worktree: "/tmp/fn-ghost", updatedAt: new Date(Date.now() - 5_000).toISOString(), columnMovedAt: new Date(Date.now() - 5_000).toISOString(), paused: false, status: null, mergeDetails: {} }]),
|
||||
listTasks: vi.fn().mockResolvedValue([{ id: "FN-GHOST", column: "in-review", worktree: "/tmp/fn-ghost", updatedAt: new Date(Date.now() - 5_000).toISOString(), columnMovedAt: new Date(Date.now() - 5_000).toISOString(), paused: false, status: null, mergeDetails: {}, steps: [{ status: "pending" }] }]),
|
||||
});
|
||||
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||
const recovered = await manager.recoverGhostReviewTasks();
|
||||
|
||||
@@ -4842,6 +4842,9 @@ export class SelfHealingManager {
|
||||
!task.paused &&
|
||||
!executingIds.has(task.id) &&
|
||||
!(task.status && GHOST_REVIEW_PRESERVED_STATUSES.has(task.status)) &&
|
||||
// FN-5448: completed tasks promoted by stranded-completed recovery must
|
||||
// not be kicked back to todo by ghost-review fallback.
|
||||
!(Array.isArray(task.steps) && task.steps.length > 0 && task.steps.every((step) => step.status === "done" || step.status === "skipped")) &&
|
||||
// Confirmed merges belong in `done` (handled by `recoverMergedReviewTasks`).
|
||||
task.mergeDetails?.mergeConfirmed !== true &&
|
||||
now - new Date(task.columnMovedAt ?? task.updatedAt).getTime() >= timeoutMs
|
||||
|
||||
Reference in New Issue
Block a user