FN-5738: make mission loop no-assertion auto-pass deterministic
Ensure mission validation loop advancement is deterministic when no assertions are linked and audit output is emitted once. - update mission execution loop handling to auto-pass zero-assertion validations deterministically - emit and guard validation_auto_passed_no_assertions audit behavior against duplicate recovery re-fire - expand mission loop and reliability interaction tests for loop state, validator status, and replay safety - refresh mission/architecture docs and add the FN-5738 changeset entry Files changed: .changeset/fn-5732a-loop-advance.md | 5 ++ AGENTS.md | 1 + docs/architecture.md | 3 +- docs/missions-completion-contract.md | 2 +- docs/missions.md | 4 +- .../src/__tests__/mission-execution-loop.test.ts | 27 ++++++++++- .../mission-validation-trigger-gap.test.ts | 22 +++++++-- packages/engine/src/mission-execution-loop.ts | 54 +++++++++++++--------- 8 files changed, 86 insertions(+), 32 deletions(-) Fusion-Task-Id: FN-5738 Fusion-Task-Lineage: 141b73e3-fa49-468a-87b0-f36e091e716f
This commit is contained in:
@@ -523,15 +523,38 @@ describe("MissionExecutionLoop", () => {
|
||||
"warning",
|
||||
expect.stringContaining("auto-passed"),
|
||||
expect.objectContaining({
|
||||
code: "feature_auto_passed_no_assertions",
|
||||
code: "validation_auto_passed_no_assertions",
|
||||
featureId: "F-001",
|
||||
reason: "no_assertions_linked",
|
||||
reason: "No assertions linked",
|
||||
taskId: "FN-001",
|
||||
}),
|
||||
);
|
||||
expectNoValidationBoardTaskMutation(taskStore);
|
||||
});
|
||||
|
||||
it("emits no-assertions auto-pass event exactly once across re-entry", async () => {
|
||||
const feature = createMockFeature({ loopState: "implementing", taskId: "FN-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([]);
|
||||
|
||||
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");
|
||||
|
||||
const noAssertionEvents = missionStore.logMissionEvent.mock.calls.filter(
|
||||
([, , , payload]) => payload?.code === "validation_auto_passed_no_assertions",
|
||||
);
|
||||
expect(noAssertionEvents).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses validator path for later-added feature with managed assertion", async () => {
|
||||
const feature = createMockFeature({
|
||||
id: "F-LATER",
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
|
||||
expect(missionStore.updateFeatureStatus).not.toHaveBeenCalledWith("F-001", "done");
|
||||
});
|
||||
|
||||
it("keeps no-assertion completion path unchanged", async () => {
|
||||
it("keeps assertion-linked completion path unchanged", async () => {
|
||||
const feature = makeFeature();
|
||||
const missionStore = {
|
||||
getFeatureByTaskId: vi.fn(() => feature),
|
||||
@@ -99,6 +99,7 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
|
||||
milestones: [{ status: "active", slices: [{ status: "active", features: [feature] }] }],
|
||||
})),
|
||||
listAssertionsForFeature: vi.fn(() => [{ id: "CA-1" }]),
|
||||
getFeature: vi.fn(() => feature),
|
||||
transitionLoopState: vi.fn(),
|
||||
};
|
||||
const taskStore = {
|
||||
@@ -129,6 +130,7 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
|
||||
milestones: [{ status: "active", slices: [{ status: "active", features: [feature] }] }],
|
||||
})),
|
||||
listAssertionsForFeature: vi.fn(() => [{ id: "CA-1" }]),
|
||||
getFeature: vi.fn(() => feature),
|
||||
transitionLoopState: vi.fn(),
|
||||
};
|
||||
const taskStore = {
|
||||
@@ -151,6 +153,7 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
|
||||
|
||||
it("recovery replays implementing done tasks with zero assertions and advances loop state", async () => {
|
||||
const feature = makeFeature({ status: "done", lastValidatorStatus: undefined, loopState: "implementing" });
|
||||
const currentFeature = { ...feature };
|
||||
const missionStore = {
|
||||
listMissions: vi.fn(() => [{ id: "M-001", status: "active" }]),
|
||||
getMissionWithHierarchy: vi.fn(() => ({
|
||||
@@ -158,10 +161,13 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
|
||||
status: "active",
|
||||
milestones: [{ status: "active", slices: [{ status: "active", features: [feature] }] }],
|
||||
})),
|
||||
getFeatureByTaskId: vi.fn(() => feature),
|
||||
getFeature: vi.fn(() => feature),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
updateFeature: vi.fn(),
|
||||
getFeatureByTaskId: vi.fn(() => currentFeature),
|
||||
getFeature: vi.fn(() => currentFeature),
|
||||
updateFeatureStatus: vi.fn((featureId: string, status: "done") => ({ ...currentFeature, id: featureId, status })),
|
||||
updateFeature: vi.fn((_featureId: string, patch: Partial<MissionFeature>) => {
|
||||
Object.assign(currentFeature, patch);
|
||||
return { ...currentFeature };
|
||||
}),
|
||||
listAssertionsForFeature: vi.fn(() => []),
|
||||
getSlice: vi.fn(() => ({ id: "SL-001", milestoneId: "MS-001", status: "active" })),
|
||||
getMilestone: vi.fn(() => ({ id: "MS-001", missionId: "M-001" })),
|
||||
@@ -181,12 +187,18 @@ describe("FN-5715 reliability: mission validation trigger gap", () => {
|
||||
});
|
||||
loop.start();
|
||||
|
||||
await loop.recoverActiveMissions();
|
||||
await loop.recoverActiveMissions();
|
||||
|
||||
expect(missionStore.updateFeature).toHaveBeenCalledTimes(1);
|
||||
expect(missionStore.updateFeature).toHaveBeenCalledWith(
|
||||
"F-001",
|
||||
expect.objectContaining({ loopState: "passed", lastValidatorStatus: "passed" }),
|
||||
);
|
||||
const noAssertionEvents = missionStore.logMissionEvent.mock.calls.filter(
|
||||
([, type, , payload]) => type === "warning" && payload?.code === "validation_auto_passed_no_assertions",
|
||||
);
|
||||
expect(noAssertionEvents).toHaveLength(1);
|
||||
loop.stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,11 +210,16 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
|
||||
// Features that remained implementing while their linked task already finished
|
||||
// can be stranded after restart; recover by re-triggering task outcome.
|
||||
if (
|
||||
feature.loopState === "implementing"
|
||||
&& feature.taskId
|
||||
&& feature.lastValidatorStatus !== "passed"
|
||||
) {
|
||||
if (feature.loopState === "implementing" && feature.taskId) {
|
||||
const currentFeature = this.missionStore.getFeature(feature.id) ?? feature;
|
||||
if (
|
||||
this.activeValidations.has(feature.id)
|
||||
|| currentFeature.loopState === "passed"
|
||||
|| currentFeature.lastValidatorStatus === "passed"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const linkedTask = await this.taskStore.getTask(feature.taskId).catch(() => null);
|
||||
if (linkedTask && (linkedTask.column === "done" || linkedTask.column === "archived")) {
|
||||
@@ -803,23 +808,30 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
|
||||
}
|
||||
|
||||
if (!runId && feature) {
|
||||
// Auto-pass path has no validator run, so we must advance the loop state directly.
|
||||
if (feature.loopState !== "passed" || feature.lastValidatorStatus !== "passed") {
|
||||
this.missionStore.updateFeature(featureId, {
|
||||
loopState: "passed",
|
||||
lastValidatorStatus: "passed",
|
||||
});
|
||||
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",
|
||||
},
|
||||
);
|
||||
}
|
||||
this.logFeatureMissionEvent(
|
||||
featureId,
|
||||
"warning",
|
||||
"feature_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`);
|
||||
|
||||
Reference in New Issue
Block a user