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

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

View File

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

View File

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

View File

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

View File

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