feat(FN-980): add mission autopilot for autonomous slice progression
- Add MissionAutopilot class with state machine (inactive → watching → activating → completing) - Add autopilot database schema: autopilotEnabled, autopilotState, lastAutopilotActivityAt columns - Integrate autopilot with scheduler: handleTaskCompletion() triggers slice activation - Add API routes: GET/PATCH /missions/:id/autopilot, POST start/stop endpoints - Add autopilot toggle UI in MissionManager dashboard component - Add retry logic with exponential backoff (up to 3 attempts) for slice activation - Add background poll (60s) to detect stale autopilot missions - Include changeset for @gsxdsm/fusion minor bump
This commit is contained in:
@@ -2874,7 +2874,8 @@ When all steps are complete: call \`task_done()\`
|
||||
If a build command is configured, run that exact command in this worktree before calling \`task_done()\`.
|
||||
Treat a non-zero exit code as a blocking failure. Do not claim success without a real passing run.
|
||||
Run the configured/full test suite and fix failures even when that requires edits outside the original File Scope.
|
||||
If the repo has a typecheck command, run it before \`task_done()\` and fix any failures it reports.`;
|
||||
If the repo has a typecheck command, run it before \`task_done()\` and fix any failures it reports.
|
||||
Use \`task_create\` for truly separate follow-up work, not for fixes required to get tests, build, or typecheck back to green.`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ export { AgentSemaphore, PRIORITY_MERGE, PRIORITY_EXECUTE, PRIORITY_SPECIFY } fr
|
||||
export { TriageProcessor, type TriageProcessorOptions } from "./triage.js";
|
||||
export { TaskExecutor, type TaskExecutorOptions } from "./executor.js";
|
||||
export { Scheduler, type SchedulerOptions } from "./scheduler.js";
|
||||
export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopilot.js";
|
||||
export { aiMergeTask, type MergerOptions } from "./merger.js";
|
||||
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
|
||||
export { createKbAgent, type AgentOptions, type AgentResult } from "./pi.js";
|
||||
|
||||
@@ -76,3 +76,6 @@ export const projectManagerLog = createLogger("project-manager");
|
||||
|
||||
/** Logger for the hybrid executor subsystem. */
|
||||
export const hybridExecutorLog = createLogger("hybrid-executor");
|
||||
|
||||
/** Logger for the mission autopilot subsystem. */
|
||||
export const autopilotLog = createLogger("autopilot");
|
||||
|
||||
544
packages/engine/src/mission-autopilot.test.ts
Normal file
544
packages/engine/src/mission-autopilot.test.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
/**
|
||||
* MissionAutopilot unit tests.
|
||||
*
|
||||
* Tests the autopilot monitoring class with mocked TaskStore and MissionStore.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { MissionAutopilot } from "./mission-autopilot.js";
|
||||
import type { Mission, Milestone, Slice, MissionFeature } from "@fusion/core";
|
||||
|
||||
// ── Mock Factories ──────────────────────────────────────────────────
|
||||
|
||||
function createMockMission(overrides: Partial<Mission> = {}): Mission {
|
||||
return {
|
||||
id: "M-TEST1",
|
||||
title: "Test Mission",
|
||||
status: "active",
|
||||
interviewState: "not_started",
|
||||
autoAdvance: true,
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "inactive",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockMilestone(overrides: Partial<Milestone> = {}): Milestone {
|
||||
return {
|
||||
id: "MS-001",
|
||||
missionId: "M-TEST1",
|
||||
title: "Test Milestone",
|
||||
status: "active",
|
||||
orderIndex: 0,
|
||||
interviewState: "not_started",
|
||||
dependencies: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockSlice(overrides: Partial<Slice> = {}): Slice {
|
||||
return {
|
||||
id: "SL-001",
|
||||
milestoneId: "MS-001",
|
||||
title: "Test Slice",
|
||||
status: "pending",
|
||||
orderIndex: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockFeature(overrides: Partial<MissionFeature> = {}): MissionFeature {
|
||||
return {
|
||||
id: "F-001",
|
||||
sliceId: "SL-001",
|
||||
title: "Test Feature",
|
||||
status: "defined",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockMissionStore(missions: Mission[] = []) {
|
||||
const missionMap = new Map(missions.map((m) => [m.id, m]));
|
||||
|
||||
return {
|
||||
getMission: vi.fn((id: string) => missionMap.get(id)),
|
||||
listMissions: vi.fn(() => [...missionMap.values()]),
|
||||
updateMission: vi.fn((id: string, updates: Partial<Mission>) => {
|
||||
const existing = missionMap.get(id);
|
||||
if (!existing) throw new Error(`Mission ${id} not found`);
|
||||
const updated = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
||||
missionMap.set(id, updated);
|
||||
return updated;
|
||||
}),
|
||||
getMilestone: vi.fn(),
|
||||
listMilestones: vi.fn(),
|
||||
getSlice: vi.fn(),
|
||||
listSlices: vi.fn(),
|
||||
getFeatureByTaskId: vi.fn(),
|
||||
listFeatures: vi.fn(),
|
||||
getMissionWithHierarchy: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockTaskStore() {
|
||||
return {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockScheduler() {
|
||||
return {
|
||||
activateNextPendingSlice: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("MissionAutopilot", () => {
|
||||
let autopilot: MissionAutopilot;
|
||||
let missionStore: ReturnType<typeof createMockMissionStore>;
|
||||
let taskStore: ReturnType<typeof createMockTaskStore>;
|
||||
let scheduler: ReturnType<typeof createMockScheduler>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
const mission = createMockMission();
|
||||
missionStore = createMockMissionStore([mission]);
|
||||
taskStore = createMockTaskStore();
|
||||
scheduler = createMockScheduler();
|
||||
|
||||
autopilot = new MissionAutopilot(
|
||||
taskStore as any,
|
||||
missionStore as any,
|
||||
{ scheduler },
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
autopilot.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────
|
||||
|
||||
describe("start/stop", () => {
|
||||
it("should start and be running", () => {
|
||||
autopilot.start();
|
||||
// No error means success
|
||||
});
|
||||
|
||||
it("should be idempotent on start", () => {
|
||||
autopilot.start();
|
||||
autopilot.start();
|
||||
// Should not throw
|
||||
});
|
||||
|
||||
it("should stop cleanly", () => {
|
||||
autopilot.start();
|
||||
autopilot.stop();
|
||||
// Should not throw
|
||||
});
|
||||
|
||||
it("should be idempotent on stop", () => {
|
||||
autopilot.stop();
|
||||
// Should not throw
|
||||
});
|
||||
});
|
||||
|
||||
// ── Watching ─────────────────────────────────────────────────────
|
||||
|
||||
describe("watchMission", () => {
|
||||
it("should watch a mission with autopilot enabled", () => {
|
||||
autopilot.watchMission("M-TEST1");
|
||||
|
||||
expect(autopilot.isWatching("M-TEST1")).toBe(true);
|
||||
expect(missionStore.updateMission).toHaveBeenCalledWith(
|
||||
"M-TEST1",
|
||||
expect.objectContaining({ autopilotState: "watching" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not watch a mission without autopilot enabled", () => {
|
||||
const mission = createMockMission({ autopilotEnabled: false });
|
||||
const store = createMockMissionStore([mission]);
|
||||
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
|
||||
|
||||
ap.watchMission("M-TEST1");
|
||||
expect(ap.isWatching("M-TEST1")).toBe(false);
|
||||
});
|
||||
|
||||
it("should not watch a non-existent mission", () => {
|
||||
autopilot.watchMission("M-NONEXISTENT");
|
||||
expect(autopilot.isWatching("M-NONEXISTENT")).toBe(false);
|
||||
});
|
||||
|
||||
it("should be idempotent — watching same mission twice", () => {
|
||||
autopilot.watchMission("M-TEST1");
|
||||
autopilot.watchMission("M-TEST1");
|
||||
expect(autopilot.getWatchedMissionIds()).toEqual(["M-TEST1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unwatchMission", () => {
|
||||
it("should unwatch a mission", () => {
|
||||
autopilot.watchMission("M-TEST1");
|
||||
autopilot.unwatchMission("M-TEST1");
|
||||
|
||||
expect(autopilot.isWatching("M-TEST1")).toBe(false);
|
||||
expect(missionStore.updateMission).toHaveBeenCalledWith(
|
||||
"M-TEST1",
|
||||
expect.objectContaining({ autopilotState: "inactive" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should be a no-op for non-watched mission", () => {
|
||||
autopilot.unwatchMission("M-OTHER");
|
||||
// No updateMission call for state change
|
||||
expect(missionStore.updateMission).not.toHaveBeenCalledWith(
|
||||
"M-OTHER",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWatchedMissionIds", () => {
|
||||
it("should return empty array when nothing is watched", () => {
|
||||
expect(autopilot.getWatchedMissionIds()).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return all watched mission IDs", () => {
|
||||
const m2 = createMockMission({ id: "M-TEST2", autopilotEnabled: true });
|
||||
const store = createMockMissionStore([
|
||||
createMockMission(),
|
||||
m2,
|
||||
]);
|
||||
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
|
||||
|
||||
ap.watchMission("M-TEST1");
|
||||
ap.watchMission("M-TEST2");
|
||||
|
||||
expect(ap.getWatchedMissionIds()).toEqual(["M-TEST1", "M-TEST2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAutopilotStatus", () => {
|
||||
it("should return status for a watched mission", () => {
|
||||
autopilot.watchMission("M-TEST1");
|
||||
|
||||
const status = autopilot.getAutopilotStatus("M-TEST1");
|
||||
expect(status).toEqual({
|
||||
enabled: true,
|
||||
state: "watching",
|
||||
watched: true,
|
||||
lastActivityAt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return status for a non-watched mission", () => {
|
||||
const status = autopilot.getAutopilotStatus("M-NONEXISTENT");
|
||||
expect(status).toEqual({
|
||||
enabled: false,
|
||||
state: "inactive",
|
||||
watched: false,
|
||||
lastActivityAt: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Task Completion ──────────────────────────────────────────────
|
||||
|
||||
describe("handleTaskCompletion", () => {
|
||||
it("should do nothing if task has no linked feature", async () => {
|
||||
missionStore.getFeatureByTaskId.mockReturnValue(undefined);
|
||||
|
||||
await autopilot.handleTaskCompletion("FN-001");
|
||||
// Should not attempt to advance
|
||||
expect(scheduler.activateNextPendingSlice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should do nothing if mission is not being watched", async () => {
|
||||
const feature = createMockFeature({ taskId: "FN-001", status: "done" });
|
||||
const slice = createMockSlice({ id: "SL-001" });
|
||||
const milestone = createMockMilestone();
|
||||
|
||||
missionStore.getFeatureByTaskId.mockReturnValue(feature);
|
||||
missionStore.getSlice.mockReturnValue(slice);
|
||||
missionStore.getMilestone.mockReturnValue(milestone);
|
||||
// Not watching this mission
|
||||
|
||||
await autopilot.handleTaskCompletion("FN-001");
|
||||
expect(scheduler.activateNextPendingSlice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should advance to next slice when all features are done", async () => {
|
||||
const feature = createMockFeature({ taskId: "FN-001", status: "done" });
|
||||
const slice = createMockSlice({ id: "SL-001" });
|
||||
const milestone = createMockMilestone();
|
||||
|
||||
missionStore.getFeatureByTaskId.mockReturnValue(feature);
|
||||
missionStore.getSlice.mockReturnValue(slice);
|
||||
missionStore.getMilestone.mockReturnValue(milestone);
|
||||
missionStore.listFeatures.mockReturnValue([feature]);
|
||||
|
||||
// Return an activated slice so advanceToNextSlice succeeds
|
||||
const activatedSlice = createMockSlice({ id: "SL-002", status: "active" });
|
||||
scheduler.activateNextPendingSlice.mockResolvedValue(activatedSlice);
|
||||
|
||||
// Watch the mission first
|
||||
autopilot.watchMission("M-TEST1");
|
||||
|
||||
await autopilot.handleTaskCompletion("FN-001");
|
||||
expect(scheduler.activateNextPendingSlice).toHaveBeenCalledWith("M-TEST1");
|
||||
});
|
||||
|
||||
it("should not advance when not all features are done", async () => {
|
||||
const feature1 = createMockFeature({ id: "F-001", taskId: "FN-001", status: "done" });
|
||||
const feature2 = createMockFeature({ id: "F-002", status: "in-progress" });
|
||||
const slice = createMockSlice({ id: "SL-001" });
|
||||
const milestone = createMockMilestone();
|
||||
|
||||
missionStore.getFeatureByTaskId.mockReturnValue(feature1);
|
||||
missionStore.getSlice.mockReturnValue(slice);
|
||||
missionStore.getMilestone.mockReturnValue(milestone);
|
||||
missionStore.listFeatures.mockReturnValue([feature1, feature2]);
|
||||
|
||||
autopilot.watchMission("M-TEST1");
|
||||
|
||||
await autopilot.handleTaskCompletion("FN-001");
|
||||
expect(scheduler.activateNextPendingSlice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors gracefully", async () => {
|
||||
missionStore.getFeatureByTaskId.mockImplementation(() => {
|
||||
throw new Error("DB error");
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
await autopilot.handleTaskCompletion("FN-001");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Advance to Next Slice ────────────────────────────────────────
|
||||
|
||||
describe("advanceToNextSlice", () => {
|
||||
it("should update state to activating then watching", async () => {
|
||||
const activatedSlice = createMockSlice({ id: "SL-002", status: "active" });
|
||||
scheduler.activateNextPendingSlice.mockResolvedValue(activatedSlice);
|
||||
|
||||
autopilot.watchMission("M-TEST1");
|
||||
await autopilot.advanceToNextSlice("M-TEST1");
|
||||
|
||||
// Should have been called with activating then watching
|
||||
const calls = missionStore.updateMission.mock.calls.filter(
|
||||
(call: any[]) => call[1]?.autopilotState !== undefined,
|
||||
);
|
||||
const states = calls.map((call: any[]) => call[1].autopilotState);
|
||||
expect(states).toContain("activating");
|
||||
expect(states).toContain("watching");
|
||||
});
|
||||
|
||||
it("should update lastAutopilotActivityAt on success", async () => {
|
||||
const activatedSlice = createMockSlice({ id: "SL-002", status: "active" });
|
||||
scheduler.activateNextPendingSlice.mockResolvedValue(activatedSlice);
|
||||
|
||||
autopilot.watchMission("M-TEST1");
|
||||
await autopilot.advanceToNextSlice("M-TEST1");
|
||||
|
||||
expect(missionStore.updateMission).toHaveBeenCalledWith(
|
||||
"M-TEST1",
|
||||
expect.objectContaining({ lastAutopilotActivityAt: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should do nothing if mission is not being watched", async () => {
|
||||
await autopilot.advanceToNextSlice("M-TEST1");
|
||||
expect(scheduler.activateNextPendingSlice).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Check and Start Mission ──────────────────────────────────────
|
||||
|
||||
describe("checkAndStartMission", () => {
|
||||
it("should transition planning mission to active", async () => {
|
||||
const mission = createMockMission({ status: "planning" });
|
||||
const store = createMockMissionStore([mission]);
|
||||
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
|
||||
|
||||
const activatedSlice = createMockSlice({ id: "SL-001", status: "active" });
|
||||
scheduler.activateNextPendingSlice.mockResolvedValue(activatedSlice);
|
||||
|
||||
await ap.checkAndStartMission("M-TEST1");
|
||||
|
||||
expect(store.updateMission).toHaveBeenCalledWith(
|
||||
"M-TEST1",
|
||||
expect.objectContaining({ status: "active" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not transition active mission", async () => {
|
||||
// Mission is already active
|
||||
await autopilot.checkAndStartMission("M-TEST1");
|
||||
|
||||
// Should not change status
|
||||
const statusCalls = missionStore.updateMission.mock.calls.filter(
|
||||
(call: any[]) => call[1]?.status !== undefined,
|
||||
);
|
||||
expect(statusCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should not transition mission without autopilot enabled", async () => {
|
||||
const mission = createMockMission({ status: "planning", autopilotEnabled: false });
|
||||
const store = createMockMissionStore([mission]);
|
||||
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
|
||||
|
||||
await ap.checkAndStartMission("M-TEST1");
|
||||
// No status update should happen
|
||||
expect(scheduler.activateNextPendingSlice).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Check Mission Completion ─────────────────────────────────────
|
||||
|
||||
describe("checkMissionCompletion", () => {
|
||||
it("should detect when all milestones are complete", async () => {
|
||||
const m1 = createMockMilestone({ status: "complete" });
|
||||
missionStore.listMilestones.mockReturnValue([m1]);
|
||||
|
||||
autopilot.watchMission("M-TEST1");
|
||||
const result = await autopilot.checkMissionCompletion("M-TEST1");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(missionStore.updateMission).toHaveBeenCalledWith(
|
||||
"M-TEST1",
|
||||
expect.objectContaining({ status: "complete" }),
|
||||
);
|
||||
expect(autopilot.isWatching("M-TEST1")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when milestones are not all complete", async () => {
|
||||
const m1 = createMockMilestone({ status: "active" });
|
||||
missionStore.listMilestones.mockReturnValue([m1]);
|
||||
|
||||
const result = await autopilot.checkMissionCompletion("M-TEST1");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when there are no milestones", async () => {
|
||||
missionStore.listMilestones.mockReturnValue([]);
|
||||
|
||||
const result = await autopilot.checkMissionCompletion("M-TEST1");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for non-existent mission", async () => {
|
||||
const result = await autopilot.checkMissionCompletion("M-NONEXISTENT");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Stop cleanup ─────────────────────────────────────────────────
|
||||
|
||||
describe("stop cleanup", () => {
|
||||
it("should unwatch all missions on stop", () => {
|
||||
const m2 = createMockMission({ id: "M-TEST2", autopilotEnabled: true });
|
||||
const store = createMockMissionStore([
|
||||
createMockMission(),
|
||||
m2,
|
||||
]);
|
||||
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
|
||||
|
||||
ap.start();
|
||||
ap.watchMission("M-TEST1");
|
||||
ap.watchMission("M-TEST2");
|
||||
expect(ap.getWatchedMissionIds()).toHaveLength(2);
|
||||
|
||||
ap.stop();
|
||||
expect(ap.getWatchedMissionIds()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── setScheduler ─────────────────────────────────────────────────
|
||||
|
||||
describe("setScheduler", () => {
|
||||
it("should allow setting scheduler after construction", async () => {
|
||||
// Create autopilot without scheduler
|
||||
const ap = new MissionAutopilot(taskStore as any, missionStore as any);
|
||||
ap.start();
|
||||
ap.watchMission("M-TEST1");
|
||||
|
||||
// advanceToNextSlice should be a no-op without scheduler
|
||||
await ap.advanceToNextSlice("M-TEST1");
|
||||
|
||||
const newScheduler = createMockScheduler();
|
||||
ap.setScheduler(newScheduler);
|
||||
|
||||
// Now advanceToNextSlice should use the new scheduler
|
||||
// (but will be blocked by autoAdvance guard since default mission has autoAdvance: true)
|
||||
newScheduler.activateNextPendingSlice.mockResolvedValue(
|
||||
createMockSlice({ id: "SL-002", status: "active" }),
|
||||
);
|
||||
await ap.advanceToNextSlice("M-TEST1");
|
||||
expect(newScheduler.activateNextPendingSlice).toHaveBeenCalledWith("M-TEST1");
|
||||
|
||||
ap.stop();
|
||||
});
|
||||
});
|
||||
|
||||
// ── autoAdvance guard ────────────────────────────────────────────
|
||||
|
||||
describe("autoAdvance guard", () => {
|
||||
it("should not advance slice when autoAdvance is false", async () => {
|
||||
const mission = createMockMission({ autoAdvance: false });
|
||||
const store = createMockMissionStore([mission]);
|
||||
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
|
||||
|
||||
ap.start();
|
||||
ap.watchMission("M-TEST1");
|
||||
|
||||
await ap.advanceToNextSlice("M-TEST1");
|
||||
|
||||
// Should NOT call scheduler to activate next slice
|
||||
expect(scheduler.activateNextPendingSlice).not.toHaveBeenCalled();
|
||||
|
||||
ap.stop();
|
||||
});
|
||||
|
||||
it("should not advance slice when autoAdvance is undefined", async () => {
|
||||
const mission = createMockMission({ autoAdvance: undefined });
|
||||
const store = createMockMissionStore([mission]);
|
||||
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
|
||||
|
||||
ap.start();
|
||||
ap.watchMission("M-TEST1");
|
||||
|
||||
await ap.advanceToNextSlice("M-TEST1");
|
||||
|
||||
// Should NOT call scheduler to activate next slice
|
||||
expect(scheduler.activateNextPendingSlice).not.toHaveBeenCalled();
|
||||
|
||||
ap.stop();
|
||||
});
|
||||
|
||||
it("should advance slice when autoAdvance is true", async () => {
|
||||
autopilot.watchMission("M-TEST1");
|
||||
const activatedSlice = createMockSlice({ id: "SL-002", status: "active" });
|
||||
scheduler.activateNextPendingSlice.mockResolvedValue(activatedSlice);
|
||||
|
||||
await autopilot.advanceToNextSlice("M-TEST1");
|
||||
|
||||
expect(scheduler.activateNextPendingSlice).toHaveBeenCalledWith("M-TEST1");
|
||||
});
|
||||
});
|
||||
});
|
||||
436
packages/engine/src/mission-autopilot.ts
Normal file
436
packages/engine/src/mission-autopilot.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* MissionAutopilot — Background monitoring for autonomous mission progression.
|
||||
*
|
||||
* Watches missions with `autopilotEnabled: true` and automatically:
|
||||
* - Activates slices when previous ones complete
|
||||
* - Tracks overall mission health and state
|
||||
* - Detects and recovers from failures
|
||||
*
|
||||
* **Integration pattern:** The Scheduler handles low-level task scheduling
|
||||
* and calls `missionAutopilot.handleTaskCompletion()` after updating feature
|
||||
* status. MissionAutopilot does NOT register its own event listeners.
|
||||
*
|
||||
* **State machine:**
|
||||
* - `inactive` → `watching`: User enables autopilot
|
||||
* - `watching` → `activating`: Task completes, autopilot progresses
|
||||
* - `activating` → `watching`: Slice activated successfully
|
||||
* - `watching/activating` → `inactive`: User disables or engine stops
|
||||
* - `activating` → `completing`: All slices done, mission wrapping up
|
||||
* - `completing` → `inactive`: Mission complete
|
||||
*/
|
||||
|
||||
import type { TaskStore, MissionStore, Mission, AutopilotState, AutopilotStatus, Slice } from "@fusion/core";
|
||||
import { autopilotLog } from "./logger.js";
|
||||
|
||||
/** Maximum retry attempts for slice activation failures. */
|
||||
const MAX_RETRY_ATTEMPTS = 3;
|
||||
|
||||
/** Base delay for exponential backoff between retries (ms). */
|
||||
const RETRY_BASE_DELAY_MS = 1000;
|
||||
|
||||
/** Background poll interval for checking mission health (ms). */
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
/** Time after which a mission is considered stale (5 minutes). */
|
||||
const STALE_THRESHOLD_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Per-mission tracking state. */
|
||||
interface WatchedMissionState {
|
||||
missionId: string;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
export interface MissionAutopilotOptions {
|
||||
/** Optional Scheduler instance for slice activation. Can also be set via setScheduler(). */
|
||||
scheduler?: {
|
||||
activateNextPendingSlice(missionId: string): Promise<Slice | null>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MissionAutopilot monitors missions with `autopilotEnabled: true` and
|
||||
* autonomously progresses through slices as tasks complete.
|
||||
*
|
||||
* It does NOT register event listeners on TaskStore or MissionStore.
|
||||
* Instead, the Scheduler calls `handleTaskCompletion()` after performing
|
||||
* its own feature status updates. This avoids duplicate event handling.
|
||||
*/
|
||||
export class MissionAutopilot {
|
||||
private watchedMissions = new Map<string, WatchedMissionState>();
|
||||
private running = false;
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private scheduler: MissionAutopilotOptions["scheduler"];
|
||||
|
||||
constructor(
|
||||
private taskStore: TaskStore,
|
||||
private missionStore: MissionStore,
|
||||
options: MissionAutopilotOptions = {},
|
||||
) {
|
||||
this.scheduler = options.scheduler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the scheduler instance after construction.
|
||||
* Used to break circular dependency: Scheduler is constructed with
|
||||
* MissionAutopilot, then calls setScheduler(this) after both are created.
|
||||
*/
|
||||
setScheduler(scheduler: MissionAutopilotOptions["scheduler"]): void {
|
||||
this.scheduler = scheduler;
|
||||
}
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start the autopilot background service.
|
||||
* Begins periodic polling for mission health checks.
|
||||
*/
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
this.pollTimer = setInterval(() => this.poll(), POLL_INTERVAL_MS);
|
||||
autopilotLog.log("Started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the autopilot background service.
|
||||
* Unwatches all missions and clears state.
|
||||
*/
|
||||
stop(): void {
|
||||
if (!this.running) return;
|
||||
this.running = false;
|
||||
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
|
||||
// Unwatch all missions
|
||||
for (const [missionId] of this.watchedMissions) {
|
||||
try {
|
||||
this.setAutopilotState(missionId, "inactive");
|
||||
} catch {
|
||||
// Best effort — mission may have been deleted
|
||||
}
|
||||
}
|
||||
this.watchedMissions.clear();
|
||||
autopilotLog.log("Stopped");
|
||||
}
|
||||
|
||||
// ── Mission Watching ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start watching a mission.
|
||||
* Sets `autopilotState` to `watching` and adds to watched set.
|
||||
*
|
||||
* @param missionId - Mission ID to watch
|
||||
*/
|
||||
watchMission(missionId: string): void {
|
||||
if (this.watchedMissions.has(missionId)) {
|
||||
autopilotLog.log(`Already watching mission ${missionId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const mission = this.missionStore.getMission(missionId);
|
||||
if (!mission) {
|
||||
autopilotLog.warn(`Mission ${missionId} not found — cannot watch`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mission.autopilotEnabled) {
|
||||
autopilotLog.warn(`Mission ${missionId} does not have autopilot enabled — skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.watchedMissions.set(missionId, { missionId, retryCount: 0 });
|
||||
this.setAutopilotState(missionId, "watching");
|
||||
autopilotLog.log(`Watching mission ${missionId} (${mission.title})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching a mission.
|
||||
* Sets `autopilotState` to `inactive` and removes from watched set.
|
||||
*
|
||||
* @param missionId - Mission ID to unwatch
|
||||
*/
|
||||
unwatchMission(missionId: string): void {
|
||||
if (!this.watchedMissions.has(missionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.watchedMissions.delete(missionId);
|
||||
try {
|
||||
this.setAutopilotState(missionId, "inactive");
|
||||
} catch {
|
||||
// Mission may have been deleted
|
||||
}
|
||||
autopilotLog.log(`Unwatched mission ${missionId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mission is currently being watched.
|
||||
*/
|
||||
isWatching(missionId: string): boolean {
|
||||
return this.watchedMissions.has(missionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all currently watched mission IDs.
|
||||
*/
|
||||
getWatchedMissionIds(): string[] {
|
||||
return [...this.watchedMissions.keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current autopilot status for a mission.
|
||||
*/
|
||||
getAutopilotStatus(missionId: string): AutopilotStatus {
|
||||
const mission = this.missionStore.getMission(missionId);
|
||||
const watched = this.watchedMissions.has(missionId);
|
||||
|
||||
return {
|
||||
enabled: mission?.autopilotEnabled ?? false,
|
||||
state: mission?.autopilotState ?? "inactive",
|
||||
watched,
|
||||
lastActivityAt: mission?.lastAutopilotActivityAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Progression Logic ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Called by the Scheduler after a task with a sliceId completes.
|
||||
*
|
||||
* 1. Finds the feature linked to the task
|
||||
* 2. Checks if the slice is now complete (all features done)
|
||||
* 3. If so, advances to the next slice
|
||||
*
|
||||
* @param taskId - The completed task ID
|
||||
*/
|
||||
async handleTaskCompletion(taskId: string): Promise<void> {
|
||||
try {
|
||||
const feature = this.missionStore.getFeatureByTaskId(taskId);
|
||||
if (!feature) {
|
||||
// Task is not linked to any feature — not a mission task
|
||||
return;
|
||||
}
|
||||
|
||||
const slice = this.missionStore.getSlice(feature.sliceId);
|
||||
if (!slice) {
|
||||
autopilotLog.warn(`Slice ${feature.sliceId} not found for feature ${feature.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve mission ID for this slice
|
||||
const milestone = this.missionStore.getMilestone(slice.milestoneId);
|
||||
if (!milestone) return;
|
||||
const missionId = milestone.missionId;
|
||||
|
||||
// Only proceed if we're watching this mission
|
||||
if (!this.isWatching(missionId)) return;
|
||||
|
||||
// Check if all features in the slice are done
|
||||
const features = this.missionStore.listFeatures(slice.id);
|
||||
const allDone = features.length > 0 && features.every((f) => f.status === "done");
|
||||
|
||||
if (allDone) {
|
||||
autopilotLog.log(`Slice ${slice.id} is complete — advancing mission ${missionId}`);
|
||||
await this.advanceToNextSlice(missionId);
|
||||
}
|
||||
} catch (err) {
|
||||
autopilotLog.error(`Error handling task completion for ${taskId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the next pending slice in a mission.
|
||||
* Uses the scheduler's `activateNextPendingSlice()` method.
|
||||
*
|
||||
* @param missionId - Mission ID to advance
|
||||
*/
|
||||
async advanceToNextSlice(missionId: string): Promise<void> {
|
||||
const state = this.watchedMissions.get(missionId);
|
||||
if (!state) return;
|
||||
|
||||
// Respect the mission's autoAdvance setting — if the user opted for
|
||||
// manual slice activation, autopilot should NOT auto-advance even when
|
||||
// it is watching and enabled.
|
||||
const mission = this.missionStore.getMission(missionId);
|
||||
if (!mission?.autoAdvance) {
|
||||
autopilotLog.log(`Mission ${missionId} has autoAdvance disabled — skipping slice activation`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.setAutopilotState(missionId, "activating");
|
||||
|
||||
if (this.scheduler) {
|
||||
const activated = await this.scheduler.activateNextPendingSlice(missionId);
|
||||
if (activated) {
|
||||
autopilotLog.log(`Activated slice ${activated.id} for mission ${missionId}`);
|
||||
this.updateActivity(missionId);
|
||||
// Reset retry count on success
|
||||
state.retryCount = 0;
|
||||
} else {
|
||||
// No pending slice — check for mission completion
|
||||
const complete = await this.checkMissionCompletion(missionId);
|
||||
if (complete) {
|
||||
return; // already transitions state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.setAutopilotState(missionId, "watching");
|
||||
} catch (err) {
|
||||
autopilotLog.error(`Error advancing slice for mission ${missionId}:`, err);
|
||||
|
||||
// Retry with exponential backoff
|
||||
state.retryCount++;
|
||||
if (state.retryCount <= MAX_RETRY_ATTEMPTS) {
|
||||
const delay = RETRY_BASE_DELAY_MS * Math.pow(3, state.retryCount - 1);
|
||||
autopilotLog.log(`Retrying slice activation for mission ${missionId} (attempt ${state.retryCount}/${MAX_RETRY_ATTEMPTS}, delay ${delay}ms)`);
|
||||
setTimeout(() => {
|
||||
if (this.isWatching(missionId)) {
|
||||
void this.advanceToNextSlice(missionId);
|
||||
}
|
||||
}, delay);
|
||||
} else {
|
||||
autopilotLog.error(`Max retries exceeded for mission ${missionId} — pausing autopilot`);
|
||||
this.setAutopilotState(missionId, "watching");
|
||||
state.retryCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a mission is in planning and should be started.
|
||||
* If mission is `planning` and `autopilotEnabled: true`, transitions to `active`
|
||||
* and activates the first pending slice.
|
||||
*
|
||||
* @param missionId - Mission ID to check and start
|
||||
*/
|
||||
async checkAndStartMission(missionId: string): Promise<void> {
|
||||
const mission = this.missionStore.getMission(missionId);
|
||||
if (!mission) return;
|
||||
|
||||
if (mission.status === "planning" && mission.autopilotEnabled) {
|
||||
autopilotLog.log(`Starting mission ${missionId} (transitioning from planning to active)`);
|
||||
|
||||
this.missionStore.updateMission(missionId, { status: "active" });
|
||||
this.updateActivity(missionId);
|
||||
|
||||
// Activate first pending slice
|
||||
if (this.scheduler) {
|
||||
const activated = await this.scheduler.activateNextPendingSlice(missionId);
|
||||
if (activated) {
|
||||
autopilotLog.log(`Activated first slice ${activated.id} for mission ${missionId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all milestones in a mission are complete.
|
||||
* If so, set the mission to complete and return true.
|
||||
*
|
||||
* @param missionId - Mission ID to check
|
||||
* @returns true if mission is complete, false otherwise
|
||||
*/
|
||||
async checkMissionCompletion(missionId: string): Promise<boolean> {
|
||||
const mission = this.missionStore.getMission(missionId);
|
||||
if (!mission) return false;
|
||||
|
||||
const milestones = this.missionStore.listMilestones(missionId);
|
||||
if (milestones.length === 0) return false;
|
||||
|
||||
const allComplete = milestones.every((m) => m.status === "complete");
|
||||
if (allComplete) {
|
||||
autopilotLog.log(`Mission ${missionId} is complete!`);
|
||||
this.setAutopilotState(missionId, "completing");
|
||||
this.missionStore.updateMission(missionId, { status: "complete" });
|
||||
this.updateActivity(missionId);
|
||||
this.setAutopilotState(missionId, "inactive");
|
||||
this.watchedMissions.delete(missionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Background Poll ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Periodic health check for watched missions.
|
||||
* - Re-watches missions with `autopilotEnabled: true` that aren't being tracked
|
||||
* - Starts missions in `planning` with autopilot enabled
|
||||
* - Flags stale missions
|
||||
*/
|
||||
private poll(): void {
|
||||
if (!this.running) return;
|
||||
|
||||
try {
|
||||
const missions = this.missionStore.listMissions();
|
||||
|
||||
for (const mission of missions) {
|
||||
// Auto-watch missions with autopilot enabled that aren't being watched
|
||||
if (mission.autopilotEnabled && !this.isWatching(mission.id) && mission.status !== "complete" && mission.status !== "archived") {
|
||||
autopilotLog.log(`Poll: auto-watching mission ${mission.id}`);
|
||||
this.watchMission(mission.id);
|
||||
}
|
||||
|
||||
// Start planning missions with autopilot
|
||||
if (mission.autopilotEnabled && mission.status === "planning" && this.isWatching(mission.id)) {
|
||||
void this.checkAndStartMission(mission.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for stale missions
|
||||
const now = Date.now();
|
||||
for (const [missionId, state] of this.watchedMissions) {
|
||||
const mission = this.missionStore.getMission(missionId);
|
||||
if (!mission) {
|
||||
// Mission deleted — unwatch
|
||||
this.watchedMissions.delete(missionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mission.lastAutopilotActivityAt) {
|
||||
const lastActivity = new Date(mission.lastAutopilotActivityAt).getTime();
|
||||
if (now - lastActivity > STALE_THRESHOLD_MS) {
|
||||
autopilotLog.warn(`Mission ${missionId} is stale (no activity for ${Math.round((now - lastActivity) / 60_000)} minutes)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
autopilotLog.error("Error during autopilot poll:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Update the `autopilotState` on a mission in the store.
|
||||
*/
|
||||
private setAutopilotState(missionId: string, state: AutopilotState): void {
|
||||
try {
|
||||
const mission = this.missionStore.getMission(missionId);
|
||||
if (mission && mission.autopilotState !== state) {
|
||||
this.missionStore.updateMission(missionId, { autopilotState: state });
|
||||
}
|
||||
} catch (err) {
|
||||
autopilotLog.error(`Error setting autopilot state for mission ${missionId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the `lastAutopilotActivityAt` timestamp on a mission.
|
||||
*/
|
||||
private updateActivity(missionId: string): void {
|
||||
try {
|
||||
this.missionStore.updateMission(missionId, {
|
||||
lastAutopilotActivityAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
autopilotLog.error(`Error updating activity for mission ${missionId}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1567,4 +1567,186 @@ describe("Scheduler", () => {
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-012", "in-progress");
|
||||
});
|
||||
});
|
||||
|
||||
describe("autopilot integration", () => {
|
||||
it("watches missions with autopilotEnabled on start", () => {
|
||||
const store = createMockStore();
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
listMissions: vi.fn().mockReturnValue([
|
||||
{ id: "M-001", autopilotEnabled: true, status: "active" },
|
||||
{ id: "M-002", autopilotEnabled: false, status: "active" },
|
||||
{ id: "M-003", autopilotEnabled: true, status: "complete" },
|
||||
]),
|
||||
getMission: vi.fn((id: string) => {
|
||||
const missions: Record<string, any> = {
|
||||
"M-001": { id: "M-001", autopilotEnabled: true, autopilotState: "inactive" },
|
||||
"M-002": { id: "M-002", autopilotEnabled: false, autopilotState: "inactive" },
|
||||
};
|
||||
return missions[id];
|
||||
}),
|
||||
});
|
||||
const mockAutopilot = {
|
||||
setScheduler: vi.fn(),
|
||||
watchMission: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
missionStore: mockMissionStore as any,
|
||||
missionAutopilot: mockAutopilot as any,
|
||||
});
|
||||
scheduler.start();
|
||||
|
||||
// setScheduler should be called with the scheduler instance
|
||||
expect(mockAutopilot.setScheduler).toHaveBeenCalledWith(scheduler);
|
||||
// Only M-001 should be watched (autopilotEnabled, not complete/archived)
|
||||
expect(mockAutopilot.watchMission).toHaveBeenCalledWith("M-001");
|
||||
expect(mockAutopilot.watchMission).not.toHaveBeenCalledWith("M-002");
|
||||
expect(mockAutopilot.watchMission).not.toHaveBeenCalledWith("M-003");
|
||||
// Autopilot should be started
|
||||
expect(mockAutopilot.start).toHaveBeenCalled();
|
||||
|
||||
scheduler.stop();
|
||||
// Autopilot should be stopped
|
||||
expect(mockAutopilot.stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not start autopilot when no missionAutopilot option", () => {
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store);
|
||||
scheduler.start();
|
||||
// Should not throw
|
||||
scheduler.stop();
|
||||
});
|
||||
|
||||
it("delegates to autopilot.handleTaskCompletion when autopilot is available", async () => {
|
||||
const store = createMockStore();
|
||||
const mockAutopilot = {
|
||||
setScheduler: vi.fn(),
|
||||
watchMission: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
handleTaskCompletion: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const completeSlice = {
|
||||
id: "SL-001",
|
||||
milestoneId: "MS-001",
|
||||
status: "complete",
|
||||
orderIndex: 0,
|
||||
};
|
||||
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue({
|
||||
id: "F-001",
|
||||
sliceId: "SL-001",
|
||||
status: "active",
|
||||
}),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
getSlice: vi.fn().mockReturnValue(completeSlice),
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
missionStore: mockMissionStore as any,
|
||||
missionAutopilot: mockAutopilot as any,
|
||||
});
|
||||
|
||||
// Simulate task:moved event: task with sliceId moves to "done"
|
||||
await (scheduler as any).handleMissionTaskCompletion("FN-001", "SL-001");
|
||||
|
||||
// Feature status should be updated to "done"
|
||||
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done");
|
||||
// Should delegate to autopilot (not call onSliceComplete)
|
||||
expect(mockAutopilot.handleTaskCompletion).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
|
||||
it("falls back to onSliceComplete when no autopilot", async () => {
|
||||
const store = createMockStore();
|
||||
const completeSlice = {
|
||||
id: "SL-001",
|
||||
milestoneId: "MS-001",
|
||||
status: "complete",
|
||||
orderIndex: 0,
|
||||
};
|
||||
|
||||
const missionHierarchy = {
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
autoAdvance: true,
|
||||
milestones: [
|
||||
{
|
||||
id: "MS-001",
|
||||
missionId: "M-001",
|
||||
status: "active",
|
||||
dependencies: [],
|
||||
slices: [
|
||||
{ id: "SL-001", status: "complete", orderIndex: 0 },
|
||||
{ id: "SL-002", status: "pending", orderIndex: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue({
|
||||
id: "F-001",
|
||||
sliceId: "SL-001",
|
||||
status: "active",
|
||||
}),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
getSlice: vi.fn().mockReturnValue(completeSlice),
|
||||
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
|
||||
getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autoAdvance: true }),
|
||||
getMissionWithHierarchy: vi.fn().mockReturnValue(missionHierarchy),
|
||||
activateSlice: vi.fn().mockResolvedValue({ id: "SL-002" }),
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
missionStore: mockMissionStore as any,
|
||||
});
|
||||
|
||||
await (scheduler as any).handleMissionTaskCompletion("FN-001", "SL-001");
|
||||
|
||||
// Feature status should be updated
|
||||
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done");
|
||||
// Legacy path: activateSlice should be called via onSliceComplete
|
||||
expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002");
|
||||
});
|
||||
|
||||
it("autopilot does not advance when autoAdvance is false", async () => {
|
||||
const store = createMockStore();
|
||||
const mockAutopilot = {
|
||||
setScheduler: vi.fn(),
|
||||
watchMission: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
handleTaskCompletion: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue({
|
||||
id: "F-001",
|
||||
sliceId: "SL-001",
|
||||
status: "done",
|
||||
}),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
getSlice: vi.fn().mockReturnValue({
|
||||
id: "SL-001",
|
||||
milestoneId: "MS-001",
|
||||
status: "complete",
|
||||
orderIndex: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
missionStore: mockMissionStore as any,
|
||||
missionAutopilot: mockAutopilot as any,
|
||||
});
|
||||
|
||||
await (scheduler as any).handleMissionTaskCompletion("FN-001", "SL-001");
|
||||
|
||||
// Delegates to autopilot, which internally checks autoAdvance
|
||||
expect(mockAutopilot.handleTaskCompletion).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface SchedulerOptions {
|
||||
prMonitor?: PrMonitor;
|
||||
/** Optional MissionStore for slice activation and auto-advance */
|
||||
missionStore?: MissionStore;
|
||||
/** Optional MissionAutopilot for autonomous mission progression */
|
||||
missionAutopilot?: import("./mission-autopilot.js").MissionAutopilot;
|
||||
/**
|
||||
* Called when a task with a closed/merged PR moves out of in-review
|
||||
* and the PrMonitor has buffered actionable comments.
|
||||
@@ -281,6 +283,19 @@ export class Scheduler {
|
||||
this.pollInterval = setInterval(() => this.schedule(), interval);
|
||||
this.schedule();
|
||||
schedulerLog.log(`Started (poll interval: ${interval}ms)`);
|
||||
|
||||
// Wire up MissionAutopilot: set scheduler reference for lazy injection
|
||||
// and start watching all missions with autopilotEnabled: true
|
||||
if (this.options.missionAutopilot && this.options.missionStore) {
|
||||
this.options.missionAutopilot.setScheduler(this);
|
||||
const missions = this.options.missionStore.listMissions();
|
||||
for (const mission of missions) {
|
||||
if (mission.autopilotEnabled && mission.status !== "complete" && mission.status !== "archived") {
|
||||
this.options.missionAutopilot.watchMission(mission.id);
|
||||
}
|
||||
}
|
||||
this.options.missionAutopilot.start();
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
@@ -294,6 +309,10 @@ export class Scheduler {
|
||||
if (this.options.prMonitor) {
|
||||
this.options.prMonitor.stopAll();
|
||||
}
|
||||
// Stop MissionAutopilot when scheduler shuts down
|
||||
if (this.options.missionAutopilot) {
|
||||
this.options.missionAutopilot.stop();
|
||||
}
|
||||
schedulerLog.log("Stopped");
|
||||
}
|
||||
|
||||
@@ -681,7 +700,10 @@ export class Scheduler {
|
||||
* When a task moves to "done", update the linked feature status to "done".
|
||||
* updateFeatureStatus cascades via recomputeSliceStatus — if all features
|
||||
* in the slice are done the slice status becomes "complete" automatically.
|
||||
* We then call onSliceComplete to trigger auto-advance to the next slice.
|
||||
*
|
||||
* If MissionAutopilot is configured, delegate slice advancement to it
|
||||
* (which tracks autopilot state and handles retries). Otherwise fall back
|
||||
* to the legacy onSliceComplete() path for non-autopilot missions.
|
||||
*/
|
||||
private async handleMissionTaskCompletion(taskId: string, sliceId: string): Promise<void> {
|
||||
if (!this.options.missionStore) return;
|
||||
@@ -709,8 +731,18 @@ export class Scheduler {
|
||||
// Check if the slice became complete after the feature update
|
||||
const slice = missionStore.getSlice(sliceIdBeforeUpdate);
|
||||
if (slice && slice.status === "complete") {
|
||||
schedulerLog.log(`Slice ${slice.id} is complete — triggering auto-advance`);
|
||||
await this.onSliceComplete(slice);
|
||||
// If MissionAutopilot is available, delegate progression to it.
|
||||
// The autopilot handles: watching missions, autoAdvance guard,
|
||||
// retry logic, and state tracking. The autopilot will call back
|
||||
// into scheduler.activateNextPendingSlice() when appropriate.
|
||||
if (this.options.missionAutopilot) {
|
||||
schedulerLog.log(`Slice ${slice.id} is complete — delegating to autopilot`);
|
||||
await this.options.missionAutopilot.handleTaskCompletion(taskId);
|
||||
} else {
|
||||
// Legacy path for missions without autopilot
|
||||
schedulerLog.log(`Slice ${slice.id} is complete — triggering auto-advance`);
|
||||
await this.onSliceComplete(slice);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
schedulerLog.error(`Error handling mission task completion for ${taskId}:`, err);
|
||||
|
||||
Reference in New Issue
Block a user