feat(FN-4962): complete Step 4 — dashboard fallback and reliability coverage

Fusion-Task-Id: FN-4962
Fusion-Task-Lineage: de4ce54b-c5e4-40f9-a048-677adac3e1a0
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 15:32:26 -07:00
committed by gsxdsm
parent 40f9aa99e6
commit d13ee7f526
5 changed files with 211 additions and 17 deletions

View File

@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { EventEmitter } from "node:events";
import type { Task } from "@fusion/core";
import { createServer } from "../server.js";
const runGitCommandMock = vi.fn<(...args: any[]) => Promise<string>>();
vi.mock("../routes/resolve-diff-base.js", () => ({
resolveDiffBase: vi.fn(async () => "main"),
runGitCommand: (...args: any[]) => runGitCommandMock(...args),
}));
class MockStore extends EventEmitter {
private tasks = new Map<string, Task>();
getRootDir(): string {
return "/tmp/fn-4962";
}
getFusionDir(): string {
return "/tmp/fn-4962/.fusion";
}
getDatabase() {
return {
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
};
}
getMissionStore() {
return {
listMissions: vi.fn().mockResolvedValue([]),
createMission: vi.fn(),
getMission: vi.fn(),
updateMission: vi.fn(),
deleteMission: vi.fn(),
listTemplates: vi.fn().mockResolvedValue([]),
createTemplate: vi.fn(),
getTemplate: vi.fn(),
updateTemplate: vi.fn(),
deleteTemplate: vi.fn(),
instantiateMission: vi.fn(),
};
}
async listTasks(): Promise<Task[]> {
return [...this.tasks.values()];
}
getTask(id: string): Task | undefined {
return this.tasks.get(id);
}
addTask(task: Task): void {
this.tasks.set(task.id, task);
}
}
async function requestSessionFiles(app: Parameters<typeof import("../test-request.js").get>[0], taskId = "FN-9999") {
const { get } = await import("../test-request.js");
return get(app, `/api/tasks/${taskId}/session-files`);
}
describe("session-files fallback for stale worktree + null branch", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses derived fusion/<id> branch hint when task.branch is null", async () => {
const store = new MockStore();
store.addTask({
id: "FN-9999",
title: "stale",
description: "stale",
column: "in-review",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-05-17T00:00:00.000Z",
updatedAt: "2026-05-17T00:00:00.000Z",
worktree: "/definitely/missing",
branch: null,
baseBranch: "main",
} as Task);
runGitCommandMock.mockImplementation(async (args: string[]) => {
const cmd = args.join(" ");
if (cmd === "rev-parse --verify --quiet fusion/fn-9999") return "abc123";
if (cmd === "diff --name-only main..fusion/fn-9999") return "src/feature.ts\n";
throw new Error(`Unexpected command: ${cmd}`);
});
const app = createServer(store as any);
const response = await requestSessionFiles(app);
expect(response.status).toBe(200);
expect(response.body).toEqual(["src/feature.ts"]);
});
});

View File

@@ -51,8 +51,8 @@ type BranchFallbackTask = {
baseCommitSha?: string;
};
async function resolveTaskBranchRef(task: BranchFallbackTask, rootDir: string): Promise<string | undefined> {
const branch = task.branch?.trim();
async function resolveTaskBranchRef(task: BranchFallbackTask, rootDir: string, derivedBranchHint?: string): Promise<string | undefined> {
const branch = task.branch?.trim() || derivedBranchHint?.trim();
if (!branch) return undefined;
try {
@@ -73,8 +73,8 @@ async function resolveTaskBranchRef(task: BranchFallbackTask, rootDir: string):
return undefined;
}
async function resolveBranchDiffBaseInRoot(task: BranchFallbackTask, rootDir: string): Promise<{ baseRef: string; branchRef: string } | undefined> {
const branchRef = await resolveTaskBranchRef(task, rootDir);
async function resolveBranchDiffBaseInRoot(task: BranchFallbackTask, rootDir: string, derivedBranchHint?: string): Promise<{ baseRef: string; branchRef: string } | undefined> {
const branchRef = await resolveTaskBranchRef(task, rootDir, derivedBranchHint);
if (!branchRef) return undefined;
const baseRef = await resolveDiffBase(task, rootDir, branchRef, runGitCommand, { enableDisplayRecovery: true });
@@ -86,8 +86,9 @@ async function resolveBranchDiffBaseInRoot(task: BranchFallbackTask, rootDir: st
async function tryBranchRefFallbackFiles(
task: BranchFallbackTask & { id: string },
rootDir: string,
derivedBranchHint?: string,
): Promise<string[]> {
const resolved = await resolveBranchDiffBaseInRoot(task, rootDir);
const resolved = await resolveBranchDiffBaseInRoot(task, rootDir, derivedBranchHint);
if (!resolved) return [];
try {
@@ -101,11 +102,12 @@ async function tryBranchRefFallbackFiles(
async function tryBranchRefFallbackDetailedDiff(
task: BranchFallbackTask,
rootDir: string,
derivedBranchHint?: string,
): Promise<{
files: Array<{ path: string; status: "added" | "modified" | "deleted"; additions: number; deletions: number; patch: string }>;
stats: { filesChanged: number; additions: number; deletions: number };
}> {
const resolved = await resolveBranchDiffBaseInRoot(task, rootDir);
const resolved = await resolveBranchDiffBaseInRoot(task, rootDir, derivedBranchHint);
if (!resolved) {
return { files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } };
}
@@ -152,8 +154,9 @@ async function tryBranchRefFallbackDetailedDiff(
async function tryBranchRefFallbackFileDiffs(
task: BranchFallbackTask,
rootDir: string,
derivedBranchHint?: string,
): Promise<Array<{ path: string; status: "added" | "modified" | "deleted" | "renamed"; diff: string; oldPath?: string }>> {
const resolved = await resolveBranchDiffBaseInRoot(task, rootDir);
const resolved = await resolveBranchDiffBaseInRoot(task, rootDir, derivedBranchHint);
if (!resolved) return [];
const fileMap = new Map<string, { statusCode: string; oldPath?: string }>();
@@ -511,8 +514,10 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
return;
}
const derivedBranchHint = task.branch?.trim() ? undefined : `fusion/${task.id.toLowerCase()}`;
if (!task.worktree) {
const files = await tryBranchRefFallbackFiles(task, scopedStore.getRootDir());
const files = await tryBranchRefFallbackFiles(task, scopedStore.getRootDir(), derivedBranchHint);
sessionFilesCache.set(task.id, {
files,
expiresAt: Date.now() + 10000,
@@ -530,7 +535,7 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
}
if (!worktreeExists) {
const files = await tryBranchRefFallbackFiles(task, scopedStore.getRootDir());
const files = await tryBranchRefFallbackFiles(task, scopedStore.getRootDir(), derivedBranchHint);
sessionFilesCache.set(task.id, {
files,
expiresAt: Date.now() + 10000,
@@ -541,7 +546,7 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
const worktree = task.worktree;
if (!(await worktreeStillBelongsToTask(worktree, task.branch))) {
const files = await tryBranchRefFallbackFiles(task, scopedStore.getRootDir());
const files = await tryBranchRefFallbackFiles(task, scopedStore.getRootDir(), derivedBranchHint);
sessionFilesCache.set(task.id, {
files,
expiresAt: Date.now() + 10000,
@@ -746,9 +751,10 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
const worktree = typeof req.query.worktree === "string" ? req.query.worktree : undefined;
const resolvedWorktree = worktree || task.worktree;
const derivedBranchHint = task.branch?.trim() ? undefined : `fusion/${task.id.toLowerCase()}`;
if (!resolvedWorktree) {
const fallback = await tryBranchRefFallbackDetailedDiff(task, scopedStore.getRootDir());
const fallback = await tryBranchRefFallbackDetailedDiff(task, scopedStore.getRootDir(), derivedBranchHint);
res.json(fallback);
return;
}
@@ -760,12 +766,12 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
worktreeExists = false;
}
if (!worktreeExists) {
const fallback = await tryBranchRefFallbackDetailedDiff(task, scopedStore.getRootDir());
const fallback = await tryBranchRefFallbackDetailedDiff(task, scopedStore.getRootDir(), derivedBranchHint);
res.json(fallback);
return;
}
if (!(await worktreeStillBelongsToTask(resolvedWorktree, task.branch))) {
const fallback = await tryBranchRefFallbackDetailedDiff(task, scopedStore.getRootDir());
const fallback = await tryBranchRefFallbackDetailedDiff(task, scopedStore.getRootDir(), derivedBranchHint);
res.json(fallback);
return;
}
@@ -948,9 +954,10 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
return;
}
const derivedBranchHint = task.branch?.trim() ? undefined : `fusion/${task.id.toLowerCase()}`;
if (!task.worktree) {
const fallbackFiles = await tryBranchRefFallbackFileDiffs(task, scopedStore.getRootDir());
const fallbackFiles = await tryBranchRefFallbackFileDiffs(task, scopedStore.getRootDir(), derivedBranchHint);
fileDiffsCache.set(task.id, {
files: fallbackFiles,
expiresAt: Date.now() + 10000,
@@ -968,7 +975,7 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
}
if (!worktreeExists) {
const fallbackFiles = await tryBranchRefFallbackFileDiffs(task, scopedStore.getRootDir());
const fallbackFiles = await tryBranchRefFallbackFileDiffs(task, scopedStore.getRootDir(), derivedBranchHint);
fileDiffsCache.set(task.id, {
files: fallbackFiles,
expiresAt: Date.now() + 10000,
@@ -979,7 +986,7 @@ export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRoute
const worktree = task.worktree;
if (!(await worktreeStillBelongsToTask(worktree, task.branch))) {
const fallbackFiles = await tryBranchRefFallbackFileDiffs(task, scopedStore.getRootDir());
const fallbackFiles = await tryBranchRefFallbackFileDiffs(task, scopedStore.getRootDir(), derivedBranchHint);
fileDiffsCache.set(task.id, {
files: fallbackFiles,
expiresAt: Date.now() + 10000,

View File

@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { EventEmitter } from "node:events";
import type { Task, TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../../self-healing.js";
import * as worktreePoolModule from "../../worktree-pool.js";
function task(id: string, overrides: Partial<Task> = {}): Task {
return {
id,
title: id,
description: id,
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
} as Task;
}
function makeStore(tasks: Task[]): TaskStore & EventEmitter {
const map = new Map(tasks.map((t) => [t.id, t]));
return Object.assign(new EventEmitter(), {
getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false })),
listTasks: vi.fn(async () => [...map.values()]),
updateTask: vi.fn(async (id: string, patch: Partial<Task>) => {
map.set(id, { ...(map.get(id) as Task), ...patch });
return map.get(id);
}),
getTask: vi.fn(async (id: string) => map.get(id)),
recordRunAuditEvent: vi.fn(async () => undefined),
moveTask: vi.fn(async () => undefined),
logEntry: vi.fn(async () => undefined),
}) as unknown as TaskStore & EventEmitter;
}
describe("reliability interactions: worktree metadata reconcile", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("defers while task is in executingTaskIds (recoverOrphaned/resume interaction)", async () => {
const store = makeStore([task("FN-1", { column: "in-review", worktree: "/missing", branch: null })]);
vi.spyOn(worktreePoolModule, "getRegisteredWorktreeBranchMap").mockResolvedValue(new Map([["fusion/fn-1", "/live"]]));
const manager = new SelfHealingManager(store, {
rootDir: "/repo",
getExecutingTaskIds: () => new Set(["FN-1"]),
});
const repaired = await manager.reconcileTaskWorktreeMetadata();
expect(repaired).toBe(0);
expect((store as any).updateTask).not.toHaveBeenCalled();
});
it("rebinds exactly once after deferred pass (acquireTaskWorktree interaction)", async () => {
const store = makeStore([task("FN-2", { column: "todo", worktree: "/missing", branch: null })]);
vi.spyOn(worktreePoolModule, "getRegisteredWorktreeBranchMap").mockResolvedValue(new Map([["fusion/fn-2", "/live"]]));
let executing = true;
const manager = new SelfHealingManager(store, {
rootDir: "/repo",
getExecutingTaskIds: () => (executing ? new Set(["FN-2"]) : new Set<string>()),
});
expect(await manager.reconcileTaskWorktreeMetadata()).toBe(0);
executing = false;
expect(await manager.reconcileTaskWorktreeMetadata()).toBe(1);
expect((store as any).updateTask).toHaveBeenCalledTimes(1);
});
it("skips done tasks during periodic reconcile (completion fan-out owns done lifecycle)", async () => {
const store = makeStore([task("FN-3", { column: "done", worktree: "/missing", branch: null })]);
vi.spyOn(worktreePoolModule, "getRegisteredWorktreeBranchMap").mockResolvedValue(new Map([["fusion/fn-3", "/live"]]));
const manager = new SelfHealingManager(store, { rootDir: "/repo" });
const repaired = await manager.reconcileTaskWorktreeMetadata();
expect(repaired).toBe(0);
expect((store as any).updateTask).not.toHaveBeenCalled();
});
});

View File

@@ -163,6 +163,8 @@ export type DatabaseMutationType =
| "task:auto-recover-finalize-already-on-main"
| "task:auto-recover-branch-misbound"
| "task:auto-recover-node-unreachable"
| "task:auto-recover-worktree-metadata-rebound"
| "task:auto-recover-worktree-metadata-cleared"
| "task:auto-reconciled-self-defeating-dep"
/**
* Metadata shape for node:handoff:* and node:lease:* events:

View File

@@ -2321,7 +2321,7 @@ export class SelfHealingManager {
phase: "worktree-metadata-reconcile",
});
await auditor.database({
type: input.mutationType as never,
type: input.mutationType,
target: input.taskId,
metadata: {
taskId: input.taskId,