test(FN-4818): complete Step 2 — add owning-node handoff interaction coverage
Fusion-Task-Id: FN-4818 Fusion-Task-Lineage: 1619844a-3945-4681-af58-ca32e5533514
This commit is contained in:
committed by
gsxdsm
parent
003b343b4e
commit
5c5b922d79
@@ -0,0 +1,265 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { existsSync, mkdtempSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { NodeStatus, OwningNodeHandoffPolicy, Task, TaskStore } from "@fusion/core";
|
||||
import { TaskStore as CoreTaskStore } from "@fusion/core";
|
||||
import { MeshLeaseManager } from "../../mesh-lease-manager.js";
|
||||
import { Scheduler } from "../../scheduler.js";
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return {
|
||||
...actual,
|
||||
readFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "fn-owning-node-handoff-"));
|
||||
}
|
||||
|
||||
function createMockTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-200",
|
||||
description: "scheduler handoff",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
prompt: "",
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function createMockStore(task: Task, settings: Record<string, unknown> = {}): TaskStore {
|
||||
return {
|
||||
listTasks: vi.fn().mockResolvedValue([task]),
|
||||
getSettings: vi.fn().mockResolvedValue(settings),
|
||||
getTask: vi.fn().mockResolvedValue(task),
|
||||
updateTask: vi.fn().mockResolvedValue(undefined),
|
||||
moveTask: vi.fn().mockResolvedValue(undefined),
|
||||
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getRootDir: vi.fn().mockReturnValue("/tmp/test"),
|
||||
getTasksDir: vi.fn().mockReturnValue("/tmp/test/.fusion/tasks"),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
function createMockHealthMonitor(statusMap: Record<string, NodeStatus | undefined>) {
|
||||
return {
|
||||
getNodeHealth: vi.fn((id: string) => statusMap[id]),
|
||||
} as unknown as import("../node-health-monitor.js").NodeHealthMonitor;
|
||||
}
|
||||
|
||||
describe("reliability interactions: owning-node unavailable handoff", () => {
|
||||
let rootDir = "";
|
||||
let globalDir = "";
|
||||
let taskStore: CoreTaskStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readFile).mockResolvedValue("# Task\nFN-4813");
|
||||
rootDir = makeTmpDir();
|
||||
globalDir = join(rootDir, ".fusion-global");
|
||||
taskStore = new CoreTaskStore(rootDir, globalDir);
|
||||
await taskStore.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
taskStore?.close();
|
||||
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
||||
});
|
||||
|
||||
async function seedCheckedOutTask(overrides: Partial<Task> = {}): Promise<Task> {
|
||||
const created = await taskStore.createTask({ description: "FN-4813 owning-node handoff" });
|
||||
return taskStore.updateTask(created.id, {
|
||||
column: "todo",
|
||||
checkedOutBy: "agent-1",
|
||||
checkedOutAt: new Date().toISOString(),
|
||||
checkoutNodeId: "node-a",
|
||||
checkoutRunId: "run-a-1",
|
||||
checkoutLeaseRenewedAt: new Date().toISOString(),
|
||||
checkoutLeaseEpoch: 1,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it.each(["block", "reassign-to-local", "reassign-any-healthy"] as const)(
|
||||
"FN-4813: owner online never auto-steals lease (policy=%s)",
|
||||
async (policy) => {
|
||||
const task = await seedCheckedOutTask({
|
||||
checkedOutAt: new Date().toISOString(),
|
||||
checkoutLeaseRenewedAt: new Date().toISOString(),
|
||||
});
|
||||
const manager = new MeshLeaseManager({
|
||||
taskStore,
|
||||
nodeHealthMonitor: { getNodeHealth: () => "online" } as any,
|
||||
getHandoffPolicy: async () => policy,
|
||||
localNodeId: "node-local",
|
||||
});
|
||||
|
||||
const recovered = await manager.recoverAbandonedLease(task.id, "scheduler detected stale todo lease");
|
||||
expect(recovered).toBe(false);
|
||||
|
||||
const persisted = await taskStore.getTask(task.id);
|
||||
expect(persisted?.checkedOutBy).toBe("agent-1");
|
||||
expect(persisted?.checkoutNodeId).toBe("node-a");
|
||||
expect(persisted?.checkoutLeaseEpoch).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("FN-4813: owner offline + block policy parks lease", async () => {
|
||||
const task = await seedCheckedOutTask();
|
||||
const manager = new MeshLeaseManager({
|
||||
taskStore,
|
||||
nodeHealthMonitor: { getNodeHealth: () => "offline" } as any,
|
||||
getHandoffPolicy: async () => "block",
|
||||
localNodeId: "node-local",
|
||||
});
|
||||
|
||||
const recovered = await manager.recoverAbandonedLease(task.id, "scheduler detected stale todo lease");
|
||||
expect(recovered).toBe(false);
|
||||
|
||||
const persisted = await taskStore.getTask(task.id);
|
||||
expect(persisted?.checkedOutBy).toBe("agent-1");
|
||||
expect(persisted?.checkoutNodeId).toBe("node-a");
|
||||
expect(persisted?.checkoutLeaseEpoch).toBe(1);
|
||||
expect(persisted?.checkoutRunId).toBe("run-a-1");
|
||||
});
|
||||
|
||||
it.each(["reassign-to-local", "reassign-any-healthy"] as const)(
|
||||
"FN-4813: owner offline + policy=%s clears lease with epoch bump",
|
||||
async (policy) => {
|
||||
const task = await seedCheckedOutTask({ checkoutNodeId: "node-a" });
|
||||
const manager = new MeshLeaseManager({
|
||||
taskStore,
|
||||
nodeHealthMonitor: { getNodeHealth: () => "offline" } as any,
|
||||
getHandoffPolicy: async () => policy,
|
||||
localNodeId: "node-local",
|
||||
});
|
||||
|
||||
const recovered = await manager.recoverAbandonedLease(task.id, "scheduler detected stale todo lease");
|
||||
expect(recovered).toBe(true);
|
||||
|
||||
const persisted = await taskStore.getTask(task.id);
|
||||
expect(persisted?.checkedOutBy ?? null).toBeNull();
|
||||
expect(persisted?.checkoutNodeId ?? null).toBeNull();
|
||||
expect(persisted?.checkoutRunId ?? null).toBeNull();
|
||||
expect(persisted?.checkoutLeaseRenewedAt ?? null).toBeNull();
|
||||
expect(persisted?.checkoutLeaseEpoch).toBe(2);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["block", "reassign-to-local", "reassign-any-healthy"] as const)(
|
||||
"FN-4813: self-owned offline recovery ignores policy and clears lease (policy=%s)",
|
||||
async (policy) => {
|
||||
const task = await seedCheckedOutTask({ checkoutNodeId: "node-local" });
|
||||
const manager = new MeshLeaseManager({
|
||||
taskStore,
|
||||
nodeHealthMonitor: { getNodeHealth: () => "offline" } as any,
|
||||
getHandoffPolicy: async () => policy,
|
||||
localNodeId: "node-local",
|
||||
});
|
||||
|
||||
const recovered = await manager.recoverAbandonedLease(task.id, "scheduler detected stale todo lease");
|
||||
expect(recovered).toBe(true);
|
||||
|
||||
const persisted = await taskStore.getTask(task.id);
|
||||
expect(persisted?.checkedOutBy ?? null).toBeNull();
|
||||
expect(persisted?.checkoutNodeId ?? null).toBeNull();
|
||||
expect(persisted?.checkoutLeaseEpoch).toBe(2);
|
||||
},
|
||||
);
|
||||
|
||||
it("FN-4813: owner returning online during recovery window parks handoff", async () => {
|
||||
const task = await seedCheckedOutTask();
|
||||
let calls = 0;
|
||||
const manager = new MeshLeaseManager({
|
||||
taskStore,
|
||||
nodeHealthMonitor: {
|
||||
getNodeHealth: () => {
|
||||
calls += 1;
|
||||
return calls === 1 ? "offline" : "online";
|
||||
},
|
||||
} as any,
|
||||
getHandoffPolicy: async () => "reassign-to-local",
|
||||
localNodeId: "node-local",
|
||||
});
|
||||
|
||||
const recovered = await manager.recoverAbandonedLease(task.id, "scheduler detected stale todo lease");
|
||||
expect(recovered).toBe(false);
|
||||
|
||||
const persisted = await taskStore.getTask(task.id);
|
||||
expect(persisted?.checkedOutBy).toBe("agent-1");
|
||||
expect(persisted?.checkoutNodeId).toBe("node-a");
|
||||
expect(persisted?.checkoutLeaseEpoch).toBe(1);
|
||||
});
|
||||
|
||||
it("FN-4813: scheduler does not apply owning-node handoff when owner is online", async () => {
|
||||
const task = createMockTask({
|
||||
id: "FN-201",
|
||||
checkedOutBy: "agent-1",
|
||||
checkoutNodeId: "node-a",
|
||||
checkoutLeaseEpoch: 1,
|
||||
nodeId: "node-b",
|
||||
});
|
||||
const store = createMockStore(task, { maxConcurrent: 1, maxWorktrees: 1, owningNodeHandoffPolicy: "reassign-to-local" });
|
||||
const scheduler = new Scheduler(store, {
|
||||
leaseManager: {
|
||||
recoverAbandonedLease: vi.fn().mockResolvedValue(false),
|
||||
} as any,
|
||||
nodeHealthMonitor: createMockHealthMonitor({ "node-a": "online", "node-b": "online" }),
|
||||
validateNodeDispatch: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
});
|
||||
(scheduler as unknown as { running: boolean }).running = true;
|
||||
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(store.logEntry).not.toHaveBeenCalledWith(task.id, expect.stringContaining("Owning-node handoff applied"));
|
||||
expect(store.updateTask).toHaveBeenCalledWith(task.id, { status: "queued" });
|
||||
});
|
||||
|
||||
it("FN-4813: scheduler reassigns local dispatch when owner offline and policy is reassign-to-local", async () => {
|
||||
const task = createMockTask({
|
||||
id: "FN-202",
|
||||
checkedOutBy: "agent-1",
|
||||
checkoutNodeId: "node-a",
|
||||
checkoutLeaseEpoch: 1,
|
||||
nodeId: "node-b",
|
||||
});
|
||||
const store = createMockStore(task, {
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 1,
|
||||
owningNodeHandoffPolicy: "reassign-to-local" satisfies OwningNodeHandoffPolicy,
|
||||
unavailableNodePolicy: "block",
|
||||
});
|
||||
const scheduler = new Scheduler(store, {
|
||||
leaseManager: { recoverAbandonedLease: vi.fn().mockResolvedValue(true) } as any,
|
||||
nodeHealthMonitor: createMockHealthMonitor({ "node-a": "offline", "node-b": "online" }),
|
||||
validateNodeDispatch: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
});
|
||||
(scheduler as unknown as { running: boolean }).running = true;
|
||||
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(store.logEntry).toHaveBeenCalledWith(task.id, expect.stringContaining("Owning-node handoff applied"));
|
||||
expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({ effectiveNodeId: null }));
|
||||
expect(store.moveTask).toHaveBeenCalledWith(task.id, "in-progress", expect.any(Object));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user