FN-5902: make mission validation AI-run all criteria
Route every mission feature through validator-backed completion checks. - lazily restore a managed feature assertion before validation instead of auto-passing zero-assertion features - thread milestone acceptance criteria into validator prompts and system instructions as enforced requirements - update MissionManager copy/tests to present criteria as AI-validated runtime gates and remove informational-only/zero-assertion warnings - document the all-criteria AI-run contract and add a changeset for @runfusion/fusion Files changed: .changeset/fn-5902-mission-validation-ai-run.md | 5 + AGENTS.md | 2 +- docs/architecture.md | 2 +- docs/missions-completion-contract.md | 198 ++++++--------------- docs/missions.md | 5 +- packages/core/src/__tests__/mission-store.test.ts | 23 ++- packages/core/src/mission-store.ts | 10 ++ packages/dashboard/app/components/MissionManager.css | 31 ---- packages/dashboard/app/components/MissionManager.tsx | 86 +++------ packages/dashboard/app/components/__tests__/MissionManager.test.tsx | 60 +++++-- packages/engine/src/__tests__/mission-execution-loop.test.ts | 111 +++++++++--- packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts | 57 +++--- packages/engine/src/mission-execution-loop.ts | 78 ++++---- 13 files changed, 318 insertions(+), 350 deletions(-) Fusion-Task-Id: FN-5902 Fusion-Task-Lineage: 5f25caad-33c9-42ff-822b-1ea092afc29f
This commit is contained in:
@@ -3338,14 +3338,33 @@ describe("MissionStore", () => {
|
||||
expect(linked[0].sourceFeatureId).toBe(feature.id);
|
||||
});
|
||||
|
||||
it("lazily re-links exactly one managed assertion for legacy acceptance-criteria features", () => {
|
||||
const mission = store.createMission({ title: "M" });
|
||||
const milestone = store.addMilestone(mission.id, { title: "MS" });
|
||||
const slice = store.addSlice(milestone.id, { title: "SL" });
|
||||
const feature = store.addFeature(slice.id, { title: "Feature", acceptanceCriteria: "AC text" });
|
||||
const [managed] = store.listAssertionsForFeature(feature.id);
|
||||
store.unlinkFeatureFromAssertion(feature.id, managed.id);
|
||||
store.deleteContractAssertion(managed.id);
|
||||
|
||||
const first = store.ensureFeatureAssertionLinked(feature.id);
|
||||
const second = store.ensureFeatureAssertionLinked(feature.id);
|
||||
|
||||
expect(first).toHaveLength(1);
|
||||
expect(first[0].assertion).toBe("AC text");
|
||||
expect(second).toHaveLength(1);
|
||||
expect(second[0].id).toBe(first[0].id);
|
||||
expect(store.listAssertionsForFeature(feature.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("derives managed assertion text from description or fallback", () => {
|
||||
const mission = store.createMission({ title: "M" });
|
||||
const milestone = store.addMilestone(mission.id, { title: "MS" });
|
||||
const slice = store.addSlice(milestone.id, { title: "SL" });
|
||||
const fromDescription = store.addFeature(slice.id, { title: "Desc Feature", description: "Desc text" });
|
||||
const fallback = store.addFeature(slice.id, { title: "Fallback Feature" });
|
||||
expect(store.listAssertionsForFeature(fromDescription.id)[0].assertion).toBe("Desc text");
|
||||
expect(store.listAssertionsForFeature(fallback.id)[0].assertion).toBe("Verify implementation of: Fallback Feature");
|
||||
expect(store.ensureFeatureAssertionLinked(fromDescription.id)[0].assertion).toBe("Desc text");
|
||||
expect(store.ensureFeatureAssertionLinked(fallback.id)[0].assertion).toBe("Verify implementation of: Fallback Feature");
|
||||
});
|
||||
|
||||
it("syncs managed assertion in place on acceptanceCriteria update", () => {
|
||||
|
||||
@@ -2197,6 +2197,16 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
ensureFeatureAssertionLinked(featureId: string): MissionContractAssertion[] {
|
||||
const feature = this.getFeature(featureId);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
|
||||
this.ensureFeatureAssertion(feature);
|
||||
return this.listAssertionsForFeature(featureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently seed authored contract assertions for specific features.
|
||||
*
|
||||
|
||||
@@ -1770,15 +1770,6 @@
|
||||
padding: calc(var(--space-xs) * 0.5) var(--space-sm);
|
||||
}
|
||||
|
||||
.mission-assertions__mode-tag--warning {
|
||||
color: var(--color-warning);
|
||||
border-color: color-mix(in srgb, var(--color-warning) 40%, var(--border));
|
||||
}
|
||||
|
||||
.mission-assertions__mode-tag--informational {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mission-assertions__rollup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1840,28 +1831,6 @@
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.mission-assertion__enforcement {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: calc(var(--space-xs) * 0.5) var(--space-sm);
|
||||
color: var(--text-dim);
|
||||
background: color-mix(in srgb, var(--surface) 85%, var(--bg));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mission-assertion__enforcement--enforced {
|
||||
color: var(--color-success);
|
||||
border-color: color-mix(in srgb, var(--color-success) 40%, var(--border));
|
||||
}
|
||||
|
||||
.mission-assertion__enforcement--informational {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mission-assertion__linked-count {
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
|
||||
color: var(--text-dim);
|
||||
|
||||
@@ -900,7 +900,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
const [missionHealthById, setMissionHealthById] = useState<Map<string, MissionHealth>>(new Map());
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"structure" | "activity">("structure");
|
||||
const milestoneAssertionGapSignatureRef = useRef<Map<string, string>>(new Map());
|
||||
const [missionEvents, setMissionEvents] = useState<MissionEvent[]>([]);
|
||||
const missionEventsRef = useRef<MissionEvent[]>([]);
|
||||
const missionsRef = useRef<MissionWithSummary[]>([]);
|
||||
@@ -917,30 +916,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
|
||||
const activityEventsContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedMission) return;
|
||||
|
||||
const nextSignatures = new Map<string, string>();
|
||||
for (const milestone of selectedMission.milestones) {
|
||||
const featuresWithAcceptanceCriteria = milestone.slices
|
||||
.flatMap((slice) => slice.features)
|
||||
.filter((feature) => (feature.acceptanceCriteria ?? "").trim().length > 0);
|
||||
const assertionCount = assertionsByMilestone.get(milestone.id)?.length ?? 0;
|
||||
const hasZeroAssertionGuard = featuresWithAcceptanceCriteria.length > 0 && assertionCount === 0;
|
||||
const signature = `${hasZeroAssertionGuard}:${featuresWithAcceptanceCriteria.length}:${assertionCount}`;
|
||||
const previousSignature = milestoneAssertionGapSignatureRef.current.get(milestone.id);
|
||||
if (hasZeroAssertionGuard && previousSignature !== signature) {
|
||||
console.warn("[MissionManager] milestone_zero_assertion_guard", {
|
||||
milestoneId: milestone.id,
|
||||
featureAcceptanceCriteriaCount: featuresWithAcceptanceCriteria.length,
|
||||
assertionCount,
|
||||
});
|
||||
}
|
||||
nextSignatures.set(milestone.id, signature);
|
||||
}
|
||||
|
||||
milestoneAssertionGapSignatureRef.current = nextSignatures;
|
||||
}, [assertionsByMilestone, selectedMission]);
|
||||
const activityEventsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keep latest state available to long-lived SSE handlers without reconnect churn.
|
||||
@@ -2802,7 +2777,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
const milestoneAssertions = Array.isArray(assertionsByMilestone.get(milestone.id))
|
||||
? assertionsByMilestone.get(milestone.id)!
|
||||
: [] as MissionContractAssertion[];
|
||||
const hasZeroAssertionGuard = featuresWithAcceptanceCriteria.length > 0 && milestoneAssertions.length === 0;
|
||||
|
||||
return (
|
||||
<div key={milestone.id} className="mission-milestone">
|
||||
@@ -3613,17 +3587,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
{/* Assertions Panel */}
|
||||
<div className="mission-assertions">
|
||||
<div className="mission-assertions__header">
|
||||
<span className="mission-assertions__title">Contract assertions (validator-enforced when linked)</span>
|
||||
<span className="mission-assertions__title">Contract assertions (AI-validated)</span>
|
||||
<span className="mission-assertions__mode-tag" data-testid="milestone-assertions-enforced-indicator">
|
||||
<span className="status-dot status-dot--running" />
|
||||
Enforced by autopilot
|
||||
AI-validated mission gate
|
||||
</span>
|
||||
{hasZeroAssertionGuard && (
|
||||
<span className="mission-assertions__mode-tag mission-assertions__mode-tag--warning" data-testid="milestone-zero-assertion-guard">
|
||||
<span className="status-dot status-dot--pending" />
|
||||
Feature criteria present but no enforced contract assertions linked
|
||||
</span>
|
||||
)}
|
||||
{milestoneRollup && (
|
||||
<span
|
||||
className="mission-status-badge mission-status-badge--sm"
|
||||
@@ -3757,23 +3725,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
{(() => {
|
||||
const linked = linkedFeaturesByAssertion.get(assertion.id);
|
||||
const count = linked?.length ?? 0;
|
||||
const isEnforced = count > 0;
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={`mission-assertion__enforcement ${isEnforced ? "mission-assertion__enforcement--enforced" : "mission-assertion__enforcement--informational"}`}
|
||||
data-testid={`mission-assertion-enforcement-${assertion.id}`}
|
||||
>
|
||||
<span className={`status-dot ${isEnforced ? "status-dot--running" : "status-dot--pending"}`} />
|
||||
{isEnforced ? "Enforced gate" : "Informational"}
|
||||
</span>
|
||||
{count > 0 ? (
|
||||
<span className="mission-assertion__linked-count" title={`${count} linked feature${count !== 1 ? "s" : ""}`}>
|
||||
({count} linked)
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
return count > 0 ? (
|
||||
<span className="mission-assertion__linked-count" title={`${count} linked feature${count !== 1 ? "s" : ""}`}>
|
||||
({count} linked)
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
<button
|
||||
className="mission-icon-btn"
|
||||
@@ -3882,22 +3838,19 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
{(milestoneAssertions.length === 0)
|
||||
&& !isCreatingAssertion
|
||||
&& (
|
||||
// Render from feature prose presence directly. Legacy
|
||||
// hasProseButNoAssertions telemetry is no longer the gate.
|
||||
featuresWithAcceptanceCriteria.length > 0 ? (
|
||||
// Product contract source of truth: docs/missions-completion-contract.md (FN-5718).
|
||||
// MissionFeature.acceptanceCriteria is informational authored intent; linked
|
||||
// MissionContractAssertion rows are the validator-enforced completion gate.
|
||||
// When criteria prose exists but assertions are absent, keep criteria visible
|
||||
// and warn that the surface is informational until assertions are linked.
|
||||
<>
|
||||
<div className="mission-manager__empty mission-assertions__empty">
|
||||
<span>No contract assertions are linked yet. Feature acceptance criteria are present below and remain informational until assertions are linked.</span>
|
||||
<span>No linked contract assertions are loaded yet. Feature criteria below will still be AI-validated when mission validation runs.</span>
|
||||
</div>
|
||||
<div className="mission-assertions__list" data-testid="milestone-feature-acceptance-rollup">
|
||||
<div className="mission-assertions__rollup-header">
|
||||
<span className="mission-assertions__title">Feature acceptance criteria (informational source)</span>
|
||||
<span className="mission-assertions__mode-tag mission-assertions__mode-tag--informational" data-testid="milestone-feature-acceptance-informational-indicator">
|
||||
<span className="status-dot status-dot--pending" />
|
||||
Not enforced by autopilot
|
||||
<span className="mission-assertions__title">Feature criteria awaiting assertion sync</span>
|
||||
<span className="mission-assertions__mode-tag" data-testid="milestone-feature-acceptance-ai-validated-indicator">
|
||||
<span className="status-dot status-dot--running" />
|
||||
AI-validated at runtime
|
||||
</span>
|
||||
</div>
|
||||
{featuresWithAcceptanceCriteria.map((feature) => (
|
||||
@@ -3905,11 +3858,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
<div className="mission-assertion__header">
|
||||
<span className="mission-assertion__title">{feature.title}</span>
|
||||
<span
|
||||
className="mission-assertion__enforcement mission-assertion__enforcement--informational"
|
||||
data-testid={`mission-feature-acceptance-enforcement-${feature.id}`}
|
||||
className="mission-status-badge mission-status-badge--sm"
|
||||
data-testid={`mission-feature-acceptance-status-${feature.id}`}
|
||||
style={{
|
||||
backgroundColor: featureStatusColors[feature.status].bg,
|
||||
color: featureStatusColors[feature.status].text,
|
||||
}}
|
||||
>
|
||||
<span className="status-dot status-dot--pending" />
|
||||
Informational
|
||||
{feature.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mission-assertion__text">
|
||||
|
||||
@@ -5009,7 +5009,7 @@ describe("MissionManager", () => {
|
||||
});
|
||||
|
||||
describe("milestone assertions empty-state", () => {
|
||||
const emptyAssertionsWithFeaturesCopy = "No contract assertions are linked yet. Feature acceptance criteria are present below and remain informational until assertions are linked.";
|
||||
const emptyAssertionsWithFeaturesCopy = "No linked contract assertions are loaded yet. Feature criteria below will still be AI-validated when mission validation runs.";
|
||||
const emptyAssertionsNoFeaturesCopy = "No feature acceptance criteria or contract assertions defined yet.";
|
||||
|
||||
it("keeps empty-state nudge when assertions and feature acceptance criteria are both missing", async () => {
|
||||
@@ -5090,13 +5090,47 @@ describe("MissionManager", () => {
|
||||
expect(screen.queryByText(emptyAssertionsNoFeaturesCopy)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(emptyAssertionsWithFeaturesCopy)).toBeInTheDocument();
|
||||
const rollup = screen.getByTestId("milestone-feature-acceptance-rollup");
|
||||
expect(within(rollup).getByText("Feature acceptance criteria (informational source)")).toBeInTheDocument();
|
||||
expect(within(rollup).getByTestId("milestone-feature-acceptance-informational-indicator")).toHaveTextContent("Not enforced by autopilot");
|
||||
expect(within(rollup).getByText("Feature criteria awaiting assertion sync")).toBeInTheDocument();
|
||||
expect(within(rollup).getByTestId("milestone-feature-acceptance-ai-validated-indicator")).toHaveTextContent("AI-validated at runtime");
|
||||
expect(within(rollup).getByText("Session handling")).toBeInTheDocument();
|
||||
expect(within(rollup).getByText("Session refresh succeeds without logout", { exact: false })).toBeInTheDocument();
|
||||
expect(within(rollup).getByText("Token storage")).toBeInTheDocument();
|
||||
expect(within(rollup).getByText("Tokens remain encrypted at rest", { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("milestone-zero-assertion-guard")).toHaveTextContent("Feature criteria present but no enforced contract assertions linked");
|
||||
expect(screen.queryByTestId("milestone-zero-assertion-guard")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/informational/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Not enforced by autopilot/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows feature acceptance rollup even when legacy gap telemetry is false", async () => {
|
||||
const missionDetail = JSON.parse(JSON.stringify(mockMissionDetail)) as typeof mockMissionDetail;
|
||||
missionDetail.milestones[0].acceptanceCriteria = "";
|
||||
missionDetail.milestones[0].slices[0].features = [
|
||||
{
|
||||
...missionDetail.milestones[0].slices[0].features[0],
|
||||
id: "F-ROLLUP-FALSE",
|
||||
title: "Runtime validation",
|
||||
acceptanceCriteria: "Validator still checks this feature",
|
||||
},
|
||||
];
|
||||
|
||||
const telemetryOverride = {
|
||||
...mockMilestoneValidationTelemetry,
|
||||
rollup: {
|
||||
...mockMilestoneValidationRollup,
|
||||
hasProseButNoAssertions: false,
|
||||
},
|
||||
};
|
||||
|
||||
globalThis.fetch = createDetailFetchMockForMissionDetail(missionDetail, telemetryOverride);
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
fireEvent.click(await screen.findByText("Build Auth System"));
|
||||
await waitForDetailLoaded();
|
||||
|
||||
const rollup = screen.getByTestId("milestone-feature-acceptance-rollup");
|
||||
expect(within(rollup).getByText("Feature criteria awaiting assertion sync")).toBeInTheDocument();
|
||||
expect(within(rollup).getByText("Runtime validation")).toBeInTheDocument();
|
||||
expect(within(rollup).getByText("Validator still checks this feature", { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps structured assertions precedence and hides rollup when assertions exist", async () => {
|
||||
@@ -5111,12 +5145,14 @@ describe("MissionManager", () => {
|
||||
await waitForDetailLoaded();
|
||||
|
||||
expect(screen.getByText("Auth works")).toBeInTheDocument();
|
||||
expect(screen.getByText("Contract assertions (validator-enforced when linked)")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("milestone-assertions-enforced-indicator")).toHaveTextContent("Enforced by autopilot");
|
||||
expect(screen.getByText("Contract assertions (AI-validated)")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("milestone-assertions-enforced-indicator")).toHaveTextContent("AI-validated mission gate");
|
||||
expect(screen.queryByTestId("milestone-feature-acceptance-rollup")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/informational/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Not enforced by autopilot/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows per-row informational and enforced indicators", async () => {
|
||||
it("shows validator status without informational enforcement labels", async () => {
|
||||
const missionDetail = JSON.parse(JSON.stringify(mockMissionDetail)) as typeof mockMissionDetail;
|
||||
missionDetail.milestones[0].acceptanceCriteria = "";
|
||||
const assertion = {
|
||||
@@ -5152,7 +5188,7 @@ describe("MissionManager", () => {
|
||||
await waitForDetailLoaded();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mission-assertion-enforcement-CA-ENF-1")).toHaveTextContent("Enforced gate");
|
||||
expect(screen.queryByTestId("mission-assertion-enforcement-CA-ENF-1")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const noAssertionMission = JSON.parse(JSON.stringify(missionDetail)) as typeof missionDetail;
|
||||
@@ -5163,10 +5199,12 @@ describe("MissionManager", () => {
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
fireEvent.click(await screen.findByText("Build Auth System"));
|
||||
await waitForDetailLoaded();
|
||||
expect(await screen.findByTestId("mission-feature-acceptance-enforcement-F-INFO-1")).toHaveTextContent("Informational");
|
||||
expect(await screen.findByTestId("mission-feature-acceptance-status-F-INFO-1")).toHaveTextContent("defined");
|
||||
expect(screen.queryByText(/Informational/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Not enforced/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows zero-assertion guard only when milestone has feature acceptance criteria and no assertions", async () => {
|
||||
it("never renders the zero-assertion guard after lazy assertion ensure contract", async () => {
|
||||
const missionDetail = JSON.parse(JSON.stringify(mockMissionDetail)) as typeof mockMissionDetail;
|
||||
missionDetail.milestones[0].acceptanceCriteria = "";
|
||||
|
||||
@@ -5175,7 +5213,7 @@ describe("MissionManager", () => {
|
||||
|
||||
fireEvent.click(await screen.findByText("Build Auth System"));
|
||||
await waitForDetailLoaded();
|
||||
expect(screen.getByTestId("milestone-zero-assertion-guard")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("milestone-zero-assertion-guard")).not.toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
globalThis.fetch = createDetailFetchMockForMissionDetail(
|
||||
|
||||
@@ -204,6 +204,16 @@ function createMockMissionStore() {
|
||||
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
|
||||
@@ -764,12 +774,21 @@ describe("MissionExecutionLoop", () => {
|
||||
expect(missionStore.completeValidatorRun).toHaveBeenCalledWith(expect.any(String), "passed", "Recovered validation passed");
|
||||
});
|
||||
|
||||
it("should auto-pass if feature has no linked assertions", async () => {
|
||||
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001" });
|
||||
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().mockReturnValue([]);
|
||||
missionStore.listAssertionsForFeature = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce([])
|
||||
.mockImplementation((featureId: string) => (missionStore as any).getAssertionsForFeature(featureId));
|
||||
|
||||
loop = new MissionExecutionLoop({
|
||||
taskStore: taskStore as any,
|
||||
@@ -777,41 +796,39 @@ describe("MissionExecutionLoop", () => {
|
||||
rootDir: "/tmp",
|
||||
});
|
||||
const emitSpy = vi.spyOn(loop, "emit");
|
||||
vi.spyOn(loop as any, "runValidation").mockResolvedValue({ status: "pass", summary: "ok" });
|
||||
loop.start();
|
||||
|
||||
await loop.processTaskOutcome("FN-001");
|
||||
|
||||
// When there are no assertions, we skip starting a validator run
|
||||
expect(missionStore.startValidatorRun).not.toHaveBeenCalled();
|
||||
// But the passed event should be emitted
|
||||
expect(missionStore.ensureFeatureAssertionLinked).toHaveBeenCalledWith("F-001");
|
||||
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion");
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
"validation:passed",
|
||||
expect.objectContaining({ featureId: "F-001" }),
|
||||
);
|
||||
expect(missionStore.updateFeature).toHaveBeenCalledWith(
|
||||
"F-001",
|
||||
expect.objectContaining({ loopState: "passed", lastValidatorStatus: "passed" }),
|
||||
);
|
||||
expect(missionStore.logMissionEvent).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
"warning",
|
||||
expect.stringContaining("auto-passed"),
|
||||
expect.objectContaining({
|
||||
code: "validation_auto_passed_no_assertions",
|
||||
featureId: "F-001",
|
||||
reason: "No assertions linked",
|
||||
taskId: "FN-001",
|
||||
}),
|
||||
const noAssertionEvents = missionStore.logMissionEvent.mock.calls.filter(
|
||||
([, , , payload]) => payload?.code === "validation_auto_passed_no_assertions",
|
||||
);
|
||||
expect(noAssertionEvents).toHaveLength(0);
|
||||
expectNoValidationBoardTaskMutation(taskStore);
|
||||
});
|
||||
|
||||
it("emits no-assertions auto-pass event exactly once across re-entry", async () => {
|
||||
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001" });
|
||||
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().mockReturnValue([]);
|
||||
missionStore.listAssertionsForFeature = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce([])
|
||||
.mockImplementation((featureId: string) => (missionStore as any).getAssertionsForFeature(featureId));
|
||||
|
||||
loop = new MissionExecutionLoop({
|
||||
taskStore: taskStore as any,
|
||||
@@ -823,10 +840,11 @@ describe("MissionExecutionLoop", () => {
|
||||
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(1);
|
||||
expect(noAssertionEvents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses validator path for later-added feature with managed assertion", async () => {
|
||||
@@ -854,6 +872,44 @@ describe("MissionExecutionLoop", () => {
|
||||
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-LATER", "task_completion");
|
||||
});
|
||||
|
||||
it("threads milestone acceptance criteria into validator prompts", () => {
|
||||
const feature = createMockFeature({
|
||||
id: "F-MILESTONE",
|
||||
title: "Feature under milestone",
|
||||
acceptanceCriteria: "Feature criteria",
|
||||
});
|
||||
const milestone = createMockMilestone({
|
||||
id: "MS-MILESTONE",
|
||||
acceptanceCriteria: "Milestone pass bar text",
|
||||
});
|
||||
const assertions = [
|
||||
{
|
||||
id: "CA-1",
|
||||
milestoneId: milestone.id,
|
||||
title: "Managed assertion",
|
||||
assertion: "Feature criteria",
|
||||
status: "pending" as const,
|
||||
orderIndex: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
loop = new MissionExecutionLoop({
|
||||
taskStore: taskStore as any,
|
||||
missionStore: missionStore as any,
|
||||
rootDir: "/tmp",
|
||||
});
|
||||
|
||||
const prompt = (loop as any).buildValidationPrompt(feature, assertions, milestone);
|
||||
const systemPrompt = (loop as any).buildValidationSystemPrompt(feature, assertions, "Task context", milestone);
|
||||
|
||||
expect(prompt).toContain("Milestone pass bar text");
|
||||
expect(prompt).toContain("must also be satisfied for this feature to pass");
|
||||
expect(systemPrompt).toContain("Milestone pass bar text");
|
||||
expect(systemPrompt).toContain("validator-executed requirements");
|
||||
});
|
||||
|
||||
it("does NOT create a board task for single-feature validation", async () => {
|
||||
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-001", sliceId: "SL-001" });
|
||||
missionStore._setFeature(feature);
|
||||
@@ -1464,7 +1520,7 @@ describe("MissionExecutionLoop", () => {
|
||||
});
|
||||
missionStore._setFeature(feature);
|
||||
missionStore.getFeatureByTaskId = vi.fn().mockReturnValue(feature);
|
||||
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue([]); // No assertions = auto-pass
|
||||
missionStore.listAssertionsForFeature = vi.fn().mockReturnValue([]);
|
||||
taskStore._setTask({ id: "FN-001", title: "Test", description: "Test", log: [] });
|
||||
|
||||
const notifySpy = vi.fn();
|
||||
@@ -1477,12 +1533,13 @@ describe("MissionExecutionLoop", () => {
|
||||
},
|
||||
});
|
||||
const emitSpy = vi.spyOn(loop, "emit");
|
||||
vi.spyOn(loop as any, "runValidation").mockResolvedValue({ status: "pass", summary: "ok" });
|
||||
loop.start();
|
||||
|
||||
await loop.processTaskOutcome("FN-001");
|
||||
|
||||
// No validator run started (no assertions)
|
||||
expect(missionStore.startValidatorRun).not.toHaveBeenCalled();
|
||||
expect(missionStore.ensureFeatureAssertionLinked).toHaveBeenCalledWith("F-001");
|
||||
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion");
|
||||
|
||||
// validation:passed event emitted
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
|
||||
@@ -186,9 +186,10 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
|
||||
loop.stop();
|
||||
});
|
||||
|
||||
it("periodic recovery pass replays implementing done tasks with zero assertions and advances loop state", async () => {
|
||||
const feature = makeFeature({ status: "done", lastValidatorStatus: undefined, loopState: "implementing" });
|
||||
it("periodic recovery lazily ensures assertions and AI-validates zero-link legacy features", async () => {
|
||||
const feature = makeFeature({ status: "done", lastValidatorStatus: undefined, loopState: "implementing", acceptanceCriteria: "must pass" });
|
||||
const currentFeature = { ...feature };
|
||||
const linkedAssertions: Array<{ id: string }> = [];
|
||||
const missionStore = {
|
||||
listMissions: vi.fn(() => [{ id: "M-001", status: "active" }]),
|
||||
getMissionWithHierarchy: vi.fn(() => ({
|
||||
@@ -203,11 +204,21 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
|
||||
Object.assign(currentFeature, patch);
|
||||
return { ...currentFeature };
|
||||
}),
|
||||
listAssertionsForFeature: vi.fn(() => []),
|
||||
listAssertionsForFeature: vi.fn(() => linkedAssertions),
|
||||
ensureFeatureAssertionLinked: vi.fn(() => {
|
||||
if (linkedAssertions.length === 0) {
|
||||
linkedAssertions.push({ id: "CA-ENSURED" });
|
||||
}
|
||||
return linkedAssertions;
|
||||
}),
|
||||
startValidatorRun: vi.fn(() => ({ id: "VR-001", featureId: "F-001" })),
|
||||
completeValidatorRun: vi.fn(),
|
||||
getSlice: vi.fn(() => ({ id: "SL-001", milestoneId: "MS-001", status: "active" })),
|
||||
getMilestone: vi.fn(() => ({ id: "MS-001", missionId: "M-001" })),
|
||||
logMissionEvent: vi.fn(),
|
||||
transitionLoopState: vi.fn(),
|
||||
setFeatureCurrentTaskRunId: vi.fn(),
|
||||
getFailuresForRun: vi.fn(() => []),
|
||||
};
|
||||
const taskStore = {
|
||||
getTask: vi.fn(async () => ({ id: "FN-001", column: "done", status: "done" })),
|
||||
@@ -220,25 +231,23 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
|
||||
taskStore: taskStore as any,
|
||||
rootDir: process.cwd(),
|
||||
});
|
||||
vi.spyOn(loop as any, "runValidation").mockResolvedValue({ status: "pass", summary: "ok" });
|
||||
loop.start();
|
||||
|
||||
const periodicMaintenancePass = async () => loop.recoverActiveMissions();
|
||||
await periodicMaintenancePass();
|
||||
await periodicMaintenancePass();
|
||||
|
||||
expect(missionStore.updateFeature).toHaveBeenCalledTimes(1);
|
||||
expect(missionStore.updateFeature).toHaveBeenCalledWith(
|
||||
"F-001",
|
||||
expect.objectContaining({ loopState: "passed", lastValidatorStatus: "passed" }),
|
||||
);
|
||||
expect(missionStore.ensureFeatureAssertionLinked).toHaveBeenCalledWith("F-001");
|
||||
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion");
|
||||
const noAssertionEvents = missionStore.logMissionEvent.mock.calls.filter(
|
||||
([, type, , payload]) => type === "warning" && payload?.code === "validation_auto_passed_no_assertions",
|
||||
);
|
||||
expect(noAssertionEvents).toHaveLength(1);
|
||||
expect(noAssertionEvents).toHaveLength(0);
|
||||
loop.stop();
|
||||
});
|
||||
|
||||
it("routes through validator after assertion backfill instead of no-assertion auto-pass", async () => {
|
||||
it("keeps backfill optional because runtime lazy-ensure routes through validator", async () => {
|
||||
const feature = makeFeature({ status: "done", acceptanceCriteria: "must pass", loopState: "implementing" });
|
||||
const currentFeature = { ...feature };
|
||||
const linkedAssertions: Array<{ id: string }> = [];
|
||||
@@ -258,6 +267,12 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
|
||||
return { ...currentFeature };
|
||||
}),
|
||||
listAssertionsForFeature: vi.fn(() => linkedAssertions),
|
||||
ensureFeatureAssertionLinked: vi.fn(() => {
|
||||
if (linkedAssertions.length === 0) {
|
||||
linkedAssertions.push({ id: "CA-001" });
|
||||
}
|
||||
return linkedAssertions;
|
||||
}),
|
||||
startValidatorRun: vi.fn(() => ({ id: "VR-001", featureId: "F-001" })),
|
||||
completeValidatorRun: vi.fn(),
|
||||
getSlice: vi.fn(() => ({ id: "SL-001", milestoneId: "MS-001", status: "active" })),
|
||||
@@ -279,27 +294,13 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
|
||||
|
||||
await loop.recoverActiveMissions();
|
||||
|
||||
const noAssertionEventsBefore = missionStore.logMissionEvent.mock.calls.filter(
|
||||
([, type, , payload]) => type === "warning" && payload?.code === "validation_auto_passed_no_assertions",
|
||||
);
|
||||
expect(noAssertionEventsBefore).toHaveLength(1);
|
||||
expect(missionStore.startValidatorRun).not.toHaveBeenCalled();
|
||||
|
||||
linkedAssertions.push({ id: "CA-001" });
|
||||
currentFeature.loopState = "implementing";
|
||||
currentFeature.lastValidatorStatus = undefined;
|
||||
|
||||
await loop.processTaskOutcome("FN-001");
|
||||
|
||||
expect(missionStore.ensureFeatureAssertionLinked).toHaveBeenCalledWith("F-001");
|
||||
expect(missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion");
|
||||
const noAssertionEventsAfter = missionStore.logMissionEvent.mock.calls.filter(
|
||||
const noAssertionEvents = missionStore.logMissionEvent.mock.calls.filter(
|
||||
([, type, , payload]) => type === "warning" && payload?.code === "validation_auto_passed_no_assertions",
|
||||
);
|
||||
expect(noAssertionEventsAfter).toHaveLength(1);
|
||||
expect(missionStore.updateFeature).toHaveBeenCalledWith(
|
||||
"F-001",
|
||||
expect.objectContaining({ loopState: "passed", lastValidatorStatus: "passed" }),
|
||||
);
|
||||
expect(noAssertionEvents).toHaveLength(0);
|
||||
expect(missionStore.completeValidatorRun).toHaveBeenCalledWith("VR-001", "passed", "ok");
|
||||
|
||||
loop.stop();
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
MissionValidatorRun,
|
||||
AgentStore,
|
||||
Settings,
|
||||
Milestone,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
TEST_MODE_RESOLVED,
|
||||
@@ -353,13 +354,12 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get linked assertions for this feature
|
||||
const assertions = this.missionStore.listAssertionsForFeature(feature.id);
|
||||
// Lazily guarantee a linked assertion before validation so every feature
|
||||
// is evaluated by the validator even when legacy data is missing links.
|
||||
let assertions = this.missionStore.listAssertionsForFeature(feature.id);
|
||||
if (assertions.length === 0) {
|
||||
loopLog.log(`Feature ${feature.id} has no linked assertions; marking as passed`);
|
||||
// No assertions = automatically pass
|
||||
await this.handleValidationPass(feature.id, undefined, "No assertions linked");
|
||||
return;
|
||||
loopLog.log(`Feature ${feature.id} has no linked assertions; lazily ensuring store-managed assertion linkage`);
|
||||
assertions = this.missionStore.ensureFeatureAssertionLinked(feature.id);
|
||||
}
|
||||
|
||||
// Mark feature as being validated
|
||||
@@ -408,8 +408,10 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
): Promise<ValidationResult> {
|
||||
loopLog.log(`Running validation for feature ${feature.id} with ${assertions.length} assertions`);
|
||||
|
||||
const milestone = this.resolveFeatureMilestone(feature);
|
||||
|
||||
// Build the validation prompt
|
||||
const prompt = this.buildValidationPrompt(feature, assertions);
|
||||
const prompt = this.buildValidationPrompt(feature, assertions, milestone);
|
||||
|
||||
// Get task context for validation
|
||||
const task = feature.taskId ? await this.taskStore.getTask(feature.taskId) : null;
|
||||
@@ -441,7 +443,7 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
runtimeHint: validationRuntimeHint,
|
||||
pluginRunner: this.pluginRunner,
|
||||
cwd: this.rootDir,
|
||||
systemPrompt: this.buildValidationSystemPrompt(feature, assertions, taskContext),
|
||||
systemPrompt: this.buildValidationSystemPrompt(feature, assertions, taskContext, milestone),
|
||||
tools: "readonly",
|
||||
defaultProvider: validationSessionModel.provider,
|
||||
defaultModelId: validationSessionModel.modelId,
|
||||
@@ -796,19 +798,27 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
/**
|
||||
* Build the validation prompt sent to the AI agent.
|
||||
*/
|
||||
private buildValidationPrompt(feature: MissionFeature, assertions: MissionContractAssertion[]): string {
|
||||
private buildValidationPrompt(
|
||||
feature: MissionFeature,
|
||||
assertions: MissionContractAssertion[],
|
||||
milestone?: Milestone,
|
||||
): string {
|
||||
const assertionTexts = assertions
|
||||
.map((a, i) => `${i + 1}. **${a.title}**: ${a.assertion}`)
|
||||
.join("\n");
|
||||
const milestoneAcceptanceCriteria = milestone?.acceptanceCriteria?.trim();
|
||||
const milestoneContext = milestoneAcceptanceCriteria
|
||||
? `\nMilestone acceptance criteria (must also be satisfied for this feature to pass):\n${milestoneAcceptanceCriteria}\n`
|
||||
: "";
|
||||
|
||||
return `Evaluate the implementation for feature "${feature.title}" against the following contract assertions:
|
||||
|
||||
${assertionTexts}
|
||||
|
||||
${assertionTexts}${milestoneContext}
|
||||
For each assertion:
|
||||
- Determine if the implementation satisfies the assertion (pass/fail/blocked)
|
||||
- If failed, explain what was expected vs what was actually observed
|
||||
- If blocked, explain what external factor prevented validation
|
||||
- Also verify that the implementation satisfies any milestone acceptance criteria provided above
|
||||
|
||||
Respond with a JSON object in this format:
|
||||
{
|
||||
@@ -833,22 +843,25 @@ Be thorough and objective. If any assertion fails, the overall status should be
|
||||
* Build the system prompt for the validation agent.
|
||||
*/
|
||||
private buildValidationSystemPrompt(
|
||||
feature: MissionFeature,
|
||||
_feature: MissionFeature,
|
||||
_assertions: MissionContractAssertion[],
|
||||
taskContext: string,
|
||||
milestone?: Milestone,
|
||||
): string {
|
||||
const milestoneAcceptanceCriteria = milestone?.acceptanceCriteria?.trim();
|
||||
return `You are a validation agent responsible for evaluating whether an implementation satisfies its contract assertions.
|
||||
|
||||
You will receive:
|
||||
1. A feature description with its acceptance criteria
|
||||
2. Contract assertions to evaluate against
|
||||
3. Task context including the implementation details
|
||||
3. Task context including the implementation details${milestoneAcceptanceCriteria ? `\n4. Milestone acceptance criteria text that also applies to this feature: ${milestoneAcceptanceCriteria}` : ""}
|
||||
|
||||
Your job is to:
|
||||
1. Carefully review the implementation as described in the task context
|
||||
2. Evaluate each contract assertion objectively
|
||||
3. Determine if the implementation fully satisfies each assertion
|
||||
4. Return a structured JSON response with your findings
|
||||
4. Verify the implementation also satisfies any milestone acceptance criteria provided for the parent milestone
|
||||
5. Return a structured JSON response with your findings
|
||||
|
||||
Be thorough and precise. A contract assertion represents a commitment made during planning - the implementation must fully satisfy it or it is considered failed.
|
||||
|
||||
@@ -857,6 +870,7 @@ Evaluation guidance:
|
||||
- "fail" means one or more assertions are unmet or only partially satisfied.
|
||||
- "blocked" means you cannot evaluate due to missing/insufficient evidence or external constraints.
|
||||
- Partial satisfaction must be marked as failed with clear expected vs actual details.
|
||||
- Milestone acceptance criteria are validator-executed requirements, not informational context.
|
||||
|
||||
Response format: Return ONLY a JSON object (no additional text) with this structure:
|
||||
{
|
||||
@@ -898,6 +912,15 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
private resolveFeatureMilestone(feature: MissionFeature): Milestone | undefined {
|
||||
const slice = this.missionStore.getSlice(feature.sliceId);
|
||||
if (!slice) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.missionStore.getMilestone(slice.milestoneId);
|
||||
}
|
||||
|
||||
private completeValidatorRunIfStillRunning(
|
||||
runId: string | undefined,
|
||||
status: "passed" | "failed" | "blocked" | "error",
|
||||
@@ -938,33 +961,6 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
|
||||
this.missionStore.updateFeatureStatus(featureId, "done");
|
||||
}
|
||||
|
||||
if (!runId && feature) {
|
||||
const alreadyAutoPassed =
|
||||
feature.status === "done" &&
|
||||
feature.loopState === "passed" &&
|
||||
feature.lastValidatorStatus === "passed";
|
||||
|
||||
if (!alreadyAutoPassed) {
|
||||
// Auto-pass path has no validator run, so we must advance loop bookkeeping here.
|
||||
if (feature.loopState !== "passed" || feature.lastValidatorStatus !== "passed") {
|
||||
this.missionStore.updateFeature(featureId, {
|
||||
loopState: "passed",
|
||||
lastValidatorStatus: "passed",
|
||||
});
|
||||
}
|
||||
|
||||
this.logFeatureWarningEvent(
|
||||
featureId,
|
||||
"validation_auto_passed_no_assertions",
|
||||
`Feature ${featureId} auto-passed because no assertions were linked.`,
|
||||
{
|
||||
taskId: feature.taskId,
|
||||
reason: "No assertions linked",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
loopLog.log(`Feature ${featureId} passed validation`);
|
||||
|
||||
// Notify autopilot if configured
|
||||
|
||||
Reference in New Issue
Block a user