refactor(FN-1353): simplify mission autopilot to single toggle
- Deprecate autoAdvance field in favor of autopilotEnabled as the sole control - Remove autoAdvance guard logic from MissionAutopilot engine class - Simplify MissionManager UI to use single autopilot toggle with visual state indicator - Update MissionAutopilot tests to use autopilotEnabled instead of autoAdvance - Update MissionManager component tests for simplified UI - Update AGENTS.md documentation to reflect the simplified autopilot model
This commit is contained in:
19
AGENTS.md
19
AGENTS.md
@@ -1787,7 +1787,7 @@ Missions can run in **autopilot mode** for autonomous progression through slices
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **User enables autopilot** on a mission via the dashboard UI
|
||||
1. **User enables autopilot** on a mission via the dashboard UI (the only control needed)
|
||||
2. The `MissionAutopilot` class starts watching the mission
|
||||
3. As tasks complete, the scheduler notifies the autopilot
|
||||
4. Autopilot checks if the current slice is complete
|
||||
@@ -1803,12 +1803,15 @@ Missions can run in **autopilot mode** for autonomous progression through slices
|
||||
| `activating` | Progressing to the next slice |
|
||||
| `completing` | Wrapping up the mission |
|
||||
|
||||
### Relationship Between autopilotEnabled and autoAdvance
|
||||
### autopilotEnabled
|
||||
|
||||
- **`autoAdvance`** — When a slice completes, automatically activate the next pending slice (existing behavior)
|
||||
- **`autopilotEnabled`** — Enable the autopilot monitoring system for this mission (new behavior)
|
||||
- Both can be true simultaneously. `autopilotEnabled: true` with `autoAdvance: true` provides full automation
|
||||
- `autopilotEnabled: true` with `autoAdvance: false` provides monitoring but manual slice activation
|
||||
The `autopilotEnabled` flag is the sole control for autopilot behavior. When enabled:
|
||||
|
||||
- Autopilot automatically watches the mission and monitors task completion
|
||||
- When a slice completes, autopilot auto-advances to the next pending slice
|
||||
- The autopilot toggle in the mission edit form is the only control needed
|
||||
|
||||
**Note:** The `autoAdvance` field is deprecated and superseded by `autopilotEnabled`. It is kept for backward compatibility with existing mission data but is no longer user-facing.
|
||||
|
||||
### Key Implementation
|
||||
|
||||
@@ -1822,8 +1825,8 @@ Missions can run in **autopilot mode** for autonomous progression through slices
|
||||
|
||||
- `GET /api/missions/:missionId/autopilot` — Returns autopilot status
|
||||
- `PATCH /api/missions/:missionId/autopilot` — Enable/disable autopilot (`{ enabled: boolean }`)
|
||||
- `POST /api/missions/:missionId/autopilot/start` — Manually start watching
|
||||
- `POST /api/missions/:missionId/autopilot/stop` — Manually stop watching
|
||||
- `POST /api/missions/:missionId/autopilot/start` — Manually start watching (programmatic access)
|
||||
- `POST /api/missions/:missionId/autopilot/stop` — Manually stop watching (programmatic access)
|
||||
|
||||
## Workflow Steps
|
||||
|
||||
|
||||
@@ -116,7 +116,11 @@ export interface Mission {
|
||||
status: MissionStatus;
|
||||
/** State of the AI specification interview process */
|
||||
interviewState: InterviewState;
|
||||
/** When true, automatically activate the next pending slice when current slice completes */
|
||||
/**
|
||||
* @deprecated Superseded by `autopilotEnabled`. Kept for backward compatibility
|
||||
* with existing mission data. Autopilot now always auto-advances slices when
|
||||
* enabled and watching.
|
||||
*/
|
||||
autoAdvance?: boolean;
|
||||
/** When true, enable autopilot monitoring system for this mission */
|
||||
autopilotEnabled?: boolean;
|
||||
|
||||
@@ -16,9 +16,7 @@ import {
|
||||
Link,
|
||||
Unlink,
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Zap,
|
||||
Activity,
|
||||
@@ -62,14 +60,10 @@ import {
|
||||
unlinkFeatureFromTask,
|
||||
triageFeature,
|
||||
triageAllSliceFeatures,
|
||||
pauseMission,
|
||||
resumeMission,
|
||||
stopMission,
|
||||
startMission,
|
||||
fetchMissionAutopilotStatus,
|
||||
updateMissionAutopilot,
|
||||
startMissionAutopilot,
|
||||
stopMissionAutopilot,
|
||||
fetchMissionHealth,
|
||||
fetchMissionsHealth,
|
||||
fetchMissionEvents,
|
||||
@@ -130,7 +124,6 @@ interface MissionFormData {
|
||||
title: string;
|
||||
description: string;
|
||||
status: MissionStatus;
|
||||
autoAdvance: boolean;
|
||||
autopilotEnabled: boolean;
|
||||
}
|
||||
|
||||
@@ -158,7 +151,6 @@ const EMPTY_MISSION_FORM: MissionFormData = {
|
||||
title: "",
|
||||
description: "",
|
||||
status: "planning",
|
||||
autoAdvance: false,
|
||||
autopilotEnabled: false,
|
||||
};
|
||||
|
||||
@@ -382,8 +374,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
// Delete confirmation
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<{ type: string; id: string } | null>(null);
|
||||
|
||||
// Autopilot state
|
||||
const [autopilotStatus, setAutopilotStatus] = useState<AutopilotStatusType | null>(null);
|
||||
// Autopilot loading state
|
||||
const [autopilotLoading, setAutopilotLoading] = useState(false);
|
||||
|
||||
const [missionHealthById, setMissionHealthById] = useState<Map<string, MissionHealth>>(new Map());
|
||||
@@ -679,7 +670,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
title: mission.title,
|
||||
description: mission.description || "",
|
||||
status: mission.status,
|
||||
autoAdvance: mission.autoAdvance ?? false,
|
||||
autopilotEnabled: mission.autopilotEnabled ?? false,
|
||||
});
|
||||
}, []);
|
||||
@@ -706,13 +696,18 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
}, projectId);
|
||||
addToast("Mission created", "success");
|
||||
} else if (editingMissionId) {
|
||||
await updateMission(editingMissionId, {
|
||||
// Build update payload - when autopilot is enabled, also set autoAdvance
|
||||
// for backward compat with the engine (though engine no longer reads it)
|
||||
const updates: Record<string, unknown> = {
|
||||
title: missionForm.title.trim(),
|
||||
description: missionForm.description.trim() || undefined,
|
||||
status: missionForm.status,
|
||||
autoAdvance: missionForm.autoAdvance,
|
||||
autopilotEnabled: missionForm.autopilotEnabled,
|
||||
}, projectId);
|
||||
};
|
||||
if (missionForm.autopilotEnabled) {
|
||||
updates.autoAdvance = true;
|
||||
}
|
||||
await updateMission(editingMissionId, updates as Parameters<typeof updateMission>[1], projectId);
|
||||
addToast("Mission updated", "success");
|
||||
// Refresh detail view if viewing this mission
|
||||
if (selectedMission?.id === editingMissionId) {
|
||||
@@ -1036,18 +1031,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
}
|
||||
}, [addToast, loadMissionDetail, selectedMission, projectId]);
|
||||
|
||||
// Pause mission — set status to "blocked"
|
||||
const handlePauseMission = useCallback(async (missionId: string) => {
|
||||
try {
|
||||
await pauseMission(missionId, projectId);
|
||||
addToast("Mission paused", "success");
|
||||
await loadMissionDetail(missionId);
|
||||
loadMissions();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to pause mission", "error");
|
||||
}
|
||||
}, [addToast, loadMissionDetail, loadMissions, projectId]);
|
||||
|
||||
// Resume a paused mission — set status back to "active"
|
||||
const handleResumeMission = useCallback(async (missionId: string) => {
|
||||
try {
|
||||
@@ -1087,20 +1070,10 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
|
||||
// ── Autopilot handlers ──
|
||||
|
||||
const loadAutopilotStatus = useCallback(async (missionId: string) => {
|
||||
try {
|
||||
const status = await fetchMissionAutopilotStatus(missionId, projectId);
|
||||
setAutopilotStatus(status);
|
||||
} catch {
|
||||
// Silently ignore — autopilot status is supplementary
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const handleToggleAutopilot = useCallback(async (missionId: string, enabled: boolean) => {
|
||||
try {
|
||||
setAutopilotLoading(true);
|
||||
const status = await updateMissionAutopilot(missionId, { enabled }, projectId);
|
||||
setAutopilotStatus(status);
|
||||
await updateMissionAutopilot(missionId, { enabled }, projectId);
|
||||
addToast(enabled ? "Autopilot enabled" : "Autopilot disabled", "success");
|
||||
// Reload mission detail to reflect updated fields
|
||||
await loadMissionDetail(missionId);
|
||||
@@ -1112,36 +1085,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
}
|
||||
}, [addToast, loadMissionDetail, loadMissions, projectId]);
|
||||
|
||||
const handleStartAutopilot = useCallback(async (missionId: string) => {
|
||||
try {
|
||||
setAutopilotLoading(true);
|
||||
const status = await startMissionAutopilot(missionId, projectId);
|
||||
setAutopilotStatus(status);
|
||||
addToast("Autopilot started", "success");
|
||||
await loadMissionDetail(missionId);
|
||||
loadMissions();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to start autopilot", "error");
|
||||
} finally {
|
||||
setAutopilotLoading(false);
|
||||
}
|
||||
}, [addToast, loadMissionDetail, loadMissions, projectId]);
|
||||
|
||||
const handleStopAutopilot = useCallback(async (missionId: string) => {
|
||||
try {
|
||||
setAutopilotLoading(true);
|
||||
const status = await stopMissionAutopilot(missionId, projectId);
|
||||
setAutopilotStatus(status);
|
||||
addToast("Autopilot stopped", "success");
|
||||
await loadMissionDetail(missionId);
|
||||
loadMissions();
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to stop autopilot", "error");
|
||||
} finally {
|
||||
setAutopilotLoading(false);
|
||||
}
|
||||
}, [addToast, loadMissionDetail, loadMissions, projectId]);
|
||||
|
||||
const handleSelectMission = useCallback((mission: Mission) => {
|
||||
setActiveTab("structure");
|
||||
setMissionEvents([]);
|
||||
@@ -1149,12 +1092,10 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
setEventsFilter("all");
|
||||
setExpandedEventMetadata(new Set());
|
||||
loadMissionDetail(mission.id);
|
||||
loadAutopilotStatus(mission.id);
|
||||
}, [loadMissionDetail, loadAutopilotStatus]);
|
||||
}, [loadMissionDetail]);
|
||||
|
||||
const handleBackToList = useCallback(() => {
|
||||
setSelectedMission(null);
|
||||
setAutopilotStatus(null);
|
||||
setActiveTab("structure");
|
||||
setMissionEvents([]);
|
||||
setEventsTotal(0);
|
||||
@@ -1164,11 +1105,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
}, [loadMissions]);
|
||||
|
||||
const hasMoreEvents = missionEvents.length < eventsTotal;
|
||||
const autopilotState = (autopilotStatus?.state ?? selectedMission?.autopilotState ?? "inactive") as AutopilotState;
|
||||
const autopilotState = (selectedMission?.autopilotState ?? "inactive") as AutopilotState;
|
||||
const autopilotPulseActive = autopilotState === "watching" || autopilotState === "activating";
|
||||
const autopilotActivitySummary = getAutopilotActivitySummary(
|
||||
autopilotState,
|
||||
autopilotStatus?.lastActivityAt ?? selectedMission?.lastAutopilotActivityAt,
|
||||
selectedMission?.lastAutopilotActivityAt,
|
||||
);
|
||||
|
||||
const handleLoadMoreEvents = useCallback(() => {
|
||||
@@ -1313,11 +1254,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
<p className="mission-detail__description">{selectedMission.description}</p>
|
||||
)}
|
||||
<div className="mission-detail__meta">
|
||||
{selectedMission.autoAdvance && (
|
||||
<span className="mission-detail__meta-badge">
|
||||
<Play size={12} /> Auto-advance
|
||||
</span>
|
||||
)}
|
||||
<span className="mission-detail__meta-info">
|
||||
{selectedMission.milestones.length} milestones
|
||||
</span>
|
||||
@@ -1359,65 +1295,18 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
{autopilotActivitySummary}
|
||||
</span>
|
||||
)}
|
||||
{autopilotStatus?.nextScheduledCheck && (
|
||||
<span className="mission-detail__autopilot-next-check">
|
||||
Next check: {new Date(autopilotStatus.nextScheduledCheck).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<div className="mission-detail__autopilot-actions">
|
||||
<button
|
||||
className="mission-btn mission-btn--primary mission-btn--sm"
|
||||
onClick={() => handleStartAutopilot(selectedMission.id)}
|
||||
disabled={autopilotLoading || !selectedMission.autopilotEnabled || Boolean(autopilotStatus?.watched)}
|
||||
title="Start autopilot watching"
|
||||
aria-label="Start autopilot watching"
|
||||
data-testid="mission-autopilot-start"
|
||||
>
|
||||
<Play size={12} /> Start
|
||||
</button>
|
||||
<button
|
||||
className="mission-btn mission-btn--danger mission-btn--sm"
|
||||
onClick={() => handleStopAutopilot(selectedMission.id)}
|
||||
disabled={autopilotLoading || !autopilotStatus?.watched}
|
||||
title="Stop autopilot watching"
|
||||
aria-label="Stop autopilot watching"
|
||||
data-testid="mission-autopilot-stop"
|
||||
>
|
||||
<Square size={12} /> Stop
|
||||
</button>
|
||||
<button
|
||||
className="mission-btn mission-btn--ghost mission-btn--sm"
|
||||
onClick={() => loadAutopilotStatus(selectedMission.id)}
|
||||
disabled={autopilotLoading}
|
||||
title="Refresh autopilot status"
|
||||
aria-label="Refresh autopilot status"
|
||||
data-testid="mission-autopilot-refresh"
|
||||
>
|
||||
<RefreshCw size={12} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mission-detail__actions">
|
||||
{selectedMission.status === "active" && (
|
||||
<>
|
||||
<button
|
||||
className="mission-icon-btn"
|
||||
onClick={() => handlePauseMission(selectedMission.id)}
|
||||
title="Pause mission"
|
||||
aria-label="Pause mission"
|
||||
>
|
||||
<Pause size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="mission-icon-btn mission-icon-btn--danger"
|
||||
onClick={() => handleStopMission(selectedMission.id)}
|
||||
title="Stop mission"
|
||||
aria-label="Stop mission"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
</>
|
||||
<button
|
||||
className="mission-icon-btn mission-icon-btn--danger"
|
||||
onClick={() => handleStopMission(selectedMission.id)}
|
||||
title="Stop mission"
|
||||
aria-label="Stop mission"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
)}
|
||||
{selectedMission.status === "blocked" && (
|
||||
<button
|
||||
@@ -1426,7 +1315,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
title="Resume mission"
|
||||
aria-label="Resume mission"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
<Play size={14} />
|
||||
</button>
|
||||
)}
|
||||
{selectedMission.status === "planning" && (
|
||||
@@ -1486,14 +1375,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
<option value="complete">Complete</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
<label className="mission-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={missionForm.autoAdvance}
|
||||
onChange={(e) => setMissionForm({ ...missionForm, autoAdvance: e.target.checked })}
|
||||
/>
|
||||
Auto-advance slices
|
||||
</label>
|
||||
<label className="mission-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -2187,22 +2068,13 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
</div>
|
||||
<div className="mission-list__item-actions" onClick={(e) => e.stopPropagation()}>
|
||||
{m.status === "active" && (
|
||||
<>
|
||||
<button
|
||||
className="mission-icon-btn"
|
||||
onClick={() => handlePauseMission(m.id)}
|
||||
title="Pause mission"
|
||||
>
|
||||
<Pause size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="mission-icon-btn mission-icon-btn--danger"
|
||||
onClick={() => handleStopMission(m.id)}
|
||||
title="Stop mission"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
</>
|
||||
<button
|
||||
className="mission-icon-btn mission-icon-btn--danger"
|
||||
onClick={() => handleStopMission(m.id)}
|
||||
title="Stop mission"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
)}
|
||||
{m.status === "blocked" && (
|
||||
<button
|
||||
@@ -2210,7 +2082,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
onClick={() => handleResumeMission(m.id)}
|
||||
title="Resume mission"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
<Play size={14} />
|
||||
</button>
|
||||
)}
|
||||
{m.status === "planning" && (
|
||||
@@ -2269,14 +2141,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
<option value="complete">Complete</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
<label className="mission-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={missionForm.autoAdvance}
|
||||
onChange={(e) => setMissionForm({ ...missionForm, autoAdvance: e.target.checked })}
|
||||
/>
|
||||
Auto-advance slices
|
||||
</label>
|
||||
<label className="mission-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -23,7 +23,6 @@ const mockMissions = [
|
||||
title: "Build Auth System",
|
||||
description: "Complete authentication flow",
|
||||
status: "planning",
|
||||
autoAdvance: false,
|
||||
milestones: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
@@ -33,7 +32,8 @@ const mockMissions = [
|
||||
title: "API Redesign",
|
||||
description: "Redesign the REST API",
|
||||
status: "active",
|
||||
autoAdvance: true,
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "watching",
|
||||
milestones: [],
|
||||
summary: {
|
||||
totalMilestones: 2,
|
||||
@@ -52,7 +52,6 @@ const mockMissionDetail = {
|
||||
title: "Build Auth System",
|
||||
description: "Complete authentication flow",
|
||||
status: "planning",
|
||||
autoAdvance: false,
|
||||
milestones: [
|
||||
{
|
||||
id: "MS-001",
|
||||
@@ -1056,7 +1055,6 @@ describe("MissionManager", () => {
|
||||
title: "Generated Mission",
|
||||
description: "Mission with realistic generated ID",
|
||||
status: "planning",
|
||||
autoAdvance: false,
|
||||
milestones: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
@@ -1068,7 +1066,6 @@ describe("MissionManager", () => {
|
||||
title: "Generated Mission",
|
||||
description: "Mission with realistic generated ID",
|
||||
status: "planning",
|
||||
autoAdvance: false,
|
||||
milestones: [
|
||||
{
|
||||
id: generatedMilestoneId,
|
||||
@@ -1412,9 +1409,9 @@ describe("MissionManager", () => {
|
||||
title: "Autopilot Mission",
|
||||
description: "Mission with autopilot enabled",
|
||||
status: "active",
|
||||
autoAdvance: true,
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "watching",
|
||||
lastAutopilotActivityAt: "2026-01-01T00:00:00.000Z",
|
||||
milestones: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
@@ -1424,7 +1421,6 @@ describe("MissionManager", () => {
|
||||
title: "Normal Mission",
|
||||
description: "Mission without autopilot",
|
||||
status: "planning",
|
||||
autoAdvance: false,
|
||||
autopilotEnabled: false,
|
||||
milestones: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
@@ -1437,9 +1433,9 @@ describe("MissionManager", () => {
|
||||
title: "Autopilot Mission",
|
||||
description: "Mission with autopilot enabled",
|
||||
status: "active",
|
||||
autoAdvance: true,
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "watching",
|
||||
lastAutopilotActivityAt: "2026-01-01T00:00:00.000Z",
|
||||
milestones: [
|
||||
{
|
||||
id: "MS-001",
|
||||
@@ -1532,7 +1528,7 @@ describe("MissionManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows enhanced autopilot controls with expected button states", async () => {
|
||||
it("shows autopilot toggle and status badge", async () => {
|
||||
globalThis.fetch = createAutopilotFetchMock();
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
@@ -1542,20 +1538,16 @@ describe("MissionManager", () => {
|
||||
fireEvent.click(screen.getByText("Autopilot Mission"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mission-autopilot-start")).toBeDefined();
|
||||
expect(screen.getByTestId("mission-autopilot-stop")).toBeDefined();
|
||||
expect(screen.getByTestId("mission-autopilot-refresh")).toBeDefined();
|
||||
// Should show autopilot toggle and state badge
|
||||
expect(screen.getByLabelText("Autopilot")).toBeDefined();
|
||||
expect(screen.getByTestId("autopilot-state-badge")).toBeDefined();
|
||||
expect(screen.getByText(/Watching since/)).toBeDefined();
|
||||
});
|
||||
|
||||
const startButton = screen.getByTestId("mission-autopilot-start") as HTMLButtonElement;
|
||||
const stopButton = screen.getByTestId("mission-autopilot-stop") as HTMLButtonElement;
|
||||
const refreshButton = screen.getByTestId("mission-autopilot-refresh") as HTMLButtonElement;
|
||||
|
||||
expect(startButton.disabled).toBe(true);
|
||||
expect(stopButton.disabled).toBe(false);
|
||||
expect(refreshButton.disabled).toBe(false);
|
||||
expect(screen.getByText(/Watching since/)).toBeDefined();
|
||||
expect(screen.getByText(/Next check:/)).toBeDefined();
|
||||
// Verify no action buttons exist (they were removed)
|
||||
expect(screen.queryByTestId("mission-autopilot-start")).toBeNull();
|
||||
expect(screen.queryByTestId("mission-autopilot-stop")).toBeNull();
|
||||
expect(screen.queryByTestId("mission-autopilot-refresh")).toBeNull();
|
||||
});
|
||||
|
||||
it("toggles autopilot with a PATCH request", async () => {
|
||||
|
||||
@@ -1844,8 +1844,13 @@ export function createMissionRouter(
|
||||
throw badRequest("No pending slices found");
|
||||
}
|
||||
|
||||
// Set autoAdvance: true so activateSlice() will auto-triage features
|
||||
missionStore.updateMission(missionId, { autoAdvance: true, status: "active" });
|
||||
// Enable autopilot (and autoAdvance for backward compat) so the mission
|
||||
// will auto-advance slices when autopilot is watching
|
||||
missionStore.updateMission(missionId, {
|
||||
autopilotEnabled: true,
|
||||
autoAdvance: true, // kept for backward compat with existing mission data
|
||||
status: "active",
|
||||
});
|
||||
|
||||
// Activate the first pending slice (triggers auto-triage via activateSlice)
|
||||
await missionStore.activateSlice(nextSlice.id);
|
||||
|
||||
@@ -1005,7 +1005,6 @@ describe("MissionAutopilot", () => {
|
||||
ap.setScheduler(newScheduler);
|
||||
|
||||
// Now advanceToNextSlice should use the new scheduler
|
||||
// (but will be blocked by autoAdvance guard since default mission has autoAdvance: true)
|
||||
newScheduler.activateNextPendingSlice.mockResolvedValue(
|
||||
createMockSlice({ id: "SL-002", status: "active" }),
|
||||
);
|
||||
@@ -1016,42 +1015,10 @@ describe("MissionAutopilot", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── autoAdvance guard ────────────────────────────────────────────
|
||||
// ── Slice Advancement ────────────────────────────────────────────
|
||||
|
||||
describe("autoAdvance guard", () => {
|
||||
it("should not advance slice when autoAdvance is false", async () => {
|
||||
const mission = createMockMission({ autoAdvance: false });
|
||||
const store = createMockMissionStore([mission]);
|
||||
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
|
||||
|
||||
ap.start();
|
||||
ap.watchMission("M-TEST1");
|
||||
|
||||
await ap.advanceToNextSlice("M-TEST1");
|
||||
|
||||
// Should NOT call scheduler to activate next slice
|
||||
expect(scheduler.activateNextPendingSlice).not.toHaveBeenCalled();
|
||||
|
||||
ap.stop();
|
||||
});
|
||||
|
||||
it("should not advance slice when autoAdvance is undefined", async () => {
|
||||
const mission = createMockMission({ autoAdvance: undefined });
|
||||
const store = createMockMissionStore([mission]);
|
||||
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
|
||||
|
||||
ap.start();
|
||||
ap.watchMission("M-TEST1");
|
||||
|
||||
await ap.advanceToNextSlice("M-TEST1");
|
||||
|
||||
// Should NOT call scheduler to activate next slice
|
||||
expect(scheduler.activateNextPendingSlice).not.toHaveBeenCalled();
|
||||
|
||||
ap.stop();
|
||||
});
|
||||
|
||||
it("should advance slice when autoAdvance is true", async () => {
|
||||
describe("slice advancement", () => {
|
||||
it("should advance slice when autopilot is watching", async () => {
|
||||
autopilot.watchMission("M-TEST1");
|
||||
const activatedSlice = createMockSlice({ id: "SL-002", status: "active" });
|
||||
scheduler.activateNextPendingSlice.mockResolvedValue(activatedSlice);
|
||||
@@ -1060,5 +1027,12 @@ describe("MissionAutopilot", () => {
|
||||
|
||||
expect(scheduler.activateNextPendingSlice).toHaveBeenCalledWith("M-TEST1");
|
||||
});
|
||||
|
||||
it("should not advance slice when mission is not being watched", async () => {
|
||||
// Don't watch the mission
|
||||
await autopilot.advanceToNextSlice("M-TEST1");
|
||||
|
||||
expect(scheduler.activateNextPendingSlice).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,7 +173,6 @@ export class MissionAutopilot {
|
||||
{
|
||||
source: "watchMission",
|
||||
missionStatus: mission.status,
|
||||
autoAdvance: mission.autoAdvance ?? false,
|
||||
},
|
||||
);
|
||||
autopilotLog.log(`Watching mission ${missionId} (${mission.title})`);
|
||||
@@ -360,15 +359,6 @@ export class MissionAutopilot {
|
||||
const state = this.watchedMissions.get(missionId);
|
||||
if (!state) return;
|
||||
|
||||
// Respect the mission's autoAdvance setting — if the user opted for
|
||||
// manual slice activation, autopilot should NOT auto-advance even when
|
||||
// it is watching and enabled.
|
||||
const mission = this.missionStore.getMission(missionId);
|
||||
if (!mission?.autoAdvance) {
|
||||
autopilotLog.log(`Mission ${missionId} has autoAdvance disabled — skipping slice activation`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.setAutopilotState(missionId, "activating");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user