feat(KB-635): integrate mission workflows across CLI, extension, and engine

- Add mission CLI commands and pi extension tools for creating missions, milestones, slices, features, and task links
- Extend core mission storage and schema migrations with auto-advance support, task slice linkage, and comment normalization coverage
- Wire mission-aware scheduler and executor behavior so linked features progress with task execution and completed slices can auto-activate follow-on work
- Add regression tests for mission CLI parsing, extension behaviors, scheduler mission semantics, and executor integration
This commit is contained in:
gsxdsm
2026-04-01 13:45:56 -07:00
parent a63ee7443a
commit e8b6b98d57
13 changed files with 984 additions and 229 deletions

View File

@@ -407,7 +407,7 @@ describe("kb pi extension", () => {
const tool = api.tools.get("kb_mission_create")!;
const result = await tool.execute(
"call-1",
{ title: "Test Mission", description: "Test description" },
{ title: "Test Mission", description: "Test description", autoAdvance: true },
undefined,
undefined,
makeCtx(tmpDir),
@@ -415,8 +415,10 @@ describe("kb pi extension", () => {
expect(result.details.missionId).toBeDefined();
expect(result.details.title).toBe("Test Mission");
expect(result.details.autoAdvance).toBe(true);
expect(result.content[0].text).toContain("Created");
expect(result.content[0].text).toContain("Test Mission");
expect(result.content[0].text).toContain("Auto-advance: enabled");
});
});
@@ -443,6 +445,7 @@ describe("kb pi extension", () => {
expect(result.details.count).toBeGreaterThanOrEqual(1);
expect(result.content[0].text).toContain("Missions");
expect(result.content[0].text).toContain("Summary:");
});
});
@@ -512,33 +515,272 @@ describe("kb pi extension", () => {
});
});
describe("kb_milestone_add", () => {
it("creates a milestone in the mission store", async () => {
const missionTool = api.tools.get("kb_mission_create")!;
const milestoneTool = api.tools.get("kb_milestone_add")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const result = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone", description: "Phase 1" },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getMilestone(result.details.milestoneId);
expect(result.content[0].text).toContain("Added");
expect(persisted?.title).toBe("Milestone");
expect(persisted?.description).toBe("Phase 1");
});
});
describe("kb_slice_add", () => {
it("creates a slice in the mission store", async () => {
const missionTool = api.tools.get("kb_mission_create")!;
const milestoneTool = api.tools.get("kb_milestone_add")!;
const sliceTool = api.tools.get("kb_slice_add")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice", description: "Work unit" },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getSlice(result.details.sliceId);
expect(result.content[0].text).toContain("Added");
expect(persisted?.title).toBe("Slice");
expect(persisted?.description).toBe("Work unit");
});
});
describe("kb_feature_add", () => {
it("creates a feature in the mission store", async () => {
const missionTool = api.tools.get("kb_mission_create")!;
const milestoneTool = api.tools.get("kb_milestone_add")!;
const sliceTool = api.tools.get("kb_slice_add")!;
const featureTool = api.tools.get("kb_feature_add")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature", description: "Deliverable", acceptanceCriteria: "Must pass" },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getFeature(result.details.featureId);
expect(result.content[0].text).toContain("Added");
expect(persisted?.title).toBe("Feature");
expect(persisted?.acceptanceCriteria).toBe("Must pass");
});
});
describe("kb_slice_activate", () => {
it("returns error when slice is already active", async () => {
const missionTool = api.tools.get("kb_mission_create")!;
const milestoneTool = api.tools.get("kb_milestone_add")!;
const sliceTool = api.tools.get("kb_slice_add")!;
const activateTool = api.tools.get("kb_slice_activate")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
await activateTool.execute("sl2", { id: slice.details.sliceId }, undefined, undefined, makeCtx(tmpDir));
const result = await activateTool.execute("sl3", { id: slice.details.sliceId }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("not pending");
});
it("activates slice and updates status", async () => {
// This test would need a full mission hierarchy setup
// For now, verify the tool exists and has correct parameters
const tool = api.tools.get("kb_slice_activate")!;
expect(tool).toBeDefined();
expect(tool.parameters.properties.id).toBeDefined();
const missionTool = api.tools.get("kb_mission_create")!;
const milestoneTool = api.tools.get("kb_milestone_add")!;
const sliceTool = api.tools.get("kb_slice_add")!;
const activateTool = api.tools.get("kb_slice_activate")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await activateTool.execute(
"sl2",
{ id: slice.details.sliceId },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getSlice(slice.details.sliceId);
expect(result.content[0].text).toContain("Activated");
expect(result.details.status).toBe("active");
expect(persisted?.status).toBe("active");
});
});
describe("kb_feature_link_task", () => {
it("returns error when task is missing", async () => {
const missionTool = api.tools.get("kb_mission_create")!;
const milestoneTool = api.tools.get("kb_milestone_add")!;
const sliceTool = api.tools.get("kb_slice_add")!;
const featureTool = api.tools.get("kb_feature_add")!;
const linkTool = api.tools.get("kb_feature_link_task")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const feature = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await linkTool.execute(
"l0",
{ featureId: feature.details.featureId, taskId: "FN-999" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Task FN-999 not found");
});
it("links feature to task", async () => {
// Create a task first
const missionTool = api.tools.get("kb_mission_create")!;
const milestoneTool = api.tools.get("kb_milestone_add")!;
const sliceTool = api.tools.get("kb_slice_add")!;
const featureTool = api.tools.get("kb_feature_add")!;
const createTaskTool = api.tools.get("kb_task_create")!;
const linkTool = api.tools.get("kb_feature_link_task")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const feature = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
const taskResult = await createTaskTool.execute(
"c1",
"t1",
{ description: "Task for feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
// Verify the tool exists with correct parameters
const tool = api.tools.get("kb_feature_link_task")!;
expect(tool).toBeDefined();
expect(tool.parameters.properties.featureId).toBeDefined();
expect(tool.parameters.properties.taskId).toBeDefined();
const result = await linkTool.execute(
"l1",
{ featureId: feature.details.featureId, taskId: taskResult.details.taskId },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const missionStore = store.getMissionStore();
const persisted = missionStore.getFeature(feature.details.featureId);
const linkedTask = await store.getTask(taskResult.details.taskId);
expect(result.content[0].text).toContain(taskResult.details.taskId);
expect(result.details.taskId).toBe(taskResult.details.taskId);
expect(persisted?.taskId).toBe(taskResult.details.taskId);
expect(persisted?.status).toBe("triaged");
expect(linkedTask.sliceId).toBe(slice.details.sliceId);
});
});
});

View File

@@ -0,0 +1,179 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const commandMocks = vi.hoisted(() => ({
runDashboard: vi.fn(),
runTaskCreate: vi.fn(),
runTaskList: vi.fn(),
runTaskMove: vi.fn(),
runTaskMerge: vi.fn(),
runTaskUpdate: vi.fn(),
runTaskLog: vi.fn(),
runTaskLogs: vi.fn(),
runTaskShow: vi.fn(),
runTaskAttach: vi.fn(),
runTaskPause: vi.fn(),
runTaskUnpause: vi.fn(),
runTaskImportFromGitHub: vi.fn(),
runTaskDuplicate: vi.fn(),
runTaskArchive: vi.fn(),
runTaskUnarchive: vi.fn(),
runTaskRefine: vi.fn(),
runTaskPlan: vi.fn(),
runTaskDelete: vi.fn(),
runTaskRetry: vi.fn(),
runTaskComment: vi.fn(),
runTaskComments: vi.fn(),
runTaskSteer: vi.fn(),
runTaskPrCreate: vi.fn(),
runSettingsShow: vi.fn(),
runSettingsSet: vi.fn(),
runSettingsExport: vi.fn(),
runSettingsImport: vi.fn(),
runGitStatus: vi.fn(),
runGitFetch: vi.fn(),
runGitPull: vi.fn(),
runGitPush: vi.fn(),
runBackupCreate: vi.fn(),
runBackupList: vi.fn(),
runBackupRestore: vi.fn(),
runBackupCleanup: vi.fn(),
runMissionCreate: vi.fn(),
runMissionList: vi.fn(),
runMissionShow: vi.fn(),
runMissionDelete: vi.fn(),
runMissionActivateSlice: vi.fn(),
runProjectList: vi.fn(),
runProjectAdd: vi.fn(),
runProjectRemove: vi.fn(),
runProjectInfo: vi.fn(),
}));
vi.mock("./commands/dashboard.js", () => ({ runDashboard: commandMocks.runDashboard }));
vi.mock("./commands/task.js", () => ({
runTaskCreate: commandMocks.runTaskCreate,
runTaskList: commandMocks.runTaskList,
runTaskMove: commandMocks.runTaskMove,
runTaskMerge: commandMocks.runTaskMerge,
runTaskUpdate: commandMocks.runTaskUpdate,
runTaskLog: commandMocks.runTaskLog,
runTaskLogs: commandMocks.runTaskLogs,
runTaskShow: commandMocks.runTaskShow,
runTaskAttach: commandMocks.runTaskAttach,
runTaskPause: commandMocks.runTaskPause,
runTaskUnpause: commandMocks.runTaskUnpause,
runTaskImportFromGitHub: commandMocks.runTaskImportFromGitHub,
runTaskDuplicate: commandMocks.runTaskDuplicate,
runTaskArchive: commandMocks.runTaskArchive,
runTaskUnarchive: commandMocks.runTaskUnarchive,
runTaskRefine: commandMocks.runTaskRefine,
runTaskPlan: commandMocks.runTaskPlan,
runTaskDelete: commandMocks.runTaskDelete,
runTaskRetry: commandMocks.runTaskRetry,
runTaskComment: commandMocks.runTaskComment,
runTaskComments: commandMocks.runTaskComments,
runTaskSteer: commandMocks.runTaskSteer,
runTaskPrCreate: commandMocks.runTaskPrCreate,
}));
vi.mock("./commands/settings.js", () => ({
runSettingsShow: commandMocks.runSettingsShow,
runSettingsSet: commandMocks.runSettingsSet,
}));
vi.mock("./commands/settings-export.js", () => ({ runSettingsExport: commandMocks.runSettingsExport }));
vi.mock("./commands/settings-import.js", () => ({ runSettingsImport: commandMocks.runSettingsImport }));
vi.mock("./commands/git.js", () => ({
runGitStatus: commandMocks.runGitStatus,
runGitFetch: commandMocks.runGitFetch,
runGitPull: commandMocks.runGitPull,
runGitPush: commandMocks.runGitPush,
}));
vi.mock("./commands/backup.js", () => ({
runBackupCreate: commandMocks.runBackupCreate,
runBackupList: commandMocks.runBackupList,
runBackupRestore: commandMocks.runBackupRestore,
runBackupCleanup: commandMocks.runBackupCleanup,
}));
vi.mock("./commands/mission.js", () => ({
runMissionCreate: commandMocks.runMissionCreate,
runMissionList: commandMocks.runMissionList,
runMissionShow: commandMocks.runMissionShow,
runMissionDelete: commandMocks.runMissionDelete,
runMissionActivateSlice: commandMocks.runMissionActivateSlice,
}));
vi.mock("./commands/project.js", () => ({
runProjectList: commandMocks.runProjectList,
runProjectAdd: commandMocks.runProjectAdd,
runProjectRemove: commandMocks.runProjectRemove,
runProjectInfo: commandMocks.runProjectInfo,
}));
const originalArgv = process.argv;
const originalExit = process.exit;
const originalEnvProject = process.env.FN_PROJECT;
let importCounter = 0;
async function runBin(args: string[]) {
process.argv = ["node", "bin.ts", ...args];
importCounter += 1;
if (importCounter === 1) {
await import("./bin.ts?test=1");
} else if (importCounter === 2) {
await import("./bin.ts?test=2");
} else if (importCounter === 3) {
await import("./bin.ts?test=3");
} else if (importCounter === 4) {
await import("./bin.ts?test=4");
} else {
await import("./bin.ts?test=5");
}
}
describe("bin mission command integration", () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.FN_PROJECT;
process.exit = vi.fn(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as typeof process.exit);
});
afterEach(() => {
process.argv = originalArgv;
process.exit = originalExit;
if (originalEnvProject === undefined) {
delete process.env.FN_PROJECT;
} else {
process.env.FN_PROJECT = originalEnvProject;
}
});
it("routes mission create with multi-word description and project flag", async () => {
await runBin(["mission", "create", "Test Mission", "Detailed", "mission", "description", "--project", "demo"]);
expect(commandMocks.runMissionCreate).toHaveBeenCalledWith(
"Test Mission",
"Detailed mission description",
"demo",
);
});
it("routes mission list alias", async () => {
await runBin(["mission", "ls"]);
expect(commandMocks.runMissionList).toHaveBeenCalledWith(undefined);
});
it("routes mission show alias", async () => {
await runBin(["mission", "info", "M-001"]);
expect(commandMocks.runMissionShow).toHaveBeenCalledWith("M-001", undefined);
});
it("routes mission delete with force flag", async () => {
await runBin(["mission", "delete", "M-001", "--force"]);
expect(commandMocks.runMissionDelete).toHaveBeenCalledWith("M-001", true, undefined);
});
it("routes mission activate-slice", async () => {
await runBin(["mission", "activate-slice", "SL-001"]);
expect(commandMocks.runMissionActivateSlice).toHaveBeenCalledWith("SL-001", undefined);
});
});

View File

@@ -101,10 +101,10 @@ Usage:
fn backup --list List all database backups
fn backup --restore <file> Restore database from a backup file
fn backup --cleanup Remove old backups exceeding retention limit
fn mission create [title] [description] Create a new mission
fn mission create [title] [description...] Create a new mission
fn mission list List all missions
fn mission show <id> Show mission with hierarchy
fn mission delete <id> [--force] Delete mission
fn mission delete <id> [--force] Delete a mission
fn mission activate-slice <slice-id> Activate a pending slice
Options:
@@ -711,21 +711,8 @@ async function main() {
const subcommand = args[1];
switch (subcommand) {
case "create": {
const titleParts: string[] = [];
for (let i = 2; i < args.length; i++) {
titleParts.push(args[i]);
}
const fullInput = titleParts.join(" ");
// Split on first space to separate title and description if provided
const firstSpaceIdx = fullInput.indexOf(" ");
let title: string | undefined;
let description: string | undefined;
if (firstSpaceIdx > 0) {
title = fullInput.slice(0, firstSpaceIdx);
description = fullInput.slice(firstSpaceIdx + 1).trim();
} else {
title = fullInput || undefined;
}
const title = args[2];
const description = args.length > 3 ? args.slice(3).join(" ") : undefined;
await runMissionCreate(title, description, projectName);
break;
}

View File

@@ -1,10 +1,10 @@
import { MissionStore, type Mission, type Slice, type MissionWithHierarchy, type MilestoneWithSlices, type SliceWithFeatures, type MissionCreateInput, type MilestoneCreateInput, type SliceCreateInput, type FeatureCreateInput } from "@fusion/core";
import { type MissionStatus, type MilestoneStatus, type SliceStatus, type FeatureStatus } from "@fusion/core";
import { createInterface } from "node:readline/promises";
import { getStore } from "../project-resolver.js";
// ── Status Labels for Display ───────────────────────────────────────────────
const MISSION_STATUS_LABELS: Record<string, string> = {
const MISSION_STATUS_LABELS: Record<MissionStatus, string> = {
planning: "Planning",
active: "Active",
blocked: "Blocked",
@@ -12,26 +12,54 @@ const MISSION_STATUS_LABELS: Record<string, string> = {
archived: "Archived",
};
const MILESTONE_STATUS_LABELS: Record<string, string> = {
const MILESTONE_STATUS_LABELS: Record<MilestoneStatus, string> = {
planning: "Planning",
active: "Active",
blocked: "Blocked",
complete: "Complete",
};
const SLICE_STATUS_LABELS: Record<string, string> = {
const SLICE_STATUS_LABELS: Record<SliceStatus, string> = {
pending: "Pending",
active: "Active",
complete: "Complete",
};
const FEATURE_STATUS_LABELS: Record<string, string> = {
const FEATURE_STATUS_LABELS: Record<FeatureStatus, string> = {
defined: "Defined",
triaged: "Triaged",
"in-progress": "In Progress",
done: "Done",
};
async function promptForTitleAndDescription(
titleArg: string | undefined,
titlePrompt: string,
descriptionPrompt: string,
): Promise<{ title: string; description?: string }> {
let title = titleArg;
let description: string | undefined;
if (!title) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
title = await rl.question(titlePrompt);
if (!title?.trim()) {
rl.close();
console.error("Title is required");
process.exit(1);
}
description = await rl.question(descriptionPrompt);
rl.close();
}
return {
title: title.trim(),
description: description?.trim() || undefined,
};
}
// ── Mission Commands ─────────────────────────────────────────────────────────
/**
@@ -39,30 +67,20 @@ const FEATURE_STATUS_LABELS: Record<string, string> = {
* If arguments are omitted, prompts interactively.
*/
export async function runMissionCreate(titleArg?: string, descriptionArg?: string, projectName?: string) {
let title = titleArg;
let description = descriptionArg;
// Interactive prompts if title not provided
if (!title) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
title = await rl.question("Mission title: ");
if (!title?.trim()) {
rl.close();
console.error("Title is required");
process.exit(1);
}
description = await rl.question("Mission description (optional): ");
rl.close();
}
const store = await getStore({ project: projectName });
const missionStore = store.getMissionStore();
const { title, description } = titleArg
? { title: titleArg.trim(), description: descriptionArg?.trim() || undefined }
: await promptForTitleAndDescription(
titleArg,
"Mission title: ",
"Mission description (optional): ",
);
const mission = missionStore.createMission({
title: title.trim(),
description: description?.trim() || undefined,
title,
description,
});
console.log();
@@ -254,54 +272,35 @@ export async function runMissionActivateSlice(id: string, projectName?: string)
console.log();
}
// ── Milestone Commands ──────────────────────────────────────────────────────
/**
* Add a milestone to a mission.
*/
export async function runMilestoneAdd(
missionId: string,
titleArg?: string,
descriptionArg?: string,
projectName?: string
projectName?: string,
) {
if (!missionId) {
console.error("Usage: fn mission add-milestone <mission-id> [title] [description]");
process.exit(1);
}
let title = titleArg;
let description = descriptionArg;
// Interactive prompts if title not provided
if (!title) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
title = await rl.question("Milestone title: ");
if (!title?.trim()) {
rl.close();
console.error("Title is required");
process.exit(1);
}
description = await rl.question("Milestone description (optional): ");
rl.close();
}
const store = await getStore({ project: projectName });
const missionStore = store.getMissionStore();
// Check if mission exists
const mission = missionStore.getMission(missionId);
if (!mission) {
console.error(`✗ Mission ${missionId} not found`);
process.exit(1);
}
const milestone = missionStore.addMilestone(missionId, {
title: title.trim(),
description: description?.trim() || undefined,
});
const { title, description } = titleArg
? { title: titleArg.trim(), description: descriptionArg?.trim() || undefined }
: await promptForTitleAndDescription(
titleArg,
"Milestone title: ",
"Milestone description (optional): ",
);
const milestone = missionStore.addMilestone(missionId, { title, description });
console.log();
console.log(` ✓ Added ${milestone.id}: "${milestone.title}" to ${missionId}`);
@@ -309,54 +308,35 @@ export async function runMilestoneAdd(
console.log();
}
// ── Slice Commands ───────────────────────────────────────────────────────────
/**
* Add a slice to a milestone.
*/
export async function runSliceAdd(
milestoneId: string,
titleArg?: string,
descriptionArg?: string,
projectName?: string
projectName?: string,
) {
if (!milestoneId) {
console.error("Usage: fn mission add-slice <milestone-id> [title] [description]");
process.exit(1);
}
let title = titleArg;
let description = descriptionArg;
// Interactive prompts if title not provided
if (!title) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
title = await rl.question("Slice title: ");
if (!title?.trim()) {
rl.close();
console.error("Title is required");
process.exit(1);
}
description = await rl.question("Slice description (optional): ");
rl.close();
}
const store = await getStore({ project: projectName });
const missionStore = store.getMissionStore();
// Check if milestone exists
const milestone = missionStore.getMilestone(milestoneId);
if (!milestone) {
console.error(`✗ Milestone ${milestoneId} not found`);
process.exit(1);
}
const slice = missionStore.addSlice(milestoneId, {
title: title.trim(),
description: description?.trim() || undefined,
});
const { title, description } = titleArg
? { title: titleArg.trim(), description: descriptionArg?.trim() || undefined }
: await promptForTitleAndDescription(
titleArg,
"Slice title: ",
"Slice description (optional): ",
);
const slice = missionStore.addSlice(milestoneId, { title, description });
console.log();
console.log(` ✓ Added ${slice.id}: "${slice.title}" to ${milestoneId}`);
@@ -364,57 +344,50 @@ export async function runSliceAdd(
console.log();
}
// ── Feature Commands ─────────────────────────────────────────────────────────
/**
* Add a feature to a slice.
*/
export async function runFeatureAdd(
sliceId: string,
titleArg?: string,
descriptionArg?: string,
acceptanceCriteriaArg?: string,
projectName?: string
projectName?: string,
) {
if (!sliceId) {
console.error("Usage: fn mission add-feature <slice-id> [title] [description] [--acceptance-criteria <criteria>]");
process.exit(1);
}
let title = titleArg;
let description = descriptionArg;
let acceptanceCriteria = acceptanceCriteriaArg;
const store = await getStore({ project: projectName });
const missionStore = store.getMissionStore();
const slice = missionStore.getSlice(sliceId);
if (!slice) {
console.error(`✗ Slice ${sliceId} not found`);
process.exit(1);
}
let title = titleArg;
let description = descriptionArg?.trim() || undefined;
let acceptanceCriteria = acceptanceCriteriaArg?.trim() || undefined;
// Interactive prompts if title not provided
if (!title) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
title = await rl.question("Feature title: ");
if (!title?.trim()) {
rl.close();
console.error("Title is required");
process.exit(1);
}
description = await rl.question("Feature description (optional): ");
acceptanceCriteria = await rl.question("Acceptance criteria (optional): ");
description = (await rl.question("Feature description (optional): ")).trim() || undefined;
acceptanceCriteria = (await rl.question("Acceptance criteria (optional): ")).trim() || undefined;
rl.close();
}
const store = await getStore({ project: projectName });
const missionStore = store.getMissionStore();
// Check if slice exists
const slice = missionStore.getSlice(sliceId);
if (!slice) {
console.error(`✗ Slice ${sliceId} not found`);
process.exit(1);
}
const feature = missionStore.addFeature(sliceId, {
title: title.trim(),
description: description?.trim() || undefined,
acceptanceCriteria: acceptanceCriteria?.trim() || undefined,
description,
acceptanceCriteria,
});
console.log();
@@ -426,9 +399,6 @@ export async function runFeatureAdd(
console.log();
}
/**
* Link a feature to a task.
*/
export async function runFeatureLinkTask(featureId: string, taskId: string, projectName?: string) {
if (!featureId || !taskId) {
console.error("Usage: fn mission link-feature <feature-id> <task-id>");
@@ -437,15 +407,13 @@ export async function runFeatureLinkTask(featureId: string, taskId: string, proj
const store = await getStore({ project: projectName });
const missionStore = store.getMissionStore();
// Check if feature exists
const feature = missionStore.getFeature(featureId);
if (!feature) {
console.error(`✗ Feature ${featureId} not found`);
process.exit(1);
}
// Check if task exists
try {
await store.getTask(taskId);
} catch {
@@ -454,8 +422,10 @@ export async function runFeatureLinkTask(featureId: string, taskId: string, proj
}
const updated = missionStore.linkFeatureToTask(featureId, taskId);
console.log();
console.log(` ✓ Linked ${updated.id}: "${updated.title}" → ${taskId}`);
console.log(` Status: ${FEATURE_STATUS_LABELS[updated.status]}`);
console.log();
}

View File

@@ -997,6 +997,9 @@ export default function kbExtension(pi: ExtensionAPI) {
description: Type.Optional(
Type.String({ description: "Detailed mission objectives and context" })
),
autoAdvance: Type.Optional(
Type.Boolean({ description: "Automatically activate the next pending slice when the current slice completes" })
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -1008,14 +1011,25 @@ export default function kbExtension(pi: ExtensionAPI) {
description: params.description?.trim(),
});
if (params.autoAdvance !== undefined) {
missionStore.updateMission(mission.id, { autoAdvance: params.autoAdvance });
}
const createdMission = missionStore.getMission(mission.id)!;
return {
content: [
{
type: "text",
text: `Created ${mission.id}: ${mission.title}\nStatus: ${mission.status}`,
text: `Created ${createdMission.id}: ${createdMission.title}\nStatus: ${createdMission.status}${createdMission.autoAdvance ? "\nAuto-advance: enabled" : ""}`,
},
],
details: { missionId: mission.id, title: mission.title, status: mission.status },
details: {
missionId: createdMission.id,
title: createdMission.title,
status: createdMission.status,
autoAdvance: createdMission.autoAdvance ?? false,
},
};
},
});
@@ -1047,12 +1061,24 @@ export default function kbExtension(pi: ExtensionAPI) {
};
}
const summary = {
planning: missions.filter((mission) => mission.status === "planning").length,
active: missions.filter((mission) => mission.status === "active").length,
blocked: missions.filter((mission) => mission.status === "blocked").length,
complete: missions.filter((mission) => mission.status === "complete").length,
archived: missions.filter((mission) => mission.status === "archived").length,
};
const lines: string[] = [];
lines.push(`Missions (${missions.length}):\n`);
lines.push(`Missions (${missions.length})`);
lines.push(
`Summary: active ${summary.active}, planning ${summary.planning}, blocked ${summary.blocked}, complete ${summary.complete}, archived ${summary.archived}\n`,
);
for (const mission of missions) {
const statusIcon = mission.status === "complete" ? "✓" : mission.status === "active" ? "●" : "○";
lines.push(` ${statusIcon} ${mission.id}: ${mission.title} (${mission.status})`);
const statusIcon = mission.status === "complete" ? "✓" : mission.status === "active" ? "●" : mission.status === "blocked" ? "⚠" : "○";
const autoAdvance = mission.autoAdvance ? " · auto-advance" : "";
lines.push(` ${statusIcon} ${mission.id}: ${mission.title} (${mission.status}${autoAdvance})`);
}
return {
@@ -1401,6 +1427,7 @@ export default function kbExtension(pi: ExtensionAPI) {
}
const updated = missionStore.linkFeatureToTask(params.featureId, params.taskId);
await store.updateTask(params.taskId, { sliceId: feature.sliceId });
return {
content: [

View File

@@ -255,6 +255,7 @@ CREATE TABLE IF NOT EXISTS missions (
description TEXT,
status TEXT NOT NULL,
interviewState TEXT NOT NULL,
autoAdvance INTEGER DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
@@ -395,6 +396,7 @@ export class Database {
if (version < 5) {
this.applyMigration(5, () => {
this.addColumnIfMissing("missions", "autoAdvance", "INTEGER DEFAULT 0");
this.migrateLegacyCommentsToUnifiedComments();
});
}

View File

@@ -93,6 +93,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
description: row.description || undefined,
status: row.status as MissionStatus,
interviewState: row.interviewState as InterviewState,
autoAdvance: Boolean(row.autoAdvance),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
@@ -169,19 +170,21 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
description: input.description,
status: "planning",
interviewState: "not_started",
autoAdvance: false,
createdAt: now,
updatedAt: now,
};
this.db.prepare(`
INSERT INTO missions (id, title, description, status, interviewState, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO missions (id, title, description, status, interviewState, autoAdvance, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
mission.id,
mission.title,
mission.description ?? null,
mission.status,
mission.interviewState,
mission.autoAdvance ? 1 : 0,
mission.createdAt,
mission.updatedAt,
);
@@ -270,6 +273,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
description = ?,
status = ?,
interviewState = ?,
autoAdvance = ?,
updatedAt = ?
WHERE id = ?
`).run(
@@ -277,6 +281,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
updated.description ?? null,
updated.status,
updated.interviewState,
updated.autoAdvance ? 1 : 0,
updated.updatedAt,
updated.id,
);

View File

@@ -113,6 +113,16 @@ function createMockStore() {
return store as any;
}
function createMockMissionStore(overrides: Record<string, unknown> = {}) {
return {
getFeatureByTaskId: vi.fn(),
getSlice: vi.fn(),
computeSliceStatus: vi.fn(),
updateFeatureStatus: vi.fn(),
...overrides,
} as any;
}
describe("TaskExecutor with semaphore", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -4027,6 +4037,144 @@ describe("Workflow Steps Execution", () => {
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
});
it("marks linked mission feature done when task reaches in-review", async () => {
const store = createMockStore();
const missionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001" }),
getSlice: vi.fn().mockReturnValueOnce({ id: "SL-001", status: "active" }).mockReturnValueOnce({ id: "SL-001", status: "complete" }),
computeSliceStatus: vi.fn().mockReturnValue("complete"),
updateFeatureStatus: vi.fn(),
});
const onSliceComplete = vi.fn();
store.getTask.mockResolvedValue({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
sliceId: "SL-001",
dependencies: [],
steps: [{ name: "Preflight", status: "pending" }],
currentStep: 0,
log: [],
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
createAgentWithTaskDone();
const executor = new TaskExecutor(store, "/tmp/test", { missionStore, onSliceComplete });
await executor.execute({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
sliceId: "SL-001",
dependencies: [],
steps: [{ name: "Preflight", status: "pending" }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as any);
expect(missionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done");
expect(onSliceComplete).toHaveBeenCalledWith(expect.objectContaining({ id: "SL-001", status: "complete" }));
expect(store.logEntry).toHaveBeenCalledWith(
"FN-001",
expect.stringContaining("Slice SL-001 completed"),
"Mission feature implementation ready for review",
);
});
it("skips mission updates when linked feature slice does not match task sliceId", async () => {
const store = createMockStore();
const missionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-OTHER" }),
updateFeatureStatus: vi.fn(),
});
store.getTask.mockResolvedValue({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
sliceId: "SL-001",
dependencies: [],
steps: [{ name: "Preflight", status: "pending" }],
currentStep: 0,
log: [],
prompt: "# test\n## Steps\n### Step 0: Preflight\n- [ ] check",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
createAgentWithTaskDone();
const executor = new TaskExecutor(store, "/tmp/test", { missionStore });
await executor.execute({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
sliceId: "SL-001",
dependencies: [],
steps: [{ name: "Preflight", status: "pending" }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as any);
expect(missionStore.updateFeatureStatus).not.toHaveBeenCalled();
expect(missionStore.computeSliceStatus).not.toHaveBeenCalled();
});
it("does not update mission progress when agent finishes without task_done", async () => {
const store = createMockStore();
const missionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001" }),
computeSliceStatus: vi.fn(),
updateFeatureStatus: vi.fn(),
});
const onSliceComplete = vi.fn();
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
subscribe: vi.fn(),
on: vi.fn(),
sessionManager: { getLeafId: vi.fn().mockReturnValue("leaf-1") },
state: {},
},
} as any);
const executor = new TaskExecutor(store, "/tmp/test", { missionStore, onSliceComplete });
await executor.execute({
id: "FN-001",
title: "Test",
description: "Test task",
column: "in-progress",
sliceId: "SL-001",
dependencies: [],
steps: [{ name: "Preflight", status: "pending" }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as any);
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-review");
expect(missionStore.updateFeatureStatus).not.toHaveBeenCalled();
expect(missionStore.computeSliceStatus).not.toHaveBeenCalled();
expect(onSliceComplete).not.toHaveBeenCalled();
});
it("skips workflow steps with no prompt", async () => {
const store = createMockStore();

View File

@@ -1,7 +1,7 @@
import { execSync } from "node:child_process";
import { join } from "node:path";
import { existsSync } from "node:fs";
import type { TaskStore, Task, TaskDetail, StepStatus, Settings, WorkflowStep } from "@fusion/core";
import type { TaskStore, Task, TaskDetail, StepStatus, Settings, WorkflowStep, MissionStore, Slice } from "@fusion/core";
import { findWorktreeUser } from "./merger.js";
import { generateWorktreeName, slugify } from "./worktree-names.js";
import { Type, type Static } from "@mariozechner/pi-ai";
@@ -156,6 +156,8 @@ export interface TaskExecutorOptions {
usageLimitPauser?: UsageLimitPauser;
/** Stuck task detector — monitors agent sessions for stagnation and triggers recovery. */
stuckTaskDetector?: StuckTaskDetector;
missionStore?: MissionStore;
onSliceComplete?: (slice: Slice) => void;
onStart?: (task: Task, worktreePath: string) => void;
onComplete?: (task: Task) => void;
onError?: (task: Task, error: Error) => void;

View File

@@ -125,10 +125,12 @@ export class InProcessRuntime
this.globalSemaphore = new AgentSemaphore(() => globalLimit);
// 4. Initialize Scheduler
const missionStore = this.taskStore.getMissionStore();
this.scheduler = new Scheduler(this.taskStore, {
maxConcurrent: this.config.maxConcurrent,
maxWorktrees: this.config.maxWorktrees,
semaphore: this.globalSemaphore,
missionStore,
onSchedule: (task) => {
this.recordActivity();
runtimeLog.log(`Scheduled task ${task.id}`);
@@ -144,6 +146,10 @@ export class InProcessRuntime
pool: this.worktreePool,
usageLimitPauser: this.usageLimitPauser,
stuckTaskDetector: this.stuckTaskDetector,
missionStore,
onSliceComplete: (slice) => {
void this.scheduler.onSliceComplete(slice);
},
onStart: (task, worktreePath) => {
this.recordActivity();
runtimeLog.log(`Started executing task ${task.id} in ${worktreePath}`);

View File

@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { PrMonitor } from "./pr-monitor.js";
import { Scheduler, pathsOverlap } from "./scheduler.js";
import { AgentSemaphore } from "./concurrency.js";
import type { TaskStore, Task } from "@fusion/core";
@@ -263,6 +264,30 @@ describe("Scheduler", () => {
});
describe("filesystem validation", () => {
it("validates tasks using the .kb task directory layout", async () => {
const todoTask = createMockTask({ id: "FN-010", column: "todo" });
const moveTask = vi.fn().mockResolvedValue(undefined);
const updateTask = vi.fn().mockResolvedValue(undefined);
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([todoTask]),
moveTask,
updateTask,
});
vi.mocked(existsSync).mockImplementation((path) => {
const value = String(path);
return value.includes(".kb/tasks/FN-010") || value.includes("PROMPT.md");
});
vi.mocked(readFile).mockResolvedValue("# Prompt\n" as any);
const scheduler = new Scheduler(store);
scheduler.start();
await scheduler.schedule();
expect(moveTask).toHaveBeenCalledWith("FN-010", "in-progress");
expect(moveTask).not.toHaveBeenCalledWith("FN-010", "triage");
});
it("moves task to triage when task directory is missing", async () => {
const tasks = [
createMockTask({ id: "FN-001", column: "todo", dependencies: [] }),
@@ -456,6 +481,29 @@ describe("Scheduler", () => {
});
});
describe("pr monitoring", () => {
it("stops monitoring when task moves out of in-review based on from column", () => {
const prMonitor = {
startMonitoring: vi.fn(),
stopMonitoring: vi.fn(),
updatePrInfo: vi.fn(),
getTrackedPrs: vi.fn().mockReturnValue(new Map()),
stopAll: vi.fn(),
} as unknown as PrMonitor;
const store = createMockStore();
new Scheduler(store, { prMonitor });
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", column: "done", prInfo: { status: "open" } as any });
movedHandler({ task, from: "in-review", to: "done" });
expect(prMonitor.stopMonitoring).toHaveBeenCalledWith("FN-001");
});
});
describe("mission integration", () => {
// Helper to create mock MissionStore
function createMockMissionStore(overrides = {}) {
@@ -466,6 +514,7 @@ describe("Scheduler", () => {
getMilestone: vi.fn(),
computeSliceStatus: vi.fn(),
getMission: vi.fn(),
getMissionWithHierarchy: vi.fn(),
findNextPendingSlice: vi.fn(),
activateSlice: vi.fn(),
...overrides,
@@ -495,7 +544,8 @@ describe("Scheduler", () => {
// Simulate task moving to in-progress with sliceId
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
await movedHandler({ task, to: "in-progress" });
movedHandler({ task, to: "in-progress" });
await Promise.resolve();
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "in-progress");
@@ -514,7 +564,8 @@ describe("Scheduler", () => {
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" });
movedHandler({ task, to: "in-progress" });
await Promise.resolve();
expect(mockMissionStore.getFeatureByTaskId).toHaveBeenCalledWith("FN-001");
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
@@ -539,37 +590,53 @@ describe("Scheduler", () => {
// Simulate task moving to done with sliceId
const task = createMockTask({ id: "FN-001", sliceId: "SL-001" });
await movedHandler({ task, to: "done" });
movedHandler({ task, to: "done" });
await Promise.resolve();
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 missionHierarchy = {
id: "M-001",
status: "active",
milestones: [
{
id: "MS-001",
dependencies: [],
slices: [
{ id: "SL-001", status: "complete" },
{ id: "SL-002", status: "pending" },
],
},
],
};
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" }),
getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autoAdvance: true }),
getMissionWithHierarchy: vi.fn().mockReturnValue(missionHierarchy),
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" });
movedHandler({ task, to: "done" });
await Promise.resolve();
await Promise.resolve();
expect(mockMissionStore.computeSliceStatus).toHaveBeenCalledWith("SL-001");
expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001");
expect(mockMissionStore.findNextPendingSlice).toHaveBeenCalledWith("M-001");
expect(mockMissionStore.getMissionWithHierarchy).toHaveBeenCalledWith("M-001");
expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002");
});
@@ -580,21 +647,64 @@ describe("Scheduler", () => {
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 }),
getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", 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" });
movedHandler({ task, to: "done" });
await Promise.resolve();
expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001");
expect(mockMissionStore.activateSlice).not.toHaveBeenCalled();
});
it("skips mission progression when task sliceId mismatches linked feature sliceId", async () => {
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-OTHER" }),
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" });
movedHandler({ task, from: "in-progress", to: "done" });
await Promise.resolve();
expect(mockMissionStore.updateFeatureStatus).not.toHaveBeenCalled();
expect(mockMissionStore.getSlice).not.toHaveBeenCalled();
});
it("does not auto-advance when mission is not active", 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", status: "planning", autoAdvance: true }),
});
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" });
movedHandler({ task, to: "done" });
await Promise.resolve();
expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001");
expect(mockMissionStore.findNextPendingSlice).not.toHaveBeenCalled();
expect(mockMissionStore.activateSlice).not.toHaveBeenCalled();
});
@@ -611,16 +721,32 @@ describe("Scheduler", () => {
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" });
movedHandler({ task, to: "done" });
await Promise.resolve();
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 nextSlice = { id: "SL-002", status: "pending", orderIndex: 1 };
const mockMissionStore = createMockMissionStore({
findNextPendingSlice: vi.fn().mockReturnValue(nextSlice),
getMissionWithHierarchy: vi.fn().mockReturnValue({
id: "M-001",
status: "active",
milestones: [
{
id: "MS-001",
orderIndex: 0,
dependencies: [],
slices: [
nextSlice,
{ id: "SL-003", status: "pending", orderIndex: 2 },
{ id: "SL-001", status: "complete", orderIndex: 0 },
],
},
],
}),
activateSlice: vi.fn().mockReturnValue({ ...nextSlice, status: "active" }),
});
@@ -629,14 +755,77 @@ describe("Scheduler", () => {
const result = await scheduler.activateNextPendingSlice("M-001");
expect(mockMissionStore.findNextPendingSlice).toHaveBeenCalledWith("M-001");
expect(mockMissionStore.getMissionWithHierarchy).toHaveBeenCalledWith("M-001");
expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002");
expect(result).toEqual({ id: "SL-002", status: "active" });
});
it("activateNextPendingSlice skips milestones with incomplete dependencies", async () => {
const mockMissionStore = createMockMissionStore({
getMissionWithHierarchy: vi.fn().mockReturnValue({
id: "M-001",
status: "active",
milestones: [
{
id: "MS-001",
orderIndex: 0,
status: "planning",
dependencies: ["MS-999"],
slices: [{ id: "SL-001", status: "pending", orderIndex: 0 }],
},
{
id: "MS-002",
orderIndex: 1,
status: "planning",
dependencies: [],
slices: [{ id: "SL-002", status: "pending", orderIndex: 0 }],
},
],
}),
activateSlice: vi.fn().mockReturnValue({ id: "SL-002", status: "active" }),
});
const store = createMockStore();
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
const result = await scheduler.activateNextPendingSlice("M-001");
expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002");
expect(result).toEqual({ id: "SL-002", status: "active" });
});
it("activateNextPendingSlice returns null when mission is not active", async () => {
const mockMissionStore = createMockMissionStore({
getMissionWithHierarchy: vi.fn().mockReturnValue({
id: "M-001",
status: "planning",
milestones: [],
}),
});
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();
});
it("activateNextPendingSlice returns null when no pending slices", async () => {
const mockMissionStore = createMockMissionStore({
findNextPendingSlice: vi.fn().mockReturnValue(undefined),
getMissionWithHierarchy: vi.fn().mockReturnValue({
id: "M-001",
status: "active",
milestones: [
{
id: "MS-001",
orderIndex: 0,
dependencies: [],
slices: [{ id: "SL-001", status: "complete", orderIndex: 0 }],
},
],
}),
});
const store = createMockStore();

View File

@@ -128,7 +128,7 @@ export class Scheduler {
* 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 }) => {
this.store.on("task:moved", ({ task, from, to }) => {
// PR Monitoring
if (this.options.prMonitor) {
if (to === "in-review" && task.prInfo) {
@@ -137,7 +137,7 @@ export class Scheduler {
if (repo) {
this.options.prMonitor.startMonitoring(task.id, repo.owner, repo.repo, task.prInfo);
}
} else if (task.column === "in-review" && to !== "in-review") {
} else if (from === "in-review" && to !== "in-review") {
// Task moved out of in-review, stop monitoring
this.options.prMonitor.stopMonitoring(task.id);
@@ -148,13 +148,9 @@ export class Scheduler {
}
}
// 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);
}
// Mission progress tracking: when task with sliceId moves to in-progress
if (task.sliceId && this.options.missionStore && to === "in-progress") {
void this.handleMissionTaskStart(task.id, task.sliceId);
}
});
@@ -188,7 +184,7 @@ export class Scheduler {
* @returns Object with `valid: true` if checks pass, or `valid: false` with a `reason` string if they fail
*/
private async validateTaskFilesystem(id: string): Promise<{ valid: boolean; reason?: string }> {
const taskDir = join(this.store.getRootDir(), ".fusion", "tasks", id);
const taskDir = join(this.store.getRootDir(), ".kb", "tasks", id);
// Check if task directory exists
if (!existsSync(taskDir)) {
@@ -523,60 +519,40 @@ export class Scheduler {
}
}
/**
* 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> {
async onSliceComplete(slice: import("@fusion/core").Slice): 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}`);
schedulerLog.warn(`Milestone ${slice.milestoneId} not found for slice ${slice.id}`);
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)`);
const mission = missionStore.getMission(milestone.missionId);
if (!mission || mission.status !== "active" || !mission.autoAdvance) {
return;
}
// 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}`);
}
}
const missionHierarchy = missionStore.getMissionWithHierarchy(mission.id);
const hasActiveSlice = missionHierarchy?.milestones.some((candidateMilestone) =>
candidateMilestone.slices.some((candidateSlice) =>
candidateSlice.id !== slice.id && candidateSlice.status === "active"
)
);
if (hasActiveSlice) {
schedulerLog.log(`Mission ${mission.id} already has an active slice; skipping auto-advance`);
return;
}
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);
schedulerLog.error(`Error handling slice completion for ${slice.id}:`, err);
}
}
@@ -594,15 +570,37 @@ export class Scheduler {
const missionStore = this.options.missionStore;
try {
const nextSlice = missionStore.findNextPendingSlice(missionId);
if (!nextSlice) {
schedulerLog.log(`Mission ${missionId}: no pending slices to activate`);
const mission = missionStore.getMissionWithHierarchy(missionId);
if (!mission || mission.status !== "active") {
schedulerLog.log(`Mission ${missionId}: not active, skipping slice activation`);
return null;
}
const activated = missionStore.activateSlice(nextSlice.id);
schedulerLog.log(`Activated slice ${activated.id} for mission ${missionId}`);
return activated;
const sortedMilestones = [...mission.milestones].sort((a, b) => a.orderIndex - b.orderIndex);
for (const milestone of sortedMilestones) {
const dependenciesMet = milestone.dependencies.every((dependencyId) => {
const dependency = mission.milestones.find((candidate) => candidate.id === dependencyId);
return dependency?.status === "complete";
});
if (!dependenciesMet) {
continue;
}
const pendingSlice = [...milestone.slices]
.sort((a, b) => a.orderIndex - b.orderIndex)
.find((slice) => slice.status === "pending");
if (!pendingSlice) {
continue;
}
const activated = missionStore.activateSlice(pendingSlice.id);
schedulerLog.log(`Activated slice ${activated.id} for mission ${missionId}`);
return activated;
}
schedulerLog.log(`Mission ${missionId}: no pending slices to activate`);
return null;
} catch (err) {
schedulerLog.error(`Error activating next slice for mission ${missionId}:`, err);
return null;