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 31437c5a74
commit 2a3a320d19
22 changed files with 1965 additions and 24 deletions

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