fix(missions): address lifecycle review blockers
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user