feat(FN-1320): re-engage autopilot when resuming missions

- Re-engage MissionAutopilot when a paused mission is resumed with autopilot enabled
- Wire mission autopilot into dashboard route setup so resume flows can trigger watcher start
- Extend mission e2e tests to cover autopilot mock shape and resume re-engagement behavior
- Add a patch changeset documenting the mission resume autopilot fix
This commit is contained in:
gsxdsm
2026-04-08 16:29:46 -07:00
parent e044cfbed7
commit 719cf901aa
4 changed files with 121 additions and 5 deletions

View File

@@ -439,6 +439,7 @@ function createMockMissionAutopilot() {
lastActivityAt: undefined,
}),
checkAndStartMission: vi.fn().mockResolvedValue(undefined),
recoverStaleMission: vi.fn().mockResolvedValue(undefined),
start: vi.fn(),
stop: vi.fn(),
};
@@ -2448,12 +2449,13 @@ describe("Mission API", () => {
});
describe("POST /api/missions/:missionId/resume", () => {
it("should resume a blocked mission", async () => {
const { app, missionStore } = buildApp();
it("re-watches autopilot-enabled missions on resume", async () => {
const missionAutopilot = createMockMissionAutopilot();
const { app, missionStore } = buildApp({ missionAutopilot });
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Test Mission" });
ms.updateMission(mission.id, { status: "blocked" });
ms.updateMission(mission.id, { status: "blocked", autopilotEnabled: true });
const res = await request(
app,
@@ -2465,6 +2467,86 @@ describe("Mission API", () => {
expect(res.status).toBe(200);
expect(res.body.status).toBe("active");
expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id);
});
it("triggers stale recovery when active slice is already complete", async () => {
const missionAutopilot = createMockMissionAutopilot();
const { app, missionStore } = buildApp({ missionAutopilot });
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Test Mission" });
ms.updateMission(mission.id, { status: "blocked", autopilotEnabled: true });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
ms.updateMilestone(milestone.id, { status: "active" });
const activeSlice = ms.addSlice(milestone.id, { title: "Active Slice" });
ms.updateSlice(activeSlice.id, { status: "active" });
const doneFeature = ms.addFeature(activeSlice.id, { title: "Done feature" });
ms.updateFeature(doneFeature.id, { status: "done" });
ms.addSlice(milestone.id, { title: "Pending Slice" });
const res = await request(
app,
"POST",
`/api/missions/${mission.id}/resume`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(200);
expect(missionAutopilot.recoverStaleMission).toHaveBeenCalledWith(mission.id);
});
it("does not trigger stale recovery when active slice still has in-progress features", async () => {
const missionAutopilot = createMockMissionAutopilot();
const { app, missionStore } = buildApp({ missionAutopilot });
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Test Mission" });
ms.updateMission(mission.id, { status: "blocked", autopilotEnabled: true });
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
const activeSlice = ms.addSlice(milestone.id, { title: "Active Slice" });
ms.updateSlice(activeSlice.id, { status: "active" });
const feature = ms.addFeature(activeSlice.id, { title: "In-progress feature" });
ms.updateFeature(feature.id, { status: "in-progress" });
const res = await request(
app,
"POST",
`/api/missions/${mission.id}/resume`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(200);
expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id);
expect(missionAutopilot.recoverStaleMission).not.toHaveBeenCalled();
});
it("skips autopilot re-engagement when mission autopilot is disabled", async () => {
const missionAutopilot = createMockMissionAutopilot();
const { app, missionStore } = buildApp({ missionAutopilot });
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
const mission = ms.createMission({ title: "Test Mission" });
ms.updateMission(mission.id, { status: "blocked", autopilotEnabled: false });
const res = await request(
app,
"POST",
`/api/missions/${mission.id}/resume`,
JSON.stringify({}),
{ "content-type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.status).toBe("active");
expect(missionAutopilot.watchMission).not.toHaveBeenCalled();
expect(missionAutopilot.recoverStaleMission).not.toHaveBeenCalled();
});
it("should return 400 if mission is not blocked", async () => {

View File

@@ -201,6 +201,7 @@ export function createMissionRouter(
isWatching(missionId: string): boolean;
getAutopilotStatus(missionId: string): import("@fusion/core").AutopilotStatus;
checkAndStartMission(missionId: string): Promise<void>;
recoverStaleMission(missionId: string): Promise<void>;
start(): void;
stop(): void;
},
@@ -1727,8 +1728,35 @@ export function createMissionRouter(
throw badRequest("Mission is not paused (status must be 'blocked' to resume)");
}
const updated = missionStore.updateMission(missionId, { status: "active" });
res.json(updated);
missionStore.updateMission(missionId, { status: "active" });
// Re-engage autopilot if enabled and autopilot instance is available.
// The autopilot may have been stopped or the mission unwatched during
// the pause/stop lifecycle — re-register it and trigger progression.
if (missionAutopilot && mission.autopilotEnabled) {
missionAutopilot.watchMission(missionId);
// Check whether the current active slice is already complete or
// there are no active slices — if so, trigger recovery which
// advances to the next pending slice.
const hierarchy = missionStore.getMissionWithHierarchy(missionId);
if (hierarchy) {
const activeSlices = hierarchy.milestones
.flatMap((milestone) => milestone.slices)
.filter((slice) => slice.status === "active");
const hasCompletedActiveSlice = activeSlices.some(
(slice) => slice.features.length > 0 && slice.features.every((feature) => feature.status === "done"),
);
if (hasCompletedActiveSlice || activeSlices.length === 0) {
await missionAutopilot.recoverStaleMission(missionId);
}
}
}
const refreshed = missionStore.getMission(missionId);
res.json(refreshed);
})
);

View File

@@ -67,6 +67,7 @@ export interface ServerOptions {
isWatching(missionId: string): boolean;
getAutopilotStatus(missionId: string): import("@fusion/core").AutopilotStatus;
checkAndStartMission(missionId: string): Promise<void>;
recoverStaleMission(missionId: string): Promise<void>;
start(): void;
stop(): void;
};