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

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

View File

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

View File

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

View File

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

View File

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

View File

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