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:
@@ -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