feat(FN-938): add feature triage, mission pause/stop/resume, and scheduler blocked check
- Add mission store methods for pausing, stopping, and resuming missions with proper state transitions - Implement feature triage flow that evaluates and classifies mission features - Add scheduler blocked-task check to prevent scheduling when dependencies are unmet - Create dashboard mission management UI with pause/stop/resume controls - Add mission API routes for triage, pause, stop, and resume operations - Add e2e tests for mission routes and unit tests for mission store and scheduler
This commit is contained in:
@@ -110,6 +110,22 @@ describe("pathsOverlap", () => {
|
||||
});
|
||||
|
||||
describe("Scheduler", () => {
|
||||
// Helper to create mock MissionStore (shared across mission-related test suites)
|
||||
function createMockMissionStore(overrides = {}) {
|
||||
return {
|
||||
getFeatureByTaskId: vi.fn(),
|
||||
updateFeatureStatus: vi.fn().mockResolvedValue(undefined),
|
||||
getSlice: vi.fn(),
|
||||
getMilestone: vi.fn(),
|
||||
computeSliceStatus: vi.fn(),
|
||||
getMission: vi.fn(),
|
||||
getMissionWithHierarchy: vi.fn(),
|
||||
findNextPendingSlice: vi.fn(),
|
||||
activateSlice: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("constructor", () => {
|
||||
it("initializes with default options", () => {
|
||||
const store = createMockStore();
|
||||
@@ -1004,22 +1020,6 @@ describe("Scheduler", () => {
|
||||
});
|
||||
|
||||
describe("mission integration", () => {
|
||||
// Helper to create mock MissionStore
|
||||
function createMockMissionStore(overrides = {}) {
|
||||
return {
|
||||
getFeatureByTaskId: vi.fn(),
|
||||
updateFeatureStatus: vi.fn().mockResolvedValue(undefined),
|
||||
getSlice: vi.fn(),
|
||||
getMilestone: vi.fn(),
|
||||
computeSliceStatus: vi.fn(),
|
||||
getMission: vi.fn(),
|
||||
getMissionWithHierarchy: vi.fn(),
|
||||
findNextPendingSlice: vi.fn(),
|
||||
activateSlice: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("activateNextPendingSlice returns null when no missionStore", async () => {
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store);
|
||||
@@ -1282,6 +1282,97 @@ describe("Scheduler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("blocked mission scheduling", () => {
|
||||
it("skips tasks belonging to a blocked mission", async () => {
|
||||
const task = createMockTask({
|
||||
id: "FN-100",
|
||||
column: "todo",
|
||||
sliceId: "SL-001",
|
||||
});
|
||||
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001" }),
|
||||
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
|
||||
getMission: vi.fn().mockReturnValue({ id: "M-001", status: "blocked" }),
|
||||
});
|
||||
|
||||
(existsSync as any).mockReturnValue(true);
|
||||
(readFile as any).mockResolvedValue("# Task\n\nSome content\n");
|
||||
|
||||
const store = createMockStore({
|
||||
listTasks: vi.fn().mockResolvedValue([task]),
|
||||
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
|
||||
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"),
|
||||
});
|
||||
|
||||
const onSchedule = vi.fn();
|
||||
const scheduler = new Scheduler(store, { onSchedule, missionStore: mockMissionStore as any });
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
// Task should NOT be scheduled because its mission is blocked
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(onSchedule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("schedules tasks when mission is active", async () => {
|
||||
const task = createMockTask({
|
||||
id: "FN-100",
|
||||
column: "todo",
|
||||
sliceId: "SL-001",
|
||||
});
|
||||
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001" }),
|
||||
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
|
||||
getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active" }),
|
||||
});
|
||||
|
||||
(existsSync as any).mockReturnValue(true);
|
||||
(readFile as any).mockResolvedValue("# Task\n\nSome content\n");
|
||||
|
||||
const store = createMockStore({
|
||||
listTasks: vi.fn().mockResolvedValue([task]),
|
||||
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
|
||||
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"),
|
||||
});
|
||||
|
||||
const onSchedule = vi.fn();
|
||||
const scheduler = new Scheduler(store, { onSchedule, missionStore: mockMissionStore as any });
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-100", "in-progress");
|
||||
});
|
||||
|
||||
it("schedules tasks without sliceId regardless of mission state", async () => {
|
||||
const task = createMockTask({
|
||||
id: "FN-100",
|
||||
column: "todo",
|
||||
// No sliceId — not associated with any mission
|
||||
});
|
||||
|
||||
(existsSync as any).mockReturnValue(true);
|
||||
(readFile as any).mockResolvedValue("# Task\n\nSome content\n");
|
||||
|
||||
const store = createMockStore({
|
||||
listTasks: vi.fn().mockResolvedValue([task]),
|
||||
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
|
||||
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"),
|
||||
});
|
||||
|
||||
const onSchedule = vi.fn();
|
||||
const scheduler = new Scheduler(store, { onSchedule, missionStore: createMockMissionStore() as any });
|
||||
(scheduler as any).running = true;
|
||||
await scheduler.schedule();
|
||||
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-100", "in-progress");
|
||||
});
|
||||
});
|
||||
|
||||
describe("recovery due-time gating (nextRecoveryAt)", () => {
|
||||
it("skips todo tasks whose nextRecoveryAt is in the future", async () => {
|
||||
const future = new Date(Date.now() + 60_000).toISOString();
|
||||
|
||||
@@ -450,12 +450,39 @@ export class Scheduler {
|
||||
if (available <= 0) return;
|
||||
|
||||
const now = Date.now();
|
||||
const todo = tasks.filter((t) => {
|
||||
let todo = tasks.filter((t) => {
|
||||
if (t.column !== "todo" || t.paused) return false;
|
||||
// Skip tasks with a recovery backoff that hasn't elapsed yet
|
||||
if (t.nextRecoveryAt && new Date(t.nextRecoveryAt).getTime() > now) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Filter out tasks belonging to blocked missions
|
||||
if (todo.length > 0 && this.options.missionStore) {
|
||||
const blockedSliceIds = new Set<string>();
|
||||
for (const t of todo) {
|
||||
if (t.sliceId && !blockedSliceIds.has(t.sliceId)) {
|
||||
try {
|
||||
const slice = this.options.missionStore.getSlice(t.sliceId);
|
||||
if (slice) {
|
||||
const milestone = this.options.missionStore.getMilestone(slice.milestoneId);
|
||||
if (milestone) {
|
||||
const mission = this.options.missionStore.getMission(milestone.missionId);
|
||||
if (mission && mission.status === "blocked") {
|
||||
blockedSliceIds.add(t.sliceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If lookup fails, don't block the task
|
||||
}
|
||||
}
|
||||
}
|
||||
if (blockedSliceIds.size > 0) {
|
||||
todo = todo.filter((t) => !t.sliceId || !blockedSliceIds.has(t.sliceId));
|
||||
}
|
||||
}
|
||||
|
||||
if (todo.length === 0) return;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user