Files
fusion/packages/engine/src/__tests__/mission-execution-loop.test.ts
gsxdsm 4ea9d66b4f FN-6219: prefer fresh agent model settings for automatic runs
Automatic agent runs now resolve lane-specific task and settings models before falling back to durable runtime defaults.

- Prefer fresh execution, planning, heartbeat, merger, and validator model resolution over stale assigned-agent runtime config when complete settings are available.
- Centralize validator session model resolution and align mission validation with the shared helper.
- Add regression coverage for automatic run model precedence and test-mode handling.
- Add a patch changeset for the published CLI package.

Files changed:
 .changeset/fuzzy-agents-run.md                     |   5 +
 .../core/src/__tests__/model-resolution.test.ts    |  40 +++++
 .../agent-session-helpers-test-mode.test.ts        |  35 ++--
 .../src/__tests__/agent-session-helpers.test.ts    | 199 ++++++++++++++++++---
 .../src/__tests__/heartbeat-executor.test.ts       |  10 +-
 .../src/__tests__/mission-execution-loop.test.ts   |   6 +-
 packages/engine/src/__tests__/triage.test.ts       |   4 +-
 packages/engine/src/agent-session-helpers.ts       | 111 +++++++-----
 packages/engine/src/mission-execution-loop.ts      |  33 +---
 packages/engine/src/triage.ts                      |   6 +-
 10 files changed, 326 insertions(+), 123 deletions(-)

Fusion-Task-Id: FN-6219
Fusion-Task-Lineage: c3ef6d91-234f-4ed1-97c7-9be8408cd86c
2026-06-11 09:21:23 -07:00

2456 lines
88 KiB
TypeScript

/**
* MissionExecutionLoop unit tests.
*
* Tests the validation cycle orchestration class with mocked TaskStore, MissionStore,
* and AI agent (createFnAgent/promptWithFallback).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { TEST_MODE_RESOLVED } from "@fusion/core";
import type {
Mission,
Milestone,
Slice,
MissionFeature,
MissionContractAssertion,
MissionValidatorRun,
} from "@fusion/core";
// ── Mock AI dependencies ─────────────────────────────────────────────────────
// Shared mock state that can be configured per test
const mockSessionHolder: {
session: {
state: { messages: Array<{ role: string; content: string }> };
dispose: ReturnType<typeof vi.fn>;
};
} = {
session: {
state: { messages: [] },
dispose: vi.fn(),
},
};
// Mock the pi module before MissionExecutionLoop is imported
vi.mock("../pi.js", () => {
const createFnAgent = vi.fn(() => Promise.resolve({ session: mockSessionHolder.session }));
const promptWithFallback = vi.fn().mockResolvedValue(undefined);
return { createFnAgent, promptWithFallback };
});
vi.mock("../logger.js", () => ({
createLogger: vi.fn((_name: string) => ({
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
}));
vi.mock("../agent-session-helpers.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../agent-session-helpers.js")>();
return {
...actual,
createResolvedAgentSession: vi.fn(async () => ({
session: mockSessionHolder.session as any,
sessionFile: undefined,
runtimeId: "test-runtime",
wasConfigured: true,
})),
};
});
// Helper to reset mock session state
function resetMockSession() {
mockSessionHolder.session.state.messages = [];
mockSessionHolder.session.dispose = vi.fn();
}
// Import AFTER vi.mock so the mock is applied
import { createResolvedAgentSession } from "../agent-session-helpers.js";
import { MissionExecutionLoop, loopLog } from "../mission-execution-loop.js";
// ── Mock Factories ──────────────────────────────────────────────────────────
function createMockMission(overrides: Partial<Mission> = {}): Mission {
return {
id: "M-TEST1",
title: "Test Mission",
status: "active",
interviewState: "not_started",
autoAdvance: true,
autopilotEnabled: true,
autopilotState: "inactive",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function createMockMilestone(overrides: Partial<Milestone> = {}): Milestone {
return {
id: "MS-001",
missionId: "M-TEST1",
title: "Test Milestone",
status: "active",
orderIndex: 0,
interviewState: "not_started",
dependencies: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function createMockSlice(overrides: Partial<Slice> = {}): Slice {
return {
id: "SL-001",
milestoneId: "MS-001",
title: "Test Slice",
status: "active",
planState: "not_started",
orderIndex: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function createMockFeature(overrides: Partial<MissionFeature> = {}): MissionFeature {
return {
id: "F-001",
sliceId: "SL-001",
title: "Test Feature",
status: "defined",
loopState: "idle",
implementationAttemptCount: 0,
validatorAttemptCount: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function createMockValidatorRun(overrides: Partial<MissionValidatorRun> = {}): MissionValidatorRun {
return {
id: "VR-001",
featureId: "F-001",
milestoneId: "MS-001",
sliceId: "SL-001",
status: "running",
triggerType: "task_completion",
implementationAttempt: 1,
validatorAttempt: 1,
startedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function createMockMissionStore() {
const missions = new Map<string, Mission>();
const features = new Map<string, MissionFeature>();
const assertionsByFeature = new Map<string, Array<{ id: string; milestoneId: string; title: string; assertion: string; status: "pending" | "passed" | "failed" | "blocked"; orderIndex: number; createdAt: string; updatedAt: string; sourceFeatureId?: string }>>();
const validatorRuns = new Map<string, MissionValidatorRun>();
const store = {
// Mission methods
getMission: vi.fn((id: string) => missions.get(id)),
listMissions: vi.fn(() => [...missions.values()]),
updateMission: vi.fn((id: string, updates: Partial<Mission>) => {
const existing = missions.get(id);
if (!existing) throw new Error(`Mission ${id} not found`);
const updated = { ...existing, ...updates, updatedAt: new Date().toISOString() };
missions.set(id, updated);
return updated;
}),
getMissionWithHierarchy: vi.fn((id: string) => {
const mission = missions.get(id);
if (!mission) return undefined;
return {
...mission,
milestones: [createMockMilestone({ missionId: id })],
};
}),
logMissionEvent: vi.fn(),
// Feature methods
getFeature: vi.fn((id: string) => features.get(id)),
getFeatureByTaskId: vi.fn((taskId: string) => {
for (const feature of features.values()) {
if (feature.taskId === taskId) return feature;
}
return undefined;
}),
listFeatures: vi.fn(() => [...features.values()]),
updateFeatureStatus: vi.fn((id: string, status: MissionFeature["status"]) => {
const feature = features.get(id);
if (!feature) throw new Error(`Feature ${id} not found`);
const updated = { ...feature, status, updatedAt: new Date().toISOString() };
features.set(id, updated);
return updated;
}),
updateFeature: vi.fn((id: string, updates: Partial<MissionFeature>) => {
const feature = features.get(id);
if (!feature) throw new Error(`Feature ${id} not found`);
const updated = { ...feature, ...updates, updatedAt: new Date().toISOString() };
features.set(id, updated);
return updated;
}),
transitionLoopState: vi.fn((id: string, newState: MissionFeature["loopState"]) => {
const feature = features.get(id);
if (!feature) throw new Error(`Feature ${id} not found`);
const updated = { ...feature, loopState: newState, updatedAt: new Date().toISOString() };
features.set(id, updated);
return updated;
}),
listAssertionsForFeature: vi.fn((featureId: string) => assertionsByFeature.get(featureId) ?? []),
ensureFeatureAssertionLinked: vi.fn((featureId: string) => {
const feature = features.get(featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
if ((assertionsByFeature.get(featureId) ?? []).length === 0) {
store._addFeatureWithManagedAssertion(feature);
}
return assertionsByFeature.get(featureId) ?? [];
}),
getAssertionsForFeature: vi.fn((featureId: string) => assertionsByFeature.get(featureId) ?? []),
getSlice: vi.fn((id: string) => {
// Return a mock slice with milestoneId for the hierarchy
return createMockSlice({ id });
}),
getMilestone: vi.fn((id: string) => {
// Return a mock milestone with missionId for the hierarchy
return createMockMilestone({ id });
}),
// Validator run methods
startValidatorRun: vi.fn((featureId: string, _triggerType?: string, _taskId?: string) => {
const run = createMockValidatorRun({ featureId });
validatorRuns.set(run.id, run);
return run;
}),
listStaleRunningValidatorRuns: vi.fn((_maxAgeMs: number) => [...validatorRuns.values()].filter((run) => run.status === "running")),
reapValidatorRun: vi.fn((id: string, reason: string) => {
const run = validatorRuns.get(id);
if (!run) {
throw new Error(`Validator run ${id} not found`);
}
if (run.status !== "running") {
return run;
}
const updated = {
...run,
status: "error" as const,
summary: reason,
completedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
validatorRuns.set(id, updated);
const feature = features.get(run.featureId);
if (feature) {
features.set(run.featureId, {
...feature,
loopState: "needs_fix",
lastValidatorStatus: "error",
updatedAt: new Date().toISOString(),
});
}
return updated;
}),
getValidatorRun: vi.fn((id: string) => validatorRuns.get(id)),
completeValidatorRun: vi.fn((id: string, status: MissionValidatorRun["status"], summary?: string) => {
const run = validatorRuns.get(id);
if (!run) throw new Error(`Validator run ${id} not found`);
if (run.status !== "running") {
throw new Error(`Validator run ${id} is not in 'running' status`);
}
const updated = {
...run,
status,
summary,
completedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
validatorRuns.set(id, updated);
const feature = features.get(run.featureId);
if (feature) {
if (status === "passed") {
features.set(run.featureId, {
...feature,
loopState: "passed",
lastValidatorStatus: "passed",
updatedAt: new Date().toISOString(),
});
} else if (status === "failed") {
features.set(run.featureId, {
...feature,
loopState: "needs_fix",
lastValidatorStatus: "failed",
updatedAt: new Date().toISOString(),
});
} else if (status === "blocked") {
features.set(run.featureId, {
...feature,
loopState: "blocked",
lastValidatorStatus: "blocked",
updatedAt: new Date().toISOString(),
});
} else if (status === "error") {
features.set(run.featureId, {
...feature,
loopState: "validating",
lastValidatorStatus: "error",
updatedAt: new Date().toISOString(),
});
}
}
return updated;
}),
recordValidatorFailures: vi.fn(() => []),
createGeneratedFixFeature: vi.fn((sourceFeatureId: string, runId: string, _failedAssertionIds: string[]) => {
const sourceFeature = features.get(sourceFeatureId);
if (!sourceFeature) throw new Error(`Feature ${sourceFeatureId} not found`);
const fixFeature = createMockFeature({
id: `FIX-${sourceFeatureId}`,
sliceId: sourceFeature.sliceId,
title: `Fix for ${sourceFeature.title}`,
taskId: `TASK-FIX-${sourceFeatureId}`,
generatedFromFeatureId: sourceFeatureId,
generatedFromRunId: runId,
loopState: "implementing",
implementationAttemptCount: 0,
});
features.set(fixFeature.id, fixFeature);
const updatedSource = {
...sourceFeature,
implementationAttemptCount: (sourceFeature.implementationAttemptCount ?? 0) + 1,
loopState: "implementing" as const,
updatedAt: new Date().toISOString(),
};
features.set(sourceFeatureId, updatedSource);
return fixFeature;
}),
triageFeature: vi.fn(async (featureId: string) => {
const feature = features.get(featureId);
if (!feature) throw new Error(`Feature ${featureId} not found`);
// Simulate triage by updating the feature
const updated = { ...feature, status: "triaged" as const, updatedAt: new Date().toISOString() };
features.set(featureId, updated);
return updated;
}),
// Event emitter
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
// Internal setters for test setup
_setMission: (m: Mission) => missions.set(m.id, m),
_setFeature: (f: MissionFeature) => features.set(f.id, f),
_setAssertionsForFeature: (featureId: string, assertions: Array<{ id: string; milestoneId: string; title: string; assertion: string; status: "pending" | "passed" | "failed" | "blocked"; orderIndex: number; createdAt: string; updatedAt: string; sourceFeatureId?: string }>) => {
assertionsByFeature.set(featureId, assertions);
},
_addFeatureWithManagedAssertion: (f: MissionFeature) => {
features.set(f.id, f);
const now = new Date().toISOString();
assertionsByFeature.set(f.id, [{
id: `CA-${f.id}`,
milestoneId: "MS-001",
title: f.title,
assertion: f.acceptanceCriteria || f.description || `Verify implementation of: ${f.title}`,
status: "pending",
orderIndex: 0,
createdAt: now,
updatedAt: now,
sourceFeatureId: f.id,
}]);
},
_getValidatorRun: (id: string) => validatorRuns.get(id),
_clear: () => {
missions.clear();
features.clear();
assertionsByFeature.clear();
validatorRuns.clear();
},
};
return store;
}
function createMockTaskStore() {
const tasks = new Map<string, {
id: string;
title?: string;
description?: string;
log?: Array<{ action?: string }>;
column?: string;
missionId?: string;
sliceId?: string;
status?: string;
assignedAgentId?: string;
validatorModelProvider?: string;
validatorModelId?: string;
}>();
const store = {
getTask: vi.fn(async (id: string) => tasks.get(id)),
createTask: vi.fn(async (input: { title?: string; description?: string; column?: string; missionId?: string; sliceId?: string }) => {
const id = `KB-${tasks.size + 1}`;
const task = { id, ...input };
tasks.set(id, task);
return task;
}),
moveTask: vi.fn(async () => {}),
updateTask: vi.fn(async () => {}),
getSettings: vi.fn().mockResolvedValue({
missionStaleThresholdMs: 600_000,
missionMaxTaskRetries: 3,
}),
recordRunAuditEvent: vi.fn(),
on: vi.fn(),
off: vi.fn(),
_setTask: (t: {
id: string;
title?: string;
description?: string;
log?: Array<{ action?: string }>;
column?: string;
missionId?: string;
sliceId?: string;
status?: string;
assignedAgentId?: string;
validatorModelProvider?: string;
validatorModelId?: string;
}) => tasks.set(t.id, t),
_clear: () => tasks.clear(),
};
return store;
}
// Helper to make mock session with AI response
function makeMockSession(responseContent: string) {
return {
state: {
messages: [
{ role: "user", content: "Validate this" },
{ role: "assistant", content: responseContent },
],
},
dispose: vi.fn(),
};
}
// Helper to make assertions
function makeAssertions(count: number): MissionContractAssertion[] {
return Array.from({ length: count }, (_, i) => ({
id: `CA-${i + 1}`,
milestoneId: "MS-001",
title: `Assertion ${i + 1}`,
assertion: `Should do thing ${i + 1}`,
status: "pending" as const,
orderIndex: i,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
}
function expectNoValidationBoardTaskMutation(taskStore: ReturnType<typeof createMockTaskStore>) {
expect(taskStore.updateTask).not.toHaveBeenCalled();
expect(taskStore.moveTask).not.toHaveBeenCalled();
}
// ── Tests ───────────────────────────────────────────────────────────────────
describe("MissionExecutionLoop", () => {
let loop: MissionExecutionLoop;
let missionStore: ReturnType<typeof createMockMissionStore>;
let taskStore: ReturnType<typeof createMockTaskStore>;
let agentStore: { getAgent: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.useFakeTimers();
missionStore = createMockMissionStore();
taskStore = createMockTaskStore();
agentStore = {
getAgent: vi.fn(),
};
vi.mocked(createResolvedAgentSession).mockReset();
vi.mocked(createResolvedAgentSession).mockResolvedValue({
session: mockSessionHolder.session as any,
sessionFile: undefined,
runtimeId: "test-runtime",
wasConfigured: true,
});
const mission = createMockMission();
missionStore._setMission(mission);
// Reset mock session state before each test
resetMockSession();
});
afterEach(() => {
loop?.stop();
vi.useRealTimers();
vi.restoreAllMocks();
});
// ── Lifecycle ────────────────────────────────────────────────────────────
describe("start/stop", () => {
it("should start and be running", () => {
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
expect(loop.isRunning()).toBe(true);
});
it("should be idempotent on start", () => {
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
loop.start(); // Should not throw
expect(loop.isRunning()).toBe(true);
});
it("should stop cleanly", () => {
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
loop.stop();
expect(loop.isRunning()).toBe(false);
});
it("should be idempotent on stop", () => {
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.stop(); // Should not throw
expect(loop.isRunning()).toBe(false);
});
});
describe("recoverActiveMissions stranded done features", () => {
function wireHierarchy(slice: Slice, features: MissionFeature[]) {
missionStore.getMissionWithHierarchy = vi.fn((id: string) => {
const mission = missionStore.getMission(id);
if (!mission) return undefined;
return {
...mission,
milestones: [
{
...createMockMilestone({ missionId: id }),
slices: [{ ...slice, features }],
},
],
};
}) as any;
}
it("re-validates a done feature stranded in 'implementing' with no linked task", async () => {
// Regression: a feature marked "done" whose loopState never left
// "implementing" (and which was never validated and has no board task)
// can never validate on its own — the prior recovery loop only re-drove
// implementing features that still had a taskId. The slice-completion
// gate then refuses to count it, wedging the whole mission. Recovery
// must re-drive validation so the slice can eventually complete.
const mission = createMockMission({ id: "M-STRAND", status: "active" });
missionStore._setMission(mission);
const slice = createMockSlice({ id: "SL-STRAND", milestoneId: "MS-001", status: "active" });
const orphan = createMockFeature({
id: "F-STRAND",
sliceId: "SL-STRAND",
status: "done",
loopState: "implementing",
lastValidatorStatus: undefined,
taskId: undefined,
});
(missionStore as any)._addFeatureWithManagedAssertion(orphan);
wireHierarchy(slice, [missionStore.getFeature("F-STRAND") as MissionFeature]);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
const result = await loop.recoverActiveMissions();
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-STRAND", "task_completion");
expect(result.recoveredCount).toBeGreaterThanOrEqual(1);
});
it("leaves an already-validated done feature untouched", async () => {
const mission = createMockMission({ id: "M-OK", status: "active" });
missionStore._setMission(mission);
const slice = createMockSlice({ id: "SL-OK", milestoneId: "MS-001", status: "active" });
const validated = createMockFeature({
id: "F-OK",
sliceId: "SL-OK",
status: "done",
loopState: "passed",
lastValidatorStatus: "passed",
taskId: undefined,
});
(missionStore as any)._addFeatureWithManagedAssertion(validated);
wireHierarchy(slice, [missionStore.getFeature("F-OK") as MissionFeature]);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await loop.recoverActiveMissions();
expect(missionStore.startValidatorRun).not.toHaveBeenCalled();
});
});
describe("reapStaleValidatorRuns", () => {
it("reaps stale runs across trigger types and records audit metadata", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-01T12:00:00.000Z"));
const mission = createMockMission({ id: "M-001" });
missionStore._setMission(mission);
const featureManual = createMockFeature({ id: "F-manual", taskId: "FN-manual", loopState: "validating" });
const featureAuto = createMockFeature({ id: "F-auto", taskId: "FN-auto", loopState: "validating" });
missionStore._setFeature(featureManual);
missionStore._setFeature(featureAuto);
missionStore.getMilestone = vi.fn(() => createMockMilestone({ id: "MS-001", missionId: mission.id }));
missionStore.listStaleRunningValidatorRuns = vi.fn(() => [
createMockValidatorRun({ id: "VR-manual", featureId: featureManual.id, triggerType: "manual", startedAt: "2026-06-01T11:40:00.000Z" }),
createMockValidatorRun({ id: "VR-auto", featureId: featureAuto.id, triggerType: "auto", startedAt: "2026-06-01T11:50:00.000Z" }),
]);
missionStore.reapValidatorRun = vi.fn((id: string, reason: string) => ({
...createMockValidatorRun({
id,
featureId: id === "VR-manual" ? featureManual.id : featureAuto.id,
triggerType: id === "VR-manual" ? "manual" : "auto",
startedAt: id === "VR-manual" ? "2026-06-01T11:40:00.000Z" : "2026-06-01T11:50:00.000Z",
}),
status: "error",
summary: reason,
completedAt: "2026-06-01T12:00:00.000Z",
updatedAt: "2026-06-01T12:00:00.000Z",
}));
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const result = await loop.reapStaleValidatorRuns(15 * 60 * 1000);
expect(result).toEqual({ reapedCount: 2 });
expect(missionStore.reapValidatorRun).toHaveBeenCalledTimes(2);
expect(taskStore.recordRunAuditEvent).toHaveBeenNthCalledWith(1, expect.objectContaining({
agentId: "store",
runId: "validator-run-reaper",
domain: "database",
mutationType: "mission:validator-run-reaped",
target: "VR-manual",
metadata: expect.objectContaining({
runId: "VR-manual",
featureId: featureManual.id,
missionId: mission.id,
triggerType: "manual",
elapsedMs: 20 * 60 * 1000,
}),
}));
expect(taskStore.recordRunAuditEvent).toHaveBeenNthCalledWith(2, expect.objectContaining({
target: "VR-auto",
metadata: expect.objectContaining({
runId: "VR-auto",
featureId: featureAuto.id,
missionId: mission.id,
triggerType: "auto",
elapsedMs: 10 * 60 * 1000,
}),
}));
});
it("skips stale runs still actively owned in-process", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-01T12:00:00.000Z"));
const feature = createMockFeature({ id: "F-live", taskId: "FN-live", loopState: "implementing" });
missionStore._setFeature(feature);
missionStore.listStaleRunningValidatorRuns = vi.fn(() => [
createMockValidatorRun({ id: "VR-live", featureId: feature.id, startedAt: "2026-06-01T11:30:00.000Z" }),
]);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
(loop as any).activeValidations.add(feature.id);
const reaped = await loop.reapStaleValidatorRuns(15 * 60 * 1000);
expect(reaped).toEqual({ reapedCount: 0 });
expect(missionStore.reapValidatorRun).not.toHaveBeenCalled();
expect(taskStore.recordRunAuditEvent).not.toHaveBeenCalled();
});
it("isolates per-run reap failures", async () => {
missionStore.listStaleRunningValidatorRuns = vi.fn(() => [
createMockValidatorRun({ id: "VR-bad", featureId: "F-bad" }),
createMockValidatorRun({ id: "VR-good", featureId: "F-good" }),
]);
missionStore.reapValidatorRun = vi.fn((id: string) => {
if (id === "VR-bad") {
throw new Error("boom");
}
return {
...createMockValidatorRun({ id, featureId: "F-good" }),
status: "error",
summary: "reaped",
completedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
});
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const result = await loop.reapStaleValidatorRuns(15 * 60 * 1000);
expect(result).toEqual({ reapedCount: 1 });
expect(missionStore.reapValidatorRun).toHaveBeenCalledTimes(2);
expect(taskStore.recordRunAuditEvent).toHaveBeenCalledTimes(1);
});
});
// ── processTaskOutcome ───────────────────────────────────────────────────
describe("processTaskOutcome", () => {
it("should skip if loop is not running", async () => {
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001" });
missionStore._setFeature(feature);
taskStore._setTask({ id: "FN-001" });
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
// Don't start - loop is not running
await loop.processTaskOutcome("FN-001");
// Should not start validator run
expect(missionStore.startValidatorRun).not.toHaveBeenCalled();
});
it("should skip if task has no linked feature", async () => {
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(undefined);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await loop.processTaskOutcome("FN-999");
expect(missionStore.startValidatorRun).not.toHaveBeenCalled();
});
it("should skip if feature is not in implementing state", async () => {
const feature = createMockFeature({ loopState: "idle", taskId: "FN-001" });
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await loop.processTaskOutcome("FN-001");
expect(missionStore.startValidatorRun).not.toHaveBeenCalled();
expect(missionStore.logMissionEvent).toHaveBeenCalledWith(
expect.any(String),
"warning",
expect.stringContaining("Validation skipped"),
expect.objectContaining({
code: "validation_skipped_loop_state",
featureId: "F-001",
taskId: "FN-001",
loopState: "idle",
}),
);
});
it("requeues needs_fix features back through validation", async () => {
const assertions = makeAssertions(1);
const response = JSON.stringify({
status: "pass",
assertions: [{ assertionId: "CA-1", passed: true, message: "OK" }],
summary: "Recovered validation passed",
});
mockSessionHolder.session.state.messages = [
{ role: "user", content: "Validate this" },
{ role: "assistant", content: response },
];
const feature = createMockFeature({ loopState: "needs_fix", taskId: "FN-NEEDS-FIX" });
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue(assertions);
taskStore._setTask({ id: "FN-NEEDS-FIX", title: "Test", description: "Implementation", log: [], column: "done" });
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await loop.processTaskOutcome("FN-NEEDS-FIX");
expect(missionStore.transitionLoopState).toHaveBeenCalledWith("F-001", "implementing");
expect(missionStore.startValidatorRun).toHaveBeenCalled();
expect(missionStore.completeValidatorRun).toHaveBeenCalledWith(expect.any(String), "passed", "Recovered validation passed");
});
it("lazy-ensures a managed assertion and routes zero-assertion features through validation", async () => {
const feature = createMockFeature({
id: "F-001",
loopState: "implementing",
taskId: "FN-001",
title: "Feature from prose",
acceptanceCriteria: "Feature must validate through AI",
});
missionStore._setFeature(feature);
taskStore._setTask({ id: "FN-001", title: "Test", description: "Test task", log: [] });
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi
.fn()
.mockReturnValueOnce([])
.mockImplementation((featureId: string) => (missionStore as any).getAssertionsForFeature(featureId));
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const emitSpy = vi.spyOn(loop, "emit");
vi.spyOn(loop as any, "runValidation").mockResolvedValue({ status: "pass", summary: "ok" });
loop.start();
await loop.processTaskOutcome("FN-001");
expect(missionStore.ensureFeatureAssertionLinked).toHaveBeenCalledWith("F-001");
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion");
expect(emitSpy).toHaveBeenCalledWith(
"validation:passed",
expect.objectContaining({ featureId: "F-001" }),
);
const noAssertionEvents = missionStore.logMissionEvent.mock.calls.filter(
([, , , payload]) => payload?.code === "validation_auto_passed_no_assertions",
);
expect(noAssertionEvents).toHaveLength(0);
expectNoValidationBoardTaskMutation(taskStore);
});
it("does not emit auto-pass evidence across re-entry after lazy assertion ensure", async () => {
const feature = createMockFeature({
id: "F-001",
loopState: "implementing",
taskId: "FN-001",
title: "Feature from prose",
acceptanceCriteria: "Feature must validate through AI",
});
missionStore._setFeature(feature);
taskStore._setTask({ id: "FN-001", title: "Test", description: "Test task", log: [] });
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi
.fn()
.mockReturnValueOnce([])
.mockImplementation((featureId: string) => (missionStore as any).getAssertionsForFeature(featureId));
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await loop.processTaskOutcome("FN-001");
await loop.processTaskOutcome("FN-001");
expect(missionStore.ensureFeatureAssertionLinked).toHaveBeenCalledTimes(1);
const noAssertionEvents = missionStore.logMissionEvent.mock.calls.filter(
([, , , payload]) => payload?.code === "validation_auto_passed_no_assertions",
);
expect(noAssertionEvents).toHaveLength(0);
});
it("uses validator path for later-added feature with managed assertion", async () => {
const feature = createMockFeature({
id: "F-LATER",
loopState: "implementing",
taskId: "FN-LATER",
title: "Later Feature",
acceptanceCriteria: "Later criteria",
});
(missionStore as any)._addFeatureWithManagedAssertion(feature);
taskStore._setTask({ id: "FN-LATER", title: "Later task", description: "done", log: [] });
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await loop.processTaskOutcome("FN-LATER");
expect(missionStore.listAssertionsForFeature).toHaveBeenCalledWith("F-LATER");
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-LATER", "task_completion");
});
it("threads milestone acceptance criteria into validator prompts", () => {
const feature = createMockFeature({
id: "F-MILESTONE",
title: "Feature under milestone",
acceptanceCriteria: "Feature criteria",
});
const milestone = createMockMilestone({
id: "MS-MILESTONE",
acceptanceCriteria: "Milestone pass bar text",
});
const assertions = [
{
id: "CA-1",
milestoneId: milestone.id,
title: "Managed assertion",
assertion: "Feature criteria",
status: "pending" as const,
orderIndex: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const prompt = (loop as any).buildValidationPrompt(feature, assertions, milestone);
const systemPrompt = (loop as any).buildValidationSystemPrompt(feature, assertions, "Task context", milestone);
expect(prompt).toContain("Milestone pass bar text");
expect(prompt).toContain("must also be satisfied for this feature to pass");
expect(systemPrompt).toContain("Milestone pass bar text");
expect(systemPrompt).toContain("validator-executed requirements");
});
it("does NOT create a board task for single-feature validation", async () => {
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001", sliceId: "SL-001" });
missionStore._setFeature(feature);
taskStore._setTask({ id: "FN-001", title: "Test", description: "Test task", log: [] });
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue([
{ id: "CA-1", milestoneId: "MS-001", title: "Test assertion", assertion: "Should work", status: "pending" as const, orderIndex: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
]);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await loop.processTaskOutcome("FN-001");
expect(taskStore.createTask).toHaveBeenCalledTimes(0);
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion");
});
it("does NOT set mission-validation status on any task", async () => {
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001", sliceId: "SL-001" });
missionStore._setFeature(feature);
taskStore._setTask({ id: "FN-001", title: "Test", description: "Test task", log: [] });
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue([
{ id: "CA-1", milestoneId: "MS-001", title: "Test assertion", assertion: "Should work", status: "pending" as const, orderIndex: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
]);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await loop.processTaskOutcome("FN-001");
expect(taskStore.updateTask).not.toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ status: "mission-validation" }),
);
});
it("skips duplicate trigger when feature already has active validation", async () => {
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001", sliceId: "SL-001" });
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue([
{ id: "CA-1", milestoneId: "MS-001", title: "Test assertion", assertion: "Should work", status: "pending" as const, orderIndex: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
]);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
(loop as any).activeValidations.add("F-001");
await loop.processTaskOutcome("FN-001");
expect(missionStore.startValidatorRun).not.toHaveBeenCalled();
expect(missionStore.logMissionEvent).toHaveBeenCalledWith(
expect.any(String),
"warning",
expect.stringContaining("duplicate trigger"),
expect.objectContaining({
code: "validation_deduplicated",
featureId: "F-001",
taskId: "FN-001",
}),
);
});
it("calls startValidatorRun without a board task ID", async () => {
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001", sliceId: "SL-001" });
missionStore._setFeature(feature);
taskStore._setTask({ id: "FN-001", title: "Test", description: "Test task", log: [] });
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue([
{ id: "CA-1", milestoneId: "MS-001", title: "Test assertion", assertion: "Should work", status: "pending" as const, orderIndex: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
]);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await loop.processTaskOutcome("FN-001");
expect(missionStore.startValidatorRun).toHaveBeenCalledWith(
"F-001",
"task_completion",
);
});
it("threads configured validator/default model settings into mission validation sessions", async () => {
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-MODEL-SETTINGS", status: "in-progress" });
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue(makeAssertions(1));
taskStore._setTask({
id: "FN-MODEL-SETTINGS",
title: "Validation model settings",
description: "Implementation",
log: [],
validatorModelProvider: "task-validator",
validatorModelId: "task-validator-model",
});
vi.mocked(taskStore.getSettings).mockResolvedValue({
missionStaleThresholdMs: 600_000,
missionMaxTaskRetries: 3,
validatorProvider: "project-validator",
validatorModelId: "project-validator-model",
defaultProviderOverride: "project-default-override",
defaultModelIdOverride: "project-default-override-model",
defaultProvider: "project-default",
defaultModelId: "project-default-model",
fallbackProvider: "fallback-provider",
fallbackModelId: "fallback-model",
});
mockSessionHolder.session.state.messages = [
{ role: "assistant", content: JSON.stringify({ status: "pass", assertions: [{ assertionId: "CA-1", passed: true }], summary: "all good" }) },
];
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await loop.processTaskOutcome("FN-MODEL-SETTINGS");
expect(createResolvedAgentSession).toHaveBeenCalledWith(expect.objectContaining({
sessionPurpose: "validation",
defaultProvider: "task-validator",
defaultModelId: "task-validator-model",
fallbackProvider: "fallback-provider",
fallbackModelId: "fallback-model",
settings: expect.objectContaining({
validatorProvider: "project-validator",
validatorModelId: "project-validator-model",
defaultProvider: "project-default",
defaultModelId: "project-default-model",
}),
}));
});
it("uses task/settings validator model ahead of assigned agent runtime model for mission validation", async () => {
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-MODEL-AGENT", status: "in-progress" });
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue(makeAssertions(1));
taskStore._setTask({
id: "FN-MODEL-AGENT",
title: "Validation model agent",
description: "Implementation",
log: [],
assignedAgentId: "agent-1",
validatorModelProvider: "task-validator",
validatorModelId: "task-validator-model",
});
agentStore.getAgent.mockResolvedValue({
id: "agent-1",
runtimeConfig: { model: "agent-provider/agent-model" },
});
vi.mocked(taskStore.getSettings).mockResolvedValue({
missionStaleThresholdMs: 600_000,
missionMaxTaskRetries: 3,
validatorProvider: "project-validator",
validatorModelId: "project-validator-model",
});
mockSessionHolder.session.state.messages = [
{ role: "assistant", content: JSON.stringify({ status: "pass", assertions: [{ assertionId: "CA-1", passed: true }], summary: "all good" }) },
];
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
agentStore: agentStore as any,
});
loop.start();
await loop.processTaskOutcome("FN-MODEL-AGENT");
expect(createResolvedAgentSession).toHaveBeenCalledWith(expect.objectContaining({
sessionPurpose: "validation",
defaultProvider: "task-validator",
defaultModelId: "task-validator-model",
}));
});
it("forces mock/scripted validator lane when test mode is active", async () => {
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-MODEL-TESTMODE", status: "in-progress" });
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue(makeAssertions(1));
taskStore._setTask({
id: "FN-MODEL-TESTMODE",
title: "Validation model test mode",
description: "Implementation",
log: [],
assignedAgentId: "agent-1",
validatorModelProvider: "task-validator",
validatorModelId: "task-validator-model",
});
agentStore.getAgent.mockResolvedValue({
id: "agent-1",
runtimeConfig: { model: "agent-provider/agent-model" },
});
vi.mocked(taskStore.getSettings).mockResolvedValue({
missionStaleThresholdMs: 600_000,
missionMaxTaskRetries: 3,
testMode: true,
validatorProvider: "project-validator",
validatorModelId: "project-validator-model",
fallbackProvider: "fallback-provider",
fallbackModelId: "fallback-model",
});
mockSessionHolder.session.state.messages = [
{ role: "assistant", content: JSON.stringify({ status: "pass", assertions: [{ assertionId: "CA-1", passed: true }], summary: "all good" }) },
];
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
agentStore: agentStore as any,
});
loop.start();
await loop.processTaskOutcome("FN-MODEL-TESTMODE");
expect(createResolvedAgentSession).toHaveBeenCalledWith(expect.objectContaining({
sessionPurpose: "validation",
defaultProvider: TEST_MODE_RESOLVED.provider,
defaultModelId: TEST_MODE_RESOLVED.modelId,
fallbackProvider: "fallback-provider",
fallbackModelId: "fallback-model",
}));
});
it("runs linked assertions and marks completion only when validation passes", async () => {
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-ASSERT-PASS", status: "in-progress" });
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue(makeAssertions(2));
taskStore._setTask({ id: "FN-ASSERT-PASS", title: "Assertion pass", description: "Implementation", log: [] });
mockSessionHolder.session.state.messages = [
{ role: "assistant", content: JSON.stringify({ status: "pass", assertions: [{ assertionId: "CA-1", passed: true }, { assertionId: "CA-2", passed: true }], summary: "all good" }) },
];
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await loop.processTaskOutcome("FN-ASSERT-PASS");
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion");
expect(missionStore.completeValidatorRun).toHaveBeenCalledWith(expect.any(String), "passed", expect.any(String));
expect(missionStore.getFeature("F-001")?.loopState).toBe("passed");
expect(missionStore.getFeature("F-001")?.lastValidatorStatus).toBe("passed");
expect(missionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done");
});
it("routes failed assertion validation to fix flow and does not pass feature", async () => {
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-ASSERT-FAIL", status: "in-progress" });
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue(makeAssertions(2));
taskStore._setTask({ id: "FN-ASSERT-FAIL", title: "Assertion fail", description: "Implementation", log: [] });
mockSessionHolder.session.state.messages = [
{ role: "assistant", content: JSON.stringify({ status: "fail", assertions: [{ assertionId: "CA-1", passed: false, message: "miss" }], summary: "failed" }) },
];
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await loop.processTaskOutcome("FN-ASSERT-FAIL");
expect(missionStore.completeValidatorRun).toHaveBeenCalledWith(expect.any(String), "failed", expect.any(String));
expect(missionStore.createGeneratedFixFeature).toHaveBeenCalled();
expect(missionStore.getFeature("F-001")?.lastValidatorStatus).toBe("failed");
expect(missionStore.getFeature("F-001")?.loopState).toBe("implementing");
expect(missionStore.getFeature("F-001")?.status).not.toBe("done");
});
});
// ── recoverActiveMissions ────────────────────────────────────────────────
describe("recoverActiveMissions", () => {
it("should not crash when called on stopped loop", async () => {
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
// Don't start - loop is not running
await expect(loop.recoverActiveMissions()).resolves.not.toThrow();
});
it("should not crash when getMissionWithHierarchy returns null", async () => {
const mission = createMockMission({ status: "active" });
missionStore._setMission(mission);
missionStore.getMissionWithHierarchy = vi.fn().mockReturnValue(null);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await expect(loop.recoverActiveMissions()).resolves.not.toThrow();
});
it("should not crash when getMissionWithHierarchy throws", async () => {
const mission = createMockMission({ status: "active" });
missionStore._setMission(mission);
missionStore.getMissionWithHierarchy = vi.fn().mockImplementation(() => {
throw new Error("Database error");
});
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await expect(loop.recoverActiveMissions()).resolves.not.toThrow();
});
it("logs warn when mission hierarchy lookup throws during recovery", async () => {
const mission = createMockMission({ id: "M-LOOKUP", status: "active" });
missionStore._setMission(mission);
missionStore.getMissionWithHierarchy = vi.fn().mockImplementation(() => {
throw new Error("Database error");
});
vi.mocked(loopLog.warn).mockClear();
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await loop.recoverActiveMissions();
expect(loopLog.warn).toHaveBeenCalledWith(
expect.stringContaining(
"getMissionWithHierarchy failed for mission M-LOOKUP: Database error",
),
);
});
it("should handle empty hierarchy gracefully", async () => {
const mission = createMockMission({ status: "active" });
missionStore._setMission(mission);
missionStore.getMissionWithHierarchy = vi.fn().mockReturnValue({
...mission,
milestones: [],
});
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await loop.recoverActiveMissions();
expect(missionStore.transitionLoopState).not.toHaveBeenCalled();
});
it("should not recover features from archived missions", async () => {
const mission = createMockMission({ status: "archived" });
missionStore._setMission(mission);
missionStore.getMissionWithHierarchy = vi.fn().mockReturnValue({
...mission,
milestones: [],
});
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await loop.recoverActiveMissions();
expect(missionStore.transitionLoopState).not.toHaveBeenCalled();
});
});
// ── Error handling ────────────────────────────────────────────────────────
describe("error handling", () => {
it("should not crash the loop on processTaskOutcome errors", async () => {
missionStore.getFeatureByTaskId = vi.fn().mockImplementation(() => {
throw new Error("Database error");
});
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await expect(loop.processTaskOutcome("FN-001")).resolves.not.toThrow();
});
});
// ── parseValidationResult JSON extraction ─────────────────────────────────
describe("parseValidationResult", () => {
it("should parse pass result from plain JSON", async () => {
const assertions = makeAssertions(2);
const response = JSON.stringify({
status: "pass",
assertions: [
{ assertionId: "CA-1", passed: true, message: "OK" },
{ assertionId: "CA-2", passed: true, message: "OK" },
],
summary: "All assertions passed",
});
// Set up mock session with AI response
mockSessionHolder.session.state.messages = [
{ role: "user", content: "Validate this" },
{ role: "assistant", content: response },
];
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001" });
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue(assertions);
taskStore._setTask({ id: "FN-001", title: "Test", description: "Implementation", log: [] });
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const emitSpy = vi.spyOn(loop, "emit");
loop.start();
await loop.processTaskOutcome("FN-001");
// Should emit validation:passed
expect(emitSpy).toHaveBeenCalledWith(
"validation:passed",
expect.objectContaining({ featureId: "F-001" }),
);
// completeValidatorRun should be called with passed
expect(missionStore.completeValidatorRun).toHaveBeenCalledWith(
expect.any(String),
"passed",
expect.any(String),
);
expect(missionStore.updateFeature).not.toHaveBeenCalled();
expectNoValidationBoardTaskMutation(taskStore);
});
it("should parse fail result from JSON in markdown code block", async () => {
const assertions = makeAssertions(2);
const response = {
status: "fail",
assertions: [
{ assertionId: "CA-1", passed: true, message: "OK" },
{ assertionId: "CA-2", passed: false, message: "Failed", expected: "true", actual: "false" },
],
summary: "One assertion failed",
};
// Set up mock session with AI response in markdown code block
mockSessionHolder.session.state.messages = [
{ role: "user", content: "Validate this" },
{ role: "assistant", content: "```json\n" + JSON.stringify(response) + "\n```" },
];
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001" });
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue(assertions);
taskStore._setTask({ id: "FN-001", title: "Test", description: "Implementation", log: [] });
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const emitSpy = vi.spyOn(loop, "emit");
loop.start();
await loop.processTaskOutcome("FN-001");
// Should emit validation:failed
expect(emitSpy).toHaveBeenCalledWith(
"validation:failed",
expect.objectContaining({ featureId: "F-001" }),
);
// recordValidatorFailures should be called
expect(missionStore.recordValidatorFailures).toHaveBeenCalled();
// completeValidatorRun should be called with failed
expect(missionStore.completeValidatorRun).toHaveBeenCalledWith(
expect.any(String),
"failed",
expect.any(String),
);
// createGeneratedFixFeature should be called
expect(missionStore.createGeneratedFixFeature).toHaveBeenCalled();
expectNoValidationBoardTaskMutation(taskStore);
});
it("should handle malformed JSON gracefully", async () => {
const assertions = makeAssertions(1);
// Malformed JSON with trailing comma
const malformedResponse = '{"status":"blocked","assertions":[{"assertionId":"CA-1","passed":false}],"summary":"Blocked","blockedReason":"API down",}';
// Set up mock session with malformed JSON
mockSessionHolder.session.state.messages = [
{ role: "user", content: "Validate this" },
{ role: "assistant", content: malformedResponse },
];
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001" });
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue(assertions);
taskStore._setTask({ id: "FN-001", title: "Test", description: "Implementation", log: [] });
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const emitSpy = vi.spyOn(loop, "emit");
loop.start();
await loop.processTaskOutcome("FN-001");
// When JSON is malformed and cannot be repaired, it should result in an error status
// The loop should handle the error gracefully
expect(emitSpy).toHaveBeenCalledWith(
expect.stringMatching(/validation:(passed|failed|blocked|error)/),
expect.any(Object),
);
});
it("should handle AI session returning no messages gracefully", async () => {
const assertions = makeAssertions(1);
// Session with no messages
mockSessionHolder.session.state.messages = [];
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001" });
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue(assertions);
taskStore._setTask({ id: "FN-001", title: "Test", description: "Implementation", log: [] });
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
// Should not throw - error is caught and handled
await expect(loop.processTaskOutcome("FN-001")).resolves.not.toThrow();
});
});
// ── handleValidationPass ──────────────────────────────────────────────────
describe("handleValidationPass", () => {
it("should mark feature as passed and notify autopilot", async () => {
const feature = createMockFeature({
loopState: "implementing",
taskId: "FN-001",
id: "F-001",
});
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue([]);
taskStore._setTask({ id: "FN-001", title: "Test", description: "Test", log: [] });
const notifySpy = vi.fn();
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
missionAutopilot: {
notifyValidationComplete: notifySpy,
},
});
const emitSpy = vi.spyOn(loop, "emit");
vi.spyOn(loop as any, "runValidation").mockResolvedValue({ status: "pass", summary: "ok" });
loop.start();
await loop.processTaskOutcome("FN-001");
expect(missionStore.ensureFeatureAssertionLinked).toHaveBeenCalledWith("F-001");
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion");
// validation:passed event emitted
expect(emitSpy).toHaveBeenCalledWith(
"validation:passed",
expect.objectContaining({ featureId: "F-001" }),
);
// Autopilot notified
expect(notifySpy).toHaveBeenCalledWith("F-001", "passed");
});
it("skips completion when the validator run was reaped mid-flight", async () => {
const assertions = makeAssertions(1);
const response = JSON.stringify({
status: "pass",
assertions: [{ assertionId: "CA-1", passed: true, message: "OK" }],
summary: "All assertions passed",
});
mockSessionHolder.session.state.messages = [
{ role: "user", content: "Validate this" },
{ role: "assistant", content: response },
];
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-REAPED", id: "F-REAPED" });
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue(assertions);
taskStore._setTask({ id: "FN-REAPED", title: "Test", description: "Implementation", log: [] });
const originalStartValidatorRun = missionStore.startValidatorRun;
missionStore.startValidatorRun = vi.fn((featureId: string, triggerType?: string, taskId?: string) => {
const run = originalStartValidatorRun(featureId, triggerType, taskId);
missionStore.reapValidatorRun(run.id, "stale");
return run;
});
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const emitSpy = vi.spyOn(loop, "emit");
loop.start();
await expect(loop.processTaskOutcome("FN-REAPED")).resolves.not.toThrow();
expect(missionStore.completeValidatorRun).not.toHaveBeenCalledWith(expect.any(String), "passed", expect.any(String));
expect(emitSpy).toHaveBeenCalledWith(
"validation:passed",
expect.objectContaining({ featureId: "F-REAPED" }),
);
});
});
// ── handleValidationFail ──────────────────────────────────────────────────
describe("handleValidationFail", () => {
it("should generate fix feature and record failures", async () => {
const assertions: MissionContractAssertion[] = [
{
id: "CA-1",
milestoneId: "MS-001",
title: "Test assertion",
assertion: "Should work",
status: "pending",
orderIndex: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
const feature = createMockFeature({
loopState: "implementing",
taskId: "FN-001",
id: "F-001",
implementationAttemptCount: 1,
});
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue(assertions);
// Mock AI to return fail response
const failResponse = JSON.stringify({
status: "fail",
assertions: [{ assertionId: "CA-1", passed: false, message: "Failed", expected: "ok", actual: "not ok" }],
summary: "Assertion failed",
});
mockSessionHolder.session.state.messages = [
{ role: "user", content: "Validate this" },
{ role: "assistant", content: failResponse },
];
taskStore._setTask({ id: "FN-001", title: "Test", description: "Implementation", log: [] });
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const emitSpy = vi.spyOn(loop, "emit");
loop.start();
await loop.processTaskOutcome("FN-001");
// recordValidatorFailures called
expect(missionStore.recordValidatorFailures).toHaveBeenCalled();
// completeValidatorRun called with failed
expect(missionStore.completeValidatorRun).toHaveBeenCalledWith(
expect.any(String),
"failed",
expect.any(String),
);
// createGeneratedFixFeature called
expect(missionStore.createGeneratedFixFeature).toHaveBeenCalledWith(
"F-001",
expect.any(String),
expect.arrayContaining(["CA-1"]),
);
// triageFeature called for the fix feature
expect(missionStore.triageFeature).toHaveBeenCalledWith(
expect.stringContaining("FIX-"),
);
// validation:failed event emitted
expect(emitSpy).toHaveBeenCalledWith(
"validation:failed",
expect.objectContaining({
featureId: "F-001",
failures: expect.arrayContaining([
expect.objectContaining({ assertionId: "CA-1" }),
]),
}),
);
});
it("should emit validation:failed even if triageFeature throws", async () => {
const assertions: MissionContractAssertion[] = [
{
id: "CA-1",
milestoneId: "MS-001",
title: "Test assertion",
assertion: "Should work",
status: "pending",
orderIndex: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
const feature = createMockFeature({
loopState: "implementing",
taskId: "FN-001",
id: "F-001",
implementationAttemptCount: 1,
});
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue(assertions);
// Mock AI to return fail response
const failResponse = JSON.stringify({
status: "fail",
assertions: [{ assertionId: "CA-1", passed: false, message: "Failed", expected: "ok", actual: "not ok" }],
summary: "Assertion failed",
});
mockSessionHolder.session.state.messages = [
{ role: "user", content: "Validate this" },
{ role: "assistant", content: failResponse },
];
// Make triageFeature throw an error
missionStore.triageFeature = vi.fn().mockRejectedValue(new Error("Triage failed"));
taskStore._setTask({ id: "FN-001", title: "Test", description: "Implementation", log: [] });
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const emitSpy = vi.spyOn(loop, "emit");
loop.start();
await loop.processTaskOutcome("FN-001");
// triageFeature was called but threw
expect(missionStore.triageFeature).toHaveBeenCalledWith(expect.stringContaining("FIX-"));
// validation:failed event should still be emitted
expect(emitSpy).toHaveBeenCalledWith(
"validation:failed",
expect.objectContaining({
featureId: "F-001",
}),
);
});
});
// ── handleValidationBlocked ───────────────────────────────────────────────
describe("handleValidationBlocked", () => {
it("should mark feature as blocked without generating fix", async () => {
const assertions: MissionContractAssertion[] = [
{
id: "CA-1",
milestoneId: "MS-001",
title: "Test assertion",
assertion: "Should work",
status: "pending",
orderIndex: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
const feature = createMockFeature({
loopState: "implementing",
taskId: "FN-001",
id: "F-001",
});
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue(assertions);
// Mock AI to return blocked response
const blockedResponse = JSON.stringify({
status: "blocked",
assertions: [{ assertionId: "CA-1", passed: false, message: "Blocked" }],
summary: "Validation blocked",
blockedReason: "External API not available",
});
mockSessionHolder.session.state.messages = [
{ role: "user", content: "Validate this" },
{ role: "assistant", content: blockedResponse },
];
taskStore._setTask({ id: "FN-001", title: "Test", description: "Implementation", log: [] });
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const emitSpy = vi.spyOn(loop, "emit");
loop.start();
await loop.processTaskOutcome("FN-001");
// completeValidatorRun called with blocked
expect(missionStore.completeValidatorRun).toHaveBeenCalledWith(
expect.any(String),
"blocked",
expect.stringContaining("External API not available"),
);
// createGeneratedFixFeature should NOT be called
expect(missionStore.createGeneratedFixFeature).not.toHaveBeenCalled();
// validation:blocked event emitted
expect(emitSpy).toHaveBeenCalledWith(
"validation:blocked",
expect.objectContaining({
featureId: "F-001",
reason: expect.stringContaining("External API not available"),
}),
);
expectNoValidationBoardTaskMutation(taskStore);
});
});
// ── handleValidationError ───────────────────────────────────────────────
describe("handleValidationError", () => {
it("surfaces validation session creation failures as mission errors", async () => {
const assertions = makeAssertions(1);
const feature = createMockFeature({
loopState: "implementing",
taskId: "FN-SESSION-ERROR",
id: "F-001",
});
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue(assertions);
taskStore._setTask({ id: "FN-SESSION-ERROR", title: "Test", description: "Implementation", log: [] });
vi.mocked(createResolvedAgentSession).mockRejectedValueOnce(new Error("401 insufficient credits"));
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const emitSpy = vi.spyOn(loop, "emit");
loop.start();
await loop.processTaskOutcome("FN-SESSION-ERROR");
expect(missionStore.completeValidatorRun).toHaveBeenCalledWith(
expect.any(String),
"error",
"Validation failed due to error: 401 insufficient credits",
);
expect(missionStore.createGeneratedFixFeature).not.toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith(
"validation:error",
expect.objectContaining({
featureId: "F-001",
error: "Validation failed due to error: 401 insufficient credits",
}),
);
expect(missionStore.logMissionEvent).toHaveBeenCalledWith(
expect.any(String),
"error",
expect.stringContaining("Validation error"),
expect.objectContaining({
code: "validation_error",
featureId: "F-001",
error: "Validation failed due to error: 401 insufficient credits",
}),
);
expectNoValidationBoardTaskMutation(taskStore);
});
it("emits validation:error without mutating any board task", async () => {
const assertions = makeAssertions(1);
const feature = createMockFeature({
loopState: "implementing",
taskId: "FN-001",
id: "F-001",
});
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue(assertions);
taskStore._setTask({ id: "FN-001", title: "Test", description: "Implementation", log: [] });
mockSessionHolder.session.state.messages = [
{ role: "user", content: "Validate this" },
{ role: "assistant", content: JSON.stringify({ status: "unknown", summary: "validator crashed" }) },
];
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const emitSpy = vi.spyOn(loop, "emit");
loop.start();
await loop.processTaskOutcome("FN-001");
expect(missionStore.completeValidatorRun).toHaveBeenCalledWith(
expect.any(String),
"error",
"Invalid status in validation response",
);
expect(emitSpy).toHaveBeenCalledWith(
"validation:error",
expect.objectContaining({
featureId: "F-001",
error: "Invalid status in validation response",
}),
);
expect(missionStore.logMissionEvent).toHaveBeenCalledWith(
expect.any(String),
"error",
expect.stringContaining("Validation error"),
expect.objectContaining({
code: "validation_error",
featureId: "F-001",
error: "Invalid status in validation response",
}),
);
expectNoValidationBoardTaskMutation(taskStore);
});
});
// ── Retry budget enforcement ─────────────────────────────────────────────
describe("retry budget enforcement", () => {
it("should emit budget_exhausted event when retry budget is exhausted", async () => {
// Create a feature with implementationAttemptCount at the max (3)
// Feature must be in "implementing" state to trigger validation
const feature = createMockFeature({
loopState: "implementing",
taskId: "FN-001",
id: "F-001",
implementationAttemptCount: 3, // At max budget (default maxRetryBudget=3)
});
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue([
{
id: "CA-1",
milestoneId: "MS-001",
title: "Test assertion",
assertion: "Should work",
status: "pending",
orderIndex: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
]);
// When createGeneratedFixFeature is called with exhausted budget,
// it should throw an error that includes "retry budget exhausted"
missionStore.createGeneratedFixFeature = vi.fn().mockImplementation(() => {
throw new Error("retry budget exhausted: maximum implementation attempts reached");
});
// Mock AI to return fail response
const failResponse = JSON.stringify({
status: "fail",
assertions: [{ assertionId: "CA-1", passed: false, message: "Failed" }],
summary: "Failed",
});
mockSessionHolder.session.state.messages = [
{ role: "user", content: "Validate this" },
{ role: "assistant", content: failResponse },
];
taskStore._setTask({ id: "FN-001", title: "Test", description: "Implementation", log: [] });
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
maxRetryBudget: 3,
});
const emitSpy = vi.spyOn(loop, "emit");
loop.start();
await loop.processTaskOutcome("FN-001");
// When budget exhausted, validation:budget_exhausted event should be emitted
expect(emitSpy).toHaveBeenCalledWith(
"validation:budget_exhausted",
expect.objectContaining({ featureId: "F-001" }),
);
});
it("should respect custom maxRetryBudget setting", async () => {
// Create a feature with implementationAttemptCount at custom max (2)
// Feature must be in "implementing" state to trigger validation
const feature = createMockFeature({
loopState: "implementing",
taskId: "FN-001",
id: "F-001",
implementationAttemptCount: 2, // At custom max
});
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue([
{
id: "CA-1",
milestoneId: "MS-001",
title: "Test assertion",
assertion: "Should work",
status: "pending",
orderIndex: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
]);
// When createGeneratedFixFeature is called with exhausted budget,
// it should throw
missionStore.createGeneratedFixFeature = vi.fn().mockImplementation(() => {
throw new Error("retry budget exhausted: maximum implementation attempts reached");
});
const failResponse = JSON.stringify({
status: "fail",
assertions: [{ assertionId: "CA-1", passed: false, message: "Failed" }],
summary: "Failed",
});
mockSessionHolder.session.state.messages = [
{ role: "user", content: "Validate this" },
{ role: "assistant", content: failResponse },
];
taskStore._setTask({ id: "FN-001", title: "Test", description: "Implementation", log: [] });
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
maxRetryBudget: 2, // Custom budget of 2
});
const emitSpy = vi.spyOn(loop, "emit");
loop.start();
await loop.processTaskOutcome("FN-001");
// Should emit budget_exhausted when at custom max
expect(emitSpy).toHaveBeenCalledWith(
"validation:budget_exhausted",
expect.objectContaining({ featureId: "F-001" }),
);
});
});
// ── recoverActiveMissions processTaskOutcome calls ───────────────────────
describe("recoverActiveMissions", () => {
it("should call processTaskOutcome for validating features with linked task", async () => {
const feature = createMockFeature({
id: "F-VALIDATING",
sliceId: "SL-001",
loopState: "validating",
taskId: "FN-VALIDATING",
});
missionStore._setFeature(feature);
missionStore.getMissionWithHierarchy = vi.fn().mockReturnValue({
id: "M-TEST1",
title: "Test Mission",
status: "active",
interviewState: "not_started",
autoAdvance: true,
autopilotEnabled: true,
autopilotState: "inactive",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
milestones: [
{
...createMockMilestone(),
slices: [
{
...createMockSlice(),
features: [feature],
},
],
},
],
});
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const processTaskOutcomeSpy = vi.spyOn(loop, "processTaskOutcome");
taskStore._setTask({ id: "FN-VALIDATING", column: "done" });
loop.start();
await loop.recoverActiveMissions();
// processTaskOutcome should be called for the validating feature
expect(processTaskOutcomeSpy).toHaveBeenCalledWith("FN-VALIDATING");
});
it("should call processTaskOutcome for needs_fix features with linked task", async () => {
const feature = createMockFeature({
id: "F-NEEDS-FIX",
sliceId: "SL-001",
loopState: "needs_fix",
taskId: "FN-NEEDS-FIX",
});
missionStore._setFeature(feature);
missionStore.getMissionWithHierarchy = vi.fn().mockReturnValue({
id: "M-TEST1",
title: "Test Mission",
status: "active",
interviewState: "not_started",
autoAdvance: true,
autopilotEnabled: true,
autopilotState: "inactive",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
milestones: [
{
...createMockMilestone(),
slices: [
{
...createMockSlice(),
features: [feature],
},
],
},
],
});
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const processTaskOutcomeSpy = vi.spyOn(loop, "processTaskOutcome");
taskStore._setTask({ id: "FN-NEEDS-FIX", column: "done" });
loop.start();
await loop.recoverActiveMissions();
// processTaskOutcome should be called for the needs_fix feature
expect(processTaskOutcomeSpy).toHaveBeenCalledWith("FN-NEEDS-FIX");
});
it("should transition validating feature back to implementing before processTaskOutcome", async () => {
const feature = createMockFeature({
id: "F-VALIDATING",
sliceId: "SL-001",
loopState: "validating",
taskId: "FN-VALIDATING",
});
missionStore._setFeature(feature);
missionStore.getMissionWithHierarchy = vi.fn().mockReturnValue({
id: "M-TEST1",
title: "Test Mission",
status: "active",
interviewState: "not_started",
autoAdvance: true,
autopilotEnabled: true,
autopilotState: "inactive",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
milestones: [
{
...createMockMilestone(),
slices: [
{
...createMockSlice(),
features: [feature],
},
],
},
],
});
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
await loop.recoverActiveMissions();
// transitionLoopState should be called to move from validating back to implementing
expect(missionStore.transitionLoopState).toHaveBeenCalledWith("F-VALIDATING", "implementing");
});
it("should not call processTaskOutcome when validating feature task is still in-progress", async () => {
const feature = createMockFeature({
id: "F-VALIDATING-IN-PROGRESS",
sliceId: "SL-001",
loopState: "validating",
taskId: "FN-IN-PROGRESS",
});
missionStore._setFeature(feature);
missionStore.getMissionWithHierarchy = vi.fn().mockReturnValue({
id: "M-TEST1",
title: "Test Mission",
status: "active",
interviewState: "not_started",
autoAdvance: true,
autopilotEnabled: true,
autopilotState: "inactive",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
milestones: [
{
...createMockMilestone(),
slices: [
{
...createMockSlice(),
features: [feature],
},
],
},
],
});
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const processTaskOutcomeSpy = vi.spyOn(loop, "processTaskOutcome");
taskStore._setTask({ id: "FN-IN-PROGRESS", column: "in-progress" });
loop.start();
await loop.recoverActiveMissions();
expect(processTaskOutcomeSpy).not.toHaveBeenCalled();
expect(missionStore.transitionLoopState).toHaveBeenCalledWith("F-VALIDATING-IN-PROGRESS", "implementing");
});
it("should recover implementing features whose linked task is already done and assertions are unpassed", async () => {
const feature = createMockFeature({
id: "F-IMPLEMENTING-DONE",
sliceId: "SL-001",
loopState: "implementing",
status: "done",
taskId: "FN-DONE",
lastValidatorStatus: undefined,
});
missionStore._setFeature(feature);
missionStore._setAssertionsForFeature("F-IMPLEMENTING-DONE", [
{
id: "CA-1",
milestoneId: "MS-001",
title: "Must pass",
assertion: "Assertion",
status: "pending",
orderIndex: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
]);
missionStore.getMissionWithHierarchy = vi.fn().mockReturnValue({
...createMockMission(),
milestones: [
{
...createMockMilestone(),
slices: [{ ...createMockSlice(), features: [feature] }],
},
],
});
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const processTaskOutcomeSpy = vi.spyOn(loop, "processTaskOutcome");
taskStore._setTask({ id: "FN-DONE", column: "done" });
await loop.recoverActiveMissions();
expect(processTaskOutcomeSpy).toHaveBeenCalledWith("FN-DONE");
});
it("does not re-trigger implementing features when validator already passed", async () => {
const feature = createMockFeature({
id: "F-IMPLEMENTING-PASSED",
sliceId: "SL-001",
loopState: "implementing",
status: "done",
taskId: "FN-PASSED",
lastValidatorStatus: "passed",
});
missionStore._setFeature(feature);
missionStore._setAssertionsForFeature("F-IMPLEMENTING-PASSED", [
{
id: "CA-1",
milestoneId: "MS-001",
title: "Must pass",
assertion: "Assertion",
status: "passed",
orderIndex: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
]);
missionStore.getMissionWithHierarchy = vi.fn().mockReturnValue({
...createMockMission(),
milestones: [
{
...createMockMilestone(),
slices: [{ ...createMockSlice(), features: [feature] }],
},
],
});
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const processTaskOutcomeSpy = vi.spyOn(loop, "processTaskOutcome");
taskStore._setTask({ id: "FN-PASSED", column: "done" });
await loop.recoverActiveMissions();
expect(processTaskOutcomeSpy).not.toHaveBeenCalled();
});
it("should not call processTaskOutcome for needs_fix features without taskId", async () => {
const feature = createMockFeature({
id: "F-NO-TASK",
sliceId: "SL-001",
loopState: "needs_fix",
taskId: undefined, // No linked task
});
missionStore._setFeature(feature);
missionStore.getMissionWithHierarchy = vi.fn().mockReturnValue({
id: "M-TEST1",
title: "Test Mission",
status: "active",
interviewState: "not_started",
autoAdvance: true,
autopilotEnabled: true,
autopilotState: "inactive",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
milestones: [
{
...createMockMilestone(),
slices: [
{
...createMockSlice(),
features: [feature],
},
],
},
],
});
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
const processTaskOutcomeSpy = vi.spyOn(loop, "processTaskOutcome");
loop.start();
await loop.recoverActiveMissions();
// processTaskOutcome should NOT be called (no taskId)
expect(processTaskOutcomeSpy).not.toHaveBeenCalled();
});
});
});