feat(FN-980): add mission autopilot for autonomous slice progression

- Add MissionAutopilot class with state machine (inactive → watching → activating → completing)
- Add autopilot database schema: autopilotEnabled, autopilotState, lastAutopilotActivityAt columns
- Integrate autopilot with scheduler: handleTaskCompletion() triggers slice activation
- Add API routes: GET/PATCH /missions/:id/autopilot, POST start/stop endpoints
- Add autopilot toggle UI in MissionManager dashboard component
- Add retry logic with exponential backoff (up to 3 attempts) for slice activation
- Add background poll (60s) to detect stale autopilot missions
- Include changeset for @gsxdsm/fusion minor bump
This commit is contained in:
gsxdsm
2026-04-05 23:11:28 -07:00
parent 7224e383c0
commit b89825054d
22 changed files with 1965 additions and 24 deletions

View File

@@ -89,7 +89,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(9);
expect(db.getSchemaVersion()).toBe(10);
});
it("seeds lastModified", () => {
@@ -112,7 +112,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(9);
expect(db.getSchemaVersion()).toBe(10);
});
it("does not overwrite existing config on re-init", () => {
@@ -719,7 +719,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 5 (includes v1→v2, v2→v3, v3→v4, and v4→v5 migrations)
expect(db.getSchemaVersion()).toBe(9);
expect(db.getSchemaVersion()).toBe(10);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -744,11 +744,11 @@ describe("schema migrations", () => {
const db = new Database(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(9);
expect(db.getSchemaVersion()).toBe(10);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(9);
expect(db.getSchemaVersion()).toBe(10);
db.close();
});
@@ -843,7 +843,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 5
expect(db.getSchemaVersion()).toBe(9);
expect(db.getSchemaVersion()).toBe(10);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1053,7 +1053,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(9);
expect(db.getSchemaVersion()).toBe(10);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();

View File

@@ -450,6 +450,14 @@ export class Database {
// Future migrations go here:
// if (version < 10) { this.applyMigration(10, () => { ... }); }
if (version < 10) {
this.applyMigration(10, () => {
this.addColumnIfMissing("missions", "autopilotEnabled", "INTEGER DEFAULT 0");
this.addColumnIfMissing("missions", "autopilotState", "TEXT DEFAULT 'inactive'");
this.addColumnIfMissing("missions", "lastAutopilotActivityAt", "TEXT");
});
}
}
/**

View File

@@ -80,6 +80,7 @@ export {
SLICE_STATUSES,
FEATURE_STATUSES,
INTERVIEW_STATES,
AUTOPILOT_STATES,
} from "./mission-types.js";
export type {
MissionStatus,
@@ -87,6 +88,8 @@ export type {
SliceStatus,
FeatureStatus,
InterviewState,
AutopilotState,
AutopilotStatus,
Mission,
Milestone,
Slice,

View File

@@ -29,6 +29,7 @@ import type {
SliceStatus,
FeatureStatus,
InterviewState,
AutopilotState,
} from "./mission-types.js";
// ── Mission Summary Type ─────────────────────────────────────────────
@@ -112,6 +113,9 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
status: row.status as MissionStatus,
interviewState: row.interviewState as InterviewState,
autoAdvance: Boolean(row.autoAdvance),
autopilotEnabled: Boolean(row.autopilotEnabled),
autopilotState: (row.autopilotState as AutopilotState) || "inactive",
lastAutopilotActivityAt: row.lastAutopilotActivityAt || undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
@@ -178,7 +182,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
* @param input - Mission creation input
* @returns The created mission
*/
createMission(input: MissionCreateInput): Mission {
createMission(input: MissionCreateInput & { autopilotEnabled?: boolean }): Mission {
const now = new Date().toISOString();
const id = this.generateMissionId();
@@ -189,13 +193,15 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
status: "planning",
interviewState: "not_started",
autoAdvance: false,
autopilotEnabled: input.autopilotEnabled ?? false,
autopilotState: "inactive",
createdAt: now,
updatedAt: now,
};
this.db.prepare(`
INSERT INTO missions (id, title, description, status, interviewState, autoAdvance, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO missions (id, title, description, status, interviewState, autoAdvance, autopilotEnabled, autopilotState, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
mission.id,
mission.title,
@@ -203,6 +209,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
mission.status,
mission.interviewState,
mission.autoAdvance ? 1 : 0,
mission.autopilotEnabled ? 1 : 0,
mission.autopilotState ?? "inactive",
mission.createdAt,
mission.updatedAt,
);
@@ -337,6 +345,9 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
status = ?,
interviewState = ?,
autoAdvance = ?,
autopilotEnabled = ?,
autopilotState = ?,
lastAutopilotActivityAt = ?,
updatedAt = ?
WHERE id = ?
`).run(
@@ -345,6 +356,9 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
updated.status,
updated.interviewState,
updated.autoAdvance ? 1 : 0,
updated.autopilotEnabled ? 1 : 0,
updated.autopilotState ?? "inactive",
updated.lastAutopilotActivityAt ?? null,
updated.updatedAt,
updated.id,
);

View File

@@ -31,6 +31,19 @@ export type FeatureStatus = (typeof FEATURE_STATUSES)[number];
export const INTERVIEW_STATES = ["not_started", "in_progress", "completed", "needs_update"] as const;
export type InterviewState = (typeof INTERVIEW_STATES)[number];
/** Autopilot state values for mission autonomous progression */
export const AUTOPILOT_STATES = ["inactive", "watching", "activating", "completing"] as const;
export type AutopilotState = (typeof AUTOPILOT_STATES)[number];
/** Autopilot status for a mission */
export interface AutopilotStatus {
enabled: boolean;
state: AutopilotState;
watched: boolean;
lastActivityAt?: string;
nextScheduledCheck?: string;
}
// ── Core Entity Types ───────────────────────────────────────────────
/**
@@ -50,6 +63,12 @@ export interface Mission {
interviewState: InterviewState;
/** When true, automatically activate the next pending slice when current slice completes */
autoAdvance?: boolean;
/** When true, enable autopilot monitoring system for this mission */
autopilotEnabled?: boolean;
/** Current autopilot runtime state */
autopilotState?: AutopilotState;
/** ISO-8601 timestamp of last autopilot activity (only populated when active) */
lastAutopilotActivityAt?: string;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */

View File

@@ -2356,6 +2356,18 @@ export type SliceStatus = "pending" | "active" | "complete";
/** Feature status values */
export type FeatureStatus = "defined" | "triaged" | "in-progress" | "done";
/** Autopilot state values for mission autonomous progression */
export type AutopilotState = "inactive" | "watching" | "activating" | "completing";
/** Autopilot status for a mission */
export interface AutopilotStatus {
enabled: boolean;
state: AutopilotState;
watched: boolean;
lastActivityAt?: string;
nextScheduledCheck?: string;
}
/** Mission entity */
export interface Mission {
id: string;
@@ -2364,6 +2376,12 @@ export interface Mission {
status: MissionStatus;
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
autoAdvance?: boolean;
/** When true, enable autopilot monitoring system for this mission */
autopilotEnabled?: boolean;
/** Current autopilot runtime state */
autopilotState?: AutopilotState;
/** ISO-8601 timestamp of last autopilot activity */
lastAutopilotActivityAt?: string;
createdAt: string;
updatedAt: string;
}
@@ -2441,7 +2459,7 @@ export function fetchMissions(projectId?: string): Promise<MissionWithSummary[]>
}
/** Create a new mission */
export function createMission(input: { title: string; description?: string }, projectId?: string): Promise<Mission> {
export function createMission(input: { title: string; description?: string; autoAdvance?: boolean; autopilotEnabled?: boolean }, projectId?: string): Promise<Mission> {
return api<Mission>(withProjectId("/missions", projectId), {
method: "POST",
body: JSON.stringify(input),
@@ -2628,6 +2646,35 @@ export function stopMission(missionId: string, projectId?: string): Promise<Miss
});
}
// ── Mission Autopilot API ────────────────────────────────────────────────
/** Fetch autopilot status for a mission */
export function fetchMissionAutopilotStatus(missionId: string, projectId?: string): Promise<AutopilotStatus> {
return api<AutopilotStatus>(withProjectId(`/missions/${encodeURIComponent(missionId)}/autopilot`, projectId));
}
/** Update autopilot settings for a mission (enable/disable) */
export function updateMissionAutopilot(missionId: string, updates: { enabled?: boolean }, projectId?: string): Promise<AutopilotStatus> {
return api<AutopilotStatus>(withProjectId(`/missions/${encodeURIComponent(missionId)}/autopilot`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Manually start autopilot watching for a mission */
export function startMissionAutopilot(missionId: string, projectId?: string): Promise<AutopilotStatus> {
return api<AutopilotStatus>(withProjectId(`/missions/${encodeURIComponent(missionId)}/autopilot/start`, projectId), {
method: "POST",
});
}
/** Manually stop autopilot watching for a mission */
export function stopMissionAutopilot(missionId: string, projectId?: string): Promise<AutopilotStatus> {
return api<AutopilotStatus>(withProjectId(`/missions/${encodeURIComponent(missionId)}/autopilot/stop`, projectId), {
method: "POST",
});
}
// ── Mission Interview API ─────────────────────────────────────────────────
/** Mission plan types returned by the interview AI */

View File

@@ -61,7 +61,12 @@ import {
pauseMission,
resumeMission,
stopMission,
fetchMissionAutopilotStatus,
updateMissionAutopilot,
startMissionAutopilot,
stopMissionAutopilot,
} from "../api";
import type { AutopilotStatus as AutopilotStatusType, AutopilotState } from "./mission-types";
interface MissionManagerProps {
isOpen: boolean;
@@ -104,12 +109,20 @@ const featureStatusColors: Record<FeatureStatus, { bg: string; text: string }> =
done: { bg: "rgba(59, 130, 246, 0.15)", text: "#3b82f6" },
};
const autopilotStateColors: Record<AutopilotState, { bg: string; text: string }> = {
inactive: { bg: "rgba(148, 163, 184, 0.15)", text: "#94a3b8" },
watching: { bg: "rgba(34, 197, 94, 0.15)", text: "#22c55e" },
activating: { bg: "rgba(59, 130, 246, 0.15)", text: "#3b82f6" },
completing: { bg: "rgba(168, 85, 247, 0.15)", text: "#a855f7" },
};
// Form types
interface MissionFormData {
title: string;
description: string;
status: MissionStatus;
autoAdvance: boolean;
autopilotEnabled: boolean;
}
interface MilestoneFormData {
@@ -137,6 +150,7 @@ const EMPTY_MISSION_FORM: MissionFormData = {
description: "",
status: "planning",
autoAdvance: false,
autopilotEnabled: false,
};
const EMPTY_MILESTONE_FORM: MilestoneFormData = {
@@ -206,6 +220,10 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
// Delete confirmation
const [deleteConfirmId, setDeleteConfirmId] = useState<{ type: string; id: string } | null>(null);
// Autopilot state
const [autopilotStatus, setAutopilotStatus] = useState<AutopilotStatusType | null>(null);
const [autopilotLoading, setAutopilotLoading] = useState(false);
const loadMissions = useCallback(async () => {
try {
setLoading(true);
@@ -275,6 +293,7 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
description: mission.description || "",
status: mission.status,
autoAdvance: mission.autoAdvance ?? false,
autopilotEnabled: mission.autopilotEnabled ?? false,
});
}, []);
@@ -296,6 +315,7 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
await createMission({
title: missionForm.title.trim(),
description: missionForm.description.trim() || undefined,
autopilotEnabled: missionForm.autopilotEnabled,
}, projectId);
addToast("Mission created", "success");
} else if (editingMissionId) {
@@ -304,6 +324,7 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
description: missionForm.description.trim() || undefined,
status: missionForm.status,
autoAdvance: missionForm.autoAdvance,
autopilotEnabled: missionForm.autopilotEnabled,
}, projectId);
addToast("Mission updated", "success");
// Refresh detail view if viewing this mission
@@ -665,12 +686,71 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
}
}, [addToast, loadMissionDetail, loadMissions, projectId]);
// ── 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);
addToast(enabled ? "Autopilot enabled" : "Autopilot disabled", "success");
// Reload mission detail to reflect updated fields
await loadMissionDetail(missionId);
loadMissions();
} catch (err: any) {
addToast(err.message || "Failed to update autopilot", "error");
} finally {
setAutopilotLoading(false);
}
}, [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) => {
loadMissionDetail(mission.id);
}, [loadMissionDetail]);
loadAutopilotStatus(mission.id);
}, [loadMissionDetail, loadAutopilotStatus]);
const handleBackToList = useCallback(() => {
setSelectedMission(null);
setAutopilotStatus(null);
loadMissions();
}, [loadMissions]);
@@ -781,7 +861,12 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
<div className="mission-detail">
<div className="mission-detail__header">
<div className="mission-detail__title-row">
<h3 className="mission-detail__title">{selectedMission.title}</h3>
<div className="mission-detail__title-text">
{(autopilotStatus?.watched || selectedMission.autopilotState === "watching" || selectedMission.autopilotState === "activating") && (
<span className="mission-detail__autopilot-dot" title="Autopilot watching" />
)}
<h3 className="mission-detail__title">{selectedMission.title}</h3>
</div>
<span
className="mission-status-badge"
style={{
@@ -805,6 +890,65 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
{selectedMission.milestones.length} milestones
</span>
</div>
{/* ── Autopilot section ── */}
<div className="mission-detail__autopilot">
<div className="mission-detail__autopilot-toggle">
<label className="mission-checkbox mission-checkbox--autopilot">
<input
type="checkbox"
checked={selectedMission.autopilotEnabled ?? false}
onChange={(e) => handleToggleAutopilot(selectedMission.id, e.target.checked)}
disabled={autopilotLoading}
/>
<Zap size={14} className="mission-detail__autopilot-icon" />
Autopilot
</label>
{(selectedMission.autopilotState || autopilotStatus?.state) && (
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: (autopilotStateColors[(autopilotStatus?.state ?? selectedMission.autopilotState) as AutopilotState] || autopilotStateColors.inactive).bg,
color: (autopilotStateColors[(autopilotStatus?.state ?? selectedMission.autopilotState) as AutopilotState] || autopilotStateColors.inactive).text,
}}
data-testid="autopilot-state-badge"
>
{(autopilotStatus?.watched || selectedMission.autopilotState === "watching" || selectedMission.autopilotState === "activating") && (
<span className="mission-detail__autopilot-pulse" />
)}
{autopilotStatus?.state ?? selectedMission.autopilotState ?? "inactive"}
</span>
)}
</div>
{autopilotStatus?.lastActivityAt && (
<span className="mission-detail__autopilot-activity">
Last activity: {new Date(autopilotStatus.lastActivityAt).toLocaleTimeString()}
</span>
)}
<div className="mission-detail__autopilot-actions">
{selectedMission.autopilotEnabled && !autopilotStatus?.watched && (
<button
className="mission-btn mission-btn--ghost mission-btn--sm"
onClick={() => handleStartAutopilot(selectedMission.id)}
disabled={autopilotLoading}
title="Start autopilot watching"
>
<Play size={12} /> Start
</button>
)}
{autopilotStatus?.watched && (
<button
className="mission-btn mission-btn--ghost mission-btn--sm"
onClick={() => handleStopAutopilot(selectedMission.id)}
disabled={autopilotLoading}
title="Stop autopilot watching"
>
<Square size={12} /> Stop
</button>
)}
</div>
</div>
<div className="mission-detail__actions">
{selectedMission.status === "active" && (
<>
@@ -891,6 +1035,14 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
/>
Auto-advance slices
</label>
<label className="mission-checkbox">
<input
type="checkbox"
checked={missionForm.autopilotEnabled}
onChange={(e) => setMissionForm({ ...missionForm, autopilotEnabled: e.target.checked })}
/>
<Zap size={12} /> Autopilot
</label>
</div>
<div className="mission-form-card__actions">
<button className="mission-btn mission-btn--primary" onClick={handleSaveMission} disabled={saving}>
@@ -1384,6 +1536,9 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
<div className="mission-list__item-header">
<Target size={16} className="mission-list__item-icon" />
<span className="mission-list__item-title">{m.title}</span>
{mission.autopilotEnabled && (
<span title="Autopilot enabled"><Zap size={12} className="mission-list__item-autopilot-icon" /></span>
)}
<span
className="mission-status-badge mission-status-badge--sm"
style={{
@@ -1497,6 +1652,14 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
/>
Auto-advance slices
</label>
<label className="mission-checkbox">
<input
type="checkbox"
checked={missionForm.autopilotEnabled}
onChange={(e) => setMissionForm({ ...missionForm, autopilotEnabled: e.target.checked })}
/>
<Zap size={12} /> Autopilot
</label>
</div>
<div className="mission-form-card__actions">
<button className="mission-btn mission-btn--primary" onClick={handleSaveMission} disabled={saving}>

View File

@@ -716,4 +716,133 @@ describe("MissionManager", () => {
});
});
});
// ── Autopilot UI ──
describe("autopilot UI", () => {
const autopilotMockMissions = [
{
id: "M-AUTO1",
title: "Autopilot Mission",
description: "Mission with autopilot enabled",
status: "active",
autoAdvance: true,
autopilotEnabled: true,
autopilotState: "watching",
milestones: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
{
id: "M-AUTO2",
title: "Normal Mission",
description: "Mission without autopilot",
status: "planning",
autoAdvance: false,
autopilotEnabled: false,
milestones: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
const autopilotMockDetail = {
id: "M-AUTO1",
title: "Autopilot Mission",
description: "Mission with autopilot enabled",
status: "active",
autoAdvance: true,
autopilotEnabled: true,
autopilotState: "watching",
milestones: [
{
id: "MS-001",
title: "Phase 1",
description: "First phase",
status: "active",
dependencies: [] as string[],
slices: [],
missionId: "M-AUTO1",
},
],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
function createAutopilotFetchMock() {
let callCount = 0;
return vi.fn().mockImplementation((_url: string) => {
callCount++;
if (callCount === 1) {
return Promise.resolve(mockApiResponse(autopilotMockMissions));
}
if (_url.includes("/autopilot")) {
return Promise.resolve(mockApiResponse({
enabled: true,
state: "watching",
watched: true,
lastActivityAt: "2026-01-01T12:00:00.000Z",
}));
}
return Promise.resolve(mockApiResponse(autopilotMockDetail));
});
}
it("shows autopilot icon for missions with autopilotEnabled in list view", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse(autopilotMockMissions));
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Autopilot Mission")).toBeDefined();
// Autopilot icon should have title attribute
expect(screen.getByTitle("Autopilot enabled")).toBeDefined();
});
});
it("does not show autopilot icon for missions without autopilot", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse(autopilotMockMissions));
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Normal Mission")).toBeDefined();
});
// There should be only one autopilot icon (for Autopilot Mission)
const autopilotIcons = screen.queryAllByTitle("Autopilot enabled");
expect(autopilotIcons).toHaveLength(1);
});
it("shows autopilot toggle and status badge in detail view", async () => {
globalThis.fetch = createAutopilotFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
// Navigate to detail
await waitFor(() => {
expect(screen.getByText("Autopilot Mission")).toBeDefined();
});
fireEvent.click(screen.getByText("Autopilot Mission"));
await waitFor(() => {
// Should show Autopilot label
expect(screen.getByText("Autopilot")).toBeDefined();
// Should show status badge with "watching" state
expect(screen.getByTestId("autopilot-state-badge")).toBeDefined();
});
});
it("shows pulsing dot when autopilot is watching in detail view", async () => {
globalThis.fetch = createAutopilotFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
// Navigate to detail
await waitFor(() => {
expect(screen.getByText("Autopilot Mission")).toBeDefined();
});
fireEvent.click(screen.getByText("Autopilot Mission"));
await waitFor(() => {
const dot = document.querySelector(".mission-detail__autopilot-dot");
expect(dot).toBeDefined();
});
});
});
});

View File

@@ -5,6 +5,18 @@ export type MilestoneStatus = "planning" | "active" | "blocked" | "complete";
export type SliceStatus = "pending" | "active" | "complete";
export type FeatureStatus = "defined" | "triaged" | "in-progress" | "done";
/** Autopilot state values for mission autonomous progression */
export type AutopilotState = "inactive" | "watching" | "activating" | "completing";
/** Autopilot status returned by API */
export interface AutopilotStatus {
enabled: boolean;
state: AutopilotState;
watched: boolean;
lastActivityAt?: string;
nextScheduledCheck?: string;
}
export interface Mission {
id: string;
title: string;
@@ -12,6 +24,9 @@ export interface Mission {
status: MissionStatus;
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
autoAdvance?: boolean;
autopilotEnabled?: boolean;
autopilotState?: AutopilotState;
lastAutopilotActivityAt?: string;
createdAt: string;
updatedAt: string;
}

View File

@@ -18636,6 +18636,91 @@ html .column.drag-over * {
padding-top: var(--space-xs);
}
/* ── Autopilot section ── */
.mission-detail__autopilot {
display: flex;
align-items: center;
gap: var(--space-md);
flex-wrap: wrap;
padding: var(--space-xs) 0;
}
.mission-detail__autopilot-toggle {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.mission-detail__autopilot-icon {
color: var(--color-warning, #eab308);
}
.mission-detail__autopilot-activity {
font-size: 11px;
color: var(--text-muted);
}
.mission-detail__autopilot-actions {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.mission-detail__autopilot-pulse {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: #22c55e;
margin-right: 4px;
animation: autopilot-pulse 2s ease-in-out infinite;
}
.mission-detail__autopilot-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #22c55e;
flex-shrink: 0;
animation: autopilot-pulse 2s ease-in-out infinite;
}
.mission-detail__title-text {
display: flex;
align-items: center;
gap: 6px;
}
.mission-checkbox--autopilot {
font-weight: 500;
}
.mission-btn--sm {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 11px;
min-height: 0;
}
.mission-list__item-autopilot-icon {
color: #eab308;
flex-shrink: 0;
}
@keyframes autopilot-pulse {
0%, 100% {
opacity: 1;
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4);
}
50% {
opacity: 0.7;
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0);
}
}
/* ── Milestones ── */
.mission-detail__milestones {
display: flex;

View File

@@ -141,7 +141,18 @@ function asyncHandler(fn: (req: TypedRequest, res: Response, next: NextFunction)
// ── Router Factory ──────────────────────────────────────────────────────────
export function createMissionRouter(store: TaskStore): Router {
export function createMissionRouter(
store: TaskStore,
missionAutopilot?: {
watchMission(missionId: string): void;
unwatchMission(missionId: string): void;
isWatching(missionId: string): boolean;
getAutopilotStatus(missionId: string): import("@fusion/core").AutopilotStatus;
checkAndStartMission(missionId: string): Promise<void>;
start(): void;
stop(): void;
},
): Router {
const router = Router();
const requestContext = new AsyncLocalStorage<ReturnType<TaskStore["getMissionStore"]>>();
@@ -209,7 +220,7 @@ export function createMissionRouter(store: TaskStore): Router {
router.post(
"/",
asyncHandler(async (req, res) => {
const { title, description, autoAdvance } = req.body;
const { title, description, autoAdvance, autopilotEnabled } = req.body;
const validatedTitle = validateTitle(title);
const validatedDescription = validateDescription(description);
@@ -221,10 +232,16 @@ export function createMissionRouter(store: TaskStore): Router {
const mission = missionStore.createMission(input);
const updates: Partial<Mission> = {};
if (autoAdvance !== undefined) {
const updatedMission = missionStore.updateMission(mission.id, {
autoAdvance: validateBoolean(autoAdvance, "autoAdvance"),
});
updates.autoAdvance = validateBoolean(autoAdvance, "autoAdvance");
}
if (autopilotEnabled !== undefined) {
updates.autopilotEnabled = validateBoolean(autopilotEnabled, "autopilotEnabled");
}
if (Object.keys(updates).length > 0) {
const updatedMission = missionStore.updateMission(mission.id, updates);
res.status(201).json(updatedMission);
return;
}
@@ -265,7 +282,7 @@ export function createMissionRouter(store: TaskStore): Router {
"/:missionId",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
const { title, description, status, autoAdvance } = req.body;
const { title, description, status, autoAdvance, autopilotEnabled } = req.body;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
@@ -286,6 +303,9 @@ export function createMissionRouter(store: TaskStore): Router {
if (autoAdvance !== undefined) {
updates.autoAdvance = validateBoolean(autoAdvance, "autoAdvance");
}
if (autopilotEnabled !== undefined) {
updates.autopilotEnabled = validateBoolean(autopilotEnabled, "autopilotEnabled");
}
if (Object.keys(updates).length === 0) {
res.status(400).json({ error: "No valid fields to update" });
@@ -1312,6 +1332,180 @@ export function createMissionRouter(store: TaskStore): Router {
})
);
// ── Autopilot Endpoints ──────────────────────────────────────────────────────
/**
* GET /api/missions/:missionId/autopilot
* Get the current autopilot status for a mission.
* Returns { enabled, state, watched, lastActivityAt }
*/
router.get(
"/:missionId/autopilot",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
return;
}
const mission = missionStore.getMission(missionId);
if (!mission) {
res.status(404).json({ error: "Mission not found" });
return;
}
if (missionAutopilot) {
const status = missionAutopilot.getAutopilotStatus(missionId);
res.json(status);
} else {
// No autopilot instance — return status from mission data
res.json({
enabled: mission.autopilotEnabled ?? false,
state: mission.autopilotState ?? "inactive",
watched: false,
lastActivityAt: mission.lastAutopilotActivityAt,
});
}
})
);
/**
* PATCH /api/missions/:missionId/autopilot
* Enable or disable autopilot for a mission.
* Body: { enabled?: boolean }
* When enabling: starts watching if autopilot is available.
* When disabling: stops watching if autopilot is available.
*/
router.patch(
"/:missionId/autopilot",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
const { enabled } = req.body;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
return;
}
if (enabled === undefined || typeof enabled !== "boolean") {
res.status(400).json({ error: "enabled is required and must be a boolean" });
return;
}
const mission = missionStore.getMission(missionId);
if (!mission) {
res.status(404).json({ error: "Mission not found" });
return;
}
// Update the mission's autopilotEnabled field
missionStore.updateMission(missionId, { autopilotEnabled: enabled });
if (missionAutopilot) {
if (enabled) {
// Enable: start watching and potentially start the mission
missionAutopilot.watchMission(missionId);
if (mission.status === "planning") {
await missionAutopilot.checkAndStartMission(missionId);
}
} else {
// Disable: stop watching
missionAutopilot.unwatchMission(missionId);
}
const status = missionAutopilot.getAutopilotStatus(missionId);
res.json(status);
} else {
// No autopilot instance — return updated status from mission data
const updated = missionStore.getMission(missionId);
res.json({
enabled: updated?.autopilotEnabled ?? false,
state: updated?.autopilotState ?? "inactive",
watched: false,
lastActivityAt: updated?.lastAutopilotActivityAt,
});
}
})
);
/**
* POST /api/missions/:missionId/autopilot/start
* Manually start autopilot watching for a mission.
*/
router.post(
"/:missionId/autopilot/start",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
return;
}
const mission = missionStore.getMission(missionId);
if (!mission) {
res.status(404).json({ error: "Mission not found" });
return;
}
if (!mission.autopilotEnabled) {
res.status(400).json({ error: "Autopilot is not enabled for this mission" });
return;
}
if (!missionAutopilot) {
res.status(503).json({ error: "Autopilot service is not available" });
return;
}
missionAutopilot.watchMission(missionId);
// If mission is in planning, start it
if (mission.status === "planning") {
await missionAutopilot.checkAndStartMission(missionId);
}
const status = missionAutopilot.getAutopilotStatus(missionId);
res.json(status);
})
);
/**
* POST /api/missions/:missionId/autopilot/stop
* Manually stop autopilot watching for a mission.
*/
router.post(
"/:missionId/autopilot/stop",
asyncHandler(async (req, res) => {
const { missionId } = req.params;
if (!validateMissionId(missionId)) {
res.status(400).json({ error: "Invalid mission ID format" });
return;
}
const mission = missionStore.getMission(missionId);
if (!mission) {
res.status(404).json({ error: "Mission not found" });
return;
}
if (missionAutopilot) {
missionAutopilot.unwatchMission(missionId);
const status = missionAutopilot.getAutopilotStatus(missionId);
res.json(status);
} else {
res.json({
enabled: mission.autopilotEnabled ?? false,
state: "inactive",
watched: false,
lastActivityAt: mission.lastAutopilotActivityAt,
});
}
})
);
// ── Interview Endpoints ─────────────────────────────────────────────────────
// Note: These are mounted at /api/missions/interview/* via the router

View File

@@ -7096,7 +7096,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
// ── Mission Routes ─────────────────────────────────────────────────────────
// Mount mission routes at /api/missions
router.use("/missions", createMissionRouter(store));
router.use("/missions", createMissionRouter(store, options?.missionAutopilot));
// ── AI Session Routes (Background Tasks) ─────────────────────────────────

View File

@@ -40,6 +40,16 @@ export interface ServerOptions {
automationStore?: AutomationStore;
/** Optional AiSessionStore — if not provided, one is created from the default store's database */
aiSessionStore?: AiSessionStore;
/** Optional MissionAutopilot for autonomous mission progression */
missionAutopilot?: {
watchMission(missionId: string): void;
unwatchMission(missionId: string): void;
isWatching(missionId: string): boolean;
getAutopilotStatus(missionId: string): import("@fusion/core").AutopilotStatus;
checkAndStartMission(missionId: string): Promise<void>;
start(): void;
stop(): void;
};
}
type DashboardExpressApp = ReturnType<typeof express> & {

View File

@@ -2874,7 +2874,8 @@ When all steps are complete: call \`task_done()\`
If a build command is configured, run that exact command in this worktree before calling \`task_done()\`.
Treat a non-zero exit code as a blocking failure. Do not claim success without a real passing run.
Run the configured/full test suite and fix failures even when that requires edits outside the original File Scope.
If the repo has a typecheck command, run it before \`task_done()\` and fix any failures it reports.`;
If the repo has a typecheck command, run it before \`task_done()\` and fix any failures it reports.
Use \`task_create\` for truly separate follow-up work, not for fixes required to get tests, build, or typecheck back to green.`;
}
/**

View File

@@ -3,6 +3,7 @@ export { AgentSemaphore, PRIORITY_MERGE, PRIORITY_EXECUTE, PRIORITY_SPECIFY } fr
export { TriageProcessor, type TriageProcessorOptions } from "./triage.js";
export { TaskExecutor, type TaskExecutorOptions } from "./executor.js";
export { Scheduler, type SchedulerOptions } from "./scheduler.js";
export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopilot.js";
export { aiMergeTask, type MergerOptions } from "./merger.js";
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
export { createKbAgent, type AgentOptions, type AgentResult } from "./pi.js";

View File

@@ -76,3 +76,6 @@ export const projectManagerLog = createLogger("project-manager");
/** Logger for the hybrid executor subsystem. */
export const hybridExecutorLog = createLogger("hybrid-executor");
/** Logger for the mission autopilot subsystem. */
export const autopilotLog = createLogger("autopilot");

View File

@@ -0,0 +1,544 @@
/**
* MissionAutopilot unit tests.
*
* Tests the autopilot monitoring class with mocked TaskStore and MissionStore.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { MissionAutopilot } from "./mission-autopilot.js";
import type { Mission, Milestone, Slice, MissionFeature } from "@fusion/core";
// ── Mock Factories ──────────────────────────────────────────────────
function createMockMission(overrides: Partial<Mission> = {}): Mission {
return {
id: "M-TEST1",
title: "Test Mission",
status: "active",
interviewState: "not_started",
autoAdvance: true,
autopilotEnabled: true,
autopilotState: "inactive",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function createMockMilestone(overrides: Partial<Milestone> = {}): Milestone {
return {
id: "MS-001",
missionId: "M-TEST1",
title: "Test Milestone",
status: "active",
orderIndex: 0,
interviewState: "not_started",
dependencies: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function createMockSlice(overrides: Partial<Slice> = {}): Slice {
return {
id: "SL-001",
milestoneId: "MS-001",
title: "Test Slice",
status: "pending",
orderIndex: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function createMockFeature(overrides: Partial<MissionFeature> = {}): MissionFeature {
return {
id: "F-001",
sliceId: "SL-001",
title: "Test Feature",
status: "defined",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function createMockMissionStore(missions: Mission[] = []) {
const missionMap = new Map(missions.map((m) => [m.id, m]));
return {
getMission: vi.fn((id: string) => missionMap.get(id)),
listMissions: vi.fn(() => [...missionMap.values()]),
updateMission: vi.fn((id: string, updates: Partial<Mission>) => {
const existing = missionMap.get(id);
if (!existing) throw new Error(`Mission ${id} not found`);
const updated = { ...existing, ...updates, updatedAt: new Date().toISOString() };
missionMap.set(id, updated);
return updated;
}),
getMilestone: vi.fn(),
listMilestones: vi.fn(),
getSlice: vi.fn(),
listSlices: vi.fn(),
getFeatureByTaskId: vi.fn(),
listFeatures: vi.fn(),
getMissionWithHierarchy: vi.fn(),
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
};
}
function createMockTaskStore() {
return {
on: vi.fn(),
off: vi.fn(),
};
}
function createMockScheduler() {
return {
activateNextPendingSlice: vi.fn().mockResolvedValue(null),
};
}
// ── Tests ───────────────────────────────────────────────────────────
describe("MissionAutopilot", () => {
let autopilot: MissionAutopilot;
let missionStore: ReturnType<typeof createMockMissionStore>;
let taskStore: ReturnType<typeof createMockTaskStore>;
let scheduler: ReturnType<typeof createMockScheduler>;
beforeEach(() => {
vi.useFakeTimers();
const mission = createMockMission();
missionStore = createMockMissionStore([mission]);
taskStore = createMockTaskStore();
scheduler = createMockScheduler();
autopilot = new MissionAutopilot(
taskStore as any,
missionStore as any,
{ scheduler },
);
});
afterEach(() => {
autopilot.stop();
vi.useRealTimers();
});
// ── Lifecycle ────────────────────────────────────────────────────
describe("start/stop", () => {
it("should start and be running", () => {
autopilot.start();
// No error means success
});
it("should be idempotent on start", () => {
autopilot.start();
autopilot.start();
// Should not throw
});
it("should stop cleanly", () => {
autopilot.start();
autopilot.stop();
// Should not throw
});
it("should be idempotent on stop", () => {
autopilot.stop();
// Should not throw
});
});
// ── Watching ─────────────────────────────────────────────────────
describe("watchMission", () => {
it("should watch a mission with autopilot enabled", () => {
autopilot.watchMission("M-TEST1");
expect(autopilot.isWatching("M-TEST1")).toBe(true);
expect(missionStore.updateMission).toHaveBeenCalledWith(
"M-TEST1",
expect.objectContaining({ autopilotState: "watching" }),
);
});
it("should not watch a mission without autopilot enabled", () => {
const mission = createMockMission({ autopilotEnabled: false });
const store = createMockMissionStore([mission]);
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
ap.watchMission("M-TEST1");
expect(ap.isWatching("M-TEST1")).toBe(false);
});
it("should not watch a non-existent mission", () => {
autopilot.watchMission("M-NONEXISTENT");
expect(autopilot.isWatching("M-NONEXISTENT")).toBe(false);
});
it("should be idempotent — watching same mission twice", () => {
autopilot.watchMission("M-TEST1");
autopilot.watchMission("M-TEST1");
expect(autopilot.getWatchedMissionIds()).toEqual(["M-TEST1"]);
});
});
describe("unwatchMission", () => {
it("should unwatch a mission", () => {
autopilot.watchMission("M-TEST1");
autopilot.unwatchMission("M-TEST1");
expect(autopilot.isWatching("M-TEST1")).toBe(false);
expect(missionStore.updateMission).toHaveBeenCalledWith(
"M-TEST1",
expect.objectContaining({ autopilotState: "inactive" }),
);
});
it("should be a no-op for non-watched mission", () => {
autopilot.unwatchMission("M-OTHER");
// No updateMission call for state change
expect(missionStore.updateMission).not.toHaveBeenCalledWith(
"M-OTHER",
expect.anything(),
);
});
});
describe("getWatchedMissionIds", () => {
it("should return empty array when nothing is watched", () => {
expect(autopilot.getWatchedMissionIds()).toEqual([]);
});
it("should return all watched mission IDs", () => {
const m2 = createMockMission({ id: "M-TEST2", autopilotEnabled: true });
const store = createMockMissionStore([
createMockMission(),
m2,
]);
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
ap.watchMission("M-TEST1");
ap.watchMission("M-TEST2");
expect(ap.getWatchedMissionIds()).toEqual(["M-TEST1", "M-TEST2"]);
});
});
describe("getAutopilotStatus", () => {
it("should return status for a watched mission", () => {
autopilot.watchMission("M-TEST1");
const status = autopilot.getAutopilotStatus("M-TEST1");
expect(status).toEqual({
enabled: true,
state: "watching",
watched: true,
lastActivityAt: undefined,
});
});
it("should return status for a non-watched mission", () => {
const status = autopilot.getAutopilotStatus("M-NONEXISTENT");
expect(status).toEqual({
enabled: false,
state: "inactive",
watched: false,
lastActivityAt: undefined,
});
});
});
// ── Task Completion ──────────────────────────────────────────────
describe("handleTaskCompletion", () => {
it("should do nothing if task has no linked feature", async () => {
missionStore.getFeatureByTaskId.mockReturnValue(undefined);
await autopilot.handleTaskCompletion("FN-001");
// Should not attempt to advance
expect(scheduler.activateNextPendingSlice).not.toHaveBeenCalled();
});
it("should do nothing if mission is not being watched", async () => {
const feature = createMockFeature({ taskId: "FN-001", status: "done" });
const slice = createMockSlice({ id: "SL-001" });
const milestone = createMockMilestone();
missionStore.getFeatureByTaskId.mockReturnValue(feature);
missionStore.getSlice.mockReturnValue(slice);
missionStore.getMilestone.mockReturnValue(milestone);
// Not watching this mission
await autopilot.handleTaskCompletion("FN-001");
expect(scheduler.activateNextPendingSlice).not.toHaveBeenCalled();
});
it("should advance to next slice when all features are done", async () => {
const feature = createMockFeature({ taskId: "FN-001", status: "done" });
const slice = createMockSlice({ id: "SL-001" });
const milestone = createMockMilestone();
missionStore.getFeatureByTaskId.mockReturnValue(feature);
missionStore.getSlice.mockReturnValue(slice);
missionStore.getMilestone.mockReturnValue(milestone);
missionStore.listFeatures.mockReturnValue([feature]);
// Return an activated slice so advanceToNextSlice succeeds
const activatedSlice = createMockSlice({ id: "SL-002", status: "active" });
scheduler.activateNextPendingSlice.mockResolvedValue(activatedSlice);
// Watch the mission first
autopilot.watchMission("M-TEST1");
await autopilot.handleTaskCompletion("FN-001");
expect(scheduler.activateNextPendingSlice).toHaveBeenCalledWith("M-TEST1");
});
it("should not advance when not all features are done", async () => {
const feature1 = createMockFeature({ id: "F-001", taskId: "FN-001", status: "done" });
const feature2 = createMockFeature({ id: "F-002", status: "in-progress" });
const slice = createMockSlice({ id: "SL-001" });
const milestone = createMockMilestone();
missionStore.getFeatureByTaskId.mockReturnValue(feature1);
missionStore.getSlice.mockReturnValue(slice);
missionStore.getMilestone.mockReturnValue(milestone);
missionStore.listFeatures.mockReturnValue([feature1, feature2]);
autopilot.watchMission("M-TEST1");
await autopilot.handleTaskCompletion("FN-001");
expect(scheduler.activateNextPendingSlice).not.toHaveBeenCalled();
});
it("should handle errors gracefully", async () => {
missionStore.getFeatureByTaskId.mockImplementation(() => {
throw new Error("DB error");
});
// Should not throw
await autopilot.handleTaskCompletion("FN-001");
});
});
// ── Advance to Next Slice ────────────────────────────────────────
describe("advanceToNextSlice", () => {
it("should update state to activating then watching", async () => {
const activatedSlice = createMockSlice({ id: "SL-002", status: "active" });
scheduler.activateNextPendingSlice.mockResolvedValue(activatedSlice);
autopilot.watchMission("M-TEST1");
await autopilot.advanceToNextSlice("M-TEST1");
// Should have been called with activating then watching
const calls = missionStore.updateMission.mock.calls.filter(
(call: any[]) => call[1]?.autopilotState !== undefined,
);
const states = calls.map((call: any[]) => call[1].autopilotState);
expect(states).toContain("activating");
expect(states).toContain("watching");
});
it("should update lastAutopilotActivityAt on success", async () => {
const activatedSlice = createMockSlice({ id: "SL-002", status: "active" });
scheduler.activateNextPendingSlice.mockResolvedValue(activatedSlice);
autopilot.watchMission("M-TEST1");
await autopilot.advanceToNextSlice("M-TEST1");
expect(missionStore.updateMission).toHaveBeenCalledWith(
"M-TEST1",
expect.objectContaining({ lastAutopilotActivityAt: expect.any(String) }),
);
});
it("should do nothing if mission is not being watched", async () => {
await autopilot.advanceToNextSlice("M-TEST1");
expect(scheduler.activateNextPendingSlice).not.toHaveBeenCalled();
});
});
// ── Check and Start Mission ──────────────────────────────────────
describe("checkAndStartMission", () => {
it("should transition planning mission to active", async () => {
const mission = createMockMission({ status: "planning" });
const store = createMockMissionStore([mission]);
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
const activatedSlice = createMockSlice({ id: "SL-001", status: "active" });
scheduler.activateNextPendingSlice.mockResolvedValue(activatedSlice);
await ap.checkAndStartMission("M-TEST1");
expect(store.updateMission).toHaveBeenCalledWith(
"M-TEST1",
expect.objectContaining({ status: "active" }),
);
});
it("should not transition active mission", async () => {
// Mission is already active
await autopilot.checkAndStartMission("M-TEST1");
// Should not change status
const statusCalls = missionStore.updateMission.mock.calls.filter(
(call: any[]) => call[1]?.status !== undefined,
);
expect(statusCalls.length).toBe(0);
});
it("should not transition mission without autopilot enabled", async () => {
const mission = createMockMission({ status: "planning", autopilotEnabled: false });
const store = createMockMissionStore([mission]);
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
await ap.checkAndStartMission("M-TEST1");
// No status update should happen
expect(scheduler.activateNextPendingSlice).not.toHaveBeenCalled();
});
});
// ── Check Mission Completion ─────────────────────────────────────
describe("checkMissionCompletion", () => {
it("should detect when all milestones are complete", async () => {
const m1 = createMockMilestone({ status: "complete" });
missionStore.listMilestones.mockReturnValue([m1]);
autopilot.watchMission("M-TEST1");
const result = await autopilot.checkMissionCompletion("M-TEST1");
expect(result).toBe(true);
expect(missionStore.updateMission).toHaveBeenCalledWith(
"M-TEST1",
expect.objectContaining({ status: "complete" }),
);
expect(autopilot.isWatching("M-TEST1")).toBe(false);
});
it("should return false when milestones are not all complete", async () => {
const m1 = createMockMilestone({ status: "active" });
missionStore.listMilestones.mockReturnValue([m1]);
const result = await autopilot.checkMissionCompletion("M-TEST1");
expect(result).toBe(false);
});
it("should return false when there are no milestones", async () => {
missionStore.listMilestones.mockReturnValue([]);
const result = await autopilot.checkMissionCompletion("M-TEST1");
expect(result).toBe(false);
});
it("should return false for non-existent mission", async () => {
const result = await autopilot.checkMissionCompletion("M-NONEXISTENT");
expect(result).toBe(false);
});
});
// ── Stop cleanup ─────────────────────────────────────────────────
describe("stop cleanup", () => {
it("should unwatch all missions on stop", () => {
const m2 = createMockMission({ id: "M-TEST2", autopilotEnabled: true });
const store = createMockMissionStore([
createMockMission(),
m2,
]);
const ap = new MissionAutopilot(taskStore as any, store as any, { scheduler });
ap.start();
ap.watchMission("M-TEST1");
ap.watchMission("M-TEST2");
expect(ap.getWatchedMissionIds()).toHaveLength(2);
ap.stop();
expect(ap.getWatchedMissionIds()).toHaveLength(0);
});
});
// ── setScheduler ─────────────────────────────────────────────────
describe("setScheduler", () => {
it("should allow setting scheduler after construction", async () => {
// Create autopilot without scheduler
const ap = new MissionAutopilot(taskStore as any, missionStore as any);
ap.start();
ap.watchMission("M-TEST1");
// advanceToNextSlice should be a no-op without scheduler
await ap.advanceToNextSlice("M-TEST1");
const newScheduler = createMockScheduler();
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" }),
);
await ap.advanceToNextSlice("M-TEST1");
expect(newScheduler.activateNextPendingSlice).toHaveBeenCalledWith("M-TEST1");
ap.stop();
});
});
// ── autoAdvance guard ────────────────────────────────────────────
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 () => {
autopilot.watchMission("M-TEST1");
const activatedSlice = createMockSlice({ id: "SL-002", status: "active" });
scheduler.activateNextPendingSlice.mockResolvedValue(activatedSlice);
await autopilot.advanceToNextSlice("M-TEST1");
expect(scheduler.activateNextPendingSlice).toHaveBeenCalledWith("M-TEST1");
});
});
});

View File

@@ -0,0 +1,436 @@
/**
* MissionAutopilot — Background monitoring for autonomous mission progression.
*
* Watches missions with `autopilotEnabled: true` and automatically:
* - Activates slices when previous ones complete
* - Tracks overall mission health and state
* - Detects and recovers from failures
*
* **Integration pattern:** The Scheduler handles low-level task scheduling
* and calls `missionAutopilot.handleTaskCompletion()` after updating feature
* status. MissionAutopilot does NOT register its own event listeners.
*
* **State machine:**
* - `inactive` → `watching`: User enables autopilot
* - `watching` → `activating`: Task completes, autopilot progresses
* - `activating` → `watching`: Slice activated successfully
* - `watching/activating` → `inactive`: User disables or engine stops
* - `activating` → `completing`: All slices done, mission wrapping up
* - `completing` → `inactive`: Mission complete
*/
import type { TaskStore, MissionStore, Mission, AutopilotState, AutopilotStatus, Slice } from "@fusion/core";
import { autopilotLog } from "./logger.js";
/** Maximum retry attempts for slice activation failures. */
const MAX_RETRY_ATTEMPTS = 3;
/** Base delay for exponential backoff between retries (ms). */
const RETRY_BASE_DELAY_MS = 1000;
/** Background poll interval for checking mission health (ms). */
const POLL_INTERVAL_MS = 60_000;
/** Time after which a mission is considered stale (5 minutes). */
const STALE_THRESHOLD_MS = 5 * 60 * 1000;
/** Per-mission tracking state. */
interface WatchedMissionState {
missionId: string;
retryCount: number;
}
export interface MissionAutopilotOptions {
/** Optional Scheduler instance for slice activation. Can also be set via setScheduler(). */
scheduler?: {
activateNextPendingSlice(missionId: string): Promise<Slice | null>;
};
}
/**
* MissionAutopilot monitors missions with `autopilotEnabled: true` and
* autonomously progresses through slices as tasks complete.
*
* It does NOT register event listeners on TaskStore or MissionStore.
* Instead, the Scheduler calls `handleTaskCompletion()` after performing
* its own feature status updates. This avoids duplicate event handling.
*/
export class MissionAutopilot {
private watchedMissions = new Map<string, WatchedMissionState>();
private running = false;
private pollTimer: ReturnType<typeof setInterval> | null = null;
private scheduler: MissionAutopilotOptions["scheduler"];
constructor(
private taskStore: TaskStore,
private missionStore: MissionStore,
options: MissionAutopilotOptions = {},
) {
this.scheduler = options.scheduler;
}
/**
* Set the scheduler instance after construction.
* Used to break circular dependency: Scheduler is constructed with
* MissionAutopilot, then calls setScheduler(this) after both are created.
*/
setScheduler(scheduler: MissionAutopilotOptions["scheduler"]): void {
this.scheduler = scheduler;
}
// ── Lifecycle ──────────────────────────────────────────────────────
/**
* Start the autopilot background service.
* Begins periodic polling for mission health checks.
*/
start(): void {
if (this.running) return;
this.running = true;
this.pollTimer = setInterval(() => this.poll(), POLL_INTERVAL_MS);
autopilotLog.log("Started");
}
/**
* Stop the autopilot background service.
* Unwatches all missions and clears state.
*/
stop(): void {
if (!this.running) return;
this.running = false;
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
// Unwatch all missions
for (const [missionId] of this.watchedMissions) {
try {
this.setAutopilotState(missionId, "inactive");
} catch {
// Best effort — mission may have been deleted
}
}
this.watchedMissions.clear();
autopilotLog.log("Stopped");
}
// ── Mission Watching ───────────────────────────────────────────────
/**
* Start watching a mission.
* Sets `autopilotState` to `watching` and adds to watched set.
*
* @param missionId - Mission ID to watch
*/
watchMission(missionId: string): void {
if (this.watchedMissions.has(missionId)) {
autopilotLog.log(`Already watching mission ${missionId}`);
return;
}
const mission = this.missionStore.getMission(missionId);
if (!mission) {
autopilotLog.warn(`Mission ${missionId} not found — cannot watch`);
return;
}
if (!mission.autopilotEnabled) {
autopilotLog.warn(`Mission ${missionId} does not have autopilot enabled — skipping`);
return;
}
this.watchedMissions.set(missionId, { missionId, retryCount: 0 });
this.setAutopilotState(missionId, "watching");
autopilotLog.log(`Watching mission ${missionId} (${mission.title})`);
}
/**
* Stop watching a mission.
* Sets `autopilotState` to `inactive` and removes from watched set.
*
* @param missionId - Mission ID to unwatch
*/
unwatchMission(missionId: string): void {
if (!this.watchedMissions.has(missionId)) {
return;
}
this.watchedMissions.delete(missionId);
try {
this.setAutopilotState(missionId, "inactive");
} catch {
// Mission may have been deleted
}
autopilotLog.log(`Unwatched mission ${missionId}`);
}
/**
* Check if a mission is currently being watched.
*/
isWatching(missionId: string): boolean {
return this.watchedMissions.has(missionId);
}
/**
* Get all currently watched mission IDs.
*/
getWatchedMissionIds(): string[] {
return [...this.watchedMissions.keys()];
}
/**
* Get the current autopilot status for a mission.
*/
getAutopilotStatus(missionId: string): AutopilotStatus {
const mission = this.missionStore.getMission(missionId);
const watched = this.watchedMissions.has(missionId);
return {
enabled: mission?.autopilotEnabled ?? false,
state: mission?.autopilotState ?? "inactive",
watched,
lastActivityAt: mission?.lastAutopilotActivityAt,
};
}
// ── Progression Logic ──────────────────────────────────────────────
/**
* Called by the Scheduler after a task with a sliceId completes.
*
* 1. Finds the feature linked to the task
* 2. Checks if the slice is now complete (all features done)
* 3. If so, advances to the next slice
*
* @param taskId - The completed task ID
*/
async handleTaskCompletion(taskId: string): Promise<void> {
try {
const feature = this.missionStore.getFeatureByTaskId(taskId);
if (!feature) {
// Task is not linked to any feature — not a mission task
return;
}
const slice = this.missionStore.getSlice(feature.sliceId);
if (!slice) {
autopilotLog.warn(`Slice ${feature.sliceId} not found for feature ${feature.id}`);
return;
}
// Resolve mission ID for this slice
const milestone = this.missionStore.getMilestone(slice.milestoneId);
if (!milestone) return;
const missionId = milestone.missionId;
// Only proceed if we're watching this mission
if (!this.isWatching(missionId)) return;
// Check if all features in the slice are done
const features = this.missionStore.listFeatures(slice.id);
const allDone = features.length > 0 && features.every((f) => f.status === "done");
if (allDone) {
autopilotLog.log(`Slice ${slice.id} is complete — advancing mission ${missionId}`);
await this.advanceToNextSlice(missionId);
}
} catch (err) {
autopilotLog.error(`Error handling task completion for ${taskId}:`, err);
}
}
/**
* Activate the next pending slice in a mission.
* Uses the scheduler's `activateNextPendingSlice()` method.
*
* @param missionId - Mission ID to advance
*/
async advanceToNextSlice(missionId: string): Promise<void> {
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");
if (this.scheduler) {
const activated = await this.scheduler.activateNextPendingSlice(missionId);
if (activated) {
autopilotLog.log(`Activated slice ${activated.id} for mission ${missionId}`);
this.updateActivity(missionId);
// Reset retry count on success
state.retryCount = 0;
} else {
// No pending slice — check for mission completion
const complete = await this.checkMissionCompletion(missionId);
if (complete) {
return; // already transitions state
}
}
}
this.setAutopilotState(missionId, "watching");
} catch (err) {
autopilotLog.error(`Error advancing slice for mission ${missionId}:`, err);
// Retry with exponential backoff
state.retryCount++;
if (state.retryCount <= MAX_RETRY_ATTEMPTS) {
const delay = RETRY_BASE_DELAY_MS * Math.pow(3, state.retryCount - 1);
autopilotLog.log(`Retrying slice activation for mission ${missionId} (attempt ${state.retryCount}/${MAX_RETRY_ATTEMPTS}, delay ${delay}ms)`);
setTimeout(() => {
if (this.isWatching(missionId)) {
void this.advanceToNextSlice(missionId);
}
}, delay);
} else {
autopilotLog.error(`Max retries exceeded for mission ${missionId} — pausing autopilot`);
this.setAutopilotState(missionId, "watching");
state.retryCount = 0;
}
}
}
/**
* Check if a mission is in planning and should be started.
* If mission is `planning` and `autopilotEnabled: true`, transitions to `active`
* and activates the first pending slice.
*
* @param missionId - Mission ID to check and start
*/
async checkAndStartMission(missionId: string): Promise<void> {
const mission = this.missionStore.getMission(missionId);
if (!mission) return;
if (mission.status === "planning" && mission.autopilotEnabled) {
autopilotLog.log(`Starting mission ${missionId} (transitioning from planning to active)`);
this.missionStore.updateMission(missionId, { status: "active" });
this.updateActivity(missionId);
// Activate first pending slice
if (this.scheduler) {
const activated = await this.scheduler.activateNextPendingSlice(missionId);
if (activated) {
autopilotLog.log(`Activated first slice ${activated.id} for mission ${missionId}`);
}
}
}
}
/**
* Check if all milestones in a mission are complete.
* If so, set the mission to complete and return true.
*
* @param missionId - Mission ID to check
* @returns true if mission is complete, false otherwise
*/
async checkMissionCompletion(missionId: string): Promise<boolean> {
const mission = this.missionStore.getMission(missionId);
if (!mission) return false;
const milestones = this.missionStore.listMilestones(missionId);
if (milestones.length === 0) return false;
const allComplete = milestones.every((m) => m.status === "complete");
if (allComplete) {
autopilotLog.log(`Mission ${missionId} is complete!`);
this.setAutopilotState(missionId, "completing");
this.missionStore.updateMission(missionId, { status: "complete" });
this.updateActivity(missionId);
this.setAutopilotState(missionId, "inactive");
this.watchedMissions.delete(missionId);
return true;
}
return false;
}
// ── Background Poll ────────────────────────────────────────────────
/**
* Periodic health check for watched missions.
* - Re-watches missions with `autopilotEnabled: true` that aren't being tracked
* - Starts missions in `planning` with autopilot enabled
* - Flags stale missions
*/
private poll(): void {
if (!this.running) return;
try {
const missions = this.missionStore.listMissions();
for (const mission of missions) {
// Auto-watch missions with autopilot enabled that aren't being watched
if (mission.autopilotEnabled && !this.isWatching(mission.id) && mission.status !== "complete" && mission.status !== "archived") {
autopilotLog.log(`Poll: auto-watching mission ${mission.id}`);
this.watchMission(mission.id);
}
// Start planning missions with autopilot
if (mission.autopilotEnabled && mission.status === "planning" && this.isWatching(mission.id)) {
void this.checkAndStartMission(mission.id);
}
}
// Check for stale missions
const now = Date.now();
for (const [missionId, state] of this.watchedMissions) {
const mission = this.missionStore.getMission(missionId);
if (!mission) {
// Mission deleted — unwatch
this.watchedMissions.delete(missionId);
continue;
}
if (mission.lastAutopilotActivityAt) {
const lastActivity = new Date(mission.lastAutopilotActivityAt).getTime();
if (now - lastActivity > STALE_THRESHOLD_MS) {
autopilotLog.warn(`Mission ${missionId} is stale (no activity for ${Math.round((now - lastActivity) / 60_000)} minutes)`);
}
}
}
} catch (err) {
autopilotLog.error("Error during autopilot poll:", err);
}
}
// ── Helpers ────────────────────────────────────────────────────────
/**
* Update the `autopilotState` on a mission in the store.
*/
private setAutopilotState(missionId: string, state: AutopilotState): void {
try {
const mission = this.missionStore.getMission(missionId);
if (mission && mission.autopilotState !== state) {
this.missionStore.updateMission(missionId, { autopilotState: state });
}
} catch (err) {
autopilotLog.error(`Error setting autopilot state for mission ${missionId}:`, err);
}
}
/**
* Update the `lastAutopilotActivityAt` timestamp on a mission.
*/
private updateActivity(missionId: string): void {
try {
this.missionStore.updateMission(missionId, {
lastAutopilotActivityAt: new Date().toISOString(),
});
} catch (err) {
autopilotLog.error(`Error updating activity for mission ${missionId}:`, err);
}
}
}

View File

@@ -1567,4 +1567,186 @@ describe("Scheduler", () => {
expect(store.moveTask).toHaveBeenCalledWith("FN-012", "in-progress");
});
});
describe("autopilot integration", () => {
it("watches missions with autopilotEnabled on start", () => {
const store = createMockStore();
const mockMissionStore = createMockMissionStore({
listMissions: vi.fn().mockReturnValue([
{ id: "M-001", autopilotEnabled: true, status: "active" },
{ id: "M-002", autopilotEnabled: false, status: "active" },
{ id: "M-003", autopilotEnabled: true, status: "complete" },
]),
getMission: vi.fn((id: string) => {
const missions: Record<string, any> = {
"M-001": { id: "M-001", autopilotEnabled: true, autopilotState: "inactive" },
"M-002": { id: "M-002", autopilotEnabled: false, autopilotState: "inactive" },
};
return missions[id];
}),
});
const mockAutopilot = {
setScheduler: vi.fn(),
watchMission: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
};
const scheduler = new Scheduler(store, {
missionStore: mockMissionStore as any,
missionAutopilot: mockAutopilot as any,
});
scheduler.start();
// setScheduler should be called with the scheduler instance
expect(mockAutopilot.setScheduler).toHaveBeenCalledWith(scheduler);
// Only M-001 should be watched (autopilotEnabled, not complete/archived)
expect(mockAutopilot.watchMission).toHaveBeenCalledWith("M-001");
expect(mockAutopilot.watchMission).not.toHaveBeenCalledWith("M-002");
expect(mockAutopilot.watchMission).not.toHaveBeenCalledWith("M-003");
// Autopilot should be started
expect(mockAutopilot.start).toHaveBeenCalled();
scheduler.stop();
// Autopilot should be stopped
expect(mockAutopilot.stop).toHaveBeenCalled();
});
it("does not start autopilot when no missionAutopilot option", () => {
const store = createMockStore();
const scheduler = new Scheduler(store);
scheduler.start();
// Should not throw
scheduler.stop();
});
it("delegates to autopilot.handleTaskCompletion when autopilot is available", async () => {
const store = createMockStore();
const mockAutopilot = {
setScheduler: vi.fn(),
watchMission: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
handleTaskCompletion: vi.fn().mockResolvedValue(undefined),
};
const completeSlice = {
id: "SL-001",
milestoneId: "MS-001",
status: "complete",
orderIndex: 0,
};
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({
id: "F-001",
sliceId: "SL-001",
status: "active",
}),
updateFeatureStatus: vi.fn(),
getSlice: vi.fn().mockReturnValue(completeSlice),
});
const scheduler = new Scheduler(store, {
missionStore: mockMissionStore as any,
missionAutopilot: mockAutopilot as any,
});
// Simulate task:moved event: task with sliceId moves to "done"
await (scheduler as any).handleMissionTaskCompletion("FN-001", "SL-001");
// Feature status should be updated to "done"
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done");
// Should delegate to autopilot (not call onSliceComplete)
expect(mockAutopilot.handleTaskCompletion).toHaveBeenCalledWith("FN-001");
});
it("falls back to onSliceComplete when no autopilot", async () => {
const store = createMockStore();
const completeSlice = {
id: "SL-001",
milestoneId: "MS-001",
status: "complete",
orderIndex: 0,
};
const missionHierarchy = {
id: "M-001",
status: "active",
autoAdvance: true,
milestones: [
{
id: "MS-001",
missionId: "M-001",
status: "active",
dependencies: [],
slices: [
{ id: "SL-001", status: "complete", orderIndex: 0 },
{ id: "SL-002", status: "pending", orderIndex: 1 },
],
},
],
};
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({
id: "F-001",
sliceId: "SL-001",
status: "active",
}),
updateFeatureStatus: vi.fn(),
getSlice: vi.fn().mockReturnValue(completeSlice),
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().mockResolvedValue({ id: "SL-002" }),
});
const scheduler = new Scheduler(store, {
missionStore: mockMissionStore as any,
});
await (scheduler as any).handleMissionTaskCompletion("FN-001", "SL-001");
// Feature status should be updated
expect(mockMissionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done");
// Legacy path: activateSlice should be called via onSliceComplete
expect(mockMissionStore.activateSlice).toHaveBeenCalledWith("SL-002");
});
it("autopilot does not advance when autoAdvance is false", async () => {
const store = createMockStore();
const mockAutopilot = {
setScheduler: vi.fn(),
watchMission: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
handleTaskCompletion: vi.fn().mockResolvedValue(undefined),
};
const mockMissionStore = createMockMissionStore({
getFeatureByTaskId: vi.fn().mockReturnValue({
id: "F-001",
sliceId: "SL-001",
status: "done",
}),
updateFeatureStatus: vi.fn(),
getSlice: vi.fn().mockReturnValue({
id: "SL-001",
milestoneId: "MS-001",
status: "complete",
orderIndex: 0,
}),
});
const scheduler = new Scheduler(store, {
missionStore: mockMissionStore as any,
missionAutopilot: mockAutopilot as any,
});
await (scheduler as any).handleMissionTaskCompletion("FN-001", "SL-001");
// Delegates to autopilot, which internally checks autoAdvance
expect(mockAutopilot.handleTaskCompletion).toHaveBeenCalledWith("FN-001");
});
});
});

View File

@@ -63,6 +63,8 @@ export interface SchedulerOptions {
prMonitor?: PrMonitor;
/** Optional MissionStore for slice activation and auto-advance */
missionStore?: MissionStore;
/** Optional MissionAutopilot for autonomous mission progression */
missionAutopilot?: import("./mission-autopilot.js").MissionAutopilot;
/**
* Called when a task with a closed/merged PR moves out of in-review
* and the PrMonitor has buffered actionable comments.
@@ -281,6 +283,19 @@ export class Scheduler {
this.pollInterval = setInterval(() => this.schedule(), interval);
this.schedule();
schedulerLog.log(`Started (poll interval: ${interval}ms)`);
// Wire up MissionAutopilot: set scheduler reference for lazy injection
// and start watching all missions with autopilotEnabled: true
if (this.options.missionAutopilot && this.options.missionStore) {
this.options.missionAutopilot.setScheduler(this);
const missions = this.options.missionStore.listMissions();
for (const mission of missions) {
if (mission.autopilotEnabled && mission.status !== "complete" && mission.status !== "archived") {
this.options.missionAutopilot.watchMission(mission.id);
}
}
this.options.missionAutopilot.start();
}
}
stop(): void {
@@ -294,6 +309,10 @@ export class Scheduler {
if (this.options.prMonitor) {
this.options.prMonitor.stopAll();
}
// Stop MissionAutopilot when scheduler shuts down
if (this.options.missionAutopilot) {
this.options.missionAutopilot.stop();
}
schedulerLog.log("Stopped");
}
@@ -681,7 +700,10 @@ export class Scheduler {
* When a task moves to "done", update the linked feature status to "done".
* updateFeatureStatus cascades via recomputeSliceStatus — if all features
* in the slice are done the slice status becomes "complete" automatically.
* We then call onSliceComplete to trigger auto-advance to the next slice.
*
* If MissionAutopilot is configured, delegate slice advancement to it
* (which tracks autopilot state and handles retries). Otherwise fall back
* to the legacy onSliceComplete() path for non-autopilot missions.
*/
private async handleMissionTaskCompletion(taskId: string, sliceId: string): Promise<void> {
if (!this.options.missionStore) return;
@@ -709,8 +731,18 @@ export class Scheduler {
// Check if the slice became complete after the feature update
const slice = missionStore.getSlice(sliceIdBeforeUpdate);
if (slice && slice.status === "complete") {
schedulerLog.log(`Slice ${slice.id} is complete — triggering auto-advance`);
await this.onSliceComplete(slice);
// If MissionAutopilot is available, delegate progression to it.
// The autopilot handles: watching missions, autoAdvance guard,
// retry logic, and state tracking. The autopilot will call back
// into scheduler.activateNextPendingSlice() when appropriate.
if (this.options.missionAutopilot) {
schedulerLog.log(`Slice ${slice.id} is complete — delegating to autopilot`);
await this.options.missionAutopilot.handleTaskCompletion(taskId);
} else {
// Legacy path for missions without autopilot
schedulerLog.log(`Slice ${slice.id} is complete — triggering auto-advance`);
await this.onSliceComplete(slice);
}
}
} catch (err) {
schedulerLog.error(`Error handling mission task completion for ${taskId}:`, err);