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:
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) ─────────────────────────────────
|
||||
|
||||
|
||||
@@ -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> & {
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
544
packages/engine/src/mission-autopilot.test.ts
Normal file
544
packages/engine/src/mission-autopilot.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
436
packages/engine/src/mission-autopilot.ts
Normal file
436
packages/engine/src/mission-autopilot.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user