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:
@@ -76,13 +76,22 @@ Typical flow:
|
|||||||
|
|
||||||
## `autopilotEnabled` vs `autoAdvance`
|
## `autopilotEnabled` vs `autoAdvance`
|
||||||
|
|
||||||
- **`autopilotEnabled`**: enables background monitoring/orchestration behavior
|
- **`autopilotEnabled`**: primary control for autopilot behavior — enables background monitoring, orchestration, and automatic slice activation when a slice completes. Also triggers auto-triage (converting features to tasks) when a slice is activated.
|
||||||
- **`autoAdvance`**: allows automatic slice activation when current slice completes
|
- **`autoAdvance`**: legacy fallback for backward compatibility with existing mission data. Kept for compatibility — new missions should use `autopilotEnabled`.
|
||||||
|
|
||||||
Combination behavior:
|
**Auto-triage behavior:**
|
||||||
|
|
||||||
- `autopilotEnabled=true`, `autoAdvance=true` → full autonomous progression
|
- `autopilotEnabled=true` → features in activated slices are automatically triaged (converted to tasks)
|
||||||
- `autopilotEnabled=true`, `autoAdvance=false` → monitored mission with manual slice activation
|
- `autopilotEnabled=false`, `autoAdvance=true` → features are triaged (legacy compat)
|
||||||
|
- `autopilotEnabled=false`, `autoAdvance=false` → manual slice activation only
|
||||||
|
|
||||||
|
**Slice progression (on slice completion):**
|
||||||
|
|
||||||
|
- `autopilotEnabled=true` → next pending slice is automatically activated
|
||||||
|
- `autopilotEnabled=false`, `autoAdvance=true` → next pending slice is activated (legacy compat)
|
||||||
|
- `autopilotEnabled=false`, `autoAdvance=false` → manual activation required
|
||||||
|
|
||||||
|
**Dashboard UI:** The Mission Manager shows `autopilotEnabled` as the primary control. When enabling autopilot on an already-active mission, the system automatically checks whether recovery is needed (no active slice or completed active slice) and progresses accordingly.
|
||||||
|
|
||||||
## Autopilot API Endpoints
|
## Autopilot API Endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -1829,6 +1829,93 @@ describe("MissionStore", () => {
|
|||||||
"Slice SL-NONEXISTENT not found",
|
"Slice SL-NONEXISTENT not found",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── autopilotEnabled as primary control ──────────────────────────────────
|
||||||
|
|
||||||
|
it("triages features when autopilotEnabled is true (autoAdvance false)", async () => {
|
||||||
|
const { ts, ms } = await createStoreWithTaskStore();
|
||||||
|
|
||||||
|
const mission = ms.createMission({ title: "Mission" });
|
||||||
|
// autopilotEnabled is primary control; autoAdvance=false/unset should still work
|
||||||
|
ms.updateMission(mission.id, { autopilotEnabled: true, autoAdvance: false });
|
||||||
|
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
|
||||||
|
const slice = ms.addSlice(milestone.id, { title: "Slice" });
|
||||||
|
const f1 = ms.addFeature(slice.id, { title: "Feature 1" });
|
||||||
|
const f2 = ms.addFeature(slice.id, { title: "Feature 2" });
|
||||||
|
|
||||||
|
const activated = await ms.activateSlice(slice.id);
|
||||||
|
|
||||||
|
expect(activated.status).toBe("active");
|
||||||
|
|
||||||
|
// Both features should be triaged because autopilotEnabled=true
|
||||||
|
const updatedF1 = ms.getFeature(f1.id)!;
|
||||||
|
const updatedF2 = ms.getFeature(f2.id)!;
|
||||||
|
expect(updatedF1.status).toBe("triaged");
|
||||||
|
expect(updatedF1.taskId).toBeTruthy();
|
||||||
|
expect(updatedF2.status).toBe("triaged");
|
||||||
|
expect(updatedF2.taskId).toBeTruthy();
|
||||||
|
|
||||||
|
// Tasks should exist and be linked
|
||||||
|
const task1 = await ts.getTask(updatedF1.taskId!);
|
||||||
|
const task2 = await ts.getTask(updatedF2.taskId!);
|
||||||
|
expect(task1).toBeDefined();
|
||||||
|
expect(task1!.sliceId).toBe(slice.id);
|
||||||
|
expect(task2).toBeDefined();
|
||||||
|
expect(task2!.sliceId).toBe(slice.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triages features when autopilotEnabled is true (autoAdvance unset)", async () => {
|
||||||
|
const { ts, ms } = await createStoreWithTaskStore();
|
||||||
|
|
||||||
|
const mission = ms.createMission({ title: "Mission" });
|
||||||
|
// autopilotEnabled=true, autoAdvance undefined (neither true nor false)
|
||||||
|
ms.updateMission(mission.id, { autopilotEnabled: true });
|
||||||
|
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
|
||||||
|
const slice = ms.addSlice(milestone.id, { title: "Slice" });
|
||||||
|
const f1 = ms.addFeature(slice.id, { title: "Feature 1" });
|
||||||
|
|
||||||
|
await ms.activateSlice(slice.id);
|
||||||
|
|
||||||
|
// Feature should be triaged because autopilotEnabled=true
|
||||||
|
const updatedF1 = ms.getFeature(f1.id)!;
|
||||||
|
expect(updatedF1.status).toBe("triaged");
|
||||||
|
expect(updatedF1.taskId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not triage features when autopilotEnabled is false and autoAdvance is false", async () => {
|
||||||
|
const { ms } = await createStoreWithTaskStore();
|
||||||
|
|
||||||
|
const mission = ms.createMission({ title: "Mission" });
|
||||||
|
ms.updateMission(mission.id, { autopilotEnabled: false, autoAdvance: false });
|
||||||
|
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
|
||||||
|
const slice = ms.addSlice(milestone.id, { title: "Slice" });
|
||||||
|
const f1 = ms.addFeature(slice.id, { title: "Feature 1" });
|
||||||
|
|
||||||
|
await ms.activateSlice(slice.id);
|
||||||
|
|
||||||
|
// Feature should NOT be triaged
|
||||||
|
const updatedF1 = ms.getFeature(f1.id)!;
|
||||||
|
expect(updatedF1.status).toBe("defined");
|
||||||
|
expect(updatedF1.taskId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triages features when autopilotEnabled is false but autoAdvance is true (legacy compat)", async () => {
|
||||||
|
const { ms } = await createStoreWithTaskStore();
|
||||||
|
|
||||||
|
const mission = ms.createMission({ title: "Mission" });
|
||||||
|
// Legacy case: autoAdvance=true, autopilotEnabled=false/unset
|
||||||
|
ms.updateMission(mission.id, { autoAdvance: true });
|
||||||
|
const milestone = ms.addMilestone(mission.id, { title: "Milestone" });
|
||||||
|
const slice = ms.addSlice(milestone.id, { title: "Slice" });
|
||||||
|
const f1 = ms.addFeature(slice.id, { title: "Feature 1" });
|
||||||
|
|
||||||
|
await ms.activateSlice(slice.id);
|
||||||
|
|
||||||
|
// Feature should be triaged because autoAdvance=true (legacy compat)
|
||||||
|
const updatedF1 = ms.getFeature(f1.id)!;
|
||||||
|
expect(updatedF1.status).toBe("triaged");
|
||||||
|
expect(updatedF1.taskId).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1252,7 +1252,9 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
const milestone = this.getMilestone(slice.milestoneId);
|
const milestone = this.getMilestone(slice.milestoneId);
|
||||||
const mission = milestone ? this.getMission(milestone.missionId) : undefined;
|
const mission = milestone ? this.getMission(milestone.missionId) : undefined;
|
||||||
|
|
||||||
const shouldAutoTriage = mission?.autoAdvance === true;
|
// Use autopilotEnabled as canonical, fall back to autoAdvance for backward compat
|
||||||
|
const shouldAutoTriage =
|
||||||
|
mission?.autopilotEnabled === true || mission?.autoAdvance === true;
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const updated = this.updateSlice(id, {
|
const updated = this.updateSlice(id, {
|
||||||
@@ -1260,7 +1262,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
activatedAt: now,
|
activatedAt: now,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-triage features if autoAdvance is enabled
|
// Auto-triage features if autopilot is enabled (or legacy autoAdvance)
|
||||||
if (shouldAutoTriage) {
|
if (shouldAutoTriage) {
|
||||||
try {
|
try {
|
||||||
await this.triageSlice(id);
|
await this.triageSlice(id);
|
||||||
|
|||||||
@@ -576,6 +576,22 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
void loadMissionHealth(missions);
|
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 = () => {
|
const handleFeatureUpdated = () => {
|
||||||
refreshHealth();
|
refreshHealth();
|
||||||
// Reload the selected mission detail to reflect updated feature status
|
// 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("mission:updated", handleMissionUpdated);
|
||||||
eventSource.addEventListener("slice:updated", refreshHealth);
|
eventSource.addEventListener("slice:updated", handleSliceUpdated);
|
||||||
eventSource.addEventListener("feature:updated", handleFeatureUpdated);
|
eventSource.addEventListener("feature:updated", handleFeatureUpdated);
|
||||||
eventSource.addEventListener("mission:event", handleMissionEvent);
|
eventSource.addEventListener("mission:event", handleMissionEvent);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
eventSource.removeEventListener("mission:updated", refreshHealth);
|
eventSource.removeEventListener("mission:updated", handleMissionUpdated);
|
||||||
eventSource.removeEventListener("slice:updated", refreshHealth);
|
eventSource.removeEventListener("slice:updated", handleSliceUpdated);
|
||||||
eventSource.removeEventListener("feature:updated", handleFeatureUpdated);
|
eventSource.removeEventListener("feature:updated", handleFeatureUpdated);
|
||||||
eventSource.removeEventListener("mission:event", handleMissionEvent);
|
eventSource.removeEventListener("mission:event", handleMissionEvent);
|
||||||
eventSource.close();
|
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 () => {
|
it("shows empty state when no missions exist", async () => {
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse([]));
|
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse([]));
|
||||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|||||||
@@ -2880,6 +2880,106 @@ describe("Mission API", () => {
|
|||||||
lastActivityAt: undefined,
|
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", () => {
|
describe("POST /api/missions/:missionId/autopilot/start", () => {
|
||||||
|
|||||||
@@ -1928,10 +1928,31 @@ export function createMissionRouter(
|
|||||||
|
|
||||||
if (missionAutopilot) {
|
if (missionAutopilot) {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
// Enable: start watching and potentially start the mission
|
// Enable: start watching and potentially start/recover the mission
|
||||||
missionAutopilot.watchMission(missionId);
|
missionAutopilot.watchMission(missionId);
|
||||||
if (mission.status === "planning") {
|
if (mission.status === "planning") {
|
||||||
await missionAutopilot.checkAndStartMission(missionId);
|
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 {
|
} else {
|
||||||
// Disable: stop watching
|
// Disable: stop watching
|
||||||
|
|||||||
@@ -1234,6 +1234,120 @@ describe("Scheduler", () => {
|
|||||||
expect(mockMissionStore.activateSlice).not.toHaveBeenCalled();
|
expect(mockMissionStore.activateSlice).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── autopilotEnabled as primary control for onSliceComplete fallback ─────────
|
||||||
|
|
||||||
|
it("onSliceComplete auto-advances when autopilotEnabled is true (autoAdvance false)", async () => {
|
||||||
|
const missionHierarchy = {
|
||||||
|
id: "M-001",
|
||||||
|
status: "active",
|
||||||
|
milestones: [
|
||||||
|
{
|
||||||
|
id: "MS-001",
|
||||||
|
dependencies: [],
|
||||||
|
slices: [
|
||||||
|
{ id: "SL-001", status: "complete" },
|
||||||
|
{ id: "SL-002", status: "pending" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const mockMissionStore = createMockMissionStore({
|
||||||
|
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
|
||||||
|
getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autopilotEnabled: true, autoAdvance: false }),
|
||||||
|
getMissionWithHierarchy: vi.fn().mockReturnValue(missionHierarchy),
|
||||||
|
activateSlice: vi.fn().mockReturnValue({ id: "SL-002", status: "active" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = createMockStore();
|
||||||
|
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||||
|
|
||||||
|
const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any;
|
||||||
|
await scheduler.onSliceComplete(slice);
|
||||||
|
|
||||||
|
expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001");
|
||||||
|
expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onSliceComplete auto-advances when autopilotEnabled is true (autoAdvance unset)", async () => {
|
||||||
|
const missionHierarchy = {
|
||||||
|
id: "M-001",
|
||||||
|
status: "active",
|
||||||
|
milestones: [
|
||||||
|
{
|
||||||
|
id: "MS-001",
|
||||||
|
dependencies: [],
|
||||||
|
slices: [
|
||||||
|
{ id: "SL-001", status: "complete" },
|
||||||
|
{ id: "SL-002", status: "pending" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const mockMissionStore = createMockMissionStore({
|
||||||
|
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
|
||||||
|
getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autopilotEnabled: true }),
|
||||||
|
getMissionWithHierarchy: vi.fn().mockReturnValue(missionHierarchy),
|
||||||
|
activateSlice: vi.fn().mockReturnValue({ id: "SL-002", status: "active" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = createMockStore();
|
||||||
|
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||||
|
|
||||||
|
const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any;
|
||||||
|
await scheduler.onSliceComplete(slice);
|
||||||
|
|
||||||
|
expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001");
|
||||||
|
expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onSliceComplete does not auto-advance when both autopilotEnabled and autoAdvance are false", async () => {
|
||||||
|
const mockMissionStore = createMockMissionStore({
|
||||||
|
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
|
||||||
|
getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autopilotEnabled: false, autoAdvance: false }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = createMockStore();
|
||||||
|
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||||
|
|
||||||
|
const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any;
|
||||||
|
await scheduler.onSliceComplete(slice);
|
||||||
|
|
||||||
|
expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001");
|
||||||
|
expect(mockMissionStore.activateSlice).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onSliceComplete auto-advances when autopilotEnabled is false but autoAdvance is true (legacy compat)", async () => {
|
||||||
|
const missionHierarchy = {
|
||||||
|
id: "M-001",
|
||||||
|
status: "active",
|
||||||
|
milestones: [
|
||||||
|
{
|
||||||
|
id: "MS-001",
|
||||||
|
dependencies: [],
|
||||||
|
slices: [
|
||||||
|
{ id: "SL-001", status: "complete" },
|
||||||
|
{ id: "SL-002", status: "pending" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const mockMissionStore = createMockMissionStore({
|
||||||
|
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
|
||||||
|
getMission: vi.fn().mockReturnValue({ id: "M-001", status: "active", autoAdvance: true }),
|
||||||
|
getMissionWithHierarchy: vi.fn().mockReturnValue(missionHierarchy),
|
||||||
|
activateSlice: vi.fn().mockReturnValue({ id: "SL-002", status: "active" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = createMockStore();
|
||||||
|
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
|
||||||
|
|
||||||
|
const slice = { id: "SL-001", milestoneId: "MS-001", status: "complete" } as any;
|
||||||
|
await scheduler.onSliceComplete(slice);
|
||||||
|
|
||||||
|
expect(mockMissionStore.getMission).toHaveBeenCalledWith("M-001");
|
||||||
|
expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002");
|
||||||
|
});
|
||||||
|
|
||||||
it("skips mission progression when task sliceId mismatches linked feature sliceId", async () => {
|
it("skips mission progression when task sliceId mismatches linked feature sliceId", async () => {
|
||||||
const mockMissionStore = createMockMissionStore({
|
const mockMissionStore = createMockMissionStore({
|
||||||
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-OTHER" }),
|
getFeatureByTaskId: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-OTHER" }),
|
||||||
|
|||||||
@@ -795,7 +795,10 @@ export class Scheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mission = missionStore.getMission(milestone.missionId);
|
const mission = missionStore.getMission(milestone.missionId);
|
||||||
if (!mission || mission.status !== "active" || !mission.autoAdvance) {
|
// Use autopilotEnabled as canonical, fall back to autoAdvance for backward compat
|
||||||
|
const shouldAutoAdvance =
|
||||||
|
mission?.autopilotEnabled === true || mission?.autoAdvance === true;
|
||||||
|
if (!mission || mission.status !== "active" || !shouldAutoAdvance) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user