fix(missions): address lifecycle review blockers

This commit is contained in:
Phil Larson
2026-07-11 12:39:47 -07:00
committed by gsxdsm
parent c68b053162
commit 6ee86c5db7
6 changed files with 304 additions and 5 deletions

View File

@@ -1719,6 +1719,7 @@ describe("MissionStore", () => {
sliceId: null,
});
expect(store.computeSliceStatus(slice.id)).toBe("complete");
expect(store.getSlice(slice.id)).toMatchObject({ status: "complete" });
});
it("reconciles stale generated fix features when a source validator run passes", () => {
@@ -1759,14 +1760,16 @@ describe("MissionStore", () => {
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"]);
createTaskInDb(db, "FN-own-passed-fix", "Own passed stale fix task", undefined, { column: "todo" });
store.updateFeature(staleFix.id, {
status: "blocked",
loopState: "passed",
lastValidatorStatus: "passed",
taskId: undefined,
taskId: "FN-own-passed-fix",
});
db.prepare("UPDATE tasks SET missionId = ?, sliceId = ? WHERE id = ?").run(mission.id, slice.id, "FN-own-passed-fix");
expect(store.computeSliceStatus(slice.id)).toBe("pending");
expect(store.computeSliceStatus(slice.id)).not.toBe("complete");
const report = store.reconcileSupersededGeneratedFixFeatures(slice.id);
@@ -1777,6 +1780,10 @@ describe("MissionStore", () => {
loopState: "passed",
lastValidatorStatus: "passed",
});
expect(db.prepare("SELECT missionId, sliceId FROM tasks WHERE id = ?").get("FN-own-passed-fix")).toEqual({
missionId: null,
sliceId: null,
});
});
});

View File

@@ -3170,8 +3170,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
}
/**
* Mark generated Fix Features obsolete once an ancestor feature has already
* passed validation.
* Mark generated Fix Features obsolete once an ancestor feature, or the fix's
* own validation evidence, has already passed.
*
* Validator failures can create a chain of generated features. If the original
* feature is later validated successfully, older descendants are no longer
@@ -3181,6 +3181,10 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
* FNXC:Missions 2026-07-05-22:09:
* Superseded generated Fix Features must become terminal and lose live board-task ownership.
* Otherwise mission recovery can keep resuming stale remediation tasks after the source feature is already validated.
*
* FNXC:Missions 2026-07-11-12:35:
* A generated fix can also supersede itself once its own validator/loop evidence has passed.
* Reconciliation treats that as terminal evidence so a completed fix does not stay active only because its ancestor previously failed.
*/
reconcileSupersededGeneratedFixFeatures(sliceId: string): { supersededCount: number; featureIds: string[] } {
const features = this.listFeatures(sliceId);

View File

@@ -616,6 +616,49 @@ describe("MissionAutopilot", () => {
expect(autopilot.isWatching("M-TEST1")).toBe(false);
});
it("normalizes already-complete autopilot state when completion is checked", async () => {
const mission = createMockMission({
id: "M-COMPLETE-CHECK",
status: "complete",
autoAdvance: true,
autopilotEnabled: true,
autopilotState: "watching",
});
const store = createMockMissionStore([mission]);
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
store.listMilestones.mockReturnValue([createMockMilestone({ missionId: mission.id, status: "complete" })]);
store.getMissionWithHierarchy.mockReturnValue({
...mission,
milestones: [{
...createMockMilestone({ missionId: mission.id, status: "complete" }),
slices: [{
...createMockSlice({ id: "SL-COMPLETE-CHECK", status: "complete" }),
features: [createMockFeature({ id: "F-COMPLETE-CHECK", status: "done" })],
}],
}],
});
ap.watchMission(mission.id);
const result = await ap.checkMissionCompletion(mission.id);
expect(result).toBe(true);
expect(store.updateMission).toHaveBeenCalledWith(
mission.id,
expect.objectContaining({
autoAdvance: false,
autopilotEnabled: false,
autopilotState: "inactive",
}),
);
expect(store.logMissionEvent).toHaveBeenCalledWith(
mission.id,
"autopilot_disabled",
expect.stringContaining("Autopilot disabled for already-complete mission"),
expect.objectContaining({ source: "checkMissionCompletion" }),
);
expect(ap.isWatching(mission.id)).toBe(false);
});
it("should return false when milestones are not all complete", async () => {
const m1 = createMockMilestone({ status: "active" });
missionStore.listMilestones.mockReturnValue([m1]);
@@ -689,6 +732,34 @@ describe("MissionAutopilot", () => {
expect.objectContaining({ status: "complete" }),
);
});
it("normalizes autopilot flags when checkMissionCompletion completes the mission", async () => {
missionStore.listMilestones.mockReturnValue([createMockMilestone({ status: "complete" })]);
missionStore.getMissionWithHierarchy.mockReturnValue({
...createMockMission(),
milestones: [{
...createMockMilestone({ id: "MS-001", status: "complete" }),
slices: [{
...createMockSlice({ id: "SL-001", status: "complete" }),
features: [createMockFeature({ id: "F-001", status: "done" })],
}],
}],
});
autopilot.watchMission("M-TEST1");
missionStore.updateMission.mockClear();
missionStore.logMissionEvent.mockClear();
const result = await autopilot.checkMissionCompletion("M-TEST1");
expect(result).toBe(true);
expect(missionStore.updateMission).toHaveBeenCalledWith("M-TEST1", expect.objectContaining({ status: "complete" }));
expect(missionStore.updateMission).toHaveBeenCalledWith("M-TEST1", expect.objectContaining({
autoAdvance: false,
autopilotEnabled: false,
autopilotState: "inactive",
}));
expect(autopilot.isWatching("M-TEST1")).toBe(false);
});
});
describe("reconcileMissionConsistency", () => {
@@ -933,6 +1004,32 @@ describe("MissionAutopilot", () => {
// ── Poll / stale detection ──────────────────────────────────────
describe("poll stale detection", () => {
it("normalizes complete missions during poll", async () => {
const completeMission = createMockMission({
status: "complete",
autoAdvance: true,
autopilotEnabled: true,
autopilotState: "watching",
});
const store = createMockMissionStore([completeMission]);
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
ap.watchMission("M-TEST1");
store.updateMission.mockClear();
store.logMissionEvent.mockClear();
ap.start();
await vi.advanceTimersByTimeAsync(60_000);
expect(store.updateMission).toHaveBeenCalledWith("M-TEST1", expect.objectContaining({
autoAdvance: false,
autopilotEnabled: false,
autopilotState: "inactive",
}));
expect(ap.isWatching("M-TEST1")).toBe(false);
ap.stop();
});
it("recovers stale activating missions back to watching and advances slices", async () => {
const staleMission = createMockMission({
autopilotState: "activating",
@@ -1094,6 +1191,59 @@ describe("MissionAutopilot", () => {
expect(ap.isWatching("M-COMPLETE")).toBe(false);
});
it("normalizes complete missions that still have autopilot watching during poll", async () => {
const mission = createMockMission({
id: "M-COMPLETE-POLL",
status: "complete",
autoAdvance: true,
autopilotEnabled: true,
autopilotState: "watching",
});
const store = createMockMissionStore([mission]);
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
ap.watchMission(mission.id);
(ap as any).running = true;
await (ap as any).poll();
expect(store.updateMission).toHaveBeenCalledWith(
mission.id,
expect.objectContaining({
autoAdvance: false,
autopilotEnabled: false,
autopilotState: "inactive",
}),
);
expect(store.logMissionEvent).toHaveBeenCalledWith(
mission.id,
"autopilot_disabled",
expect.stringContaining("Autopilot disabled for already-complete mission"),
expect.objectContaining({ source: "poll" }),
);
expect(ap.isWatching(mission.id)).toBe(false);
});
it("does not clear watched state when normalization is accidentally called for an active mission", () => {
const mission = createMockMission({
id: "M-ACTIVE",
status: "active",
autoAdvance: true,
autopilotEnabled: true,
autopilotState: "watching",
});
const store = createMockMissionStore([mission]);
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
ap.watchMission("M-ACTIVE");
store.updateMission.mockClear();
store.logMissionEvent.mockClear();
(ap as any).normalizeCompleteMissionAutopilotState("M-ACTIVE", "test");
expect(ap.isWatching("M-ACTIVE")).toBe(true);
expect(store.updateMission).not.toHaveBeenCalled();
expect(store.logMissionEvent).not.toHaveBeenCalled();
});
it("recovers missions stuck in activating state", async () => {
const mission = createMockMission({ autopilotState: "activating" });
const store = createMockMissionStore([mission]);

View File

@@ -287,6 +287,104 @@ describe("FN-5754 reliability: mission stranded feature retriage", () => {
expect(missionStore.updateFeature).not.toHaveBeenCalled();
});
it("keeps ordinary done linked features in startup task-drift reconciliation", async () => {
const tasks = [{ id: "FN-DONE", title: "Completed feature", missionId: "M-001", sliceId: "SL-001", column: "done", status: "queued" }];
const features = [feature({ id: "F-DONE", title: "Completed feature", status: "done", taskId: "FN-DONE" })];
const missionStore = {
listMissions: vi.fn(() => [{ id: "M-001", status: "active", autopilotEnabled: true }]),
getMissionWithHierarchy: vi.fn(() => ({
id: "M-001",
status: "active",
milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features }] }],
})),
updateFeatureStatus: vi.fn(),
listAssertionsForFeature: vi.fn(() => []),
};
const store = createTaskStore(tasks);
const scheduler = new Scheduler(store, { missionStore: missionStore as any });
await scheduler.reconcileAllMissionFeatures();
expect(store.getTask).toHaveBeenCalledWith("FN-DONE");
});
it("keeps done generated fixes with live task ownership in startup task-drift reconciliation", async () => {
const tasks = [{ id: "FN-GENERATED-DONE", title: "Fix: completed generated remediation", missionId: "M-001", sliceId: "SL-001", column: "done", status: "queued" }];
const features = [feature({
id: "F-GENERATED-DONE",
title: "Fix: completed generated remediation",
status: "done",
generatedFromFeatureId: "F-SOURCE",
taskId: "FN-GENERATED-DONE",
})];
const missionStore = {
listMissions: vi.fn(() => [{ id: "M-001", status: "active", autopilotEnabled: true }]),
getMissionWithHierarchy: vi.fn(() => ({
id: "M-001",
status: "active",
milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features }] }],
})),
updateFeatureStatus: vi.fn(),
listAssertionsForFeature: vi.fn(() => []),
};
const store = createTaskStore(tasks);
const scheduler = new Scheduler(store, { missionStore: missionStore as any });
await scheduler.reconcileAllMissionFeatures();
expect(store.getTask).toHaveBeenCalledWith("FN-GENERATED-DONE");
});
it("does not block superseded done generated fixes during refreshed startup reconciliation", async () => {
const supersededFix = feature({
id: "F-SUPERSEDED-FIX",
title: "Fix: already validated remediation",
status: "done",
loopState: "passed",
lastValidatorStatus: "passed",
generatedFromFeatureId: "F-SOURCE",
taskId: undefined,
});
const remainingFeature = feature({ id: "F-REMAINING", title: "Remaining feature", status: "defined", taskId: undefined });
let features = [supersededFix, remainingFeature];
const tasks: any[] = [{
id: "FN-STALE-FIX",
title: "Fix: already validated remediation",
missionId: "M-001",
sliceId: "SL-001",
column: "todo",
status: "queued",
}];
const missionStore = {
listMissions: vi.fn(() => [{ id: "M-001", status: "active", autopilotEnabled: true }]),
getMissionWithHierarchy: vi.fn(() => ({
id: "M-001",
status: "active",
milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features }] }],
})),
reconcileSupersededGeneratedFixFeatures: vi.fn(() => ({ supersededCount: 1, featureIds: ["F-SUPERSEDED-FIX"] })),
listFeatures: vi.fn(() => features),
getSlice: vi.fn(() => ({ id: "SL-001", milestoneId: "MS-001", status: "active", features })),
triageFeature: vi.fn(async (featureId: string) => {
const taskId = `FN-${featureId}`;
tasks.push({ id: taskId, title: "Remaining feature", missionId: "M-001", sliceId: "SL-001", column: "todo", status: "queued" });
features = features.map((candidate) => candidate.id === featureId ? { ...candidate, taskId, status: "triaged" } : candidate);
return features.find((candidate) => candidate.id === featureId);
}),
linkFeatureToTask: vi.fn(),
updateFeature: vi.fn(),
updateFeatureStatus: vi.fn(),
listAssertionsForFeature: vi.fn(() => []),
};
const scheduler = new Scheduler(createTaskStore(tasks), { missionStore: missionStore as any });
await scheduler.reconcileAllMissionFeatures();
expect(missionStore.updateFeature).not.toHaveBeenCalledWith("F-SUPERSEDED-FIX", expect.objectContaining({ status: "blocked" }));
expect(missionStore.linkFeatureToTask).not.toHaveBeenCalledWith("F-SUPERSEDED-FIX", "FN-STALE-FIX");
expect(missionStore.triageFeature).toHaveBeenCalledWith("F-REMAINING");
});
it("leaves non-autopilot and blocked features untouched", async () => {
const autopilotOffFeature = feature({ id: "F-001", status: "defined", taskId: undefined });
const blockedFeature = feature({ id: "F-002", status: "blocked", taskId: undefined, title: "Blocked feature" });

View File

@@ -834,6 +834,15 @@ export class MissionAutopilot {
return;
}
if (mission.status !== "complete") {
/*
FNXC:Missions 2026-07-11-12:35:
Autopilot cleanup is only safe for missions that are already complete.
Active missions may still need watched-state and retry memory even if a future caller reaches this helper by mistake.
*/
return;
}
this.watchedMissions.delete(missionId);
this.perMissionTaskRetries.delete(missionId);

View File

@@ -3073,7 +3073,8 @@ export class Scheduler {
for (const slice of activeSlices) {
const missionAutoTriageEnabled = mission.autopilotEnabled === true || mission.autoAdvance === true;
const supersededFixes = missionStore.reconcileSupersededGeneratedFixFeatures(slice.id);
const supersededFixes = missionStore.reconcileSupersededGeneratedFixFeatures?.(slice.id)
?? { supersededCount: 0, featureIds: [] };
if (supersededFixes.supersededCount > 0) {
totalFixed += supersededFixes.supersededCount;
schedulerLog.warn(
@@ -3083,10 +3084,40 @@ export class Scheduler {
const features = supersededFixes.supersededCount > 0
? missionStore.listFeatures(slice.id)
: slice.features;
const supersededFeatureIds = new Set(supersededFixes.featureIds);
if (supersededFixes.supersededCount > 0) {
const refreshedSlice = missionStore.getSlice?.(slice.id);
if (refreshedSlice?.status === "complete") {
/*
FNXC:Missions 2026-07-11-12:35:
Startup reconciliation can terminalize every stale generated-fix feature in an active slice before the scheduler loop runs no-task recovery.
Treat the recomputed complete slice as complete immediately so stale fixes do not keep an active slice from advancing after restart.
*/
await this.onSliceComplete(refreshedSlice);
continue;
}
}
for (const feature of features) {
let featureForReconciliation = feature;
let task: Task | undefined;
if (supersededFeatureIds.has(feature.id)) {
/*
FNXC:Missions 2026-07-11-12:35:
The title-to-task map is built before generated-fix supersedence detaches stale task ownership.
Skip freshly superseded features so pre-reconciliation task links cannot be restored in the same startup sweep.
*/
continue;
}
if (feature.status === "done" && this.isGeneratedFixFeature(feature) && !feature.taskId) {
/*
FNXC:Missions 2026-07-11-12:35:
Done generated-fix features with no task ownership are terminal after supersedence clears their board links.
Keep any done feature that still has task ownership in startup self-healing so linked task drift can still be repaired.
*/
continue;
}
if (feature.taskId) {
task = await this.store.getTask(feature.taskId);
} else {