fix(missions): reconcile lifecycle invariants
This commit is contained in:
7
.changeset/mission-lifecycle-invariants.md
Normal file
7
.changeset/mission-lifecycle-invariants.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Reconcile completed and stale generated-fix mission invariants.
|
||||||
|
category: fix
|
||||||
|
dev: Completed missions now normalize autopilot/auto-advance to inactive during autopilot completion, polling, and restart recovery. Mission reconciliation also supersedes generated fix features whose own validator state is already passed, and the scheduler startup sweep runs stale generated-fix reconciliation before trying to relink or retriage active slice features. This prevents complete missions from remaining watched and prevents stale generated fix rows from keeping otherwise-drained missions administratively active.
|
||||||
@@ -1748,6 +1748,36 @@ describe("MissionStore", () => {
|
|||||||
expect(store.getFeature(staleFix.id)).toMatchObject({ status: "done", loopState: "passed", lastValidatorStatus: "passed" });
|
expect(store.getFeature(staleFix.id)).toMatchObject({ status: "done", loopState: "passed", lastValidatorStatus: "passed" });
|
||||||
expect(store.computeSliceStatus(slice.id)).toBe("complete");
|
expect(store.computeSliceStatus(slice.id)).toBe("complete");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reconciles generated fix features whose own validator already passed", () => {
|
||||||
|
const mission = store.createMission({ title: "Mission" });
|
||||||
|
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
|
||||||
|
const slice = store.addSlice(milestone.id, { title: "Slice" });
|
||||||
|
const source = store.addFeature(slice.id, { title: "Source" });
|
||||||
|
|
||||||
|
store.updateFeature(source.id, { status: "done" });
|
||||||
|
const failedRun = store.startValidatorRun(source.id, "task_completion");
|
||||||
|
store.completeValidatorRun(failedRun.id, "failed", "missing evidence");
|
||||||
|
const staleFix = store.createGeneratedFixFeature(source.id, failedRun.id, ["CA-source"]);
|
||||||
|
store.updateFeature(staleFix.id, {
|
||||||
|
status: "blocked",
|
||||||
|
loopState: "passed",
|
||||||
|
lastValidatorStatus: "passed",
|
||||||
|
taskId: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.computeSliceStatus(slice.id)).toBe("pending");
|
||||||
|
|
||||||
|
const report = store.reconcileSupersededGeneratedFixFeatures(slice.id);
|
||||||
|
|
||||||
|
expect(report).toEqual({ supersededCount: 1, featureIds: [staleFix.id] });
|
||||||
|
expect(store.getFeature(staleFix.id)).toMatchObject({
|
||||||
|
status: "done",
|
||||||
|
taskId: undefined,
|
||||||
|
loopState: "passed",
|
||||||
|
lastValidatorStatus: "passed",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("computeMilestoneStatus", () => {
|
describe("computeMilestoneStatus", () => {
|
||||||
|
|||||||
@@ -3210,7 +3210,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const supersededFeatureIds = features
|
const supersededFeatureIds = features
|
||||||
.filter((feature) => feature.generatedFromFeatureId && hasPassedAncestor(feature))
|
.filter((feature) => feature.generatedFromFeatureId && (featureHasPassed(feature) || hasPassedAncestor(feature)))
|
||||||
.filter((feature) => feature.status !== "done" || feature.loopState !== "passed" || feature.lastValidatorStatus !== "passed")
|
.filter((feature) => feature.status !== "done" || feature.loopState !== "passed" || feature.lastValidatorStatus !== "passed")
|
||||||
.map((feature) => feature.id);
|
.map((feature) => feature.id);
|
||||||
|
|
||||||
|
|||||||
@@ -1064,6 +1064,36 @@ describe("MissionAutopilot", () => {
|
|||||||
expect(ap.isWatching("M-FOUR")).toBe(false);
|
expect(ap.isWatching("M-FOUR")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes complete missions that still have autopilot watching", async () => {
|
||||||
|
const mission = createMockMission({
|
||||||
|
id: "M-COMPLETE",
|
||||||
|
status: "complete",
|
||||||
|
autoAdvance: true,
|
||||||
|
autopilotEnabled: true,
|
||||||
|
autopilotState: "watching",
|
||||||
|
});
|
||||||
|
const store = createMockMissionStore([mission]);
|
||||||
|
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
|
||||||
|
|
||||||
|
await ap.recoverMissions(store as any);
|
||||||
|
|
||||||
|
expect(store.updateMission).toHaveBeenCalledWith(
|
||||||
|
"M-COMPLETE",
|
||||||
|
expect.objectContaining({
|
||||||
|
autoAdvance: false,
|
||||||
|
autopilotEnabled: false,
|
||||||
|
autopilotState: "inactive",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(store.logMissionEvent).toHaveBeenCalledWith(
|
||||||
|
"M-COMPLETE",
|
||||||
|
"autopilot_disabled",
|
||||||
|
expect.stringContaining("Autopilot disabled for already-complete mission"),
|
||||||
|
expect.objectContaining({ source: "recoverMissions" }),
|
||||||
|
);
|
||||||
|
expect(ap.isWatching("M-COMPLETE")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("recovers missions stuck in activating state", async () => {
|
it("recovers missions stuck in activating state", async () => {
|
||||||
const mission = createMockMission({ autopilotState: "activating" });
|
const mission = createMockMission({ autopilotState: "activating" });
|
||||||
const store = createMockMissionStore([mission]);
|
const store = createMockMissionStore([mission]);
|
||||||
|
|||||||
@@ -528,9 +528,7 @@ export class MissionAutopilot {
|
|||||||
{ milestoneCount: milestones.length },
|
{ milestoneCount: milestones.length },
|
||||||
);
|
);
|
||||||
this.updateActivity(missionId);
|
this.updateActivity(missionId);
|
||||||
this.setAutopilotState(missionId, "inactive");
|
this.normalizeCompleteMissionAutopilotState(missionId, "checkMissionCompletion");
|
||||||
this.watchedMissions.delete(missionId);
|
|
||||||
this.perMissionTaskRetries.delete(missionId);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,8 +578,13 @@ export class MissionAutopilot {
|
|||||||
const missions = this.missionStore.listMissions();
|
const missions = this.missionStore.listMissions();
|
||||||
|
|
||||||
for (const mission of missions) {
|
for (const mission of missions) {
|
||||||
|
if (mission.status === "complete") {
|
||||||
|
this.normalizeCompleteMissionAutopilotState(mission.id, "poll");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-watch missions with autopilot enabled that aren't being watched
|
// Auto-watch missions with autopilot enabled that aren't being watched
|
||||||
if (mission.autopilotEnabled && !this.isWatching(mission.id) && mission.status !== "complete" && mission.status !== "archived") {
|
if (mission.autopilotEnabled && !this.isWatching(mission.id) && mission.status !== "archived") {
|
||||||
autopilotLog.log(`Poll: auto-watching mission ${mission.id}`);
|
autopilotLog.log(`Poll: auto-watching mission ${mission.id}`);
|
||||||
this.watchMission(mission.id);
|
this.watchMission(mission.id);
|
||||||
}
|
}
|
||||||
@@ -776,7 +779,12 @@ export class MissionAutopilot {
|
|||||||
let inconsistencyFixes = 0;
|
let inconsistencyFixes = 0;
|
||||||
|
|
||||||
for (const mission of missions) {
|
for (const mission of missions) {
|
||||||
if (!mission.autopilotEnabled || mission.status === "complete" || mission.status === "archived") {
|
if (mission.status === "complete") {
|
||||||
|
this.normalizeCompleteMissionAutopilotState(mission.id, "recoverMissions");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mission.autopilotEnabled || mission.status === "archived") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -820,6 +828,37 @@ export class MissionAutopilot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeCompleteMissionAutopilotState(missionId: string, source: string): void {
|
||||||
|
const mission = this.missionStore.getMission(missionId);
|
||||||
|
if (!mission) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.watchedMissions.delete(missionId);
|
||||||
|
this.perMissionTaskRetries.delete(missionId);
|
||||||
|
|
||||||
|
if (!mission.autopilotEnabled && !mission.autoAdvance && mission.autopilotState === "inactive") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.missionStore.updateMission(missionId, {
|
||||||
|
autoAdvance: false,
|
||||||
|
autopilotEnabled: false,
|
||||||
|
autopilotState: "inactive",
|
||||||
|
});
|
||||||
|
this.logMissionEventSafe(
|
||||||
|
missionId,
|
||||||
|
"autopilot_disabled",
|
||||||
|
`Autopilot disabled for already-complete mission ${mission.title}`,
|
||||||
|
{
|
||||||
|
source,
|
||||||
|
previousAutoAdvance: mission.autoAdvance,
|
||||||
|
previousAutopilotEnabled: mission.autopilotEnabled,
|
||||||
|
previousAutopilotState: mission.autopilotState ?? "inactive",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async reconcileMissionConsistency(
|
private async reconcileMissionConsistency(
|
||||||
mission: MissionWithHierarchy,
|
mission: MissionWithHierarchy,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
|
|||||||
@@ -3073,8 +3073,18 @@ export class Scheduler {
|
|||||||
|
|
||||||
for (const slice of activeSlices) {
|
for (const slice of activeSlices) {
|
||||||
const missionAutoTriageEnabled = mission.autopilotEnabled === true || mission.autoAdvance === true;
|
const missionAutoTriageEnabled = mission.autopilotEnabled === true || mission.autoAdvance === true;
|
||||||
|
const supersededFixes = missionStore.reconcileSupersededGeneratedFixFeatures(slice.id);
|
||||||
|
if (supersededFixes.supersededCount > 0) {
|
||||||
|
totalFixed += supersededFixes.supersededCount;
|
||||||
|
schedulerLog.warn(
|
||||||
|
`Superseded ${supersededFixes.supersededCount} stale generated fix feature(s) during mission reconciliation for slice ${slice.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const features = supersededFixes.supersededCount > 0
|
||||||
|
? missionStore.listFeatures(slice.id)
|
||||||
|
: slice.features;
|
||||||
|
|
||||||
for (const feature of slice.features) {
|
for (const feature of features) {
|
||||||
let featureForReconciliation = feature;
|
let featureForReconciliation = feature;
|
||||||
let task: Task | undefined;
|
let task: Task | undefined;
|
||||||
if (feature.taskId) {
|
if (feature.taskId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user