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:
committed by
gsxdsm
parent
40f9aa99e6
commit
d13ee7f526
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user