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.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", () => {
|
||||
|
||||
@@ -3210,7 +3210,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
};
|
||||
|
||||
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")
|
||||
.map((feature) => feature.id);
|
||||
|
||||
|
||||
@@ -1064,6 +1064,36 @@ describe("MissionAutopilot", () => {
|
||||
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 () => {
|
||||
const mission = createMockMission({ autopilotState: "activating" });
|
||||
const store = createMockMissionStore([mission]);
|
||||
|
||||
@@ -528,9 +528,7 @@ export class MissionAutopilot {
|
||||
{ milestoneCount: milestones.length },
|
||||
);
|
||||
this.updateActivity(missionId);
|
||||
this.setAutopilotState(missionId, "inactive");
|
||||
this.watchedMissions.delete(missionId);
|
||||
this.perMissionTaskRetries.delete(missionId);
|
||||
this.normalizeCompleteMissionAutopilotState(missionId, "checkMissionCompletion");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -580,8 +578,13 @@ export class MissionAutopilot {
|
||||
const missions = this.missionStore.listMissions();
|
||||
|
||||
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
|
||||
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}`);
|
||||
this.watchMission(mission.id);
|
||||
}
|
||||
@@ -776,7 +779,12 @@ export class MissionAutopilot {
|
||||
let inconsistencyFixes = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
mission: MissionWithHierarchy,
|
||||
): Promise<number> {
|
||||
|
||||
@@ -3073,8 +3073,18 @@ export class Scheduler {
|
||||
|
||||
for (const slice of activeSlices) {
|
||||
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 task: Task | undefined;
|
||||
if (feature.taskId) {
|
||||
|
||||
Reference in New Issue
Block a user