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:
gsxdsm
2026-04-07 10:40:05 -07:00
parent c852620090
commit 7a01995465
3 changed files with 108 additions and 2 deletions

View File

@@ -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);
});
});

View File

@@ -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),
};

View File

@@ -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();