feat(KB-635): add mission management CLI commands and engine integration
- Add comprehensive mission CLI commands: create, list, show, start, abort, delete, and next - Add Pi extension tools for mission operations: list_missions, show_mission, start_mission, abort_mission - Implement mission-aware scheduler with task start handling and autoAdvance support - Add sliceId to Task type and autoAdvance flag to Mission type for slice sequencing - Add MissionStore helper methods for mission lifecycle operations - Include comprehensive tests for CLI commands and Pi extension mission tools
This commit is contained in:
@@ -455,4 +455,197 @@ describe("Scheduler", () => {
|
||||
expect(moveTask).not.toHaveBeenCalledWith("FN-005", "triage");
|
||||
});
|
||||
});
|
||||
|
||||
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(),
|
||||
findNextPendingSlice: vi.fn(),
|
||||
activateSlice: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("activateNextPendingSlice returns null when no missionStore", async () => {
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store);
|
||||
const result = await scheduler.activateNextPendingSlice("M-001");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("triggers feature in-progress update when task with sliceId moves to in-progress", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001", status: "triaged" }),
|
||||
updateFeatureStatus: vi.fn().mockReturnValue({ id: "F-001", status: "in-progress" }),
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||
|
||||
// Trigger task:moved event by calling the registered handler
|
||||
const onCalls = (store.on as any).mock.calls;
|
||||
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
expect(movedHandler).toBeDefined();
|
||||
|
||||
// Simulate task moving to in-progress with sliceId
|
||||
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
|
||||
await movedHandler({ task, to: "in-progress" });
|
||||
|
||||
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
|
||||
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "in-progress");
|
||||
});
|
||||
|
||||
it("does not update feature status when already past triaged", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001", status: "in-progress" }),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||
|
||||
const onCalls = (store.on as any).mock.calls;
|
||||
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
|
||||
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
|
||||
await movedHandler({ task, to: "in-progress" });
|
||||
|
||||
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
|
||||
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers feature done update when task with sliceId moves to done", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001" }),
|
||||
updateFeatureStatus: vi.fn().mockReturnValue({ id: "F-001", status: "done" }),
|
||||
getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001" }),
|
||||
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
|
||||
computeSliceStatus: vi.fn().mockReturnValue("active"),
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||
|
||||
// Trigger task:moved event by calling the registered handler
|
||||
const onCalls = (store.on as any).mock.calls;
|
||||
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
expect(movedHandler).toBeDefined();
|
||||
|
||||
// Simulate task moving to done with sliceId
|
||||
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
|
||||
await movedHandler({ task, to: "done" });
|
||||
|
||||
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
|
||||
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done");
|
||||
});
|
||||
|
||||
it("auto-advances when slice completes and autoAdvance is enabled", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001" }),
|
||||
updateFeatureStatus: vi.fn().mockReturnValue({ id: "F-001", status: "done" }),
|
||||
getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001" }),
|
||||
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
|
||||
computeSliceStatus: vi.fn().mockReturnValue("complete"),
|
||||
getMission: vi.fn().mockReturnValue({ id: "M-001", autoAdvance: true }),
|
||||
findNextPendingSlice: vi.fn().mockReturnValue({ id: "SL-002" }),
|
||||
activateSlice: vi.fn().mockReturnValue({ id: "SL-002", status: "active" }),
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||
|
||||
// Trigger task:moved event
|
||||
const onCalls = (store.on as any).mock.calls;
|
||||
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
|
||||
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
|
||||
await movedHandler({ task, to: "done" });
|
||||
|
||||
expect(mockMissionStore.computeSliceStatus).toHaveBeenCalledWith("SL-001");
|
||||
expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001");
|
||||
expect(mockMissionStore.findNextPendingSlice).toHaveBeenCalledWith("M-001");
|
||||
expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002");
|
||||
});
|
||||
|
||||
it("does not auto-advance when autoAdvance is disabled", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001" }),
|
||||
updateFeatureStatus: vi.fn().mockReturnValue({ id: "F-001", status: "done" }),
|
||||
getSlice: vi.fn().mockReturnValue({ id: "SL-001", milestoneId: "MS-001" }),
|
||||
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
|
||||
computeSliceStatus: vi.fn().mockReturnValue("complete"),
|
||||
getMission: vi.fn().mockReturnValue({ id: "M-001", autoAdvance: false }),
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||
|
||||
// Trigger task:moved event
|
||||
const onCalls = (store.on as any).mock.calls;
|
||||
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
|
||||
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
|
||||
await movedHandler({ task, to: "done" });
|
||||
|
||||
expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001");
|
||||
expect(mockMissionStore.findNextPendingSlice).not.toHaveBeenCalled();
|
||||
expect(mockMissionStore.activateSlice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles task with sliceId but no linked feature gracefully", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue(undefined),
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||
|
||||
// Trigger task:moved event
|
||||
const onCalls = (store.on as any).mock.calls;
|
||||
const movedHandler = onCalls.find((call: any) => call[0] === "task:moved")?.[1];
|
||||
|
||||
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
|
||||
await movedHandler({ task, to: "done" });
|
||||
|
||||
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
|
||||
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("activateNextPendingSlice finds and activates correct slice", async () => {
|
||||
const nextSlice = { id: "SL-002", status: "pending" };
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
findNextPendingSlice: vi.fn().mockReturnValue(nextSlice),
|
||||
activateSlice: vi.fn().mockReturnValue({ ...nextSlice, status: "active" }),
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||
|
||||
const result = await scheduler.activateNextPendingSlice("M-001");
|
||||
|
||||
expect(mockMissionStore.findNextPendingSlice).toHaveBeenCalledWith("M-001");
|
||||
expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002");
|
||||
expect(result).toEqual({ id: "SL-002", status: "active" });
|
||||
});
|
||||
|
||||
it("activateNextPendingSlice returns null when no pending slices", async () => {
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
findNextPendingSlice: vi.fn().mockReturnValue(undefined),
|
||||
});
|
||||
|
||||
const store = createMockStore();
|
||||
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||
|
||||
const result = await scheduler.activateNextPendingSlice("M-001");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockMissionStore.activateSlice).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolveDependencyOrder, type TaskStore, type Task } from "@fusion/core";
|
||||
import { resolveDependencyOrder, type TaskStore, type Task, type MissionStore, type FeatureStatus } from "@fusion/core";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -60,6 +60,8 @@ export interface SchedulerOptions {
|
||||
onBlocked?: (task: Task, blockedBy: string[]) => void;
|
||||
/** Optional PR monitor for tracking in-review PRs */
|
||||
prMonitor?: PrMonitor;
|
||||
/** Optional MissionStore for slice activation and auto-advance */
|
||||
missionStore?: MissionStore;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,23 +124,36 @@ export class Scheduler {
|
||||
/**
|
||||
* PR Monitoring: Start monitoring when a task moves to "in-review",
|
||||
* stop monitoring when it moves out.
|
||||
*
|
||||
* Also handles mission auto-advance: when a linked task completes,
|
||||
* update feature status and potentially activate next pending slice.
|
||||
*/
|
||||
this.store.on("task:moved", ({ task, to }) => {
|
||||
if (!this.options.prMonitor) return;
|
||||
// PR Monitoring
|
||||
if (this.options.prMonitor) {
|
||||
if (to === "in-review" && task.prInfo) {
|
||||
// Start monitoring existing PR
|
||||
const repo = getCurrentGitHubRepo(this.store.getRootDir());
|
||||
if (repo) {
|
||||
this.options.prMonitor.startMonitoring(task.id, repo.owner, repo.repo, task.prInfo);
|
||||
}
|
||||
} else if (task.column === "in-review" && to !== "in-review") {
|
||||
// Task moved out of in-review, stop monitoring
|
||||
this.options.prMonitor.stopMonitoring(task.id);
|
||||
|
||||
if (to === "in-review" && task.prInfo) {
|
||||
// Start monitoring existing PR
|
||||
const repo = getCurrentGitHubRepo(this.store.getRootDir());
|
||||
if (repo) {
|
||||
this.options.prMonitor.startMonitoring(task.id, repo.owner, repo.repo, task.prInfo);
|
||||
// If task has a closed/merged PR, check for unaddressed feedback
|
||||
if (task.prInfo && (task.prInfo.status === "closed" || task.prInfo.status === "merged")) {
|
||||
// This would need the tracked PR data - handled by PrMonitor/PrCommentHandler
|
||||
}
|
||||
}
|
||||
} else if (task.column === "in-review" && to !== "in-review") {
|
||||
// Task moved out of in-review, stop monitoring
|
||||
this.options.prMonitor.stopMonitoring(task.id);
|
||||
}
|
||||
|
||||
// If task has a closed/merged PR, check for unaddressed feedback
|
||||
if (task.prInfo && (task.prInfo.status === "closed" || task.prInfo.status === "merged")) {
|
||||
// This would need the tracked PR data - handled by PrMonitor/PrCommentHandler
|
||||
// Mission progress tracking: when task with sliceId moves to "in-progress" or "done"
|
||||
if (task.sliceId && this.options.missionStore) {
|
||||
if (to === "in-progress") {
|
||||
void this.handleMissionTaskStart(task.id, task.sliceId);
|
||||
} else if (to === "done") {
|
||||
void this.handleMissionTaskCompletion(task.id, task.sliceId);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -479,4 +494,118 @@ export class Scheduler {
|
||||
this.scheduling = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mission task start.
|
||||
* When a task with a sliceId moves to "in-progress", update the linked
|
||||
* feature status to "in-progress" to reflect active work.
|
||||
*/
|
||||
private async handleMissionTaskStart(taskId: string, sliceId: string): Promise<void> {
|
||||
if (!this.options.missionStore) return;
|
||||
|
||||
const missionStore = this.options.missionStore;
|
||||
|
||||
try {
|
||||
// Find the feature linked to this task
|
||||
const feature = missionStore.getFeatureByTaskId(taskId);
|
||||
if (!feature) {
|
||||
schedulerLog.log(`Task ${taskId} has sliceId ${sliceId} but no linked feature found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only update if feature is still in "triaged" status
|
||||
if (feature.status === "triaged") {
|
||||
await missionStore.updateFeatureStatus(feature.id, "in-progress");
|
||||
schedulerLog.log(`Feature ${feature.id} marked in-progress (task ${taskId} started)`);
|
||||
}
|
||||
} catch (err) {
|
||||
schedulerLog.error(`Error handling mission task start for ${taskId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mission task completion.
|
||||
* When a task with a sliceId moves to "done", update the linked feature
|
||||
* status and check if the slice is complete. If autoAdvance is enabled
|
||||
* on the mission, activate the next pending slice.
|
||||
*/
|
||||
private async handleMissionTaskCompletion(taskId: string, sliceId: string): Promise<void> {
|
||||
if (!this.options.missionStore) return;
|
||||
|
||||
const missionStore = this.options.missionStore;
|
||||
|
||||
try {
|
||||
// Find the feature linked to this task
|
||||
const feature = missionStore.getFeatureByTaskId(taskId);
|
||||
if (!feature) {
|
||||
schedulerLog.log(`Task ${taskId} has sliceId ${sliceId} but no linked feature found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update feature status to done
|
||||
await missionStore.updateFeatureStatus(feature.id, "done");
|
||||
schedulerLog.log(`Feature ${feature.id} marked done (task ${taskId} completed)`);
|
||||
|
||||
// Get the slice to check its status
|
||||
const slice = missionStore.getSlice(sliceId);
|
||||
if (!slice) {
|
||||
schedulerLog.warn(`Slice ${sliceId} not found for task ${taskId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the milestone to find the mission
|
||||
const milestone = missionStore.getMilestone(slice.milestoneId);
|
||||
if (!milestone) {
|
||||
schedulerLog.warn(`Milestone ${slice.milestoneId} not found for slice ${sliceId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Recompute and check if slice is now complete
|
||||
const newSliceStatus = missionStore.computeSliceStatus(sliceId);
|
||||
if (newSliceStatus === "complete") {
|
||||
schedulerLog.log(`Slice ${sliceId} completed (all features done)`);
|
||||
|
||||
// Check if mission has autoAdvance enabled
|
||||
const mission = missionStore.getMission(milestone.missionId);
|
||||
if (mission?.autoAdvance) {
|
||||
// Activate next pending slice
|
||||
const nextSlice = await this.activateNextPendingSlice(mission.id);
|
||||
if (nextSlice) {
|
||||
schedulerLog.log(`Auto-advanced: activated slice ${nextSlice.id} for mission ${mission.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
schedulerLog.error(`Error handling mission task completion for ${taskId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the next pending slice in a mission.
|
||||
* Finds the first milestone with pending slices and activates
|
||||
* the first pending slice in that milestone.
|
||||
*
|
||||
* @param missionId - Mission ID
|
||||
* @returns The activated slice, or null if no pending slices
|
||||
*/
|
||||
async activateNextPendingSlice(missionId: string): Promise<import("@fusion/core").Slice | null> {
|
||||
if (!this.options.missionStore) return null;
|
||||
|
||||
const missionStore = this.options.missionStore;
|
||||
|
||||
try {
|
||||
const nextSlice = missionStore.findNextPendingSlice(missionId);
|
||||
if (!nextSlice) {
|
||||
schedulerLog.log(`Mission ${missionId}: no pending slices to activate`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const activated = missionStore.activateSlice(nextSlice.id);
|
||||
schedulerLog.log(`Activated slice ${activated.id} for mission ${missionId}`);
|
||||
return activated;
|
||||
} catch (err) {
|
||||
schedulerLog.error(`Error activating next slice for mission ${missionId}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user