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:
gsxdsm
2026-04-04 18:03:03 -07:00
parent f847dfc52d
commit bc980926e0
10 changed files with 1080 additions and 20 deletions

View File

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

View File

@@ -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;
/**