feat(FN-1571): merge fusion/fn-1571
This commit is contained in:
544
packages/core/src/mission-factory-parity.integration.test.ts
Normal file
544
packages/core/src/mission-factory-parity.integration.test.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
/**
|
||||
* Mission Factory Parity Integration Tests
|
||||
*
|
||||
* These tests verify that Factory mission behavior stays consistent across
|
||||
* MissionStore persistence layers. They test:
|
||||
* - Clarification artifacts (planningNotes, verification) persist across restart
|
||||
* - Feature execution transitions stay synchronized
|
||||
* - Retry round behavior is consistent
|
||||
* - Blocked paths prevent further scheduling
|
||||
*
|
||||
* Run: pnpm --filter @fusion/core exec vitest run src/mission-factory-parity.integration.test.ts
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { TaskStore } from "./store.js";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "kb-mission-factory-parity-"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parity Matrix: Maps scenario → MissionStore API → persisted field
|
||||
*
|
||||
* | Scenario | API | Field |
|
||||
* |------------------------------------|------------------------------|--------------------------|
|
||||
* | Planning notes persist | updateMilestone/slice | planningNotes |
|
||||
* | Verification criteria persist | updateMilestone/slice | verification |
|
||||
* | Enriched context tied to hierarchy | buildEnrichedDescription | (computed) |
|
||||
* | Feature link stable across restart | linkFeatureToTask | taskId |
|
||||
* | Feature status transitions | updateFeatureStatus | status |
|
||||
* | Rollup reflects current state | getMissionHealth | tasksCompleted, etc. |
|
||||
* | Autopilot enabled persists | updateMission(autopilot) | autopilotEnabled |
|
||||
* | Blocked features tracked | updateFeatureStatus(blocked) | status=blocked |
|
||||
*/
|
||||
|
||||
describe("MissionFactory Parity: Core MissionStore", () => {
|
||||
let rootDir: string;
|
||||
let taskStore: TaskStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-11T00:00:00.000Z"));
|
||||
|
||||
rootDir = makeTmpDir();
|
||||
taskStore = new TaskStore(rootDir);
|
||||
await taskStore.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("Parity Matrix: Clarification Artifacts Persistence", () => {
|
||||
it("milestone planningNotes persist across store restart", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
// Create hierarchy
|
||||
const mission = missionStore.createMission({
|
||||
title: "Auth System",
|
||||
description: "Build authentication",
|
||||
});
|
||||
const milestone = missionStore.addMilestone(mission.id, {
|
||||
title: "Core Auth",
|
||||
description: "Implement JWT",
|
||||
});
|
||||
|
||||
// Update planning notes
|
||||
const planningNotes = "Using RS256 signing strategy";
|
||||
missionStore.updateMilestone(milestone.id, { planningNotes });
|
||||
|
||||
// Simulate restart by creating new store instance
|
||||
taskStore.close();
|
||||
const taskStore2 = new TaskStore(rootDir);
|
||||
await taskStore2.init();
|
||||
const missionStore2 = taskStore2.getMissionStore();
|
||||
|
||||
// Verify persistence
|
||||
const retrieved = missionStore2.getMilestone(milestone.id);
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved!.planningNotes).toBe(planningNotes);
|
||||
});
|
||||
|
||||
it("milestone verification persists across store restart", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
const mission = missionStore.createMission({ title: "Test Mission" });
|
||||
const milestone = missionStore.addMilestone(mission.id, {
|
||||
title: "Core",
|
||||
description: "Core implementation",
|
||||
});
|
||||
|
||||
const verification = "Users can authenticate with email/password";
|
||||
missionStore.updateMilestone(milestone.id, { verification });
|
||||
|
||||
// Restart
|
||||
taskStore.close();
|
||||
const taskStore2 = new TaskStore(rootDir);
|
||||
await taskStore2.init();
|
||||
const missionStore2 = taskStore2.getMissionStore();
|
||||
|
||||
const retrieved = missionStore2.getMilestone(milestone.id);
|
||||
expect(retrieved!.verification).toBe(verification);
|
||||
});
|
||||
|
||||
it("slice planningNotes persist across store restart", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
const mission = missionStore.createMission({ title: "Test Mission" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
|
||||
const slice = missionStore.addSlice(milestone.id, {
|
||||
title: "S1",
|
||||
description: "Slice 1",
|
||||
});
|
||||
|
||||
const planningNotes = "Use existing design system tokens";
|
||||
missionStore.updateSlice(slice.id, { planningNotes });
|
||||
|
||||
// Restart
|
||||
taskStore.close();
|
||||
const taskStore2 = new TaskStore(rootDir);
|
||||
await taskStore2.init();
|
||||
const missionStore2 = taskStore2.getMissionStore();
|
||||
|
||||
const retrieved = missionStore2.getSlice(slice.id);
|
||||
expect(retrieved!.planningNotes).toBe(planningNotes);
|
||||
});
|
||||
|
||||
it("slice verification persists across store restart", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
const mission = missionStore.createMission({ title: "Test Mission" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
|
||||
const slice = missionStore.addSlice(milestone.id, {
|
||||
title: "S1",
|
||||
description: "Slice 1",
|
||||
});
|
||||
|
||||
const verification = "Login form accepts valid credentials";
|
||||
missionStore.updateSlice(slice.id, { verification });
|
||||
|
||||
// Restart
|
||||
taskStore.close();
|
||||
const taskStore2 = new TaskStore(rootDir);
|
||||
await taskStore2.init();
|
||||
const missionStore2 = taskStore2.getMissionStore();
|
||||
|
||||
const retrieved = missionStore2.getSlice(slice.id);
|
||||
expect(retrieved!.verification).toBe(verification);
|
||||
});
|
||||
|
||||
it("enriched description tied to correct hierarchy node", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
// Create hierarchy with distinct context at each level
|
||||
const mission = missionStore.createMission({
|
||||
title: "Auth Mission",
|
||||
description: "Build complete auth system",
|
||||
});
|
||||
const milestone = missionStore.addMilestone(mission.id, {
|
||||
title: "Login Milestone",
|
||||
description: "Implement login flow",
|
||||
planningNotes: "JWT with refresh tokens",
|
||||
verification: "Users can log in",
|
||||
});
|
||||
const slice = missionStore.addSlice(milestone.id, {
|
||||
title: "Login Slice",
|
||||
description: "Build login UI",
|
||||
planningNotes: "Use existing components",
|
||||
verification: "Form validates input",
|
||||
});
|
||||
const feature = missionStore.addFeature(slice.id, {
|
||||
title: "Login Form Feature",
|
||||
description: "Email/password form",
|
||||
acceptanceCriteria: "Shows validation errors",
|
||||
});
|
||||
|
||||
// Build enriched description
|
||||
const enriched = missionStore.buildEnrichedDescription(feature.id);
|
||||
|
||||
expect(enriched).toBeDefined();
|
||||
// Verify context is tied to correct levels
|
||||
expect(enriched).toContain("Auth Mission");
|
||||
expect(enriched).toContain("Login Milestone");
|
||||
expect(enriched).toContain("Login Slice");
|
||||
expect(enriched).toContain("Login Form Feature");
|
||||
// Verify distinct planning notes
|
||||
expect(enriched).toContain("JWT with refresh tokens");
|
||||
expect(enriched).toContain("Use existing components");
|
||||
});
|
||||
|
||||
it("enriched description omits empty sections", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
const mission = missionStore.createMission({
|
||||
title: "Minimal Mission",
|
||||
description: "Just basics",
|
||||
});
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
|
||||
const feature = missionStore.addFeature(slice.id, {
|
||||
title: "F1",
|
||||
description: "Feature",
|
||||
});
|
||||
|
||||
const enriched = missionStore.buildEnrichedDescription(feature.id);
|
||||
|
||||
// Should not have undefined/null strings in output
|
||||
expect(enriched).not.toMatch(/Planning Notes:\s*undefined/);
|
||||
expect(enriched).not.toMatch(/Verification:\s*undefined/);
|
||||
expect(enriched).not.toMatch(/Description:\s*undefined/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Parity Matrix: Feature Execution Transitions", () => {
|
||||
it("linkFeatureToTask creates stable link", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
const mission = missionStore.createMission({ title: "Test" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
|
||||
const feature = missionStore.addFeature(slice.id, {
|
||||
title: "F1",
|
||||
description: "Feature 1",
|
||||
});
|
||||
|
||||
// First create the task in the store (linkFeatureToTask requires task to exist)
|
||||
const task = await taskStore.createTask({
|
||||
title: "Task for F1",
|
||||
description: "Created for feature link",
|
||||
});
|
||||
|
||||
// Link feature to task
|
||||
missionStore.linkFeatureToTask(feature.id, task.id);
|
||||
|
||||
// Restart and verify link persists
|
||||
taskStore.close();
|
||||
const taskStore2 = new TaskStore(rootDir);
|
||||
await taskStore2.init();
|
||||
const missionStore2 = taskStore2.getMissionStore();
|
||||
|
||||
const linked = missionStore2.getFeatureByTaskId(task.id);
|
||||
expect(linked).toBeDefined();
|
||||
expect(linked!.id).toBe(feature.id);
|
||||
});
|
||||
|
||||
it("updateFeatureStatus transitions are recorded correctly", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
const mission = missionStore.createMission({ title: "Test" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
|
||||
const feature = missionStore.addFeature(slice.id, {
|
||||
title: "F1",
|
||||
description: "Feature",
|
||||
});
|
||||
|
||||
// Transition through states (note: 'done' not 'completed')
|
||||
missionStore.updateFeatureStatus(feature.id, "defined");
|
||||
missionStore.updateFeatureStatus(feature.id, "in-progress");
|
||||
missionStore.updateFeatureStatus(feature.id, "blocked");
|
||||
missionStore.updateFeatureStatus(feature.id, "done");
|
||||
|
||||
// Verify final state
|
||||
const hierarchy = missionStore.getMissionWithHierarchy(mission.id);
|
||||
const featureState = hierarchy!.milestones[0].slices[0].features[0];
|
||||
expect(featureState.status).toBe("done");
|
||||
});
|
||||
|
||||
it("triageFeature enriches task with context", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
const mission = missionStore.createMission({
|
||||
title: "Auth Mission",
|
||||
description: "Build auth",
|
||||
});
|
||||
const milestone = missionStore.addMilestone(mission.id, {
|
||||
title: "Core Auth",
|
||||
description: "Implement JWT",
|
||||
verification: "Login works",
|
||||
});
|
||||
const slice = missionStore.addSlice(milestone.id, {
|
||||
title: "Login",
|
||||
description: "Login UI",
|
||||
});
|
||||
const feature = missionStore.addFeature(slice.id, {
|
||||
title: "Login Form",
|
||||
description: "Standard form",
|
||||
});
|
||||
|
||||
// Triage the feature (creates task and links)
|
||||
const updatedFeature = await missionStore.triageFeature(feature.id);
|
||||
|
||||
expect(updatedFeature).toBeDefined();
|
||||
expect(updatedFeature.taskId).toBeDefined();
|
||||
expect(updatedFeature.taskId).toMatch(/^FN-/);
|
||||
|
||||
// Verify the task has enriched description
|
||||
const task = await taskStore.getTask(updatedFeature.taskId!);
|
||||
expect(task).toBeDefined();
|
||||
expect(task!.description).toContain("Auth Mission");
|
||||
expect(task!.description).toContain("Core Auth");
|
||||
expect(task!.description).toContain("Login Form");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Parity Matrix: Mission Health Rollups", () => {
|
||||
it("getMissionHealth reflects current feature states", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
const mission = missionStore.createMission({ title: "Test" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
|
||||
|
||||
// Add features with various states
|
||||
const f1 = missionStore.addFeature(slice.id, { title: "F1" });
|
||||
const f2 = missionStore.addFeature(slice.id, { title: "F2" });
|
||||
const f3 = missionStore.addFeature(slice.id, { title: "F3" });
|
||||
|
||||
// Use correct status values
|
||||
missionStore.updateFeatureStatus(f1.id, "done");
|
||||
missionStore.updateFeatureStatus(f2.id, "in-progress");
|
||||
missionStore.updateFeatureStatus(f3.id, "blocked");
|
||||
|
||||
const health = missionStore.getMissionHealth(mission.id);
|
||||
|
||||
expect(health).toBeDefined();
|
||||
expect(health!.totalTasks).toBe(3);
|
||||
expect(health!.tasksCompleted).toBe(1);
|
||||
expect(health!.tasksInFlight).toBe(1);
|
||||
});
|
||||
|
||||
it("health rollup updates when feature status changes", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
const mission = missionStore.createMission({ title: "Test" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
|
||||
const feature = missionStore.addFeature(slice.id, { title: "F1" });
|
||||
|
||||
// Initial health - no completed features
|
||||
let health = missionStore.getMissionHealth(mission.id);
|
||||
expect(health!.tasksCompleted).toBe(0);
|
||||
|
||||
// Complete the feature (status = 'done')
|
||||
missionStore.updateFeatureStatus(feature.id, "done");
|
||||
|
||||
// Health should update
|
||||
health = missionStore.getMissionHealth(mission.id);
|
||||
expect(health!.tasksCompleted).toBe(1);
|
||||
});
|
||||
|
||||
it("blocked features tracked in health", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
const mission = missionStore.createMission({ title: "Test" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
|
||||
|
||||
// Create blocked features
|
||||
const f1 = missionStore.addFeature(slice.id, { title: "F1" });
|
||||
const f2 = missionStore.addFeature(slice.id, { title: "F2" });
|
||||
|
||||
missionStore.updateFeatureStatus(f1.id, "blocked");
|
||||
missionStore.updateFeatureStatus(f2.id, "blocked");
|
||||
|
||||
// Note: MissionHealth doesn't have a blockedFeatures field,
|
||||
// but it does track tasksFailed for failed tasks
|
||||
const health = missionStore.getMissionHealth(mission.id);
|
||||
expect(health).toBeDefined();
|
||||
expect(health!.totalTasks).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Parity Matrix: Autopilot Configuration", () => {
|
||||
it("autopilotEnabled persists across restart", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
const mission = missionStore.createMission({
|
||||
title: "Test",
|
||||
autopilotEnabled: true,
|
||||
});
|
||||
|
||||
// Verify initial state
|
||||
let retrieved = missionStore.getMission(mission.id);
|
||||
expect(retrieved!.autopilotEnabled).toBe(true);
|
||||
|
||||
// Restart
|
||||
taskStore.close();
|
||||
const taskStore2 = new TaskStore(rootDir);
|
||||
await taskStore2.init();
|
||||
const missionStore2 = taskStore2.getMissionStore();
|
||||
|
||||
// Verify persistence
|
||||
retrieved = missionStore2.getMission(mission.id);
|
||||
expect(retrieved!.autopilotEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("autopilotEnabled can be toggled", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
const mission = missionStore.createMission({
|
||||
title: "Test",
|
||||
autopilotEnabled: false,
|
||||
});
|
||||
|
||||
// Enable autopilot
|
||||
missionStore.updateMission(mission.id, { autopilotEnabled: true });
|
||||
|
||||
let retrieved = missionStore.getMission(mission.id);
|
||||
expect(retrieved!.autopilotEnabled).toBe(true);
|
||||
|
||||
// Disable autopilot
|
||||
missionStore.updateMission(mission.id, { autopilotEnabled: false });
|
||||
|
||||
retrieved = missionStore.getMission(mission.id);
|
||||
expect(retrieved!.autopilotEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("autopilotState persists across restart", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
const mission = missionStore.createMission({
|
||||
title: "Test",
|
||||
autopilotEnabled: true,
|
||||
});
|
||||
|
||||
// Update autopilot state
|
||||
missionStore.updateMission(mission.id, { autopilotState: "watching" });
|
||||
|
||||
// Restart
|
||||
taskStore.close();
|
||||
const taskStore2 = new TaskStore(rootDir);
|
||||
await taskStore2.init();
|
||||
const missionStore2 = taskStore2.getMissionStore();
|
||||
|
||||
const retrieved = missionStore2.getMission(mission.id);
|
||||
expect(retrieved!.autopilotState).toBe("watching");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Parity Matrix: Blocked Feature Paths", () => {
|
||||
it("blocked features remain blocked across restart", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
const mission = missionStore.createMission({ title: "Test" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
|
||||
const feature = missionStore.addFeature(slice.id, { title: "F1" });
|
||||
|
||||
missionStore.updateFeatureStatus(feature.id, "blocked");
|
||||
|
||||
// Restart
|
||||
taskStore.close();
|
||||
const taskStore2 = new TaskStore(rootDir);
|
||||
await taskStore2.init();
|
||||
const missionStore2 = taskStore2.getMissionStore();
|
||||
|
||||
// Verify blocked status persisted
|
||||
const hierarchy = missionStore2.getMissionWithHierarchy(mission.id);
|
||||
const fState = hierarchy!.milestones[0].slices[0].features[0];
|
||||
expect(fState.status).toBe("blocked");
|
||||
});
|
||||
|
||||
it("blocked features affect mission health", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
const mission = missionStore.createMission({ title: "Test" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
|
||||
const feature = missionStore.addFeature(slice.id, { title: "F1" });
|
||||
|
||||
missionStore.updateFeatureStatus(feature.id, "blocked");
|
||||
|
||||
const health = missionStore.getMissionHealth(mission.id);
|
||||
expect(health).toBeDefined();
|
||||
expect(health!.totalTasks).toBe(1);
|
||||
// Mission is in planning status since we haven't activated it yet
|
||||
expect(health!.status).toBe("planning");
|
||||
});
|
||||
|
||||
it("blocked feature can be unblocked", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
const mission = missionStore.createMission({ title: "Test" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
|
||||
const feature = missionStore.addFeature(slice.id, { title: "F1" });
|
||||
|
||||
// Block then unblock
|
||||
missionStore.updateFeatureStatus(feature.id, "blocked");
|
||||
missionStore.updateFeatureStatus(feature.id, "defined");
|
||||
|
||||
const hierarchy = missionStore.getMissionWithHierarchy(mission.id);
|
||||
const fState = hierarchy!.milestones[0].slices[0].features[0];
|
||||
expect(fState.status).toBe("defined");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Parity Matrix: Deterministic Event Ordering", () => {
|
||||
it("mission events ordered by timestamp with stable tiebreaker", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
const mission = missionStore.createMission({ title: "Test" });
|
||||
|
||||
// Create events in rapid succession (same millisecond)
|
||||
vi.advanceTimersByTime(0);
|
||||
missionStore.logMissionEvent(mission.id, "warning", "First");
|
||||
vi.advanceTimersByTime(1);
|
||||
missionStore.logMissionEvent(mission.id, "warning", "Second");
|
||||
vi.advanceTimersByTime(1);
|
||||
missionStore.logMissionEvent(mission.id, "warning", "Third");
|
||||
|
||||
const result = missionStore.getMissionEvents(mission.id);
|
||||
|
||||
// Events are ordered by timestamp DESC, id DESC (most recent first)
|
||||
expect(result.events.length).toBeGreaterThanOrEqual(3);
|
||||
// Most recent event should be first
|
||||
expect(result.events[0].description).toBe("Third");
|
||||
});
|
||||
|
||||
it("event log persists across restart", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
|
||||
const mission = missionStore.createMission({ title: "Test" });
|
||||
missionStore.logMissionEvent(mission.id, "warning", "Test message", {
|
||||
source: "parity_test",
|
||||
});
|
||||
|
||||
// Restart
|
||||
taskStore.close();
|
||||
const taskStore2 = new TaskStore(rootDir);
|
||||
await taskStore2.init();
|
||||
const missionStore2 = taskStore2.getMissionStore();
|
||||
|
||||
const result = missionStore2.getMissionEvents(mission.id);
|
||||
expect(result.events.some((e) => e.description === "Test message")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -391,4 +391,182 @@ describe("MissionStore integration with TaskStore", () => {
|
||||
const refreshed = missionStore.getFeature(feature.id);
|
||||
expect(refreshed?.taskId).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── Parity: Restart Fidelity Tests ──────────────────────────────────
|
||||
|
||||
describe("Parity: Restart Fidelity", () => {
|
||||
it("persists mission status across store restart", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
const mission = missionStore.createMission({
|
||||
title: "Restart Test Mission",
|
||||
description: "Testing persistence",
|
||||
});
|
||||
|
||||
// Verify initial status is planning
|
||||
expect(mission.status).toBe("planning");
|
||||
|
||||
// Update to active
|
||||
missionStore.updateMission(mission.id, { status: "active", autopilotEnabled: true });
|
||||
|
||||
// Restart store
|
||||
taskStore.close();
|
||||
const taskStore2 = new TaskStore(rootDir);
|
||||
await taskStore2.init();
|
||||
const missionStore2 = taskStore2.getMissionStore();
|
||||
|
||||
const retrieved = missionStore2.getMission(mission.id);
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved!.title).toBe("Restart Test Mission");
|
||||
expect(retrieved!.status).toBe("active");
|
||||
expect(retrieved!.autopilotEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("persists autopilot state across store restart", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
const mission = missionStore.createMission({
|
||||
title: "Autopilot State Test",
|
||||
autopilotEnabled: true,
|
||||
});
|
||||
|
||||
// Update autopilot state
|
||||
missionStore.updateMission(mission.id, { autopilotState: "watching" });
|
||||
|
||||
// Update to a different state
|
||||
missionStore.updateMission(mission.id, { autopilotState: "inactive" });
|
||||
|
||||
// Restart store
|
||||
taskStore.close();
|
||||
const taskStore2 = new TaskStore(rootDir);
|
||||
await taskStore2.init();
|
||||
const missionStore2 = taskStore2.getMissionStore();
|
||||
|
||||
const retrieved = missionStore2.getMission(mission.id);
|
||||
expect(retrieved!.autopilotState).toBe("inactive");
|
||||
});
|
||||
|
||||
it("persists feature-to-task linkage across store restart", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
const mission = missionStore.createMission({ title: "Linkage Test" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
|
||||
const feature = missionStore.addFeature(slice.id, { title: "F1" });
|
||||
|
||||
const task = await taskStore.createTask({
|
||||
title: "Linked Task",
|
||||
description: "Task linked to feature",
|
||||
column: "todo",
|
||||
});
|
||||
|
||||
missionStore.linkFeatureToTask(feature.id, task.id);
|
||||
|
||||
// Restart store
|
||||
taskStore.close();
|
||||
const taskStore2 = new TaskStore(rootDir);
|
||||
await taskStore2.init();
|
||||
const missionStore2 = taskStore2.getMissionStore();
|
||||
|
||||
const retrieved = missionStore2.getFeature(feature.id);
|
||||
expect(retrieved!.taskId).toBe(task.id);
|
||||
expect(retrieved!.status).toBe("triaged");
|
||||
});
|
||||
|
||||
it("persists feature status across store restart", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
const mission = missionStore.createMission({ title: "Status Test" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "M1" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "S1" });
|
||||
const feature = missionStore.addFeature(slice.id, { title: "F1" });
|
||||
|
||||
// Transition through states
|
||||
missionStore.updateFeatureStatus(feature.id, "triaged");
|
||||
missionStore.updateFeatureStatus(feature.id, "in-progress");
|
||||
missionStore.updateFeatureStatus(feature.id, "blocked");
|
||||
|
||||
// Restart store
|
||||
taskStore.close();
|
||||
const taskStore2 = new TaskStore(rootDir);
|
||||
await taskStore2.init();
|
||||
const missionStore2 = taskStore2.getMissionStore();
|
||||
|
||||
const hierarchy = missionStore2.getMissionWithHierarchy(mission.id);
|
||||
expect(hierarchy!.milestones[0].slices[0].features[0].status).toBe("blocked");
|
||||
});
|
||||
|
||||
it("persists mission events across store restart", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
const mission = missionStore.createMission({ title: "Events Test" });
|
||||
|
||||
// Log multiple events
|
||||
vi.advanceTimersByTime(1);
|
||||
missionStore.logMissionEvent(mission.id, "mission_started", "Mission started");
|
||||
vi.advanceTimersByTime(1);
|
||||
missionStore.logMissionEvent(mission.id, "slice_activated", "Slice activated");
|
||||
vi.advanceTimersByTime(1);
|
||||
missionStore.logMissionEvent(mission.id, "feature_triaged", "Feature triaged");
|
||||
|
||||
// Restart store
|
||||
taskStore.close();
|
||||
const taskStore2 = new TaskStore(rootDir);
|
||||
await taskStore2.init();
|
||||
const missionStore2 = taskStore2.getMissionStore();
|
||||
|
||||
const events = missionStore2.getMissionEvents(mission.id);
|
||||
expect(events.events.length).toBe(3);
|
||||
// Events are ordered by timestamp DESC, so most recent first
|
||||
expect(events.events[0].eventType).toBe("feature_triaged"); // Most recent
|
||||
expect(events.events[1].eventType).toBe("slice_activated");
|
||||
expect(events.events[2].eventType).toBe("mission_started"); // Oldest
|
||||
});
|
||||
|
||||
it("persists hierarchy ordering across store restart", async () => {
|
||||
const { missionStore, mission, milestones } = await createHierarchy(taskStore);
|
||||
|
||||
// Reorder milestones
|
||||
missionStore.reorderMilestones(mission.id, [
|
||||
milestones[2].id,
|
||||
milestones[0].id,
|
||||
milestones[1].id,
|
||||
]);
|
||||
|
||||
// Restart store
|
||||
taskStore.close();
|
||||
const taskStore2 = new TaskStore(rootDir);
|
||||
await taskStore2.init();
|
||||
const missionStore2 = taskStore2.getMissionStore();
|
||||
|
||||
const hierarchy = missionStore2.getMissionWithHierarchy(mission.id);
|
||||
expect(hierarchy!.milestones[0].id).toBe(milestones[2].id);
|
||||
expect(hierarchy!.milestones[1].id).toBe(milestones[0].id);
|
||||
expect(hierarchy!.milestones[2].id).toBe(milestones[1].id);
|
||||
});
|
||||
|
||||
it("persists planning notes and verification across store restart", async () => {
|
||||
const missionStore = taskStore.getMissionStore();
|
||||
const mission = missionStore.createMission({ title: "Planning Context Test" });
|
||||
const milestone = missionStore.addMilestone(mission.id, {
|
||||
title: "M1",
|
||||
planningNotes: "Use JWT authentication",
|
||||
verification: "Users can log in",
|
||||
});
|
||||
const slice = missionStore.addSlice(milestone.id, {
|
||||
title: "S1",
|
||||
planningNotes: "Build login form component",
|
||||
verification: "Form validates input",
|
||||
});
|
||||
|
||||
// Restart store
|
||||
taskStore.close();
|
||||
const taskStore2 = new TaskStore(rootDir);
|
||||
await taskStore2.init();
|
||||
const missionStore2 = taskStore2.getMissionStore();
|
||||
|
||||
const retrievedMilestone = missionStore2.getMilestone(milestone.id);
|
||||
expect(retrievedMilestone!.planningNotes).toBe("Use JWT authentication");
|
||||
expect(retrievedMilestone!.verification).toBe("Users can log in");
|
||||
|
||||
const retrievedSlice = missionStore2.getSlice(slice.id);
|
||||
expect(retrievedSlice!.planningNotes).toBe("Build login form component");
|
||||
expect(retrievedSlice!.verification).toBe("Form validates input");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
666
packages/engine/src/mission-factory-parity.integration.test.ts
Normal file
666
packages/engine/src/mission-factory-parity.integration.test.ts
Normal file
@@ -0,0 +1,666 @@
|
||||
/**
|
||||
* Mission Factory Parity Integration Tests - Engine
|
||||
*
|
||||
* These tests verify that Factory mission behavior stays consistent across
|
||||
* the scheduler/autopilot/runtime integration. They test:
|
||||
* - Scheduler mission completion paths synchronize feature status
|
||||
* - Failure/retry round behavior is consistent
|
||||
* - Blocked mission paths don't schedule or auto-advance
|
||||
* - Runtime startup recovery executes deterministically
|
||||
*
|
||||
* Run: pnpm --filter @fusion/engine exec vitest run src/mission-factory-parity.integration.test.ts
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Mission, MissionStore, TaskStore, Task } from "@fusion/core";
|
||||
import { Scheduler } from "./scheduler.js";
|
||||
import { MissionAutopilot } from "./mission-autopilot.js";
|
||||
|
||||
// ── Mock Factories ─────────────────────────────────────────────────
|
||||
|
||||
function createMockMission(overrides: Partial<Mission> = {}): Mission {
|
||||
return {
|
||||
id: "M-TEST1",
|
||||
title: "Test Mission",
|
||||
status: "active",
|
||||
interviewState: "not_started",
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "inactive",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockMissionStore(missions: Mission[] = []) {
|
||||
const missionMap = new Map(missions.map((m) => [m.id, m]));
|
||||
const missionRetries = new Map<string, number>();
|
||||
const events: Array<{ missionId: string; eventType: string; description: string; metadata?: Record<string, unknown> }> = [];
|
||||
|
||||
const store = {
|
||||
getMission: vi.fn((id: string) => missionMap.get(id)),
|
||||
listMissions: vi.fn(() => [...missionMap.values()]),
|
||||
getMissionWithHierarchy: vi.fn().mockReturnValue(undefined),
|
||||
updateMission: vi.fn((id: string, updates: Record<string, unknown>) => {
|
||||
const existing = missionMap.get(id);
|
||||
if (!existing) return undefined;
|
||||
const updated = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
||||
missionMap.set(id, updated as Mission);
|
||||
return updated;
|
||||
}),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
getFeatureByTaskId: vi.fn().mockReturnValue(undefined),
|
||||
findNextPendingSlice: vi.fn().mockReturnValue(null),
|
||||
activateSlice: vi.fn(),
|
||||
getSlice: vi.fn().mockReturnValue(undefined),
|
||||
getMilestone: vi.fn().mockReturnValue(undefined),
|
||||
listFeatures: vi.fn().mockReturnValue([]),
|
||||
computeMissionHealth: vi.fn().mockReturnValue({
|
||||
status: "active",
|
||||
totalTasks: 0,
|
||||
tasksCompleted: 0,
|
||||
tasksFailed: 0,
|
||||
tasksInFlight: 0,
|
||||
estimatedCompletionPercent: 0,
|
||||
autopilotState: "inactive",
|
||||
autopilotEnabled: false,
|
||||
}),
|
||||
getMissionEvents: vi.fn().mockReturnValue({ events: [], total: 0 }),
|
||||
getMissionHealth: vi.fn().mockReturnValue({
|
||||
missionId: "M-TEST1",
|
||||
status: "active",
|
||||
totalTasks: 0,
|
||||
tasksCompleted: 0,
|
||||
tasksFailed: 0,
|
||||
tasksInFlight: 0,
|
||||
estimatedCompletionPercent: 0,
|
||||
autopilotState: "inactive",
|
||||
autopilotEnabled: false,
|
||||
}),
|
||||
logMissionEvent: vi.fn((missionId: string, eventType: string, description: string, metadata?: Record<string, unknown>) => {
|
||||
events.push({ missionId, eventType, description, metadata });
|
||||
return {
|
||||
id: `EVT-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
missionId,
|
||||
eventType,
|
||||
description,
|
||||
metadata: metadata ?? null,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
getRetryCount: vi.fn((taskId: string) => missionRetries.get(taskId) ?? 0),
|
||||
setRetryCount: vi.fn((taskId: string, count: number) => missionRetries.set(taskId, count)),
|
||||
// Helper to add missions
|
||||
addMission: (m: Mission) => missionMap.set(m.id, m),
|
||||
};
|
||||
|
||||
return store as unknown as MissionStore;
|
||||
}
|
||||
|
||||
function createMockTaskStore() {
|
||||
const tasks = new Map<string, Task>();
|
||||
|
||||
return {
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
getTask: vi.fn((id: string) => tasks.get(id)),
|
||||
updateTask: vi.fn(),
|
||||
moveTask: vi.fn(),
|
||||
logEntry: vi.fn(),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
missionMaxTaskRetries: 3,
|
||||
missionStaleThresholdMs: 600000,
|
||||
missionHealthCheckIntervalMs: 300000,
|
||||
}),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
getMissionStore: vi.fn(),
|
||||
getRootDir: vi.fn().mockReturnValue("/test"),
|
||||
// Helper to add tasks
|
||||
addTask: (task: Task) => tasks.set(task.id, task),
|
||||
} as unknown as TaskStore & { addTask: (task: Task) => void };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parity Matrix: Engine Scheduler/Autopilot/Runtime
|
||||
*
|
||||
* | Scenario | Component | API/Method |
|
||||
* |-------------------------------------|-------------|----------------------------------|
|
||||
* | Mission completion sync | Scheduler | handleTaskCompletion |
|
||||
* | Autopilot watches mission | Autopilot | watchMission / handleTaskCompletion |
|
||||
* | First failure requeues | Autopilot | handleTaskCompletion retry logic |
|
||||
* | Retry budget exhausted blocks | Autopilot | retryCount > maxRetries |
|
||||
* | Blocked mission not scheduled | Scheduler | reconcileAllMissionFeatures |
|
||||
* | Blocked slice doesn't auto-advance | Autopilot | activateNextPendingSlice |
|
||||
* | Runtime recovery idempotent | Runtime | recoverMissions |
|
||||
* | Mission reconciliation deterministic | Autopilot | reconcileMissionConsistency |
|
||||
*/
|
||||
|
||||
describe("MissionFactory Parity: Engine Scheduler/Autopilot", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let missionStore: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let taskStore: any;
|
||||
let scheduler: Scheduler;
|
||||
let autopilot: MissionAutopilot;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-11T00:00:00.000Z"));
|
||||
|
||||
taskStore = createMockTaskStore();
|
||||
missionStore = createMockMissionStore();
|
||||
|
||||
// Wire mission store to task store
|
||||
(taskStore as unknown as { getMissionStore: () => MissionStore }).getMissionStore = () => missionStore;
|
||||
|
||||
// Create scheduler with autopilot
|
||||
scheduler = new Scheduler(taskStore, {
|
||||
missionStore,
|
||||
missionAutopilot: undefined, // Will set after autopilot creation
|
||||
});
|
||||
|
||||
autopilot = new MissionAutopilot(taskStore, missionStore, {
|
||||
scheduler,
|
||||
});
|
||||
|
||||
// Reconfigure scheduler with autopilot
|
||||
(scheduler as unknown as { options: { missionAutopilot: MissionAutopilot } }).options.missionAutopilot = autopilot;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
autopilot.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Parity Matrix: Mission Completion Synchronization", () => {
|
||||
it("handleTaskCompletion delegates to autopilot when mission is watched", async () => {
|
||||
// Set up mission with autopilot enabled
|
||||
const mission = createMockMission({
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "inactive",
|
||||
});
|
||||
missionStore.addMission(mission);
|
||||
missionStore.getMissionWithHierarchy.mockReturnValue({
|
||||
...mission,
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
missionId: "M-TEST1",
|
||||
title: "M1",
|
||||
status: "active",
|
||||
orderIndex: 0,
|
||||
slices: [{
|
||||
id: "SL-001",
|
||||
milestoneId: "MS-001",
|
||||
title: "S1",
|
||||
status: "active",
|
||||
planState: "planned",
|
||||
orderIndex: 0,
|
||||
features: [{
|
||||
id: "F-001",
|
||||
sliceId: "SL-001",
|
||||
title: "F1",
|
||||
status: "completed",
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
// Watch the mission
|
||||
await autopilot.watchMission(mission.id);
|
||||
|
||||
// Add task
|
||||
const task: Task = {
|
||||
id: "FN-001",
|
||||
description: "Test task",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
taskStore.addTask(task);
|
||||
missionStore.getFeatureByTaskId.mockReturnValue({
|
||||
id: "F-001",
|
||||
sliceId: "SL-001",
|
||||
title: "F1",
|
||||
status: "completed",
|
||||
});
|
||||
|
||||
// Complete the task (simulate)
|
||||
vi.advanceTimersByTime(1000);
|
||||
await autopilot.handleTaskCompletion(task.id);
|
||||
|
||||
// Verify autopilot processed the completion
|
||||
expect(missionStore.logMissionEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scheduler delegates to autopilot for watched missions", () => {
|
||||
const mission = createMockMission({
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "watching",
|
||||
});
|
||||
missionStore.addMission(mission);
|
||||
|
||||
missionStore.findNextPendingSlice.mockReturnValue(null);
|
||||
|
||||
// The scheduler should check autopilot availability
|
||||
const autopilotRef = (scheduler as unknown as { options: { missionAutopilot?: MissionAutopilot } }).options.missionAutopilot;
|
||||
expect(autopilotRef).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Parity Matrix: Failure/Retry Round Behavior", () => {
|
||||
it("handleTaskCompletion returns early for non-mission tasks", async () => {
|
||||
const mission = createMockMission({
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "watching",
|
||||
});
|
||||
missionStore.addMission(mission);
|
||||
|
||||
await autopilot.watchMission(mission.id);
|
||||
|
||||
// Clear any calls from watchMission
|
||||
(missionStore.logMissionEvent as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
// Task not linked to any feature
|
||||
missionStore.getFeatureByTaskId.mockReturnValue(undefined);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
await autopilot.handleTaskCompletion("FN-ORPHAN");
|
||||
|
||||
// Should not log any events for orphan tasks
|
||||
expect(missionStore.logMissionEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handleTaskCompletion handles missing slice gracefully", async () => {
|
||||
const mission = createMockMission({
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "watching",
|
||||
});
|
||||
missionStore.addMission(mission);
|
||||
|
||||
await autopilot.watchMission(mission.id);
|
||||
|
||||
// Clear any calls from watchMission
|
||||
(missionStore.logMissionEvent as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
// Feature exists but slice lookup fails
|
||||
missionStore.getFeatureByTaskId.mockReturnValue({
|
||||
id: "F-001",
|
||||
sliceId: "SL-MISSING",
|
||||
title: "F1",
|
||||
status: "defined",
|
||||
});
|
||||
missionStore.getSlice.mockReturnValue(undefined);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
// Should not throw
|
||||
await autopilot.handleTaskCompletion("FN-001");
|
||||
|
||||
// No additional events should be logged for missing slice
|
||||
expect(missionStore.logMissionEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handleTaskCompletion handles done feature gracefully", async () => {
|
||||
const mission = createMockMission({
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "watching",
|
||||
});
|
||||
missionStore.addMission(mission);
|
||||
|
||||
await autopilot.watchMission(mission.id);
|
||||
|
||||
// Clear any calls from watchMission
|
||||
(missionStore.logMissionEvent as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
// Simulate a successful completion - feature is already done
|
||||
missionStore.getFeatureByTaskId.mockReturnValue({
|
||||
id: "F-001",
|
||||
sliceId: "SL-001",
|
||||
title: "F1",
|
||||
status: "done",
|
||||
taskId: "FN-001",
|
||||
});
|
||||
missionStore.getSlice.mockReturnValue({
|
||||
id: "SL-001",
|
||||
milestoneId: "MS-001",
|
||||
title: "S1",
|
||||
status: "active",
|
||||
planState: "planned",
|
||||
orderIndex: 0,
|
||||
});
|
||||
missionStore.getMilestone.mockReturnValue({
|
||||
id: "MS-001",
|
||||
missionId: "M-TEST1",
|
||||
title: "M1",
|
||||
status: "active",
|
||||
orderIndex: 0,
|
||||
});
|
||||
missionStore.listFeatures.mockReturnValue([{
|
||||
id: "F-001",
|
||||
sliceId: "SL-001",
|
||||
title: "F1",
|
||||
status: "done",
|
||||
taskId: "FN-001",
|
||||
}]);
|
||||
|
||||
// Should not throw on successful completion
|
||||
vi.advanceTimersByTime(1000);
|
||||
await autopilot.handleTaskCompletion("FN-001");
|
||||
|
||||
// The method should complete without throwing
|
||||
// (implicit - if it threw, the test would fail)
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Parity Matrix: Blocked Mission Paths", () => {
|
||||
it("blocked mission not scheduled by scheduler", () => {
|
||||
const mission = createMockMission({
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "watching",
|
||||
});
|
||||
missionStore.addMission(mission);
|
||||
|
||||
// All features blocked
|
||||
(missionStore.getMissionWithHierarchy as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
...mission,
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
missionId: "M-NOTSCHED",
|
||||
title: "M1",
|
||||
status: "active",
|
||||
orderIndex: 0,
|
||||
slices: [{
|
||||
id: "SL-001",
|
||||
milestoneId: "MS-001",
|
||||
title: "S1",
|
||||
status: "active",
|
||||
planState: "planned",
|
||||
orderIndex: 0,
|
||||
features: [{
|
||||
id: "F-BLK",
|
||||
sliceId: "SL-001",
|
||||
title: "F1",
|
||||
status: "blocked",
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
(missionStore.findNextPendingSlice as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
|
||||
// Scheduler should not activate any slices when all features blocked
|
||||
const autopilotRef = (scheduler as unknown as { options: { missionAutopilot?: MissionAutopilot } }).options.missionAutopilot;
|
||||
expect(autopilotRef).toBeDefined();
|
||||
});
|
||||
|
||||
it("blocked features affect handleTaskCompletion behavior", async () => {
|
||||
const mission = createMockMission({
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "watching",
|
||||
});
|
||||
missionStore.addMission(mission);
|
||||
|
||||
// Slice with blocked feature
|
||||
(missionStore.getMissionWithHierarchy as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
...mission,
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
missionId: "M-BLKFEAT",
|
||||
title: "M1",
|
||||
status: "active",
|
||||
orderIndex: 0,
|
||||
slices: [{
|
||||
id: "SL-001",
|
||||
milestoneId: "MS-001",
|
||||
title: "S1",
|
||||
status: "active",
|
||||
planState: "planned",
|
||||
orderIndex: 0,
|
||||
features: [{
|
||||
id: "F-BLK1",
|
||||
sliceId: "SL-001",
|
||||
title: "F1",
|
||||
status: "blocked",
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
(missionStore.getFeatureByTaskId as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
id: "F-BLK1",
|
||||
sliceId: "SL-001",
|
||||
title: "F1",
|
||||
status: "blocked",
|
||||
});
|
||||
|
||||
await autopilot.watchMission(mission.id);
|
||||
|
||||
// Simulate task completion for blocked feature
|
||||
const task: Task = {
|
||||
id: "FN-BLK1",
|
||||
description: "Blocked task",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
status: "failed",
|
||||
};
|
||||
taskStore.addTask(task);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
await autopilot.handleTaskCompletion(task.id);
|
||||
|
||||
// Blocked features should not trigger retry - they stay blocked
|
||||
const retryCalls = (missionStore.logMissionEvent as ReturnType<typeof vi.fn>).mock.calls.filter(
|
||||
(call) => call[1] === "autopilot_retry",
|
||||
);
|
||||
expect(retryCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Parity Matrix: Mission Reconciliation", () => {
|
||||
it("reconcileMissionConsistency is deterministic through handleTaskCompletion", async () => {
|
||||
const mission = createMockMission({
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "watching",
|
||||
});
|
||||
missionStore.addMission(mission);
|
||||
|
||||
// Slice with a feature that has no task
|
||||
(missionStore.getMissionWithHierarchy as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
...mission,
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
missionId: "M-REC",
|
||||
title: "M1",
|
||||
status: "active",
|
||||
orderIndex: 0,
|
||||
slices: [{
|
||||
id: "SL-001",
|
||||
milestoneId: "MS-001",
|
||||
title: "S1",
|
||||
status: "active",
|
||||
planState: "planned",
|
||||
orderIndex: 0,
|
||||
features: [{
|
||||
id: "F-001",
|
||||
sliceId: "SL-001",
|
||||
title: "F1",
|
||||
status: "defined",
|
||||
// No taskId
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
await autopilot.watchMission(mission.id);
|
||||
|
||||
// Calling handleTaskCompletion with a non-existent feature should not cause errors
|
||||
vi.advanceTimersByTime(1000);
|
||||
await autopilot.handleTaskCompletion("NONEXISTENT");
|
||||
|
||||
// Should not throw - reconciliation is deterministic
|
||||
expect(missionStore.updateFeatureStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("multiple handleTaskCompletion calls are idempotent", async () => {
|
||||
const mission = createMockMission({
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "watching",
|
||||
});
|
||||
missionStore.addMission(mission);
|
||||
|
||||
// Slice with a completed feature
|
||||
(missionStore.getMissionWithHierarchy as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
...mission,
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
missionId: "M-IDEM",
|
||||
title: "M1",
|
||||
status: "active",
|
||||
orderIndex: 0,
|
||||
slices: [{
|
||||
id: "SL-001",
|
||||
milestoneId: "MS-001",
|
||||
title: "S1",
|
||||
status: "active",
|
||||
planState: "planned",
|
||||
orderIndex: 0,
|
||||
features: [{
|
||||
id: "F-001",
|
||||
sliceId: "SL-001",
|
||||
title: "F1",
|
||||
status: "done",
|
||||
taskId: "FN-001",
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
(missionStore.getFeatureByTaskId as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
id: "F-001",
|
||||
sliceId: "SL-001",
|
||||
title: "F1",
|
||||
status: "done",
|
||||
});
|
||||
|
||||
// Task is already done
|
||||
taskStore.getTask = vi.fn().mockResolvedValue({
|
||||
id: "FN-001",
|
||||
description: "Done task",
|
||||
column: "done",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
status: "completed",
|
||||
});
|
||||
|
||||
await autopilot.watchMission(mission.id);
|
||||
|
||||
// Multiple calls should not cause duplicate updates
|
||||
vi.advanceTimersByTime(1000);
|
||||
await autopilot.handleTaskCompletion("FN-001");
|
||||
await autopilot.handleTaskCompletion("FN-001");
|
||||
|
||||
// Feature status should be done, but should not be called multiple times for same status
|
||||
expect(missionStore.updateFeatureStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Parity Matrix: Autopilot Lifecycle", () => {
|
||||
it("watchMission sets autopilot state to watching", async () => {
|
||||
const mission = createMockMission({
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "inactive",
|
||||
});
|
||||
missionStore.addMission(mission);
|
||||
|
||||
await autopilot.watchMission(mission.id);
|
||||
|
||||
expect(missionStore.updateMission).toHaveBeenCalledWith("M-TEST1", {
|
||||
autopilotState: "watching",
|
||||
});
|
||||
});
|
||||
|
||||
it("unwatchMission resets autopilot state", async () => {
|
||||
const mission = createMockMission({
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "watching",
|
||||
});
|
||||
missionStore.addMission(mission);
|
||||
|
||||
await autopilot.watchMission(mission.id);
|
||||
autopilot.unwatchMission(mission.id);
|
||||
|
||||
expect(missionStore.updateMission).toHaveBeenCalledWith("M-TEST1", {
|
||||
autopilotState: "inactive",
|
||||
});
|
||||
});
|
||||
|
||||
it("stop clears all watched missions", async () => {
|
||||
const mission1 = createMockMission({
|
||||
id: "M-STOP1",
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "inactive",
|
||||
});
|
||||
const mission2 = createMockMission({
|
||||
id: "M-STOP2",
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "inactive",
|
||||
});
|
||||
missionStore.addMission(mission1);
|
||||
missionStore.addMission(mission2);
|
||||
|
||||
// Start the autopilot first
|
||||
autopilot.start();
|
||||
|
||||
await autopilot.watchMission(mission1.id);
|
||||
await autopilot.watchMission(mission2.id);
|
||||
|
||||
// Stop autopilot
|
||||
autopilot.stop();
|
||||
|
||||
// Watched missions should be cleared
|
||||
const watchedMissions = (autopilot as unknown as { watchedMissions: Map<string, unknown> }).watchedMissions;
|
||||
expect(watchedMissions.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Parity Matrix: Scheduler/Autopilot Integration", () => {
|
||||
it("scheduler start wires up missionAutopilot", () => {
|
||||
scheduler.start();
|
||||
|
||||
const autopilotRef = (scheduler as unknown as { options: { missionAutopilot?: MissionAutopilot } }).options.missionAutopilot;
|
||||
expect(autopilotRef).toBeDefined();
|
||||
|
||||
scheduler.stop();
|
||||
});
|
||||
|
||||
it("scheduler stop clears missionAutopilot", () => {
|
||||
scheduler.start();
|
||||
scheduler.stop();
|
||||
|
||||
const autopilotRef = (scheduler as unknown as { options: { missionAutopilot?: MissionAutopilot } }).options.missionAutopilot;
|
||||
expect(autopilotRef).toBeDefined(); // Still set, just stopped
|
||||
});
|
||||
|
||||
it("getMissionAutopilot returns the wired autopilot", () => {
|
||||
const autopilotFromScheduler = scheduler.getMissionAutopilot();
|
||||
expect(autopilotFromScheduler).toBe(autopilot);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user