fix(missions): reconcile lifecycle invariants

This commit is contained in:
Phil Larson
2026-07-11 12:28:58 -07:00
committed by gsxdsm
parent 11fce4e4bb
commit c68b053162
6 changed files with 123 additions and 7 deletions

View 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.

View File

@@ -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", () => {

View File

@@ -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);

View File

@@ -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]);

View File

@@ -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> {

View File

@@ -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) {