Merge pull request #1539 from Runfusion/feature/workflow-mapping

fix(FN-6035): align workflow dispatch and broad suite
This commit is contained in:
gsxdsm
2026-06-08 23:06:53 -07:00
committed by GitHub
30 changed files with 380 additions and 265 deletions

View File

@@ -6,11 +6,13 @@ const { execMock, readFileMock, pingMock, versionMock, inspectMock, dockerCtor,
const pingMock = vi.fn();
const versionMock = vi.fn();
const inspectMock = vi.fn();
const dockerCtor = vi.fn().mockImplementation(() => ({
const dockerCtor = vi.fn().mockImplementation(function () {
return {
ping: pingMock,
version: versionMock,
getContainer: vi.fn(() => ({ inspect: inspectMock, logs: vi.fn().mockResolvedValue(Buffer.from("logs")) })),
}));
};
});
const dockerodeModuleFactoryMock = vi.fn(() => ({ default: dockerCtor }));
return { execMock, readFileMock, pingMock, versionMock, inspectMock, dockerCtor, dockerodeModuleFactoryMock };
});

View File

@@ -51,10 +51,12 @@ const {
});
vi.mock("../docker-client.js", () => ({
DockerClientService: vi.fn().mockImplementation(() => ({
getDockerInstance: getDockerInstanceMock,
getContainerInfo: vi.fn(),
})),
DockerClientService: vi.fn().mockImplementation(function () {
return {
getDockerInstance: getDockerInstanceMock,
getContainerInfo: vi.fn(),
};
}),
}));
import { DockerProvisioningService } from "../docker-provisioning";

View File

@@ -85,11 +85,13 @@ describe("NodeDiscovery", () => {
publishMock.mockReturnValue(publishService);
findMock.mockReturnValue(browser);
destroyMock.mockReturnValue(undefined);
BonjourMock.mockImplementation(() => ({
publish: publishMock,
find: findMock,
destroy: destroyMock,
}));
BonjourMock.mockImplementation(function () {
return {
publish: publishMock,
find: findMock,
destroy: destroyMock,
};
});
});
afterEach(() => {

View File

@@ -7,10 +7,12 @@ import { TaskStore, setTaskCreatedHook } from "@fusion/core";
import { HeartbeatMonitor } from "../agent-heartbeat.js";
import { createDelegateTaskTool, createTaskCreateTool } from "../agent-tools.js";
const githubTrackingHookEntry = "../../../dashboard/src/github-tracking-hook.js";
const githubTrackingEntry = "../../../dashboard/src/github-tracking.js";
const githubTrackingHookModulePromise: Promise<any> = import(/* @vite-ignore */ githubTrackingHookEntry);
const githubTrackingModulePromise: Promise<any> = import(/* @vite-ignore */ githubTrackingEntry);
const githubTrackingHookModulePromise: Promise<any> = import(
new URL("../../../dashboard/src/github-tracking-hook.js", import.meta.url).href
);
const githubTrackingModulePromise: Promise<any> = import(
new URL("../../../dashboard/src/github-tracking.js", import.meta.url).href
);
function makeTmpDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix));

View File

@@ -6,10 +6,12 @@ import { tmpdir } from "node:os";
import { TaskStore, resolveTaskGithubTracking, setTaskCreatedHook } from "@fusion/core";
import { createDelegateTaskTool, createTaskCreateTool } from "../agent-tools.js";
const githubTrackingHookEntry = "../../../dashboard/src/github-tracking-hook.js";
const githubTrackingEntry = "../../../dashboard/src/github-tracking.js";
const githubTrackingHookModulePromise: Promise<any> = import(/* @vite-ignore */ githubTrackingHookEntry);
const githubTrackingModulePromise: Promise<any> = import(/* @vite-ignore */ githubTrackingEntry);
const githubTrackingHookModulePromise: Promise<any> = import(
new URL("../../../dashboard/src/github-tracking-hook.js", import.meta.url).href
);
const githubTrackingModulePromise: Promise<any> = import(
new URL("../../../dashboard/src/github-tracking.js", import.meta.url).href
);
function makeTmpDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix));

View File

@@ -2065,42 +2065,44 @@ describe("StepSessionExecutor integration", () => {
it("persists tokenUsage incrementally during step execution before in-review transition", async () => {
const { store } = createTokenUsageStepSessionStore();
mockedStepSessionExecutor.mockImplementationOnce(((options: any) => ({
executeAll: vi.fn(async () => {
options.onStepComplete(0, {
stepIndex: 0,
success: true,
retries: 0,
tokenUsage: { inputTokens: 20, outputTokens: 10, cachedTokens: 2, totalTokens: 32 },
});
await Promise.resolve();
options.onStepComplete(1, {
stepIndex: 1,
success: true,
retries: 0,
tokenUsage: { inputTokens: 30, outputTokens: 5, cachedTokens: 1, totalTokens: 36 },
});
await Promise.resolve();
return [
{
mockedStepSessionExecutor.mockImplementationOnce(function (options: any) {
return {
executeAll: vi.fn(async () => {
options.onStepComplete(0, {
stepIndex: 0,
success: true,
retries: 0,
tokenUsage: { inputTokens: 20, outputTokens: 10, cachedTokens: 2, totalTokens: 32 },
},
{
});
await Promise.resolve();
options.onStepComplete(1, {
stepIndex: 1,
success: true,
retries: 0,
tokenUsage: { inputTokens: 30, outputTokens: 5, cachedTokens: 1, totalTokens: 36 },
},
];
}),
terminateAllSessions: mockTerminateAllSessions,
cleanup: mockCleanup,
})) as any);
});
await Promise.resolve();
return [
{
stepIndex: 0,
success: true,
retries: 0,
tokenUsage: { inputTokens: 20, outputTokens: 10, cachedTokens: 2, totalTokens: 32 },
},
{
stepIndex: 1,
success: true,
retries: 0,
tokenUsage: { inputTokens: 30, outputTokens: 5, cachedTokens: 1, totalTokens: 36 },
},
];
}),
terminateAllSessions: mockTerminateAllSessions,
cleanup: mockCleanup,
};
} as any);
const executor = new TaskExecutor(store, "/tmp/test", {});
await executor.execute(createTaskWithSteps());
@@ -2831,4 +2833,3 @@ describe("FN-5256 awaitAbortInFlightTaskWork pause synchronization", () => {
expect((executor as any).activeSessions.has("FN-PAUSE-3")).toBe(false);
});
});

View File

@@ -228,7 +228,7 @@ describe("FN-5241 executor handoff auditing", () => {
const handoff = store.getRunAuditEvents({ taskId: task.id, mutationType: "task:handoff", limit: 10 })[0];
expect(handoff?.metadata).toMatchObject({
taskId: task.id,
reason: "fn_task_done",
reason: "workflow-graph-review",
alreadyEnqueued: false,
});
});

View File

@@ -280,7 +280,7 @@ describe("per-agent heartbeat config", () => {
const config = await monitor.getAgentHeartbeatConfig("agent-001");
expect(config.pollIntervalMs).toBe(60_000);
expect(config.heartbeatTimeoutMs).toBe(30_000);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
});
@@ -571,4 +571,3 @@ describe("per-agent heartbeat config", () => {
});
// ── Heartbeat Execution Tests ──────────────────────────────────────────

View File

@@ -16,7 +16,8 @@ const nodeHealthState = vi.hoisted(() => ({
}));
vi.mock("../project-manager.js", () => ({
ProjectManager: vi.fn().mockImplementation(() => ({
ProjectManager: vi.fn().mockImplementation(function () {
return {
on: vi.fn(),
addProject: vi.fn().mockImplementation(async ({ projectId }: { projectId: string }) => {
projectManagerState.projectIds.push(projectId);
@@ -39,11 +40,13 @@ vi.mock("../project-manager.js", () => ({
releaseGlobalSlot: vi.fn().mockResolvedValue(undefined),
removeProject: vi.fn().mockResolvedValue(undefined),
stopAll: projectManagerState.stopAll,
})),
};
}),
}));
vi.mock("../node-health-monitor.js", () => ({
NodeHealthMonitor: vi.fn().mockImplementation((centralCore: CentralCore) => ({
NodeHealthMonitor: vi.fn().mockImplementation(function (centralCore: CentralCore) {
return {
start: vi.fn().mockImplementation(async () => {
const nodes = await centralCore.listNodes();
nodeHealthState.nodes.clear();
@@ -53,7 +56,8 @@ vi.mock("../node-health-monitor.js", () => ({
}),
stop: vi.fn().mockResolvedValue(undefined),
getNodeHealth: vi.fn().mockImplementation((nodeId: string) => nodeHealthState.nodes.get(nodeId)),
})),
};
}),
}));
describe("HybridExecutor multi-node routing", () => {

View File

@@ -9,21 +9,25 @@ const projectManagerState = vi.hoisted(() => ({
}));
vi.mock("../project-manager.js", () => ({
ProjectManager: vi.fn().mockImplementation(() => ({
ProjectManager: vi.fn().mockImplementation(function () {
return {
on: vi.fn(),
addProject: vi.fn().mockImplementation(async (config: { projectId: string }) => {
projectManagerState.projectIds.push(config.projectId);
}),
getProjectIds: vi.fn().mockImplementation(() => [...projectManagerState.projectIds]),
stopAll: projectManagerState.stopAll,
})),
};
}),
}));
vi.mock("../node-health-monitor.js", () => ({
NodeHealthMonitor: vi.fn().mockImplementation(() => ({
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
})),
NodeHealthMonitor: vi.fn().mockImplementation(function () {
return {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
};
}),
}));
function createCentralCore(overrides?: {

View File

@@ -22,7 +22,7 @@ const mockProjectManagerInstances: Array<{
}> = [];
vi.mock("../project-manager.js", () => ({
ProjectManager: vi.fn().mockImplementation(() => {
ProjectManager: vi.fn().mockImplementation(function () {
const instance = {
addProject: vi.fn().mockImplementation((config: ProjectRuntimeConfig) => {
const runtime = {
@@ -93,7 +93,7 @@ const mockNodeHealthMonitorInstances: Array<{
}> = [];
vi.mock("../node-health-monitor.js", () => ({
NodeHealthMonitor: vi.fn().mockImplementation(() => {
NodeHealthMonitor: vi.fn().mockImplementation(function () {
const instance = {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),

View File

@@ -81,7 +81,7 @@ describe("interpreter merge seam", () => {
const fakeEngine = fakeEngineWith({ autoEligible: true, onMerge });
const result = await (ProjectEngine.prototype as any).requestInterpreterMerge.call(fakeEngine, "FN-3");
expect(onMerge).toHaveBeenCalledWith("FN-3");
expect(onMerge).toHaveBeenCalledWith("FN-3", {});
expect(result.merged).toBe(true);
});

View File

@@ -25,17 +25,19 @@ vi.mock("../merger.js", () => ({
}));
vi.mock("../runtimes/in-process-runtime.js", () => ({
InProcessRuntime: vi.fn().mockImplementation(() => ({
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
getTaskStore: () => testState.currentStore,
getAgentStore: vi.fn(),
getMessageStore: vi.fn(),
getRoutineStore: vi.fn(),
getRoutineRunner: vi.fn(),
getHeartbeatMonitor: vi.fn(),
getTriggerScheduler: vi.fn(),
})),
InProcessRuntime: vi.fn().mockImplementation(function () {
return {
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
getTaskStore: () => testState.currentStore,
getAgentStore: vi.fn(),
getMessageStore: vi.fn(),
getRoutineStore: vi.fn(),
getRoutineRunner: vi.fn(),
getHeartbeatMonitor: vi.fn(),
getTriggerScheduler: vi.fn(),
};
}),
}));
import { ProjectEngine } from "../project-engine.js";

View File

@@ -43,10 +43,12 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
createFindTool: vi.fn(() => ({ name: "find" })),
createLsTool: vi.fn(() => ({ name: "ls" })),
createExtensionRuntime: vi.fn(),
DefaultResourceLoader: vi.fn().mockImplementation(() => ({
reload: vi.fn().mockResolvedValue(undefined),
skillsOverride: undefined,
})),
DefaultResourceLoader: vi.fn().mockImplementation(function () {
return {
reload: vi.fn().mockResolvedValue(undefined),
skillsOverride: undefined,
};
}),
DefaultPackageManager: vi.fn(),
discoverAndLoadExtensions: vi.fn().mockResolvedValue({ errors: [], runtime: { pendingProviderRegistrations: [] } }),
getAgentDir: vi.fn(() => "/test/agent-dir"),

View File

@@ -15,21 +15,23 @@ vi.mock("../engine-singleton-lock.js", () => ({
vi.mock("../project-engine.js", () => {
return {
ProjectEngine: vi.fn().mockImplementation((config: any) => ({
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
getTaskStore: vi.fn().mockReturnValue({ projectId: config.projectId }),
getHeartbeatMonitor: vi.fn().mockReturnValue(undefined),
getHeartbeatTriggerScheduler: vi.fn().mockReturnValue(undefined),
getAutomationStore: vi.fn().mockReturnValue(undefined),
getRuntime: vi.fn().mockReturnValue({
getMissionAutopilot: vi.fn().mockReturnValue(undefined),
getMissionExecutionLoop: vi.fn().mockReturnValue(undefined),
}),
getWorkingDirectory: vi.fn().mockReturnValue(config.workingDirectory),
onMerge: vi.fn().mockResolvedValue(undefined),
_config: config,
})),
ProjectEngine: vi.fn().mockImplementation(function (config: any) {
return {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
getTaskStore: vi.fn().mockReturnValue({ projectId: config.projectId }),
getHeartbeatMonitor: vi.fn().mockReturnValue(undefined),
getHeartbeatTriggerScheduler: vi.fn().mockReturnValue(undefined),
getAutomationStore: vi.fn().mockReturnValue(undefined),
getRuntime: vi.fn().mockReturnValue({
getMissionAutopilot: vi.fn().mockReturnValue(undefined),
getMissionExecutionLoop: vi.fn().mockReturnValue(undefined),
}),
getWorkingDirectory: vi.fn().mockReturnValue(config.workingDirectory),
onMerge: vi.fn().mockResolvedValue(undefined),
_config: config,
};
}),
};
});
@@ -148,15 +150,17 @@ describe("ProjectEngineManager", () => {
let callCount = 0;
(ProjectEngine as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(config: any) => ({
start: vi.fn().mockImplementation(async () => {
function (config: any) {
return {
start: vi.fn().mockImplementation(async () => {
callCount++;
if (callCount === 1) throw new Error("transient failure");
}),
stop: vi.fn().mockResolvedValue(undefined),
getTaskStore: vi.fn().mockReturnValue({ projectId: config.projectId }),
_config: config,
}),
}),
stop: vi.fn().mockResolvedValue(undefined),
getTaskStore: vi.fn().mockReturnValue({ projectId: config.projectId }),
_config: config,
};
},
);
await expect(manager.ensureEngine("proj_aaa")).rejects.toThrow("transient failure");
@@ -554,18 +558,20 @@ describe("ProjectEngineManager", () => {
// Make starts fail on the first 3 calls (one per project in the first tick)
(ProjectEngine as unknown as ReturnType<typeof vi.fn>).mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(config: any) => ({
start: vi.fn().mockImplementation(async () => {
function (config: any) {
return {
start: vi.fn().mockImplementation(async () => {
startCallCount++;
// Fail only the first 3 calls (one per project in first reconciliation tick)
if (startCallCount <= 3) {
throw new Error("transient failure");
}
}),
stop: vi.fn().mockResolvedValue(undefined),
getTaskStore: vi.fn().mockReturnValue({ projectId: config.projectId }),
_config: config,
}),
}),
stop: vi.fn().mockResolvedValue(undefined),
getTaskStore: vi.fn().mockReturnValue({ projectId: config.projectId }),
_config: config,
};
},
);
// Start reconciliation (runs immediate tick which fails all 3)
@@ -593,12 +599,14 @@ describe("ProjectEngineManager", () => {
// Reset mock for other tests
(ProjectEngine as unknown as ReturnType<typeof vi.fn>).mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(config: any) => ({
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
getTaskStore: vi.fn().mockReturnValue({ projectId: config.projectId }),
_config: config,
}),
function (config: any) {
return {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
getTaskStore: vi.fn().mockReturnValue({ projectId: config.projectId }),
_config: config,
};
},
);
});
@@ -639,12 +647,14 @@ describe("ProjectEngineManager", () => {
vi.clearAllMocks();
(ProjectEngine as unknown as ReturnType<typeof vi.fn>).mockImplementation(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(config: any) => ({
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
getTaskStore: vi.fn().mockReturnValue({ projectId: config.projectId }),
_config: config,
}),
function (config: any) {
return {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
getTaskStore: vi.fn().mockReturnValue({ projectId: config.projectId }),
_config: config,
};
},
);
// Advance more time - no new engines should be started

View File

@@ -22,40 +22,42 @@ vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return { ...actual, execFile: mocks.execFile };
});
vi.mock("../pr-monitor.js", () => ({ PrMonitor: vi.fn().mockImplementation(() => ({ onNewComments: vi.fn() })) }));
vi.mock("../pr-comment-handler.js", () => ({ PrCommentHandler: vi.fn().mockImplementation(() => ({ handleNewComments: vi.fn() })) }));
vi.mock("../pr-monitor.js", () => ({ PrMonitor: vi.fn().mockImplementation(function () { return { onNewComments: vi.fn() }; }) }));
vi.mock("../pr-comment-handler.js", () => ({ PrCommentHandler: vi.fn().mockImplementation(function () { return { handleNewComments: vi.fn() }; }) }));
vi.mock("../auth-storage.js", () => ({
createFusionAuthStorage: vi.fn(() => ({ reload: vi.fn(), getOAuthProviders: vi.fn(() => []), get: vi.fn(() => undefined) })),
getFusionOAuthAlertStatePath: vi.fn(() => "/tmp/oauth-alert-state.json"),
}));
vi.mock("../notifier.js", () => ({ NtfyNotifier: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() })) }));
vi.mock("../notifier.js", () => ({ NtfyNotifier: vi.fn().mockImplementation(function () { return { start: vi.fn(), stop: vi.fn() }; }) }));
vi.mock("../notification/index.js", () => ({
NotificationService: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() })),
OAuthAlertStateStore: vi.fn().mockImplementation(() => ({})),
OAuthExpiryMonitor: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() })),
OAuthValidityLogger: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() })),
NotificationService: vi.fn().mockImplementation(function () { return { start: vi.fn(), stop: vi.fn() }; }),
OAuthAlertStateStore: vi.fn().mockImplementation(function () { return {}; }),
OAuthExpiryMonitor: vi.fn().mockImplementation(function () { return { start: vi.fn(), stop: vi.fn() }; }),
OAuthValidityLogger: vi.fn().mockImplementation(function () { return { start: vi.fn(), stop: vi.fn() }; }),
}));
vi.mock("../cron-runner.js", () => ({
CronRunner: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() })),
CronRunner: vi.fn().mockImplementation(function () { return { start: vi.fn(), stop: vi.fn() }; }),
createAiPromptExecutor: vi.fn(async () => vi.fn()),
}));
vi.mock("../runtimes/in-process-runtime.js", () => ({
InProcessRuntime: vi.fn().mockImplementation(() => ({
start: mocks.runtimeStart,
stop: mocks.runtimeStop,
resumeAfterUnpause: mocks.runtimeResumeAfterUnpause,
getTaskStore: () => mocks.currentStore,
getAgentStore: vi.fn(),
getMessageStore: vi.fn(),
getRoutineStore: vi.fn(),
getRoutineRunner: vi.fn(),
getHeartbeatMonitor: vi.fn(),
getTriggerScheduler: vi.fn(),
configurePrMonitoring: mocks.runtimeConfigurePrMonitoring,
setActiveMergeTaskIdProvider: vi.fn(),
setMergeEnqueuer: vi.fn(),
setMergeActiveClearer: vi.fn(),
})),
InProcessRuntime: vi.fn().mockImplementation(function () {
return {
start: mocks.runtimeStart,
stop: mocks.runtimeStop,
resumeAfterUnpause: mocks.runtimeResumeAfterUnpause,
getTaskStore: () => mocks.currentStore,
getAgentStore: vi.fn(),
getMessageStore: vi.fn(),
getRoutineStore: vi.fn(),
getRoutineRunner: vi.fn(),
getHeartbeatMonitor: vi.fn(),
getTriggerScheduler: vi.fn(),
configurePrMonitoring: mocks.runtimeConfigurePrMonitoring,
setActiveMergeTaskIdProvider: vi.fn(),
setMergeEnqueuer: vi.fn(),
setMergeActiveClearer: vi.fn(),
};
}),
}));
type Listener = (...args: any[]) => void | Promise<void>;

View File

@@ -51,10 +51,12 @@ vi.mock("@fusion/core", async (importOriginal) => {
vi.mock("../cron-runner.js", () => {
return {
CronRunner: vi.fn().mockImplementation(() => ({
start: mocks.cronRunnerStart,
stop: mocks.cronRunnerStop,
})),
CronRunner: vi.fn().mockImplementation(function () {
return {
start: mocks.cronRunnerStart,
stop: mocks.cronRunnerStop,
};
}),
createAiPromptExecutor: mocks.createAiPromptExecutor,
};
});
@@ -72,40 +74,54 @@ vi.mock("node:child_process", async (importOriginal) => {
});
vi.mock("../pr-monitor.js", () => ({
PrMonitor: vi.fn().mockImplementation(() => ({
onNewComments: vi.fn(),
})),
PrMonitor: vi.fn().mockImplementation(function () {
return {
onNewComments: vi.fn(),
};
}),
}));
vi.mock("../pr-comment-handler.js", () => ({
PrCommentHandler: vi.fn().mockImplementation(() => ({
handleNewComments: vi.fn(),
createFollowUpTask: mocks.prHandlerCreateFollowUpTask,
})),
PrCommentHandler: vi.fn().mockImplementation(function () {
return {
handleNewComments: vi.fn(),
createFollowUpTask: mocks.prHandlerCreateFollowUpTask,
};
}),
}));
vi.mock("../notifier.js", () => ({
NtfyNotifier: vi.fn().mockImplementation(() => ({
start: mocks.notifierStart,
stop: mocks.notifierStop,
notifyGridlock: mocks.notifierNotifyGridlock,
})),
NtfyNotifier: vi.fn().mockImplementation(function () {
return {
start: mocks.notifierStart,
stop: mocks.notifierStop,
notifyGridlock: mocks.notifierNotifyGridlock,
};
}),
}));
vi.mock("../notification/index.js", () => ({
NotificationService: vi.fn().mockImplementation(() => ({
start: mocks.notificationServiceStart,
stop: mocks.notificationServiceStop,
})),
OAuthAlertStateStore: vi.fn().mockImplementation(() => ({})),
OAuthExpiryMonitor: vi.fn().mockImplementation(() => ({
start: mocks.oauthExpiryMonitorStart,
stop: mocks.oauthExpiryMonitorStop,
})),
OAuthValidityLogger: vi.fn().mockImplementation(() => ({
start: mocks.oauthValidityLoggerStart,
stop: mocks.oauthValidityLoggerStop,
})),
NotificationService: vi.fn().mockImplementation(function () {
return {
start: mocks.notificationServiceStart,
stop: mocks.notificationServiceStop,
};
}),
OAuthAlertStateStore: vi.fn().mockImplementation(function () {
return {};
}),
OAuthExpiryMonitor: vi.fn().mockImplementation(function () {
return {
start: mocks.oauthExpiryMonitorStart,
stop: mocks.oauthExpiryMonitorStop,
};
}),
OAuthValidityLogger: vi.fn().mockImplementation(function () {
return {
start: mocks.oauthValidityLoggerStart,
stop: mocks.oauthValidityLoggerStop,
};
}),
}));
vi.mock("../auth-storage.js", () => ({
@@ -118,19 +134,21 @@ vi.mock("../auth-storage.js", () => ({
}));
vi.mock("../runtimes/in-process-runtime.js", () => ({
InProcessRuntime: vi.fn().mockImplementation(() => ({
start: mocks.runtimeStart,
stop: mocks.runtimeStop,
resumeAfterUnpause: mocks.runtimeResumeAfterUnpause,
getTaskStore: () => mocks.currentStore,
getAgentStore: vi.fn(),
getMessageStore: vi.fn(),
getRoutineStore: vi.fn(),
getRoutineRunner: vi.fn(),
getHeartbeatMonitor: vi.fn(),
getTriggerScheduler: vi.fn(),
configurePrMonitoring: mocks.runtimeConfigurePrMonitoring,
})),
InProcessRuntime: vi.fn().mockImplementation(function () {
return {
start: mocks.runtimeStart,
stop: mocks.runtimeStop,
resumeAfterUnpause: mocks.runtimeResumeAfterUnpause,
getTaskStore: () => mocks.currentStore,
getAgentStore: vi.fn(),
getMessageStore: vi.fn(),
getRoutineStore: vi.fn(),
getRoutineRunner: vi.fn(),
getHeartbeatMonitor: vi.fn(),
getTriggerScheduler: vi.fn(),
configurePrMonitoring: mocks.runtimeConfigurePrMonitoring,
};
}),
}));
type SettingsHandlerPayload = {

View File

@@ -8,7 +8,8 @@ import type { ProjectRuntimeConfig } from "../project-runtime.js";
// Mock the runtimes
vi.mock("../runtimes/in-process-runtime.js", () => ({
InProcessRuntime: vi.fn().mockImplementation(() => ({
InProcessRuntime: vi.fn().mockImplementation(function () {
return {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
getStatus: vi.fn().mockReturnValue("active"),
@@ -20,11 +21,13 @@ vi.mock("../runtimes/in-process-runtime.js", () => ({
lastActivityAt: new Date().toISOString(),
}),
on: vi.fn().mockReturnThis(),
})),
};
}),
}));
vi.mock("../runtimes/child-process-runtime.js", () => ({
ChildProcessRuntime: vi.fn().mockImplementation(() => ({
ChildProcessRuntime: vi.fn().mockImplementation(function () {
return {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
getStatus: vi.fn().mockReturnValue("active"),
@@ -40,11 +43,13 @@ vi.mock("../runtimes/child-process-runtime.js", () => ({
lastActivityAt: new Date().toISOString(),
}),
on: vi.fn().mockReturnThis(),
})),
};
}),
}));
vi.mock("../runtimes/remote-node-runtime.js", () => ({
RemoteNodeRuntime: vi.fn().mockImplementation(() => ({
RemoteNodeRuntime: vi.fn().mockImplementation(function () {
return {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
getStatus: vi.fn().mockReturnValue("active"),
@@ -60,7 +65,8 @@ vi.mock("../runtimes/remote-node-runtime.js", () => ({
lastActivityAt: new Date().toISOString(),
}),
on: vi.fn().mockReturnThis(),
})),
};
}),
}));
describe("ProjectManager", () => {

View File

@@ -1,5 +1,5 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskStore } from "@fusion/core";
const execMock = vi.fn();
@@ -43,6 +43,10 @@ describe("reliability interactions: live-zero reclaim", () => {
execMock.mockResolvedValue("");
});
afterEach(() => {
vi.restoreAllMocks();
});
it("restart recovery and reclaim sweep converge to todo + null worktree for live-zero case", async () => {
const taskState: any = {
id: "FN-9100",

View File

@@ -1,5 +1,5 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskStore } from "@fusion/core";
const execMock = vi.fn();
@@ -42,6 +42,10 @@ describe("reliability interactions: stale cached-base branch reclaim", () => {
vi.spyOn(worktreePool, "isUsableTaskWorktree").mockResolvedValue(true);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("restart recovery + reclaim sweep ends with todo and nulled cached branch metadata", async () => {
const task: any = { id: "FN-9001", column: "in-review", checkedOutBy: null, branch: "fusion/fn-9001", worktree: "/tmp/ghost", baseCommitSha: "stale-base", paused: true, pausedReason: "branch-conflict-unrecoverable", error: "Agent exited without calling fn_task_done", status: "failed", steps: [{ status: "pending" }] };
const statefulStore: any = createStore();

View File

@@ -9,12 +9,14 @@ const mockStreamEvents = vi.hoisted(() => vi.fn());
const mockPollPendingAssignments = vi.hoisted(() => vi.fn());
vi.mock("../../runtimes/remote-node-client.js", () => ({
RemoteNodeClient: vi.fn().mockImplementation(() => ({
health: mockHealth,
getMetrics: mockGetMetrics,
streamEvents: mockStreamEvents,
pollPendingAssignments: mockPollPendingAssignments,
})),
RemoteNodeClient: vi.fn().mockImplementation(function () {
return {
health: mockHealth,
getMetrics: mockGetMetrics,
streamEvents: mockStreamEvents,
pollPendingAssignments: mockPollPendingAssignments,
};
}),
}));
const NOW = "2026-05-16T00:00:00.000Z";

View File

@@ -3,13 +3,15 @@ import { describe, expect, it } from "vitest";
// This contract test fails loudly if dashboard denial-reason enum values change without updating cross-node consumers.
describe("reliability: node settings sync auth denial-reason contract", () => {
it("pins the SyncStatusDenialReason enum values consumed by cross-node sync-status callers", async () => {
const helpersModulePath = "../../../../dashboard/src/routes/register-settings-sync-helpers.js";
const apiErrorModulePath = "../../../../dashboard/src/api-error.js";
const { SYNC_STATUS_DENIAL_REASONS, classifySyncStatusDenialReason } = await import(helpersModulePath) as {
const { SYNC_STATUS_DENIAL_REASONS, classifySyncStatusDenialReason } = await import(
new URL("../../../../dashboard/src/routes/register-settings-sync-helpers.js", import.meta.url).href
) as {
SYNC_STATUS_DENIAL_REASONS: readonly string[];
classifySyncStatusDenialReason: (err: unknown) => string;
};
const { ApiError } = await import(apiErrorModulePath) as {
const { ApiError } = await import(
new URL("../../../../dashboard/src/api-error.js", import.meta.url).href
) as {
ApiError: new (status: number, message: string) => Error;
};

View File

@@ -16,17 +16,19 @@ vi.mock("../../merger.js", async (importOriginal) => {
});
vi.mock("../../runtimes/in-process-runtime.js", () => ({
InProcessRuntime: vi.fn().mockImplementation(() => ({
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
getTaskStore: () => testState.currentStore,
getAgentStore: vi.fn(),
getMessageStore: vi.fn(),
getRoutineStore: vi.fn(),
getRoutineRunner: vi.fn(),
getHeartbeatMonitor: vi.fn(),
getTriggerScheduler: vi.fn(),
})),
InProcessRuntime: vi.fn().mockImplementation(function () {
return {
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
getTaskStore: () => testState.currentStore,
getAgentStore: vi.fn(),
getMessageStore: vi.fn(),
getRoutineStore: vi.fn(),
getRoutineRunner: vi.fn(),
getHeartbeatMonitor: vi.fn(),
getTriggerScheduler: vi.fn(),
};
}),
}));
import { ProjectEngine } from "../../project-engine.js";

View File

@@ -22,17 +22,19 @@ vi.mock("../../merger.js", async (importOriginal) => {
});
vi.mock("../../runtimes/in-process-runtime.js", () => ({
InProcessRuntime: vi.fn().mockImplementation(() => ({
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
getTaskStore: () => testState.currentStore,
getAgentStore: vi.fn(),
getMessageStore: vi.fn(),
getRoutineStore: vi.fn(),
getRoutineRunner: vi.fn(),
getHeartbeatMonitor: vi.fn(),
getTriggerScheduler: vi.fn(),
})),
InProcessRuntime: vi.fn().mockImplementation(function () {
return {
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
getTaskStore: () => testState.currentStore,
getAgentStore: vi.fn(),
getMessageStore: vi.fn(),
getRoutineStore: vi.fn(),
getRoutineRunner: vi.fn(),
getHeartbeatMonitor: vi.fn(),
getTriggerScheduler: vi.fn(),
};
}),
}));
import { ProjectEngine } from "../../project-engine.js";

View File

@@ -25,40 +25,42 @@ vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
return { ...actual, execFile: projectEngineMocks.execFile };
});
vi.mock("../../pr-monitor.js", () => ({ PrMonitor: vi.fn().mockImplementation(() => ({ onNewComments: vi.fn() })) }));
vi.mock("../../pr-comment-handler.js", () => ({ PrCommentHandler: vi.fn().mockImplementation(() => ({ handleNewComments: vi.fn() })) }));
vi.mock("../../pr-monitor.js", () => ({ PrMonitor: vi.fn().mockImplementation(function () { return { onNewComments: vi.fn() }; }) }));
vi.mock("../../pr-comment-handler.js", () => ({ PrCommentHandler: vi.fn().mockImplementation(function () { return { handleNewComments: vi.fn() }; }) }));
vi.mock("../../auth-storage.js", () => ({
createFusionAuthStorage: vi.fn(() => ({ reload: vi.fn(), getOAuthProviders: vi.fn(() => []), get: vi.fn(() => undefined) })),
getFusionOAuthAlertStatePath: vi.fn(() => "/tmp/oauth-alert-state.json"),
}));
vi.mock("../../notifier.js", () => ({ NtfyNotifier: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() })) }));
vi.mock("../../notifier.js", () => ({ NtfyNotifier: vi.fn().mockImplementation(function () { return { start: vi.fn(), stop: vi.fn() }; }) }));
vi.mock("../../notification/index.js", () => ({
NotificationService: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() })),
OAuthAlertStateStore: vi.fn().mockImplementation(() => ({})),
OAuthExpiryMonitor: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() })),
OAuthValidityLogger: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() })),
NotificationService: vi.fn().mockImplementation(function () { return { start: vi.fn(), stop: vi.fn() }; }),
OAuthAlertStateStore: vi.fn().mockImplementation(function () { return {}; }),
OAuthExpiryMonitor: vi.fn().mockImplementation(function () { return { start: vi.fn(), stop: vi.fn() }; }),
OAuthValidityLogger: vi.fn().mockImplementation(function () { return { start: vi.fn(), stop: vi.fn() }; }),
}));
vi.mock("../../cron-runner.js", () => ({
CronRunner: vi.fn().mockImplementation(() => ({ start: vi.fn(), stop: vi.fn() })),
CronRunner: vi.fn().mockImplementation(function () { return { start: vi.fn(), stop: vi.fn() }; }),
createAiPromptExecutor: vi.fn(async () => vi.fn()),
}));
vi.mock("../../runtimes/in-process-runtime.js", () => ({
InProcessRuntime: vi.fn().mockImplementation(() => ({
start: projectEngineMocks.runtimeStart,
stop: projectEngineMocks.runtimeStop,
resumeAfterUnpause: projectEngineMocks.runtimeResumeAfterUnpause,
getTaskStore: () => projectEngineMocks.currentStore,
getAgentStore: vi.fn(),
getMessageStore: vi.fn(),
getRoutineStore: vi.fn(),
getRoutineRunner: vi.fn(),
getHeartbeatMonitor: vi.fn(),
getTriggerScheduler: vi.fn(),
configurePrMonitoring: projectEngineMocks.runtimeConfigurePrMonitoring,
setActiveMergeTaskIdProvider: vi.fn(),
setMergeEnqueuer: vi.fn(),
setMergeActiveClearer: vi.fn(),
})),
InProcessRuntime: vi.fn().mockImplementation(function () {
return {
start: projectEngineMocks.runtimeStart,
stop: projectEngineMocks.runtimeStop,
resumeAfterUnpause: projectEngineMocks.runtimeResumeAfterUnpause,
getTaskStore: () => projectEngineMocks.currentStore,
getAgentStore: vi.fn(),
getMessageStore: vi.fn(),
getRoutineStore: vi.fn(),
getRoutineRunner: vi.fn(),
getHeartbeatMonitor: vi.fn(),
getTriggerScheduler: vi.fn(),
configurePrMonitoring: projectEngineMocks.runtimeConfigurePrMonitoring,
setActiveMergeTaskIdProvider: vi.fn(),
setMergeEnqueuer: vi.fn(),
setMergeActiveClearer: vi.fn(),
};
}),
}));
type Listener = (...args: any[]) => void | Promise<void>;

View File

@@ -1411,6 +1411,34 @@ describe("Scheduler", () => {
expect(store.moveTask).not.toHaveBeenCalled();
});
it("caps in-progress dispatch by the global semaphore limit even before executors acquire slots", async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
const semaphore = new AgentSemaphore(3);
const tasks = [
createMockTask({ id: "FN-001", column: "in-progress" }),
createMockTask({ id: "FN-002", column: "in-progress" }),
createMockTask({ id: "FN-003", column: "in-progress" }),
createMockTask({ id: "FN-004", column: "todo", dependencies: [] }),
createMockTask({ id: "FN-005", column: "todo", dependencies: [] }),
];
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue(tasks),
getTask: vi.fn(async (taskId: string) => tasks.find((task) => task.id === taskId) ?? null),
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 5, maxWorktrees: 10 }),
updateTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
});
const scheduler = new Scheduler(store, { semaphore });
scheduler.start();
await scheduler.schedule();
expect(store.moveTask).not.toHaveBeenCalled();
});
it("respects maxWorktrees limit", async () => {
const tasks = [
createMockTask({ id: "FN-001", column: "in-progress" }),

View File

@@ -1,5 +1,5 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../self-healing.js";
import * as branchConflicts from "../branch-conflicts.js";
@@ -29,6 +29,10 @@ describe("self-healing reclaim paused review", () => {
vi.spyOn(worktreePool, "isUsableTaskWorktree").mockResolvedValue(true);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("reclaims paused in-review branch conflict, clears paused state, and requeues to todo with audit metadata", async () => {
(store.listTasks as any)
.mockResolvedValueOnce([])

View File

@@ -137,7 +137,7 @@ vi.mock("../../worktree-pool.js", async () => {
// Mock the scheduler
vi.mock("../../scheduler.js", async () => {
return {
Scheduler: vi.fn().mockImplementation(() => {
Scheduler: vi.fn().mockImplementation(function () {
const self = {} as Record<string, unknown>;
self.start = vi.fn();
self.stop = vi.fn();
@@ -150,7 +150,7 @@ vi.mock("../../scheduler.js", async () => {
vi.mock("../../self-healing.js", async () => {
return {
SelfHealingManager: vi.fn().mockImplementation((_store, opts) => {
SelfHealingManager: vi.fn().mockImplementation(function (_store, opts) {
mockSelfHealingCtor(opts);
return {
start: mockSelfHealingStart,
@@ -164,28 +164,32 @@ vi.mock("../../self-healing.js", async () => {
vi.mock("../../restart-recovery-coordinator.js", async () => {
return {
RestartRecoveryCoordinator: vi.fn().mockImplementation(() => ({
recoverInterruptedRuns: mockRecoverInterruptedRuns,
})),
RestartRecoveryCoordinator: vi.fn().mockImplementation(function () {
return {
recoverInterruptedRuns: mockRecoverInterruptedRuns,
};
}),
};
});
// Mock the plugin runner
vi.mock("../../plugin-runner.js", async () => {
return {
PluginRunner: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
getPluginTools: vi.fn().mockReturnValue([]),
getPluginRoutes: vi.fn().mockReturnValue([]),
})),
PluginRunner: vi.fn().mockImplementation(function () {
return {
init: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
getPluginTools: vi.fn().mockReturnValue([]),
getPluginRoutes: vi.fn().mockReturnValue([]),
};
}),
};
});
// Mock the executor
vi.mock("../../executor.js", async () => {
return {
TaskExecutor: vi.fn().mockImplementation((_store, _rootDir, options) => {
TaskExecutor: vi.fn().mockImplementation(function (_store, _rootDir, options) {
mockExecutorCtor(options);
const self = {} as Record<string, unknown>;
self.resumeOrphaned = mockResumeOrphaned;

View File

@@ -10,7 +10,7 @@ const mockStreamEvents = vi.hoisted(() => vi.fn());
const mockPollPendingAssignments = vi.hoisted(() => vi.fn());
vi.mock("../remote-node-client.js", () => ({
RemoteNodeClient: vi.fn().mockImplementation((options: unknown) => {
RemoteNodeClient: vi.fn().mockImplementation(function (options: unknown) {
mockClientConstructor(options);
return {
health: mockHealth,

View File

@@ -1304,7 +1304,10 @@ export class Scheduler {
// When a semaphore is provided, factor in its available slots so we
// don't schedule more tasks than the global limit allows.
const semaphoreAvailable = this.options.semaphore
? this.options.semaphore.availableCount
? Math.min(
this.options.semaphore.availableCount,
this.options.semaphore.limit - agentSlots,
)
: Infinity;
const available = Math.min(