Files
fusion/packages/engine/src/__tests__/mission-execution-loop.test.ts
gsxdsm 844512ed3e FN-8976: prevent concurrent automatic validator runs
Prevent automatic validation from running alongside a fresh manual or non-memoized validator.

- Serialize validator admission around a feature-scoped fresh-running check.

- Report feature-versus-fingerprint blocking scope and preserve memoization semantics.

- Add regression coverage, documentation, and a patch changeset.

Files changed:

 .changeset/fn-8976-validator-admission.md          |   7 ++
 docs/missions.md                                   |   4 +-
 .../__tests__/postgres/mission-store.pg.test.ts    | 110 +++++++++++++++++++--
 .../core/src/async-stores/async-mission-store.ts   |  61 ++++++++++--
 packages/core/src/missions/mission-types.ts        |   2 +
 .../src/__tests__/mission-execution-loop.test.ts   |  10 +-
 6 files changed, 171 insertions(+), 23 deletions(-)

Fusion-Task-Id: FN-8976
Fusion-Task-Lineage: 61b487e8-5542-4cce-94f7-58d5295fe177
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-10 22:57:21 -07:00

3522 lines
136 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 { execSync, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
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(), debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
}));
vi.mock("../agents/agent-session-helpers.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../agents/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 "../agents/agent-session-helpers.js";
import { MissionExecutionLoop, loopLog, fingerprintMissionValidationInput } from "../missions/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, inputFingerprint?: string) => {
const run = createMockValidatorRun({ featureId, inputFingerprint });
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;
}),
reconcileSupersededGeneratedFixFeatures: vi.fn((sliceId: string) => {
let supersededCount = 0;
const featureIds: string[] = [];
const featureHasPassed = (feature: MissionFeature | undefined) =>
feature?.lastValidatorStatus === "passed" || feature?.loopState === "passed";
const hasPassedAncestor = (feature: MissionFeature, seen = new Set<string>()): boolean => {
const sourceFeatureId = feature.generatedFromFeatureId;
if (!sourceFeatureId || seen.has(sourceFeatureId)) return false;
seen.add(sourceFeatureId);
const sourceFeature = features.get(sourceFeatureId);
return featureHasPassed(sourceFeature) || (sourceFeature ? hasPassedAncestor(sourceFeature, seen) : false);
};
for (const feature of [...features.values()]) {
if (feature.sliceId !== sliceId || !feature.generatedFromFeatureId || !hasPassedAncestor(feature)) continue;
if (feature.status === "done" && feature.loopState === "passed" && feature.lastValidatorStatus === "passed") continue;
features.set(feature.id, {
...feature,
status: "done",
loopState: "passed",
lastValidatorStatus: "passed",
updatedAt: new Date().toISOString(),
});
supersededCount += 1;
featureIds.push(feature.id);
}
return { supersededCount, featureIds };
}),
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,
type: "static" 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 ───────────────────────────────────────────────────────────────────
// ── real-git fixtures (for the stale-workspace guard) ────────────────────────
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
function git(repo: string, command: string): string {
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
/** A throwaway git repo with one initial commit; HEAD on `main`. */
function initGitRepo(): string {
const repo = mkdtempSync(join(tmpdir(), "fn-stale-ws-"));
git(repo, "git init -b main");
git(repo, 'git config user.email "test@example.com"');
git(repo, 'git config user.name "Test User"');
git(repo, "git config commit.gpgsign false");
writeFileSync(join(repo, "foo.ts"), "line1\n");
git(repo, "git add foo.ts && git commit -m init");
return repo;
}
describe("MissionExecutionLoop", () => {
it("uses canonical UTF-8 tuple hashing for delimiter-bearing and Unicode validator inputs", () => {
const baseline = fingerprintMissionValidationInput("sha|one", "provider", "model", "system|π", "user|日本語");
expect(baseline).toMatch(/^[a-f0-9]{64}$/);
expect(baseline).toBe(fingerprintMissionValidationInput("sha|one", "provider", "model", "system|π", "user|日本語"));
expect(baseline).not.toBe(fingerprintMissionValidationInput("sha", "one|provider", "model", "system|π", "user|日本語"));
expect(baseline).not.toBe(fingerprintMissionValidationInput("sha|one", "provider", "other", "system|π", "user|日本語"));
});
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("passes the budget-block recovery checkout into its single validation execution", async () => {
const mission = createMockMission({ id: "M-BUDGET", status: "active" });
missionStore._setMission(mission);
const slice = createMockSlice({ id: "SL-BUDGET", milestoneId: "MS-001", status: "active" });
const blocked = createMockFeature({
id: "F-BUDGET",
sliceId: slice.id,
taskId: "FN-BUDGET",
status: "done",
loopState: "blocked",
validationBudgetFingerprint: "previous-fingerprint",
});
(missionStore as any)._addFeatureWithManagedAssertion(blocked);
wireHierarchy(slice, [missionStore.getFeature(blocked.id) as MissionFeature]);
taskStore._setTask({
id: "FN-BUDGET", title: "Budget task", description: "d", log: [], column: "done",
mergeDetails: { commitSha: "landed-budget-sha" },
} as any);
taskStore.getSettings.mockResolvedValue({
missionStaleThresholdMs: 600_000,
missionMaxTaskRetries: 3,
defaultProvider: "memo-provider",
defaultModelId: "memo-model",
});
const dispose = vi.fn().mockResolvedValue(undefined);
const materialize = vi.fn().mockResolvedValue({ dir: "/inspection/budget", dispose });
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/ambient-root",
checkoutMaterializer: { materialize, assertSourceClean: vi.fn() },
});
loop.start();
const runFeatureValidation = vi.spyOn(loop as any, "runFeatureValidation").mockResolvedValue(undefined);
await loop.recoverActiveMissions();
expect(materialize).toHaveBeenCalledWith("/ambient-root", "landed-budget-sha");
const budgetRecoveryCall = runFeatureValidation.mock.calls.find(
([candidate, prepared]) => candidate.id === blocked.id && prepared,
);
expect(budgetRecoveryCall?.[1]).toEqual(expect.objectContaining({
fingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
checkout: expect.objectContaining({ dir: "/inspection/budget" }),
}));
// The real execution path owns this checkout; the stub releases it here.
await budgetRecoveryCall![1].checkout.dispose();
expect(dispose).toHaveBeenCalledOnce();
});
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();
});
it("skips superseded generated fixes from the stale recovery snapshot", async () => {
const mission = createMockMission({ id: "M-SUPERSEDE", status: "active" });
missionStore._clear();
missionStore._setMission(mission);
const slice = createMockSlice({ id: "SL-SUPERSEDE", milestoneId: "MS-001", status: "active" });
const source = createMockFeature({
id: "F-SOURCE",
sliceId: slice.id,
status: "done",
loopState: "passed",
lastValidatorStatus: "passed",
});
const staleFix = createMockFeature({
id: "F-STALE-FIX",
sliceId: slice.id,
status: "defined",
loopState: "validating",
generatedFromFeatureId: source.id,
taskId: "FN-stale-fix",
});
missionStore._setFeature(source);
missionStore._setFeature(staleFix);
taskStore._setTask({ id: "FN-stale-fix", column: "done" });
wireHierarchy(slice, [source, staleFix]);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
loop.start();
const result = await loop.recoverActiveMissions();
expect(missionStore.reconcileSupersededGeneratedFixFeatures).toHaveBeenCalledWith(slice.id);
expect(missionStore.transitionLoopState).not.toHaveBeenCalledWith(staleFix.id, "implementing");
expect(taskStore.getTask).not.toHaveBeenCalledWith("FN-stale-fix");
expect(result).toEqual({ recoveredCount: 1 });
});
});
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);
});
it("awaits an asynchronous mission store instead of skipping validator recovery", async () => {
const run = createMockValidatorRun({ id: "VR-async", featureId: "F-async" });
missionStore.listStaleRunningValidatorRuns = vi.fn().mockResolvedValue([run]);
missionStore.reapValidatorRun = vi.fn().mockResolvedValue({ ...run, status: "error" });
missionStore.getMilestone = vi.fn().mockResolvedValue(createMockMilestone());
missionStore.getMission = vi.fn().mockResolvedValue(createMockMission());
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
await expect(loop.reapStaleValidatorRuns(1)).resolves.toEqual({ reapedCount: 1 });
expect(missionStore.reapValidatorRun).toHaveBeenCalledWith("VR-async", expect.stringContaining("reaped"));
});
});
// ── 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("skips validation when the feature's mission is not active", async () => {
missionStore._setMission(createMockMission({ id: "M-TEST1", status: "planning" }));
const feature = createMockFeature({ loopState: "implementing", 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_mission_inactive",
featureId: "F-001",
taskId: "FN-001",
missionId: "M-TEST1",
missionStatus: "planning",
}),
);
});
it("validates when the feature's mission is active", async () => {
missionStore._setMission(createMockMission({ id: "M-TEST1", status: "active" }));
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001" });
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
taskStore._setTask({ id: "FN-001", title: "Test", description: "Test task", log: [] });
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
vi.spyOn(loop as any, "runValidation").mockResolvedValue({
result: { status: "pass", summary: "ok" },
inspection: { inspectionRoot: "/tmp", landedSha: undefined, fallbackUsed: true, workspaceStale: false },
});
loop.start();
await loop.processTaskOutcome("FN-001");
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion", "FN-001");
});
it.each(["running", "budget-exhausted"] as const)("preserves automatic %s admission short-circuit without invoking manual admission", async (outcome) => {
/*
FNXC:MissionValidation 2026-08-11-05:38:
FN-8976 makes a feature-scoped live run report the existing running outcome. The automatic
loop must dispose its memoized checkout and return without starting, passing, or manually
admitting a validator; budget-exhausted retains the same disposal boundary.
*/
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001" });
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore._setAssertionsForFeature(feature.id, makeAssertions(1));
taskStore._setTask({ id: "FN-001", title: "Test", description: "Test task", log: [], mergeDetails: { commitSha: "landed-sha" } } as any);
taskStore.getSettings.mockResolvedValue({
missionStaleThresholdMs: 600_000,
missionMaxTaskRetries: 3,
defaultProvider: "memo-provider",
defaultModelId: "memo-model",
});
const admitValidatorRun = vi.fn().mockResolvedValue({ outcome });
const startManualValidatorRun = vi.fn();
Object.assign(missionStore, { admitValidatorRun, startManualValidatorRun });
const dispose = vi.fn().mockResolvedValue(undefined);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
checkoutMaterializer: { materialize: vi.fn().mockResolvedValue({ dir: "/inspection/landed", dispose }), assertSourceClean: vi.fn() },
});
const handleValidationPass = vi.spyOn(loop as any, "handleValidationPass");
loop.start();
await loop.processTaskOutcome("FN-001");
expect(admitValidatorRun).toHaveBeenCalledOnce();
expect(startManualValidatorRun).not.toHaveBeenCalled();
expect(missionStore.startValidatorRun).not.toHaveBeenCalled();
expect(handleValidationPass).not.toHaveBeenCalled();
expect(createResolvedAgentSession).not.toHaveBeenCalled();
expect(dispose).toHaveBeenCalledOnce();
});
it("keeps the non-memo automatic fallback separate from manual admission", async () => {
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001" });
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
taskStore._setTask({ id: "FN-001", title: "Test", description: "Test task", log: [] });
const startManualValidatorRun = vi.fn();
Object.assign(missionStore, { startManualValidatorRun });
loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" });
vi.spyOn(loop as any, "runValidation").mockResolvedValue({
result: { status: "pass", summary: "ok" },
inspection: { inspectionRoot: "/tmp", landedSha: undefined, fallbackUsed: true, workspaceStale: false },
});
loop.start();
await loop.processTaskOutcome("FN-001");
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion", "FN-001");
expect(startManualValidatorRun).not.toHaveBeenCalled();
});
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({
result: { status: "pass", summary: "ok" },
inspection: { inspectionRoot: "/tmp", landedSha: undefined, fallbackUsed: true, workspaceStale: false },
});
loop.start();
await loop.processTaskOutcome("FN-001");
expect(missionStore.ensureFeatureAssertionLinked).toHaveBeenCalledWith("F-001");
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion", "FN-001");
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", "FN-LATER");
});
it("excludes milestone acceptance criteria from feature 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, "feature");
const milestonePrompt = (loop as any).buildValidationPrompt(feature, assertions, "milestone");
const systemPrompt = (loop as any).buildValidationSystemPrompt(feature, assertions, "Task context", "feature");
expect(prompt).not.toContain("Milestone pass bar text");
expect(prompt).toContain("only the following linked feature contract assertions");
expect(milestonePrompt).toContain("only the following milestone-scoped contract assertions");
for (const assertion of assertions) {
expect(prompt).toContain(`[${assertion.id}]`);
expect(milestonePrompt).toContain(`[${assertion.id}]`);
}
expect(systemPrompt).not.toContain("Milestone pass bar text");
expect(systemPrompt).toContain("linked feature contract assertions");
expect(systemPrompt).toContain("The bracketed assertion ID shown for that assertion in the user message");
});
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", "FN-001");
});
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("threads the linked board task ID into startValidatorRun", 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",
"FN-001",
);
});
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", "FN-ASSERT-PASS");
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("defers failed assertion validation when landed-code inspection is unavailable", 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), "blocked", expect.any(String));
expect(missionStore.createGeneratedFixFeature).not.toHaveBeenCalled();
expect(missionStore.getFeature("F-001")?.lastValidatorStatus).toBe("blocked");
expect(missionStore.getFeature("F-001")?.loopState).toBe("blocked");
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();
});
});
// ── Validation assertion identity contract ─────────────────────────────────
describe("validation assertion identity contract", () => {
function createValidatorLoop() {
return new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
}
it("keeps correctly keyed and legacy passed-only results canonical", () => {
const validatorLoop = createValidatorLoop();
const assertions = makeAssertions(2);
const results = (validatorLoop as any).extractAssertionResults({
assertions: [
{ assertionId: "CA-1", verdict: "pass", passed: true },
{ assertionId: "CA-2", passed: true },
],
}, assertions);
expect(results).toMatchObject([
{ assertionId: "CA-1", verdict: "pass", passed: true },
{ assertionId: "CA-2", verdict: "pass", passed: true },
]);
expect((validatorLoop as any).deriveFeatureValidationStatus({ status: "fail", assertions: results }, false).status).toBe("pass");
});
it("recovers the reported single-assertion pass when the validator did not echo its ID", () => {
const validatorLoop = createValidatorLoop();
const assertions = makeAssertions(1);
const results = (validatorLoop as any).extractAssertionResults({
status: "pass",
assertions: [{ assertionId: "CA-NOT-ECHOED", verdict: "pass", passed: true, message: "ok" }],
}, assertions);
expect(results).toMatchObject([{
assertionId: "CA-1",
verdict: "pass",
passed: true,
message: expect.stringContaining("matched positionally"),
}]);
expect((validatorLoop as any).deriveFeatureValidationStatus({ status: "fail", assertions: results }, false).status).toBe("pass");
});
it("maps an exact-count zero-ID multi-assertion response positionally", () => {
const validatorLoop = createValidatorLoop();
const assertions = makeAssertions(2);
const results = (validatorLoop as any).extractAssertionResults({
assertions: [
{ assertionId: "unrecognized-first", verdict: "pass", passed: true },
{ passed: true },
],
}, assertions);
expect(results).toMatchObject([
{ assertionId: "CA-1", verdict: "pass", passed: true, message: expect.stringContaining("matched positionally") },
{ assertionId: "CA-2", verdict: "pass", passed: true, message: expect.stringContaining("matched positionally") },
]);
expect((validatorLoop as any).deriveFeatureValidationStatus({ status: "fail", assertions: results }, false).status).toBe("pass");
});
it("fails closed for an unrecognized mismatched count or partially recognized IDs", () => {
const validatorLoop = createValidatorLoop();
const assertions = makeAssertions(2);
const wrongCount = (validatorLoop as any).extractAssertionResults({
assertions: [{ assertionId: "CA-NOT-ECHOED", passed: true }],
}, assertions);
const partialMatch = (validatorLoop as any).extractAssertionResults({
assertions: [
{ assertionId: "CA-1", passed: true },
{ assertionId: "CA-NOT-ECHOED", passed: true },
],
}, assertions);
expect(wrongCount).toMatchObject([
{ assertionId: "CA-1", passed: false, message: "Validator result IDs did not match any linked assertion ID." },
{ assertionId: "CA-2", passed: false, message: "Validator result IDs did not match any linked assertion ID." },
]);
expect(partialMatch).toMatchObject([
{ assertionId: "CA-1", passed: true },
{ assertionId: "CA-2", passed: false, message: "Validator omitted linked assertion result." },
]);
});
it("fails closed when positional fallback receives duplicate unknown or empty IDs", () => {
const validatorLoop = createValidatorLoop();
const assertions = makeAssertions(2);
const duplicateUnknown = (validatorLoop as any).extractAssertionResults({
assertions: [
{ assertionId: "CA-NOT-ECHOED", passed: true },
{ assertionId: "CA-NOT-ECHOED", passed: true },
],
}, assertions);
const duplicateEmpty = (validatorLoop as any).extractAssertionResults({
assertions: [
{ assertionId: "", passed: true },
{ assertionId: "", passed: true },
],
}, assertions);
for (const results of [duplicateUnknown, duplicateEmpty]) {
expect(results).toMatchObject([
{ assertionId: "CA-1", passed: false, message: "Validator result IDs did not match any linked assertion ID." },
{ assertionId: "CA-2", passed: false, message: "Validator result IDs did not match any linked assertion ID." },
]);
}
});
it("preserves duplicate failure, empty/non-array failure, blocked aggregation, and zero assertions", () => {
const validatorLoop = createValidatorLoop();
const assertions = makeAssertions(1);
const duplicate = (validatorLoop as any).extractAssertionResults({
assertions: [{ assertionId: "CA-1", passed: true }, { assertionId: "CA-1", passed: true }],
}, assertions);
const empty = (validatorLoop as any).extractAssertionResults({ assertions: [] }, assertions);
const nonArray = (validatorLoop as any).extractAssertionResults({ assertions: "not-an-array" }, assertions);
const blocked = (validatorLoop as any).extractAssertionResults({
assertions: [{ assertionId: "CA-1", verdict: "blocked", passed: false }],
}, assertions);
const zeroAssertions = (validatorLoop as any).extractAssertionResults({ assertions: [] }, []);
expect(duplicate[0]).toMatchObject({ passed: false, message: "Duplicate validator result for linked assertion." });
expect(empty[0]).toMatchObject({ passed: false, message: "Validator result IDs did not match any linked assertion ID." });
expect(nonArray[0]).toMatchObject({ passed: false, message: "Validator result IDs did not match any linked assertion ID." });
expect((validatorLoop as any).deriveFeatureValidationStatus({ status: "fail", assertions: blocked }, false).status).toBe("blocked");
expect(zeroAssertions).toEqual([]);
expect((validatorLoop as any).deriveFeatureValidationStatus({ status: "pass", assertions: zeroAssertions }, false).status).toBe("fail");
});
});
// ── parseValidationResult JSON extraction ─────────────────────────────────
describe("parseValidationResult", () => {
const completePassingPayload = JSON.stringify({
status: "pass",
assertions: [
{ assertionId: "CA-1", verdict: "pass", passed: true },
{ assertionId: "CA-2", verdict: "pass", passed: true },
],
summary: "All assertions passed",
});
it.each([
["prose braces before a trailing payload", "The inspection called render({ value: 1 }).\n" + completePassingPayload],
["a malformed earlier object before a trailing payload", '{"status": broken}\n' + completePassingPayload],
["an unlabeled fenced payload", "Reasoning follows.\n```\n" + completePassingPayload + "\n```"],
["a trailing comma", completePassingPayload.replace("\n", "").replace("}", ",}")],
["safely truncated closing delimiters", completePassingPayload.slice(0, -1)],
["oversized leading prose while retaining the trailing payload", "x".repeat(256 * 1024) + completePassingPayload],
["more than eight earlier object candidates", Array.from({ length: 12 }, (_, index) => `{"example":${index}}`).join("\n") + "\n" + completePassingPayload],
])("recovers a complete validator payload after %s", async (_name, response) => {
const assertions = makeAssertions(2);
mockSessionHolder.session.state.messages = [{ role: "assistant", content: response }];
loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" });
await expect((loop as any).parseValidationResult(mockSessionHolder.session, assertions)).resolves.toMatchObject({
status: "pass",
assertions: [{ assertionId: "CA-1", passed: true }, { assertionId: "CA-2", passed: true }],
});
});
it("caps candidate extraction and preserves braces in JSON strings", () => {
const candidate = JSON.stringify({ summary: "literal ,} is evidence", status: "pass" });
loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" });
expect((loop as any).extractJsonCandidates("x".repeat(256 * 1024) + candidate)).toEqual([candidate]);
expect((loop as any).extractJsonCandidates(Array.from({ length: 12 }, (_, index) => `{"example":${index}}`).join("\n") + candidate)).toHaveLength(8);
expect((loop as any).repairJson(candidate)).toBe(candidate);
});
it("keeps recovered payload semantic validation fail-closed", async () => {
const assertions = makeAssertions(2);
const cases = [
{ status: "pass", assertions: [{ assertionId: "CA-1", passed: true }, { assertionId: "unknown", passed: true }] },
{ status: "pass", assertions: [{ assertionId: "CA-1", passed: true }] },
{ status: "pass", assertions: [{ assertionId: "CA-1", passed: true }, { assertionId: "CA-1", passed: true }] },
{ status: "unknown", assertions: [{ assertionId: "CA-1", passed: true }, { assertionId: "CA-2", passed: true }] },
];
for (const candidate of cases) {
mockSessionHolder.session.state.messages = [{ role: "assistant", content: `prose {not JSON}\n${JSON.stringify(candidate)}` }];
loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" });
const result = await (loop as any).parseValidationResult(mockSessionHolder.session, assertions);
expect(result.status).not.toBe("pass");
}
mockSessionHolder.session.state.messages = [{ role: "assistant", content: 'prose {not JSON}\n{"status":"pass","summary":"unterminated' }];
loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" });
await expect((loop as any).parseValidationResult(mockSessionHolder.session, assertions)).resolves.toMatchObject({ status: "error" });
});
it("routes a recovered trailing pass payload through processTaskOutcome", 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: "The implementation uses render({ value: 1 }).\n" + 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("derives pass from complete linked assertions despite contradictory model fail", async () => {
const assertions = makeAssertions(2);
mockSessionHolder.session.state.messages = [{
role: "assistant",
content: JSON.stringify({
status: "fail",
summary: "parent milestone work is unfinished",
assertions: [
{ assertionId: "CA-1", passed: true },
{ assertionId: "CA-2", passed: true },
{ assertionId: "unknown-parent", passed: false },
],
}),
}];
loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" });
await expect((loop as any).parseValidationResult(mockSessionHolder.session, assertions)).resolves.toMatchObject({
status: "pass",
assertions: [{ assertionId: "CA-1", passed: true }, { assertionId: "CA-2", passed: true }],
});
});
it("fails closed for omitted and duplicate linked assertion results", async () => {
const assertions = makeAssertions(2);
mockSessionHolder.session.state.messages = [{
role: "assistant",
content: JSON.stringify({
status: "pass",
assertions: [
{ assertionId: "CA-1", passed: true },
{ assertionId: "CA-1", passed: true },
],
}),
}];
loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" });
await expect((loop as any).parseValidationResult(mockSessionHolder.session, assertions)).resolves.toMatchObject({
status: "fail",
assertions: [
{ assertionId: "CA-1", passed: false },
{ assertionId: "CA-2", passed: false },
],
});
});
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");
// The parser still returns fail, but routing must defer because this
// fixture has no verifiable landed merge revision.
expect(emitSpy).toHaveBeenCalledWith(
"validation:inconclusive",
expect.objectContaining({ featureId: "F-001" }),
);
expect(missionStore.recordValidatorFailures).not.toHaveBeenCalled();
expect(missionStore.completeValidatorRun).toHaveBeenCalledWith(
expect.any(String),
"blocked",
expect.any(String),
);
// No remediation is created until the judge can inspect landed code.
expect(missionStore.createGeneratedFixFeature).not.toHaveBeenCalled();
expectNoValidationBoardTaskMutation(taskStore);
});
it("preserves judge-provided evidence references for normalized diagnostics", async () => {
const assertions = makeAssertions(1);
mockSessionHolder.session.state.messages = [{
role: "assistant",
content: JSON.stringify({
status: "fail",
assertions: [{
assertionId: "CA-1",
passed: false,
evidence: [{ kind: "test-output", text: "pnpm test: CA-1 failed" }],
}],
summary: "misleading summary",
}),
}];
loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" });
await expect((loop as any).parseValidationResult(mockSessionHolder.session, assertions)).resolves.toMatchObject({
assertions: [{ evidence: [{ kind: "test-output", text: "pnpm test: CA-1 failed" }] }],
});
});
it("preserves blocked assertion verdicts in mixed failed results", async () => {
const assertions = makeAssertions(2);
mockSessionHolder.session.state.messages = [{
role: "assistant",
content: JSON.stringify({
status: "fail",
assertions: [
{ assertionId: "CA-1", verdict: "fail", passed: false, message: "Observed failure" },
{ assertionId: "CA-2", verdict: "blocked", passed: false, message: "Service unavailable" },
],
summary: "misleading fully satisfied summary",
}),
}];
loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" });
await expect((loop as any).parseValidationResult(mockSessionHolder.session, assertions)).resolves.toMatchObject({
assertions: [
{ assertionId: "CA-1", verdict: "fail", passed: false },
{ assertionId: "CA-2", verdict: "blocked", passed: false },
],
});
});
it("routes irreparable JSON to a validator error without generated remediation", async () => {
const assertions = makeAssertions(1);
const malformedResponse = '{"status": broken, "assertions": [';
// 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");
expect(emitSpy).toHaveBeenCalledWith(
"validation:error",
expect.objectContaining({ featureId: "F-001" }),
);
expect(missionStore.createGeneratedFixFeature).not.toHaveBeenCalled();
});
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({
result: { status: "pass", summary: "ok" },
inspection: { inspectionRoot: "/tmp", landedSha: undefined, fallbackUsed: true, workspaceStale: false },
});
loop.start();
await loop.processTaskOutcome("FN-001");
expect(missionStore.ensureFeatureAssertionLinked).toHaveBeenCalledWith("F-001");
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion", "FN-001");
// 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("persists failed validation report-only without minting remediation when autonomy is off", async () => {
const feature = createMockFeature({ id: "F-REPORT", loopState: "validating", implementationAttemptCount: 1 });
missionStore._setFeature(feature);
missionStore._setMission(createMockMission({ autopilotEnabled: false, autoAdvance: false }));
const run = missionStore.startValidatorRun(feature.id, "task_completion");
loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" });
await (loop as any).handleValidationFail(feature.id, run.id, {
status: "fail",
assertions: [{ assertionId: "CA-REPORT", verdict: "fail", passed: false, message: "Expected outcome missing" }],
summary: "failed",
});
expect(missionStore.completeValidatorRun).toHaveBeenCalledWith(run.id, "failed", "failed");
expect(missionStore.createGeneratedFixFeature).not.toHaveBeenCalled();
expect(missionStore.triageFeature).not.toHaveBeenCalled();
expect(missionStore.logMissionEvent).toHaveBeenCalledWith(
"M-TEST1", "warning", expect.stringContaining("report-only"), expect.objectContaining({ code: "validation_report_only" }),
);
});
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",
type: "static",
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");
expect(missionStore.recordValidatorFailures).not.toHaveBeenCalled();
expect(missionStore.completeValidatorRun).toHaveBeenCalledWith(
expect.any(String),
"blocked",
expect.any(String),
);
expect(missionStore.createGeneratedFixFeature).not.toHaveBeenCalled();
expect(missionStore.triageFeature).not.toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith(
"validation:inconclusive",
expect.objectContaining({ featureId: "F-001" }),
);
});
it("does not triage a fix when landed-code inspection is unavailable", async () => {
const assertions: MissionContractAssertion[] = [
{
id: "CA-1",
milestoneId: "MS-001",
title: "Test assertion",
assertion: "Should work",
status: "pending",
type: "static",
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");
expect(missionStore.triageFeature).not.toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith(
"validation:inconclusive",
expect.objectContaining({ featureId: "F-001" }),
);
});
});
// ── premerge guard (fail verdict while the linked task is unmerged) ──────
describe("premerge guard", () => {
const gitRepos: string[] = [];
afterEach(() => {
for (const dir of gitRepos.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function makeGitRepo(): string {
const repo = initGitRepo();
gitRepos.push(repo);
return repo;
}
function primeFailVerdict() {
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 },
];
}
function primeFeature() {
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(makeAssertions(1));
return feature;
}
it("should defer a fail to inconclusive while the linked task is not done/archived", async () => {
primeFeature();
primeFailVerdict();
// The linked task is still in review — its code has not merged yet, so
// the validator judged a checkout that predates the work.
taskStore._setTask({ id: "FN-001", title: "Test", description: "Implementation", log: [], column: "in-review" });
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");
// Routed to the inconclusive outcome — no Fix Feature minted (R21)
expect(missionStore.createGeneratedFixFeature).not.toHaveBeenCalled();
expect(missionStore.completeValidatorRun).toHaveBeenCalledWith(
expect.any(String),
"blocked",
expect.stringContaining("code not merged yet"),
);
expect(emitSpy).toHaveBeenCalledWith(
"validation:inconclusive",
expect.objectContaining({
featureId: "F-001",
reason: expect.stringContaining('"in-review"'),
}),
);
expect(emitSpy).not.toHaveBeenCalledWith("validation:failed", expect.anything());
// The infra-failure marker keeps deferred fails separable from real ones
expect(missionStore.logMissionEvent).toHaveBeenCalledWith(
expect.any(String),
"warning",
expect.stringContaining("Verification inconclusive"),
expect.objectContaining({
code: "verification_inconclusive",
outcome: "inconclusive",
infraFailure: true,
}),
);
});
it("defers a done-task fail until the landed merge can be verified", async () => {
primeFeature();
primeFailVerdict();
taskStore._setTask({ id: "FN-001", title: "Test", description: "Implementation", log: [], column: "done" });
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.createGeneratedFixFeature).not.toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith(
"validation:inconclusive",
expect.objectContaining({ featureId: "F-001" }),
);
});
it("defers a fail when the linked task's landed revision cannot be resolved", async () => {
primeFeature();
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/tmp",
});
// Bypass the AI session (runValidation also reads the task, without a
// catch) so the rejecting getTask below only exercises the guard.
vi.spyOn(loop as any, "runValidation").mockResolvedValue({
result: {
status: "fail",
assertions: [{ assertionId: "CA-1", passed: false, message: "Failed", expected: "ok", actual: "not ok" }],
summary: "Assertion failed",
},
inspection: {
inspectionRoot: "/tmp",
landedSha: undefined,
fallbackUsed: true,
workspaceStale: false,
inspectionUnavailableReason: "landed merge SHA is unavailable",
},
});
taskStore.getTask = vi.fn().mockRejectedValue(new Error("store unavailable"));
const emitSpy = vi.spyOn(loop, "emit");
loop.start();
await loop.processTaskOutcome("FN-001");
expect(missionStore.createGeneratedFixFeature).not.toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith(
"validation:inconclusive",
expect.objectContaining({ featureId: "F-001", reason: expect.stringContaining("could not prove") }),
);
});
// ── stale-workspace guard (task done, but rootDir predates the merge) ────
(hasGit ? it : it.skip)(
"should defer a fail when the judged checkout predates the merged commit",
async () => {
primeFeature();
primeFailVerdict();
// rootDir HEAD is `main` @ commit1. The merged commit lives on a side
// branch and is NOT reachable from HEAD — exactly the stale-checkout
// case (merge landed on remote / another worktree, rootDir never reset).
const repo = makeGitRepo();
git(repo, "git checkout -q -b feature");
writeFileSync(join(repo, "foo.ts"), "line1\nmerged\n");
git(repo, "git add foo.ts && git commit -m merged");
const mergedSha = git(repo, "git rev-parse HEAD");
git(repo, "git checkout -q main");
// Column is `done`, so the premerge column guard passes; only the
// ancestry check can catch the stale workspace.
taskStore._setTask({
id: "FN-001",
title: "Test",
description: "d",
log: [],
column: "done",
mergeDetails: { commitSha: mergedSha },
} as any);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: repo,
checkoutMaterializer: {
materialize: vi.fn().mockRejectedValue(new Error("simulated checkout failure")),
assertSourceClean: vi.fn(),
},
});
const emitSpy = vi.spyOn(loop, "emit");
loop.start();
await loop.processTaskOutcome("FN-001");
expect(missionStore.createGeneratedFixFeature).not.toHaveBeenCalled();
expect(missionStore.completeValidatorRun).toHaveBeenCalledWith(
expect.any(String),
"blocked",
expect.stringContaining("predates the merged code"),
);
expect(emitSpy).toHaveBeenCalledWith(
"validation:inconclusive",
expect.objectContaining({
featureId: "F-001",
reason: expect.stringContaining("predates the merged code"),
}),
);
expect(emitSpy).not.toHaveBeenCalledWith("validation:failed", expect.anything());
},
);
(hasGit ? it : it.skip)(
"should run the normal fail path when the merged commit is an ancestor of HEAD",
async () => {
primeFeature();
primeFailVerdict();
// rootDir HEAD advanced PAST the merged commit — the workspace is fresh,
// so the fail is real and must mint a Fix Feature.
const repo = makeGitRepo();
const baseSha = git(repo, "git rev-parse HEAD");
writeFileSync(join(repo, "foo.ts"), "line1\nadvanced\n");
git(repo, "git add foo.ts && git commit -m advance");
taskStore._setTask({ id: "FN-001", title: "Test", description: "d", log: [], column: "done", mergeDetails: { commitSha: baseSha } } as any);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: repo,
});
const emitSpy = vi.spyOn(loop, "emit");
loop.start();
await loop.processTaskOutcome("FN-001");
expect(missionStore.createGeneratedFixFeature).toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith(
"validation:failed",
expect.objectContaining({ featureId: "F-001" }),
);
expect(emitSpy).not.toHaveBeenCalledWith("validation:inconclusive", expect.anything());
},
);
it("defers a fail when the task has no landed merge SHA", async () => {
primeFeature();
primeFailVerdict();
// Done, but no mergeDetails.commitSha means the inspected tree cannot
// be proven to contain delivered code.
taskStore._setTask({ id: "FN-001", title: "Test", description: "d", log: [], column: "done" });
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.createGeneratedFixFeature).not.toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith(
"validation:inconclusive",
expect.objectContaining({ featureId: "F-001", reason: expect.stringContaining("could not prove") }),
);
});
it("pins the judge and stale check to the disposable landed-merge checkout", async () => {
primeFeature();
primeFailVerdict();
const dispose = vi.fn().mockResolvedValue(undefined);
const materialize = vi.fn().mockResolvedValue({ dir: "/inspection/landed", dispose });
taskStore._setTask({
id: "FN-001",
title: "Test",
description: "d",
log: [],
column: "done",
mergeDetails: { commitSha: "landed-sha" },
} as any);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/ambient-root",
checkoutMaterializer: { materialize, assertSourceClean: vi.fn() },
});
taskStore.getSettings.mockResolvedValue({
missionStaleThresholdMs: 600_000,
missionMaxTaskRetries: 3,
defaultProvider: "memo-provider",
defaultModelId: "memo-model",
});
const staleCheck = vi.spyOn(loop as any, "isValidationWorkspaceStale").mockResolvedValue({ workspaceStale: false });
loop.start();
await loop.processTaskOutcome("FN-001");
expect(materialize).toHaveBeenCalledOnce();
expect(materialize).toHaveBeenCalledWith("/ambient-root", "landed-sha");
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion", "FN-001", expect.stringMatching(/^[a-f0-9]{64}$/));
expect(createResolvedAgentSession).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/inspection/landed" }));
expect(staleCheck).toHaveBeenCalledWith("landed-sha", "/inspection/landed");
expect(dispose).toHaveBeenCalledOnce();
});
it("defers a fallback-root fail when its landed merge is absent", async () => {
primeFeature();
primeFailVerdict();
taskStore._setTask({
id: "FN-001",
title: "Test",
description: "d",
log: [],
column: "done",
mergeDetails: { commitSha: "landed-sha" },
} as any);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/ambient-root",
checkoutMaterializer: { materialize: vi.fn().mockRejectedValue(new Error("no checkout")), assertSourceClean: vi.fn() },
});
const staleCheck = vi.spyOn(loop as any, "isValidationWorkspaceStale").mockResolvedValue({ workspaceStale: true });
const failHandler = vi.spyOn(loop as any, "handleValidationFail");
const inconclusiveHandler = vi.spyOn(loop as any, "handleValidationInconclusive");
loop.start();
await loop.processTaskOutcome("FN-001");
expect(createResolvedAgentSession).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/ambient-root" }));
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion", "FN-001");
expect(staleCheck).toHaveBeenCalledWith("landed-sha", "/ambient-root");
expect(inconclusiveHandler).toHaveBeenCalled();
expect(failHandler).not.toHaveBeenCalled();
});
it("never materializes the task fork point as delivered code", async () => {
primeFeature();
primeFailVerdict();
const materialize = vi.fn();
taskStore._setTask({
id: "FN-001",
title: "Test",
description: "d",
log: [],
column: "done",
baseCommitSha: "fork-point",
} as any);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: "/ambient-root",
checkoutMaterializer: { materialize, assertSourceClean: vi.fn() },
});
const staleCheck = vi.spyOn(loop as any, "isValidationWorkspaceStale").mockResolvedValue({ workspaceStale: false, inspectionUnavailableReason: "landed merge SHA is unavailable" });
loop.start();
await loop.processTaskOutcome("FN-001");
expect(materialize).not.toHaveBeenCalled();
expect(createResolvedAgentSession).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/ambient-root" }));
expect(staleCheck).toHaveBeenCalledWith(undefined, "/ambient-root");
});
(hasGit ? it : it.skip)(
"defers a fail when landed merge ancestry is unavailable",
async () => {
primeFeature();
primeFailVerdict();
// A bogus SHA makes `git merge-base --is-ancestor` exit 128 (bad object),
// so the judge's inspection cannot be proven to contain delivered code.
const repo = makeGitRepo();
taskStore._setTask({ id: "FN-001", title: "Test", description: "d", log: [], column: "done", mergeDetails: { commitSha: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" } } as any);
loop = new MissionExecutionLoop({
taskStore: taskStore as any,
missionStore: missionStore as any,
rootDir: repo,
});
const emitSpy = vi.spyOn(loop, "emit");
loop.start();
await loop.processTaskOutcome("FN-001");
expect(missionStore.createGeneratedFixFeature).not.toHaveBeenCalled();
expect(emitSpy).toHaveBeenCalledWith(
"validation:inconclusive",
expect.objectContaining({ featureId: "F-001", reason: expect.stringContaining("could not prove") }),
);
},
);
});
// ── 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",
type: "static",
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);
});
});
// ── milestone-only contract readiness ───────────────────────────────────
describe("milestone-only contract readiness", () => {
it("grades milestone assertions directly after all no-assertion feature work completes", async () => {
const feature = createMockFeature({
id: "F-PARENT-ONLY",
loopState: "implementing",
taskId: "FN-PARENT-ONLY",
acceptanceCriteria: undefined,
});
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue([]);
missionStore.ensureFeatureAssertionLinked = vi.fn().mockReturnValue([]);
missionStore.listSlices = vi.fn().mockReturnValue([createMockSlice({ id: "SL-001", milestoneId: "MS-001" })]);
missionStore.listFeatures = vi.fn(() => [missionStore.getFeature("F-PARENT-ONLY")]);
missionStore.listContractAssertions = vi.fn().mockReturnValue([{
...makeAssertions(1)[0],
id: "CA-MILESTONE",
scope: "milestone",
}]);
missionStore.listFeaturesForAssertion = vi.fn().mockReturnValue([]);
missionStore.updateContractAssertion = vi.fn();
taskStore._setTask({ id: "FN-PARENT-ONLY", title: "Parent-only feature", description: "done", log: [] });
loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" });
vi.spyOn(loop as any, "runValidation").mockResolvedValue({
result: {
status: "pass",
assertions: [{ assertionId: "CA-MILESTONE", passed: true, message: "parent criterion met" }],
summary: "parent criterion met",
},
inspection: { inspectionRoot: "/tmp", landedSha: undefined, fallbackUsed: true, workspaceStale: false },
});
loop.start();
await loop.processTaskOutcome("FN-PARENT-ONLY");
expect(missionStore.updateContractAssertion).toHaveBeenCalledWith("CA-MILESTONE", { status: "passed" });
});
it("runs a parent-only milestone rollup during recovery when no feature can trigger it", async () => {
const milestone = createMockMilestone({ id: "MS-NO-FEATURES", missionId: "M-TEST1" });
missionStore.getMissionWithHierarchy = vi.fn(() => ({
...missionStore.getMission("M-TEST1"),
milestones: [{ ...milestone, slices: [] }],
}));
missionStore.listSlices = vi.fn().mockReturnValue([]);
missionStore.listFeatures = vi.fn().mockReturnValue([]);
missionStore.listContractAssertions = vi.fn().mockReturnValue([{
...makeAssertions(1)[0],
id: "CA-EMPTY-MILESTONE",
milestoneId: milestone.id,
scope: "milestone",
}]);
missionStore.listFeaturesForAssertion = vi.fn().mockReturnValue([]);
missionStore.updateContractAssertion = vi.fn();
loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" });
vi.spyOn(loop as any, "runValidation").mockResolvedValue({
result: {
status: "pass",
assertions: [{ assertionId: "CA-EMPTY-MILESTONE", passed: true, message: "parent criterion met" }],
summary: "parent criterion met",
},
inspection: { inspectionRoot: "/tmp", landedSha: undefined, fallbackUsed: true, workspaceStale: false },
});
await loop.recoverActiveMissions();
expect(missionStore.updateContractAssertion).toHaveBeenCalledWith("CA-EMPTY-MILESTONE", { status: "passed" });
expect((loop as any).runValidation).toHaveBeenCalledWith(
expect.objectContaining({ id: `milestone:${milestone.id}` }),
expect.any(Array),
expect.any(Object),
"milestone",
);
});
it("does not persist a failed assertion from an untrusted pre-merge inspection", async () => {
const feature = createMockFeature({ id: "F-DEFERRED", loopState: "implementing", taskId: "FN-DEFERRED" });
missionStore._setFeature(feature);
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
const assertion = makeAssertions(1)[0];
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue([assertion]);
missionStore.updateContractAssertion = vi.fn();
taskStore._setTask({ id: "FN-DEFERRED", title: "Unmerged", description: "pending merge", log: [], column: "in-review" });
loop = new MissionExecutionLoop({ taskStore: taskStore as any, missionStore: missionStore as any, rootDir: "/tmp" });
vi.spyOn(loop as any, "runValidation").mockResolvedValue({
result: {
status: "fail",
assertions: [{ assertionId: assertion.id, passed: false, message: "not trustworthy yet" }],
summary: "not trustworthy yet",
},
inspection: { inspectionRoot: "/tmp", landedSha: undefined, fallbackUsed: true, workspaceStale: false },
});
loop.start();
await loop.processTaskOutcome("FN-DEFERRED");
expect(missionStore.updateContractAssertion).not.toHaveBeenCalled();
expect(missionStore.createGeneratedFixFeature).not.toHaveBeenCalled();
});
});
// ── 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",
type: "static",
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");
// The missing landed revision is an inspection failure, so no retry
// budget is consumed by a Fix Feature loop.
expect(emitSpy).toHaveBeenCalledWith(
"validation:inconclusive",
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",
type: "static",
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");
// No Fix Feature budget is consumed while inspection is unverifiable.
expect(emitSpy).toHaveBeenCalledWith(
"validation:inconclusive",
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();
});
});
});