FN-5645: fix mission autopilot progression to wait for assertion runs
Fusion-Task-Id: FN-5645 Fusion-Task-Lineage: ed9d2045-2bfe-4070-a7e3-caca010e6cc6
This commit is contained in:
@@ -13,7 +13,7 @@ function makeHarness({
|
||||
withAssertions?: boolean;
|
||||
initialFeatureStatus?: string;
|
||||
initialTaskColumn?: string;
|
||||
runValidationImpl?: () => Promise<{ status: "pass"; assertions: []; summary: string }>;
|
||||
runValidationImpl?: () => Promise<{ status: "pass" | "error"; assertions: []; summary: string }>;
|
||||
} = {}) {
|
||||
const mission = {
|
||||
id: "M-001",
|
||||
@@ -231,4 +231,26 @@ describe("mission autopilot end-to-end wiring", () => {
|
||||
expect(h.missionStore.startValidatorRun).toHaveBeenCalledTimes(1);
|
||||
h.scheduler.stop();
|
||||
});
|
||||
|
||||
it("emits mission error event when validator returns error", async () => {
|
||||
const h = makeHarness({
|
||||
withAssertions: true,
|
||||
runValidationImpl: async () => ({ status: "error", assertions: [], summary: "runtime unavailable" }),
|
||||
});
|
||||
|
||||
await h.emitTaskMoved("done");
|
||||
|
||||
expect(h.missionStore.completeValidatorRun).toHaveBeenCalledWith("VR-001", "error", "runtime unavailable");
|
||||
expect(h.missionStore.logMissionEvent).toHaveBeenCalledWith(
|
||||
"M-001",
|
||||
"error",
|
||||
expect.stringContaining("Validation error"),
|
||||
expect.objectContaining({
|
||||
code: "validation_error",
|
||||
featureId: "F-001",
|
||||
error: "runtime unavailable",
|
||||
}),
|
||||
);
|
||||
h.scheduler.stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,6 +155,7 @@ function createMockMissionStore() {
|
||||
milestones: [createMockMilestone({ missionId: id })],
|
||||
};
|
||||
}),
|
||||
logMissionEvent: vi.fn(),
|
||||
|
||||
// Feature methods
|
||||
getFeature: vi.fn((id: string) => features.get(id)),
|
||||
@@ -449,6 +450,17 @@ describe("MissionExecutionLoop", () => {
|
||||
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("should auto-pass if feature has no linked assertions", async () => {
|
||||
@@ -543,6 +555,16 @@ describe("MissionExecutionLoop", () => {
|
||||
await loop.processTaskOutcome("FN-001");
|
||||
|
||||
expect(missionStore.startValidatorRun).not.toHaveBeenCalled();
|
||||
expect(missionStore.logMissionEvent).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
"warning",
|
||||
expect.stringContaining("duplicate trigger"),
|
||||
expect.objectContaining({
|
||||
code: "validation_deduplicated",
|
||||
featureId: "F-001",
|
||||
taskId: "FN-001",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("calls startValidatorRun without a board task ID", async () => {
|
||||
@@ -1169,6 +1191,16 @@ describe("MissionExecutionLoop", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1339,6 +1371,7 @@ describe("MissionExecutionLoop", () => {
|
||||
rootDir: "/tmp",
|
||||
});
|
||||
const processTaskOutcomeSpy = vi.spyOn(loop, "processTaskOutcome");
|
||||
taskStore._setTask({ id: "FN-VALIDATING", column: "done" });
|
||||
loop.start();
|
||||
|
||||
await loop.recoverActiveMissions();
|
||||
@@ -1385,6 +1418,7 @@ describe("MissionExecutionLoop", () => {
|
||||
rootDir: "/tmp",
|
||||
});
|
||||
const processTaskOutcomeSpy = vi.spyOn(loop, "processTaskOutcome");
|
||||
taskStore._setTask({ id: "FN-NEEDS-FIX", column: "done" });
|
||||
loop.start();
|
||||
|
||||
await loop.recoverActiveMissions();
|
||||
@@ -1438,6 +1472,53 @@ describe("MissionExecutionLoop", () => {
|
||||
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 not call processTaskOutcome for needs_fix features without taskId", async () => {
|
||||
const feature = createMockFeature({
|
||||
id: "F-NO-TASK",
|
||||
|
||||
@@ -173,7 +173,10 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
await this.missionStore.transitionLoopState(feature.id, "implementing");
|
||||
// If the feature has a linked task that's already done, re-trigger validation
|
||||
if (feature.taskId) {
|
||||
await this.processTaskOutcome(feature.taskId);
|
||||
const linkedTask = await this.taskStore.getTask(feature.taskId).catch(() => null);
|
||||
if (linkedTask && (linkedTask.column === "done" || linkedTask.column === "archived")) {
|
||||
await this.processTaskOutcome(feature.taskId);
|
||||
}
|
||||
}
|
||||
recoveredCount++;
|
||||
} catch (err) {
|
||||
@@ -187,7 +190,10 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
// If the fix task is complete, call processTaskOutcome to continue the cycle
|
||||
if (feature.taskId) {
|
||||
try {
|
||||
await this.processTaskOutcome(feature.taskId);
|
||||
const linkedTask = await this.taskStore.getTask(feature.taskId).catch(() => null);
|
||||
if (linkedTask && (linkedTask.column === "done" || linkedTask.column === "archived")) {
|
||||
await this.processTaskOutcome(feature.taskId);
|
||||
}
|
||||
recoveredCount++;
|
||||
} catch (err) {
|
||||
loopLog.error(`Recovery failed for needs_fix feature ${feature.id}:`, err);
|
||||
@@ -237,11 +243,18 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
// Only validate features in "implementing" state
|
||||
if (feature.loopState !== "implementing") {
|
||||
loopLog.log(`Feature ${feature.id} loopState is "${feature.loopState}"; skipping validation`);
|
||||
this.logFeatureWarningEvent(feature.id, "validation_skipped_loop_state", `Validation skipped: feature ${feature.id} is in loopState "${feature.loopState}" (expected "implementing").`, {
|
||||
taskId,
|
||||
loopState: feature.loopState,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeValidations.has(feature.id)) {
|
||||
loopLog.log(`Feature ${feature.id} already has an active validation; skipping duplicate trigger`);
|
||||
this.logFeatureWarningEvent(feature.id, "validation_deduplicated", `Validation already running for feature ${feature.id}; duplicate trigger ignored.`, {
|
||||
taskId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -866,6 +879,10 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
|
||||
this.missionStore.completeValidatorRun(runId, "blocked", blockedReason);
|
||||
}
|
||||
loopLog.log(`Feature ${featureId} blocked: ${blockedReason}`);
|
||||
this.logFeatureErrorEvent(featureId, "validation_blocked", `Validation blocked for feature ${featureId}: ${blockedReason ?? "no reason provided"}`, {
|
||||
runId,
|
||||
blockedReason: blockedReason ?? null,
|
||||
});
|
||||
|
||||
// Notify autopilot if configured
|
||||
if (this.missionAutopilot?.notifyValidationComplete) {
|
||||
@@ -891,6 +908,10 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
|
||||
this.missionStore.completeValidatorRun(runId, "error", error);
|
||||
}
|
||||
loopLog.error(`Feature ${featureId} validation error: ${error}`);
|
||||
this.logFeatureErrorEvent(featureId, "validation_error", `Validation error for feature ${featureId}: ${error}`, {
|
||||
runId,
|
||||
error,
|
||||
});
|
||||
|
||||
// Notify autopilot if configured
|
||||
if (this.missionAutopilot?.notifyValidationComplete) {
|
||||
@@ -902,4 +923,49 @@ ${taskContext ? `\n\nImplementation context:\n${taskContext}` : ""}`;
|
||||
loopLog.error(`Error handling validation error for ${featureId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
private logFeatureWarningEvent(
|
||||
featureId: string,
|
||||
code: string,
|
||||
description: string,
|
||||
metadata: Record<string, unknown>,
|
||||
): void {
|
||||
this.logFeatureMissionEvent(featureId, "warning", code, description, metadata);
|
||||
}
|
||||
|
||||
private logFeatureErrorEvent(
|
||||
featureId: string,
|
||||
code: string,
|
||||
description: string,
|
||||
metadata: Record<string, unknown>,
|
||||
): void {
|
||||
this.logFeatureMissionEvent(featureId, "error", code, description, metadata);
|
||||
}
|
||||
|
||||
private logFeatureMissionEvent(
|
||||
featureId: string,
|
||||
eventType: "warning" | "error",
|
||||
code: string,
|
||||
description: string,
|
||||
metadata: Record<string, unknown>,
|
||||
): void {
|
||||
const feature = this.missionStore.getFeature(featureId);
|
||||
if (!feature) return;
|
||||
const slice = this.missionStore.getSlice(feature.sliceId);
|
||||
if (!slice) return;
|
||||
const milestone = this.missionStore.getMilestone(slice.milestoneId);
|
||||
if (!milestone) return;
|
||||
|
||||
try {
|
||||
this.missionStore.logMissionEvent?.(milestone.missionId, eventType, description, {
|
||||
code,
|
||||
featureId,
|
||||
sliceId: slice.id,
|
||||
milestoneId: milestone.id,
|
||||
...metadata,
|
||||
});
|
||||
} catch (err) {
|
||||
loopLog.warn(`Failed to log mission ${eventType} event for feature ${featureId}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,9 +311,19 @@ export class InProcessRuntime
|
||||
notifyValidationComplete: async (featureId: string) => {
|
||||
// Pass the feature's linked taskId to handleTaskCompletion, not the featureId
|
||||
const feature = missionStore.getFeature(featureId);
|
||||
if (feature?.taskId) {
|
||||
await missionAutopilot.handleTaskCompletion(feature.taskId);
|
||||
if (!feature?.taskId) {
|
||||
return;
|
||||
}
|
||||
const slice = missionStore.getSlice(feature.sliceId);
|
||||
const milestone = slice ? missionStore.getMilestone(slice.milestoneId) : undefined;
|
||||
const missionId = milestone?.missionId;
|
||||
if (missionId) {
|
||||
const mission = missionStore.getMission(missionId);
|
||||
if (mission?.autopilotEnabled && !missionAutopilot.isWatching(missionId)) {
|
||||
missionAutopilot.watchMission(missionId);
|
||||
}
|
||||
}
|
||||
await missionAutopilot.handleTaskCompletion(feature.taskId);
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
|
||||
Reference in New Issue
Block a user