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

@@ -945,6 +945,208 @@ describe("MissionStore", () => {
);
});
});
describe("triageFeature", () => {
it("throws if TaskStore reference is not available", async () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Feature" });
await expect(store.triageFeature(feature.id)).rejects.toThrow(
"TaskStore reference is required for triage operations",
);
});
it("throws if feature not found", async () => {
// Need a TaskStore reference for this test
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
await expect(msWithTs.triageFeature("F-NONEXISTENT")).rejects.toThrow(
"Feature F-NONEXISTENT not found",
);
});
it("throws if feature is already triaged", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, { title: "Feature" });
// Triaging once should work
await msWithTs.triageFeature(feature.id);
// Triaging again should fail
const updated = msWithTs.getFeature(feature.id)!;
await expect(msWithTs.triageFeature(updated.id)).rejects.toThrow(
`already triaged`,
);
});
it("creates a task and links it to the feature", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, {
title: "Login Page",
description: "Build a login page",
acceptanceCriteria: "User can log in",
});
const triaged = await msWithTs.triageFeature(feature.id);
// Feature should be triaged with a taskId
expect(triaged.status).toBe("triaged");
expect(triaged.taskId).toBeTruthy();
// Task should exist with correct properties
const task = await ts.getTask(triaged.taskId!);
expect(task).toBeDefined();
expect(task!.title).toBe("Login Page");
expect(task!.description).toContain("Build a login page");
expect(task!.description).toContain("Acceptance Criteria");
expect(task!.sliceId).toBe(slice.id);
expect(task!.missionId).toBe(mission.id);
});
it("uses provided title and description overrides", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, { title: "Original" });
const triaged = await msWithTs.triageFeature(
feature.id,
"Custom Title",
"Custom description for the task",
);
const task = await ts.getTask(triaged.taskId!);
expect(task!.title).toBe("Custom Title");
expect(task!.description).toBe("Custom description for the task");
});
it("emits feature:linked event", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const linkedHandler = vi.fn();
msWithTs.on("feature:linked", linkedHandler);
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const feature = msWithTs.addFeature(slice.id, { title: "Feature" });
const triaged = await msWithTs.triageFeature(feature.id);
expect(linkedHandler).toHaveBeenCalledWith(
expect.objectContaining({
feature: expect.objectContaining({ id: feature.id }),
taskId: triaged.taskId,
}),
);
});
});
describe("triageSlice", () => {
it("throws if TaskStore reference is not available", async () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
await expect(store.triageSlice(slice.id)).rejects.toThrow(
"TaskStore reference is required for triage operations",
);
});
it("throws if slice not found", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
await expect(msWithTs.triageSlice("SL-NONEXISTENT")).rejects.toThrow(
"Slice SL-NONEXISTENT not found",
);
});
it("triages all defined features in a slice", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const f1 = msWithTs.addFeature(slice.id, { title: "Feature 1" });
const f2 = msWithTs.addFeature(slice.id, { title: "Feature 2" });
const f3 = msWithTs.addFeature(slice.id, { title: "Feature 3" });
const triaged = await msWithTs.triageSlice(slice.id);
expect(triaged).toHaveLength(3);
expect(triaged.every((f) => f.status === "triaged")).toBe(true);
expect(triaged.every((f) => f.taskId)).toBe(true);
// All tasks should exist and be linked to the slice/mission
for (const feature of triaged) {
const task = await ts.getTask(feature.taskId!);
expect(task).toBeDefined();
expect(task!.sliceId).toBe(slice.id);
expect(task!.missionId).toBe(mission.id);
}
});
it("skips already triaged features", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const f1 = msWithTs.addFeature(slice.id, { title: "Feature 1" });
const f2 = msWithTs.addFeature(slice.id, { title: "Feature 2" });
// Triage f1 first
await msWithTs.triageFeature(f1.id);
// Now triage the whole slice — should only triage f2
const triaged = await msWithTs.triageSlice(slice.id);
expect(triaged).toHaveLength(1);
expect(triaged[0].id).toBe(f2.id);
expect(triaged[0].status).toBe("triaged");
});
it("returns empty array if no defined features", async () => {
const { TaskStore } = await import("./store.js");
const ts = new TaskStore(kbDir);
const msWithTs = ts.getMissionStore();
const mission = msWithTs.createMission({ title: "Mission" });
const milestone = msWithTs.addMilestone(mission.id, { title: "Milestone" });
const slice = msWithTs.addSlice(milestone.id, { title: "Slice" });
const triaged = await msWithTs.triageSlice(slice.id);
expect(triaged).toEqual([]);
});
});
});
// vi import for vitest mocking

View File

@@ -72,10 +72,12 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
*
* @param kbDir - Path to the .fusion directory (e.g., /path/to/project/.fusion)
* @param db - Shared Database instance (same instance used by TaskStore)
* @param taskStore - Optional TaskStore reference for triage operations that create tasks
*/
constructor(
private kbDir: string,
private db: Database,
private taskStore?: import("./store.js").TaskStore,
) {
super();
this.setMaxListeners(100);
@@ -1048,6 +1050,92 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
return this.rowToFeature(row);
}
// ── Triage Operations ────────────────────────────────────────────────
/**
* Triage a feature by creating a new task and linking it.
*
* Creates a kb task from the feature's title and description, then links
* the feature to the newly created task using `linkFeatureToTask()`.
* The feature status transitions from "defined" to "triaged".
*
* Requires MissionStore to have been constructed with a TaskStore reference.
*
* @param featureId - Feature ID to triage
* @param taskTitle - Optional title override (defaults to feature title)
* @param taskDescription - Optional description override (defaults to feature description + acceptance criteria)
* @returns The updated feature with taskId set
* @throws Error if feature not found, already triaged, or TaskStore not available
*/
async triageFeature(
featureId: string,
taskTitle?: string,
taskDescription?: string,
): Promise<MissionFeature> {
if (!this.taskStore) {
throw new Error("TaskStore reference is required for triage operations");
}
const feature = this.getFeature(featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
if (feature.status !== "defined") {
throw new Error(`Feature ${featureId} is already ${feature.status} (status must be "defined" to triage)`);
}
// Build description from feature + acceptance criteria
const description = taskDescription || [
feature.description,
feature.acceptanceCriteria ? `\n**Acceptance Criteria:**\n${feature.acceptanceCriteria}` : "",
].filter(Boolean).join("\n\n") || feature.title;
// Create the task
const task = await this.taskStore.createTask({
title: taskTitle || feature.title,
description,
});
// Link the feature to the new task (this also updates feature status to "triaged")
const updated = this.linkFeatureToTask(featureId, task.id);
return updated;
}
/**
* Triage all "defined" features in a slice.
*
* Convenience method that iterates over all features in a slice with
* status "defined" and triages each one, creating a task and linking it.
* Features that are already triaged or in-progress are skipped.
*
* @param sliceId - Slice ID whose features should be triaged
* @returns Array of updated features that were triaged
* @throws Error if slice not found or TaskStore not available
*/
async triageSlice(sliceId: string): Promise<MissionFeature[]> {
if (!this.taskStore) {
throw new Error("TaskStore reference is required for triage operations");
}
const slice = this.getSlice(sliceId);
if (!slice) {
throw new Error(`Slice ${sliceId} not found`);
}
const features = this.listFeatures(sliceId);
const definedFeatures = features.filter((f) => f.status === "defined");
const triaged: MissionFeature[] = [];
for (const feature of definedFeatures) {
const updated = await this.triageFeature(feature.id);
triaged.push(updated);
}
return triaged;
}
// ── Status Rollup Logic ───────────────────────────────────────────
/**

View File

@@ -2972,7 +2972,7 @@ ${notificationsSection}`;
*/
getMissionStore(): MissionStore {
if (!this.missionStore) {
this.missionStore = new MissionStore(this.kbDir, this.db);
this.missionStore = new MissionStore(this.kbDir, this.db, this);
}
return this.missionStore;
}

View File

@@ -2468,6 +2468,42 @@ export function unlinkFeatureFromTask(featureId: string, projectId?: string): Pr
});
}
/** Triage a feature — create a task from the feature and link it */
export function triageFeature(featureId: string, taskTitle?: string, taskDescription?: string, projectId?: string): Promise<MissionFeature> {
return api<MissionFeature>(withProjectId(`/missions/features/${encodeURIComponent(featureId)}/triage`, projectId), {
method: "POST",
body: JSON.stringify({ taskTitle, taskDescription }),
});
}
/** Triage all "defined" features in a slice */
export function triageAllSliceFeatures(sliceId: string, projectId?: string): Promise<{ triaged: MissionFeature[]; count: number }> {
return api<{ triaged: MissionFeature[]; count: number }>(withProjectId(`/missions/slices/${encodeURIComponent(sliceId)}/triage-all`, projectId), {
method: "POST",
});
}
/** Pause a mission (sets status to "blocked", in-flight tasks continue) */
export function pauseMission(missionId: string, projectId?: string): Promise<Mission> {
return api<Mission>(withProjectId(`/missions/${encodeURIComponent(missionId)}/pause`, projectId), {
method: "POST",
});
}
/** Resume a paused mission (sets status back to "active") */
export function resumeMission(missionId: string, projectId?: string): Promise<Mission> {
return api<Mission>(withProjectId(`/missions/${encodeURIComponent(missionId)}/resume`, projectId), {
method: "POST",
});
}
/** Stop a mission (sets status to "blocked" and pauses all linked tasks) */
export function stopMission(missionId: string, projectId?: string): Promise<Mission & { pausedTaskIds: string[] }> {
return api<Mission & { pausedTaskIds: string[] }>(withProjectId(`/missions/${encodeURIComponent(missionId)}/stop`, projectId), {
method: "POST",
});
}
// ── Mission Interview API ─────────────────────────────────────────────────
/** Mission plan types returned by the interview AI */

View File

@@ -16,7 +16,11 @@ import {
Link,
Unlink,
Play,
Pause,
Square,
RefreshCw,
Sparkles,
Zap,
} from "lucide-react";
import type { ToastType } from "../hooks/useToast";
import { MissionInterviewModal } from "./MissionInterviewModal";
@@ -51,6 +55,11 @@ import {
deleteFeature,
linkFeatureToTask,
unlinkFeatureFromTask,
triageFeature,
triageAllSliceFeatures,
pauseMission,
resumeMission,
stopMission,
} from "../api";
interface MissionManagerProps {
@@ -572,6 +581,71 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
}
}, [addToast, loadMissionDetail, selectedMission, projectId]);
// Triage a single feature — creates a task and links it
const handleTriageFeature = useCallback(async (featureId: string) => {
try {
setSaving(true);
await triageFeature(featureId, undefined, undefined, projectId);
addToast("Feature triaged — task created", "success");
await loadMissionDetail(selectedMission!.id);
} catch (err: any) {
addToast(err.message || "Failed to triage feature", "error");
} finally {
setSaving(false);
}
}, [addToast, loadMissionDetail, selectedMission, projectId]);
// Triage all defined features in a slice
const handleTriageAllSliceFeatures = useCallback(async (sliceId: string) => {
try {
setSaving(true);
const result = await triageAllSliceFeatures(sliceId, projectId);
addToast(`Triaged ${result.count} feature${result.count !== 1 ? "s" : ""}`, "success");
await loadMissionDetail(selectedMission!.id);
} catch (err: any) {
addToast(err.message || "Failed to triage slice features", "error");
} finally {
setSaving(false);
}
}, [addToast, loadMissionDetail, selectedMission, projectId]);
// Pause mission — set status to "blocked"
const handlePauseMission = useCallback(async (missionId: string) => {
try {
await pauseMission(missionId, projectId);
addToast("Mission paused", "success");
await loadMissionDetail(missionId);
loadMissions();
} catch (err: any) {
addToast(err.message || "Failed to pause mission", "error");
}
}, [addToast, loadMissionDetail, loadMissions, projectId]);
// Resume a paused mission — set status back to "active"
const handleResumeMission = useCallback(async (missionId: string) => {
try {
await resumeMission(missionId, projectId);
addToast("Mission resumed", "success");
await loadMissionDetail(missionId);
loadMissions();
} catch (err: any) {
addToast(err.message || "Failed to resume mission", "error");
}
}, [addToast, loadMissionDetail, loadMissions, projectId]);
// Stop mission — set status to "blocked" and pause all linked tasks
const handleStopMission = useCallback(async (missionId: string) => {
try {
const result = await stopMission(missionId, projectId);
const count = result.pausedTaskIds?.length ?? 0;
addToast(`Mission stopped (${count} task${count !== 1 ? "s" : ""} paused)`, "success");
await loadMissionDetail(missionId);
loadMissions();
} catch (err: any) {
addToast(err.message || "Failed to stop mission", "error");
}
}, [addToast, loadMissionDetail, loadMissions, projectId]);
const handleSelectMission = useCallback((mission: Mission) => {
loadMissionDetail(mission.id);
}, [loadMissionDetail]);
@@ -713,6 +787,36 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
</span>
</div>
<div className="mission-detail__actions">
{selectedMission.status === "active" && (
<>
<button
className="mission-icon-btn"
onClick={() => handlePauseMission(selectedMission.id)}
title="Pause mission"
aria-label="Pause mission"
>
<Pause size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => handleStopMission(selectedMission.id)}
title="Stop mission"
aria-label="Stop mission"
>
<Square size={14} />
</button>
</>
)}
{selectedMission.status === "blocked" && (
<button
className="mission-icon-btn mission-icon-btn--success"
onClick={() => handleResumeMission(selectedMission.id)}
title="Resume mission"
aria-label="Resume mission"
>
<RefreshCw size={14} />
</button>
)}
<button
className="mission-icon-btn"
onClick={() => handleEditMission(selectedMission)}
@@ -886,6 +990,16 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
<Play size={14} />
</button>
)}
{slice.status === "active" && slice.features?.some((f) => f.status === "defined") && (
<button
className="mission-icon-btn"
onClick={() => handleTriageAllSliceFeatures(slice.id)}
title="Triage all features"
disabled={saving}
>
{saving ? <Loader2 size={14} className="spinner" /> : <Zap size={14} />}
</button>
)}
<button
className="mission-icon-btn"
onClick={() => handleCreateFeature(slice.id)}
@@ -1004,6 +1118,16 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
</span>
)}
<div className="mission-feature__actions">
{feature.status === "defined" && !feature.taskId && (
<button
className="mission-icon-btn"
onClick={() => handleTriageFeature(feature.id)}
title="Triage — create task"
disabled={saving}
>
{saving ? <Loader2 size={14} className="spinner" /> : <Zap size={14} />}
</button>
)}
{feature.taskId ? (
<button
className="mission-icon-btn"
@@ -1012,7 +1136,7 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
>
<Unlink size={14} />
</button>
) : (
) : feature.status !== "defined" ? (
<button
className="mission-icon-btn"
onClick={() => setLinkTaskFeatureId(feature.id)}
@@ -1020,7 +1144,7 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
>
<Link size={14} />
</button>
)}
) : null}
<button
className="mission-icon-btn"
onClick={() => handleEditFeature(feature)}
@@ -1254,6 +1378,33 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
)}
</div>
<div className="mission-list__item-actions" onClick={(e) => e.stopPropagation()}>
{m.status === "active" && (
<>
<button
className="mission-icon-btn"
onClick={() => handlePauseMission(m.id)}
title="Pause mission"
>
<Pause size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => handleStopMission(m.id)}
title="Stop mission"
>
<Square size={14} />
</button>
</>
)}
{m.status === "blocked" && (
<button
className="mission-icon-btn mission-icon-btn--success"
onClick={() => handleResumeMission(m.id)}
title="Resume mission"
>
<RefreshCw size={14} />
</button>
)}
<button
className="mission-icon-btn"
onClick={() => handleEditMission(mission)}

View File

@@ -211,6 +211,36 @@ function createMockMissionStore() {
reorderMilestones: vi.fn(),
reorderSlices: vi.fn(),
// Triage methods
triageFeature: vi.fn(async (featureId: string) => {
const feature = features.get(featureId);
if (!feature) throw new Error("Feature " + featureId + " not found");
if (feature.status !== "defined") throw new Error("Feature " + featureId + " is already " + feature.status);
const taskId = "FN-" + String(features.size + 1).padStart(3, "0");
const updated = { ...feature, taskId, status: "triaged" as const, updatedAt: new Date().toISOString() };
features.set(featureId, updated);
return updated;
}),
triageSlice: vi.fn(async (sliceId: string) => {
const slice = slices.get(sliceId);
if (!slice) throw new Error("Slice " + sliceId + " not found");
const sliceFeatures = Array.from(features.values()).filter((f) => f.sliceId === sliceId && f.status === "defined");
const triaged: MissionFeature[] = [];
for (const f of sliceFeatures) {
const taskId = "FN-" + String(features.size + triaged.size + 1).padStart(3, "0");
const updated = { ...f, taskId, status: "triaged" as const, updatedAt: new Date().toISOString() };
features.set(f.id, updated);
triaged.push(updated);
}
return triaged;
}),
// Mission status helpers for pause/stop
computeMissionStatus: vi.fn(() => "active"),
getMilestone: vi.fn((id: string) => milestones.get(id)),
getMission: vi.fn((id: string) => missions.get(id)),
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
@@ -220,6 +250,7 @@ function createMockMissionStore() {
function createMockStore(): TaskStore {
return {
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
pauseTask: vi.fn(),
} as unknown as TaskStore;
}
@@ -773,4 +804,241 @@ describe("Mission API", () => {
expect(res.status).toBe(404);
});
});
// ── Feature Triage Endpoints ────────────────────────────────────────────
describe("POST /api/missions/features/:featureId/triage", () => {
it("should triage a defined feature", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
// Create mission hierarchy
const mission = ms.createMission({ title: "Test Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const feature = ms.addFeature(slice.id, { title: "Feature" });
const res = await request(
app,
"POST",
`/api/missions/features/${feature.id}/triage`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.status).toBe("triaged");
expect(res.body.taskId).toBeTruthy();
});
it("should return 404 for non-existent feature", async () => {
const { app } = buildApp();
const res = await request(
app,
"POST",
"/api/missions/features/F-NONEXISTENT-XXX/triage",
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(404);
});
it("should return 400 for already triaged feature", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Test Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const feature = ms.addFeature(slice.id, { title: "Feature" });
// Triage it first
await ms.triageFeature(feature.id);
// Try again — should fail
const res = await request(
app,
"POST",
`/api/missions/features/${feature.id}/triage`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(400);
});
});
describe("POST /api/missions/slices/:sliceId/triage-all", () => {
it("should triage all defined features in a slice", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Test Mission" });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
ms.addFeature(slice.id, { title: "Feature 1" });
ms.addFeature(slice.id, { title: "Feature 2" });
const res = await request(
app,
"POST",
`/api/missions/slices/${slice.id}/triage-all`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.count).toBe(2);
expect(res.body.triaged).toHaveLength(2);
expect(res.body.triaged.every((f: MissionFeature) => f.status === "triaged")).toBe(true);
});
it("should return 404 for non-existent slice", async () => {
const { app } = buildApp();
const res = await request(
app,
"POST",
"/api/missions/slices/SL-NONEXISTENT-XXX/triage-all",
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(404);
});
});
// ── Mission Pause/Stop/Resume Endpoints ──────────────────────────────────
describe("POST /api/missions/:missionId/pause", () => {
it("should pause an active mission", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Test Mission" });
// Set to active
ms.updateMission(mission.id, { status: "active" });
const res = await request(
app,
"POST",
`/api/missions/${mission.id}/pause`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.status).toBe("blocked");
});
it("should return 404 for non-existent mission", async () => {
const { app } = buildApp();
const res = await request(
app,
"POST",
"/api/missions/M-NONEXISTENT-XXX/pause",
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(404);
});
it("should return 400 if mission is already blocked", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Test Mission" });
ms.updateMission(mission.id, { status: "blocked" });
const res = await request(
app,
"POST",
`/api/missions/${mission.id}/pause`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(400);
});
});
describe("POST /api/missions/:missionId/resume", () => {
it("should resume a blocked mission", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Test Mission" });
ms.updateMission(mission.id, { status: "blocked" });
const res = await request(
app,
"POST",
`/api/missions/${mission.id}/resume`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.status).toBe("active");
});
it("should return 400 if mission is not blocked", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Test Mission" });
// Mission starts as "planning"
const res = await request(
app,
"POST",
`/api/missions/${mission.id}/resume`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(400);
});
});
describe("POST /api/missions/:missionId/stop", () => {
it("should stop a mission and return paused task IDs", async () => {
const { app, missionStore } = buildApp();
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Test Mission" });
ms.updateMission(mission.id, { status: "active" });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const slice = ms.addSlice(milestone.id, { title: "Slice" });
const feature = ms.addFeature(slice.id, { title: "Feature" });
// Simulate a linked task
ms.linkFeatureToTask(feature.id, "FN-001");
const res = await request(
app,
"POST",
`/api/missions/${mission.id}/stop`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.status).toBe("blocked");
expect(res.body.pausedTaskIds).toContain("FN-001");
});
it("should return 404 for non-existent mission", async () => {
const { app } = buildApp();
const res = await request(
app,
"POST",
"/api/missions/M-NONEXISTENT-XXX/stop",
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(404);
});
});
});

View File

@@ -1120,6 +1120,192 @@ export function createMissionRouter(store: TaskStore): Router {
})
);
// ── Feature Triage Endpoints ────────────────────────────────────────────────
/**
* POST /api/missions/features/:featureId/triage
* Triage a feature by creating a task and linking it.
* Body: { taskTitle?: string, taskDescription?: string }
*/
router.post(
"/features/:featureId/triage",
asyncHandler(async (req, res) => {
const { featureId } = req.params;
const { taskTitle, taskDescription } = req.body || {};
if (!validateFeatureId(featureId)) {
res.status(400).json({ error: "Invalid feature ID format" });
return;
}
const existing = missionStore.getFeature(featureId);
if (!existing) {
res.status(404).json({ error: "Feature not found" });
return;
}
try {
const feature = await missionStore.triageFeature(
featureId,
taskTitle || undefined,
taskDescription || undefined,
);
res.json(feature);
} catch (err: any) {
if (err.message?.includes("already")) {
res.status(400).json({ error: err.message });
return;
}
if (err.message?.includes("TaskStore")) {
res.status(503).json({ error: "TaskStore not available for triage operations" });
return;
}
throw err;
}
})
);
/**
* POST /api/missions/slices/:sliceId/triage-all
* Triage all "defined" features in a slice.
* Returns: { triaged: MissionFeature[], count: number }
*/
router.post(
"/slices/:sliceId/triage-all",
asyncHandler(async (req, res) => {
const { sliceId } = req.params;
if (!validateSliceId(sliceId)) {
res.status(400).json({ error: "Invalid slice ID format" });
return;
}
const slice = missionStore.getSlice(sliceId);
if (!slice) {
res.status(404).json({ error: "Slice not found" });
return;
}
try {
const triaged = await missionStore.triageSlice(sliceId);
res.json({ triaged, count: triaged.length });
} catch (err: any) {
if (err.message?.includes("TaskStore")) {
res.status(503).json({ error: "TaskStore not available for triage operations" });
return;
}
throw err;
}
})
);
// ── Mission Pause/Stop/Resume Endpoints ─────────────────────────────────────
/**
* POST /api/missions/:missionId/pause
* Pause a mission by setting status to "blocked".
* In-flight tasks continue running; no new tasks are scheduled.
*/
router.post(
"/:missionId/pause",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
return;
}
const mission = missionStore.getMission(missionId);
if (!mission) {
res.status(404).json({ error: "Mission not found" });
return;
}
if (mission.status === "blocked") {
res.status(400).json({ error: "Mission is already paused (blocked)" });
return;
}
const updated = missionStore.updateMission(missionId, { status: "blocked" });
res.json(updated);
})
);
/**
* POST /api/missions/:missionId/resume
* Resume a paused mission by setting status to "active".
*/
router.post(
"/:missionId/resume",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
return;
}
const mission = missionStore.getMission(missionId);
if (!mission) {
res.status(404).json({ error: "Mission not found" });
return;
}
if (mission.status !== "blocked") {
res.status(400).json({ error: "Mission is not paused (status must be 'blocked' to resume)" });
return;
}
const updated = missionStore.updateMission(missionId, { status: "active" });
res.json(updated);
})
);
/**
* POST /api/missions/:missionId/stop
* Stop a mission: set status to "blocked" and pause all linked tasks.
*/
router.post(
"/:missionId/stop",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
return;
}
const hierarchy = missionStore.getMissionWithHierarchy(missionId);
if (!hierarchy) {
res.status(404).json({ error: "Mission not found" });
return;
}
// Set mission status to blocked
const updated = missionStore.updateMission(missionId, { status: "blocked" });
// Pause all tasks linked to features in this mission
const pausedTaskIds: string[] = [];
for (const milestone of hierarchy.milestones) {
for (const slice of milestone.slices) {
for (const feature of slice.features) {
if (feature.taskId) {
try {
await store.pauseTask(feature.taskId, true);
pausedTaskIds.push(feature.taskId);
} catch (err: any) {
// Log but don't fail — task may already be paused or not found
}
}
}
}
}
res.json({ ...updated, pausedTaskIds });
})
);
// ── Interview Endpoints ─────────────────────────────────────────────────────
// Note: These are mounted at /api/missions/interview/* via the router

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