feat(FN-1453): fix mission autopilot progression and state refresh
- Fix mission autopilot slice activation when tasks complete - Fix state refresh in MissionManager to properly reflect autopilot status - Fix scheduler to correctly trigger autopilot progression events - Add unit tests for mission store autopilot state transitions - Add component tests for MissionManager autopilot toggle and state display - Add e2e tests for mission autopilot lifecycle (enable → task completion → slice progression) - Update documentation with autopilot state machine details
This commit is contained in:
@@ -576,6 +576,22 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
void loadMissionHealth(missions);
|
||||
};
|
||||
|
||||
const handleMissionUpdated = (rawEvent: Event) => {
|
||||
refreshHealth();
|
||||
// Reload the selected mission detail to reflect updated mission state (autopilot, status, etc.)
|
||||
if (selectedMission) {
|
||||
void loadMissionDetail(selectedMission.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSliceUpdated = (rawEvent: Event) => {
|
||||
refreshHealth();
|
||||
// Reload the selected mission detail to reflect updated slice status
|
||||
if (selectedMission) {
|
||||
void loadMissionDetail(selectedMission.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFeatureUpdated = () => {
|
||||
refreshHealth();
|
||||
// Reload the selected mission detail to reflect updated feature status
|
||||
@@ -628,14 +644,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.addEventListener("mission:updated", refreshHealth);
|
||||
eventSource.addEventListener("slice:updated", refreshHealth);
|
||||
eventSource.addEventListener("mission:updated", handleMissionUpdated);
|
||||
eventSource.addEventListener("slice:updated", handleSliceUpdated);
|
||||
eventSource.addEventListener("feature:updated", handleFeatureUpdated);
|
||||
eventSource.addEventListener("mission:event", handleMissionEvent);
|
||||
|
||||
return () => {
|
||||
eventSource.removeEventListener("mission:updated", refreshHealth);
|
||||
eventSource.removeEventListener("slice:updated", refreshHealth);
|
||||
eventSource.removeEventListener("mission:updated", handleMissionUpdated);
|
||||
eventSource.removeEventListener("slice:updated", handleSliceUpdated);
|
||||
eventSource.removeEventListener("feature:updated", handleFeatureUpdated);
|
||||
eventSource.removeEventListener("mission:event", handleMissionEvent);
|
||||
eventSource.close();
|
||||
|
||||
@@ -825,6 +825,97 @@ describe("MissionManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reloads selected mission detail when mission:updated SSE event arrives", async () => {
|
||||
const fetchMock = createDetailFetchMock(mockMissionEvents);
|
||||
globalThis.fetch = fetchMock;
|
||||
globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource;
|
||||
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Build Auth System")).toBeDefined();
|
||||
});
|
||||
|
||||
// Click on the mission to open detail view
|
||||
fireEvent.click(screen.getByText("Build Auth System"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mission-back-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
// Record initial fetch calls for mission detail
|
||||
const initialFetchCount = fetchMock.mock.calls.filter(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("/api/missions/M-001")
|
||||
).length;
|
||||
expect(initialFetchCount).toBeGreaterThan(0);
|
||||
|
||||
// Emit a mission:updated SSE event for the selected mission
|
||||
await act(async () => {
|
||||
for (const source of MockEventSource.instances) {
|
||||
source.emit("mission:updated", {
|
||||
id: "M-001",
|
||||
title: "Build Auth System",
|
||||
status: "active",
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "watching",
|
||||
lastAutopilotActivityAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Verify mission detail was reloaded (fetch was called again for the mission)
|
||||
await waitFor(() => {
|
||||
const updatedFetchCount = fetchMock.mock.calls.filter(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("/api/missions/M-001")
|
||||
).length;
|
||||
expect(updatedFetchCount).toBeGreaterThan(initialFetchCount);
|
||||
});
|
||||
});
|
||||
|
||||
it("reloads selected mission detail when slice:updated SSE event arrives", async () => {
|
||||
const fetchMock = createDetailFetchMock(mockMissionEvents);
|
||||
globalThis.fetch = fetchMock;
|
||||
globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource;
|
||||
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Build Auth System")).toBeDefined();
|
||||
});
|
||||
|
||||
// Click on the mission to open detail view
|
||||
fireEvent.click(screen.getByText("Build Auth System"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mission-back-btn")).toBeDefined();
|
||||
});
|
||||
|
||||
// Record initial fetch calls for mission detail
|
||||
const initialFetchCount = fetchMock.mock.calls.filter(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("/api/missions/M-001")
|
||||
).length;
|
||||
expect(initialFetchCount).toBeGreaterThan(0);
|
||||
|
||||
// Emit a slice:updated SSE event for a slice in the selected mission
|
||||
await act(async () => {
|
||||
for (const source of MockEventSource.instances) {
|
||||
source.emit("slice:updated", {
|
||||
id: "SL-001",
|
||||
milestoneId: "MS-001",
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Verify mission detail was reloaded (fetch was called again for the mission)
|
||||
await waitFor(() => {
|
||||
const updatedFetchCount = fetchMock.mock.calls.filter(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("/api/missions/M-001")
|
||||
).length;
|
||||
expect(updatedFetchCount).toBeGreaterThan(initialFetchCount);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state when no missions exist", async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse([]));
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
@@ -2880,6 +2880,106 @@ describe("Mission API", () => {
|
||||
lastActivityAt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("enables autopilot on already-active mission and triggers recovery", async () => {
|
||||
const missionAutopilot = createMockMissionAutopilot();
|
||||
const { app, missionStore } = buildApp({ missionAutopilot });
|
||||
|
||||
// Create an active mission with no active slices
|
||||
const mission = missionStore.createMission({ title: "Active Mission" });
|
||||
missionStore.updateMission(mission.id, { status: "active" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "MS1" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "Slice1" });
|
||||
// Slice is pending (no active slice)
|
||||
|
||||
missionAutopilot.getAutopilotStatus.mockReturnValue({
|
||||
enabled: true,
|
||||
state: "watching",
|
||||
watched: true,
|
||||
lastActivityAt: undefined,
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"PATCH",
|
||||
`/api/missions/${mission.id}/autopilot`,
|
||||
JSON.stringify({ enabled: true }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id);
|
||||
// Should call recoverStaleMission for active missions without active slices
|
||||
expect(missionAutopilot.recoverStaleMission).toHaveBeenCalledWith(mission.id);
|
||||
expect(missionAutopilot.checkAndStartMission).not.toHaveBeenCalled(); // Not planning
|
||||
});
|
||||
|
||||
it("enables autopilot on active mission with completed active slice and triggers recovery", async () => {
|
||||
const missionAutopilot = createMockMissionAutopilot();
|
||||
const { app, missionStore } = buildApp({ missionAutopilot });
|
||||
|
||||
// Create an active mission with a completed active slice
|
||||
const mission = missionStore.createMission({ title: "Active Mission 2" });
|
||||
missionStore.updateMission(mission.id, { status: "active" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "MS1" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "Slice1" });
|
||||
// Mark all features as done (slice complete)
|
||||
const feature = missionStore.addFeature(slice.id, { title: "Feature1" });
|
||||
missionStore.updateFeature(feature.id, { status: "done" });
|
||||
|
||||
missionAutopilot.getAutopilotStatus.mockReturnValue({
|
||||
enabled: true,
|
||||
state: "watching",
|
||||
watched: true,
|
||||
lastActivityAt: undefined,
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"PATCH",
|
||||
`/api/missions/${mission.id}/autopilot`,
|
||||
JSON.stringify({ enabled: true }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id);
|
||||
// Should call recoverStaleMission for active missions with completed active slices
|
||||
expect(missionAutopilot.recoverStaleMission).toHaveBeenCalledWith(mission.id);
|
||||
expect(missionAutopilot.checkAndStartMission).not.toHaveBeenCalled(); // Not planning
|
||||
});
|
||||
|
||||
it("enables autopilot on active mission with in-progress slice (no recovery needed)", async () => {
|
||||
const missionAutopilot = createMockMissionAutopilot();
|
||||
const { app, missionStore } = buildApp({ missionAutopilot });
|
||||
|
||||
// Create an active mission with an active slice (not completed)
|
||||
const mission = missionStore.createMission({ title: "Active Mission 3" });
|
||||
missionStore.updateMission(mission.id, { status: "active" });
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "MS1" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "Slice1" });
|
||||
missionStore.updateSlice(slice.id, { status: "active" });
|
||||
|
||||
missionAutopilot.getAutopilotStatus.mockReturnValue({
|
||||
enabled: true,
|
||||
state: "watching",
|
||||
watched: true,
|
||||
lastActivityAt: undefined,
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"PATCH",
|
||||
`/api/missions/${mission.id}/autopilot`,
|
||||
JSON.stringify({ enabled: true }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id);
|
||||
// Should NOT call recoverStaleMission - slice is already in-progress
|
||||
expect(missionAutopilot.recoverStaleMission).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/missions/:missionId/autopilot/start", () => {
|
||||
|
||||
@@ -1928,10 +1928,31 @@ export function createMissionRouter(
|
||||
|
||||
if (missionAutopilot) {
|
||||
if (enabled) {
|
||||
// Enable: start watching and potentially start the mission
|
||||
// Enable: start watching and potentially start/recover the mission
|
||||
missionAutopilot.watchMission(missionId);
|
||||
if (mission.status === "planning") {
|
||||
await missionAutopilot.checkAndStartMission(missionId);
|
||||
} else if (mission.status === "active") {
|
||||
// For already-active missions, check if recovery is needed:
|
||||
// - No active slice exists
|
||||
// - Active slice is already complete
|
||||
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) {
|
||||
// Need recovery: advance to next pending slice
|
||||
await missionAutopilot.recoverStaleMission(missionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Disable: stop watching
|
||||
|
||||
Reference in New Issue
Block a user