feat(FN-1051): wire MissionAutopilot into dashboard startup
- Instantiate MissionAutopilot with TaskStore and MissionStore before Scheduler creation - Pass missionAutopilot and missionStore to Scheduler constructor options - Break circular dependency via setScheduler() after Scheduler construction - Start autopilot in engine startup sequence, stop on graceful shutdown - Add comprehensive tests verifying MissionAutopilot wiring in both test files
This commit is contained in:
@@ -6,6 +6,13 @@ import { EventEmitter } from "node:events";
|
||||
// Minimal mock store backed by EventEmitter so `store.on` works
|
||||
function makeMockStore() {
|
||||
const emitter = new EventEmitter();
|
||||
const mockMissionStore = {
|
||||
listMissions: vi.fn().mockReturnValue([]),
|
||||
getMission: vi.fn(),
|
||||
updateMission: vi.fn(),
|
||||
listMilestones: vi.fn().mockReturnValue([]),
|
||||
listFeatures: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
return {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
watch: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -20,6 +27,7 @@ function makeMockStore() {
|
||||
}),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
|
||||
getMissionStore: vi.fn().mockReturnValue(mockMissionStore),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
emitter.on(event, handler);
|
||||
}),
|
||||
@@ -91,6 +99,11 @@ vi.mock("@fusion/engine", async (importOriginal) => {
|
||||
...original,
|
||||
WorktreePool: original.WorktreePool,
|
||||
AgentSemaphore: original.AgentSemaphore,
|
||||
MissionAutopilot: vi.fn().mockImplementation(() => ({
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
setScheduler: vi.fn(),
|
||||
})),
|
||||
TriageProcessor: vi.fn().mockImplementation(() => ({
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
@@ -281,3 +294,60 @@ describe("runDashboard — AuthStorage & ModelRegistry wiring", () => {
|
||||
expect(mockAuthStorage.getApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDashboard — MissionAutopilot wiring", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockDiscoverAndLoadExtensions.mockResolvedValue({
|
||||
runtime: { pendingProviderRegistrations: [] },
|
||||
errors: [],
|
||||
});
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
|
||||
});
|
||||
|
||||
it("creates a MissionAutopilot instance and passes it to createServer", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
const { MissionAutopilot } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(MissionAutopilot).toHaveBeenCalledTimes(1);
|
||||
expect(createServer).toHaveBeenCalledTimes(1);
|
||||
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
expect(serverOpts).toHaveProperty("missionAutopilot");
|
||||
expect(serverOpts.missionAutopilot).toBeDefined();
|
||||
});
|
||||
|
||||
it("passes missionAutopilot and missionStore to Scheduler options", async () => {
|
||||
const { Scheduler } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(Scheduler).toHaveBeenCalledTimes(1);
|
||||
const schedulerOpts = (Scheduler as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
expect(schedulerOpts).toHaveProperty("missionAutopilot");
|
||||
expect(schedulerOpts).toHaveProperty("missionStore");
|
||||
expect(schedulerOpts.missionAutopilot).toBeDefined();
|
||||
expect(schedulerOpts.missionStore).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls setScheduler on the MissionAutopilot instance after Scheduler creation", async () => {
|
||||
const { MissionAutopilot } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(MissionAutopilot).toHaveBeenCalledTimes(1);
|
||||
const autopilotInstance = (MissionAutopilot as ReturnType<typeof vi.fn>).mock.results[0].value;
|
||||
expect(autopilotInstance.setScheduler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts the MissionAutopilot service", async () => {
|
||||
const { MissionAutopilot } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
const autopilotInstance = (MissionAutopilot as ReturnType<typeof vi.fn>).mock.results[0].value;
|
||||
expect(autopilotInstance.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,13 @@ const {
|
||||
// Minimal mock store backed by EventEmitter so `store.on` works
|
||||
function makeMockStore() {
|
||||
const emitter = new EventEmitter();
|
||||
const mockMissionStore = {
|
||||
listMissions: vi.fn().mockReturnValue([]),
|
||||
getMission: vi.fn(),
|
||||
updateMission: vi.fn(),
|
||||
listMilestones: vi.fn().mockReturnValue([]),
|
||||
listFeatures: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
return {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
watch: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -52,6 +59,7 @@ function makeMockStore() {
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
updateTask: vi.fn().mockResolvedValue({}),
|
||||
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
|
||||
getMissionStore: vi.fn().mockReturnValue(mockMissionStore),
|
||||
close: vi.fn(),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
emitter.on(event, handler);
|
||||
@@ -219,6 +227,11 @@ vi.mock("@fusion/engine", async (importOriginal) => {
|
||||
checkStuckBudget: mockCheckStuckBudget,
|
||||
};
|
||||
}),
|
||||
MissionAutopilot: vi.fn().mockImplementation(() => ({
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
setScheduler: vi.fn(),
|
||||
})),
|
||||
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
|
||||
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createInterface } from "node:readline";
|
||||
import { TaskStore, AutomationStore, CentralCore, AgentStore, getTaskMergeBlocker } from "@fusion/core";
|
||||
import type { Settings, TaskDetail, PrInfo } from "@fusion/core";
|
||||
import { createServer, GitHubClient } from "@fusion/dashboard";
|
||||
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees, NtfyNotifier, PrMonitor, PrCommentHandler, CronRunner, StuckTaskDetector, SelfHealingManager } from "@fusion/engine";
|
||||
import { TriageProcessor, TaskExecutor, Scheduler, AgentSemaphore, WorktreePool, aiMergeTask, UsageLimitPauser, PRIORITY_MERGE, scanIdleWorktrees, cleanupOrphanedWorktrees, NtfyNotifier, PrMonitor, PrCommentHandler, CronRunner, StuckTaskDetector, SelfHealingManager, MissionAutopilot } from "@fusion/engine";
|
||||
import { AuthStorage, DefaultPackageManager, ModelRegistry, SettingsManager, discoverAndLoadExtensions, getAgentDir, createExtensionRuntime } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
/**
|
||||
@@ -563,8 +563,16 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
modelRegistry.refresh();
|
||||
}
|
||||
|
||||
// ── MissionAutopilot: autonomous mission progression ─────────────
|
||||
//
|
||||
// Declared before createServer so it can be passed to both the server
|
||||
// and the Scheduler. Assigned inside the engine block below (dev mode
|
||||
// skips the engine entirely, so missionAutopilot stays undefined).
|
||||
//
|
||||
let missionAutopilot: InstanceType<typeof MissionAutopilot> | undefined;
|
||||
|
||||
// Start the web server with AI merge, auth, and model registry wired in
|
||||
const app = createServer(store, { onMerge, authStorage, modelRegistry, automationStore });
|
||||
const app = createServer(store, { onMerge, authStorage, modelRegistry, automationStore, missionAutopilot });
|
||||
|
||||
// Start the AI engine (unless in dev mode)
|
||||
if (!opts.dev) {
|
||||
@@ -621,9 +629,19 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
prCommentHandler.handleNewComments(taskId, prInfo, comments),
|
||||
);
|
||||
|
||||
// ── MissionAutopilot: autonomous mission progression ─────────────
|
||||
//
|
||||
// Created before the Scheduler since Scheduler's constructor accepts
|
||||
// missionAutopilot. The scheduler reference is set after construction
|
||||
// via setScheduler() to break the circular dependency.
|
||||
//
|
||||
missionAutopilot = new MissionAutopilot(store, store.getMissionStore());
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
semaphore,
|
||||
prMonitor,
|
||||
missionStore: store.getMissionStore(),
|
||||
missionAutopilot,
|
||||
onSchedule: (t) => console.log(`[engine] Scheduled ${t.id}`),
|
||||
onBlocked: (t, deps) => console.log(`[engine] ${t.id} blocked by ${deps.join(", ")}`),
|
||||
onClosedPrFeedback: async (taskId, prInfo, comments) => {
|
||||
@@ -631,12 +649,16 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
},
|
||||
});
|
||||
|
||||
// Break circular dependency: Scheduler ↔ MissionAutopilot
|
||||
missionAutopilot.setScheduler(scheduler);
|
||||
|
||||
// ── CronRunner: scheduled task execution ──────────────────────────
|
||||
const cronRunner = new CronRunner(store, automationStore);
|
||||
cronRunner.start();
|
||||
|
||||
triage.start();
|
||||
scheduler.start();
|
||||
missionAutopilot.start();
|
||||
stuckTaskDetector.start();
|
||||
selfHealing.start();
|
||||
|
||||
@@ -751,6 +773,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
const shutdown = () => {
|
||||
selfHealing.stop();
|
||||
stuckTaskDetector.stop();
|
||||
missionAutopilot.stop();
|
||||
triage.stop();
|
||||
scheduler.stop();
|
||||
cronRunner.stop();
|
||||
|
||||
Reference in New Issue
Block a user