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:
gsxdsm
2026-04-09 13:45:19 -07:00
parent 4ef5d73ba1
commit e26321790c
7 changed files with 76 additions and 244 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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"

View File

@@ -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 () => {

View File

@@ -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);

View File

@@ -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();
});
});
});

View File

@@ -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");