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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
179
packages/cli/src/bin.test.ts
Normal file
179
packages/cli/src/bin.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user