FN-5679: default new missions to stopped state
Ensure mission creation always starts in a stopped planning state with autopilot off. - Force MissionStore createMission to ignore create-time autopilotEnabled and persist stopped defaults - Update mission POST route behavior to stop auto-watching on create and keep optional create-time updates scoped - Add/adjust core and dashboard tests to assert stopped-by-default creation semantics - Document mission autopilot creation behavior and add a patch changeset for @runfusion/fusion Files changed: .changeset/fn-5679-missions-default-stopped.md | 7 +++++++ docs/missions.md | 4 +++- .../mission-factory-parity.integration.test.ts | 6 +++--- packages/core/src/__tests__/mission-store.test.ts | 18 ++++++++++++++++++ packages/core/src/mission-store.ts | 5 +++-- packages/dashboard/src/__tests__/mission-e2e.test.ts | 8 +++++--- packages/dashboard/src/mission-routes.ts | 9 +-------- 7 files changed, 40 insertions(+), 17 deletions(-) Fusion-Task-Id: FN-5679 Fusion-Task-Lineage: 05eff3cb-07d8-4b52-ac7f-065979e78816
This commit is contained in:
7
.changeset/fn-5679-missions-default-stopped.md
Normal file
7
.changeset/fn-5679-missions-default-stopped.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Mission creation now always returns a stopped mission. `POST /api/missions` and the mission store ignore create-time `autopilotEnabled` input, forcing new missions to `status: "planning"` with autopilot disabled and inactive.
|
||||||
|
|
||||||
|
Autopilot remains a post-creation action via explicit mission start/update flows.
|
||||||
@@ -176,6 +176,8 @@ Manual activation is available through `fn mission activate-slice <slice-id>`.
|
|||||||
|
|
||||||
## Mission Autopilot
|
## Mission Autopilot
|
||||||
|
|
||||||
|
Missions are always created stopped (`status: "planning"`, `autopilotEnabled: false`, `autoAdvance: false`).
|
||||||
|
Autopilot must be enabled explicitly after creation (for example via start/update actions).
|
||||||
When `autopilotEnabled` is on, Fusion can watch completion events and progress missions automatically.
|
When `autopilotEnabled` is on, Fusion can watch completion events and progress missions automatically.
|
||||||
|
|
||||||
State machine:
|
State machine:
|
||||||
@@ -187,7 +189,7 @@ State machine:
|
|||||||
|
|
||||||
Typical flow:
|
Typical flow:
|
||||||
|
|
||||||
1. Mission is watched (missions created/updated with `autopilotEnabled: true` are watched immediately)
|
1. Mission is watched (missions updated with `autopilotEnabled: true` or explicitly started are watched)
|
||||||
2. Task completion updates feature status
|
2. Task completion updates feature status
|
||||||
3. If a slice is complete, autopilot activates next pending slice
|
3. If a slice is complete, autopilot activates next pending slice
|
||||||
4. When milestones are all complete, mission transitions to complete
|
4. When milestones are all complete, mission transitions to complete
|
||||||
|
|||||||
@@ -377,7 +377,7 @@ describe("MissionFactory Parity: Core MissionStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Parity Matrix: Autopilot Configuration", () => {
|
describe("Parity Matrix: Autopilot Configuration", () => {
|
||||||
it("autopilotEnabled persists across restart", async () => {
|
it("missions created with autopilotEnabled start disabled across restart", async () => {
|
||||||
const missionStore = taskStore.getMissionStore();
|
const missionStore = taskStore.getMissionStore();
|
||||||
|
|
||||||
const mission = missionStore.createMission({
|
const mission = missionStore.createMission({
|
||||||
@@ -387,7 +387,7 @@ describe("MissionFactory Parity: Core MissionStore", () => {
|
|||||||
|
|
||||||
// Verify initial state
|
// Verify initial state
|
||||||
let retrieved = missionStore.getMission(mission.id);
|
let retrieved = missionStore.getMission(mission.id);
|
||||||
expect(retrieved!.autopilotEnabled).toBe(true);
|
expect(retrieved!.autopilotEnabled).toBe(false);
|
||||||
|
|
||||||
// Restart
|
// Restart
|
||||||
taskStore.close();
|
taskStore.close();
|
||||||
@@ -397,7 +397,7 @@ describe("MissionFactory Parity: Core MissionStore", () => {
|
|||||||
|
|
||||||
// Verify persistence
|
// Verify persistence
|
||||||
retrieved = missionStore2.getMission(mission.id);
|
retrieved = missionStore2.getMission(mission.id);
|
||||||
expect(retrieved!.autopilotEnabled).toBe(true);
|
expect(retrieved!.autopilotEnabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("autopilotEnabled can be toggled", async () => {
|
it("autopilotEnabled can be toggled", async () => {
|
||||||
|
|||||||
@@ -68,6 +68,24 @@ describe("MissionStore", () => {
|
|||||||
expect(mission.updatedAt).toBeTruthy();
|
expect(mission.updatedAt).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores autopilotEnabled on create and persists stopped defaults", () => {
|
||||||
|
const mission = store.createMission({
|
||||||
|
title: "Stopped by default",
|
||||||
|
autopilotEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mission.autopilotEnabled).toBe(false);
|
||||||
|
expect(mission.autoAdvance).toBe(false);
|
||||||
|
expect(mission.status).toBe("planning");
|
||||||
|
expect(mission.autopilotState).toBe("inactive");
|
||||||
|
|
||||||
|
const persisted = store.getMission(mission.id);
|
||||||
|
expect(persisted?.autopilotEnabled).toBe(false);
|
||||||
|
expect(persisted?.autoAdvance).toBe(false);
|
||||||
|
expect(persisted?.status).toBe("planning");
|
||||||
|
expect(persisted?.autopilotState).toBe("inactive");
|
||||||
|
});
|
||||||
|
|
||||||
it("gets a mission by id", () => {
|
it("gets a mission by id", () => {
|
||||||
const created = store.createMission({ title: "Get Test" });
|
const created = store.createMission({ title: "Get Test" });
|
||||||
const retrieved = store.getMission(created.id);
|
const retrieved = store.getMission(created.id);
|
||||||
|
|||||||
@@ -567,7 +567,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new mission.
|
* Create a new mission.
|
||||||
* The mission starts in "planning" status with "not_started" interview state.
|
* Missions are always created stopped (status `planning`, autopilot disabled).
|
||||||
|
* Autopilot is only enabled via an explicit start/update after creation.
|
||||||
*
|
*
|
||||||
* @param input - Mission creation input
|
* @param input - Mission creation input
|
||||||
* @returns The created mission
|
* @returns The created mission
|
||||||
@@ -586,7 +587,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
branchStrategy: input.branchStrategy,
|
branchStrategy: input.branchStrategy,
|
||||||
autoMerge: input.autoMerge,
|
autoMerge: input.autoMerge,
|
||||||
autoAdvance: false,
|
autoAdvance: false,
|
||||||
autopilotEnabled: input.autopilotEnabled ?? false,
|
autopilotEnabled: false,
|
||||||
autopilotState: "inactive",
|
autopilotState: "inactive",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
@@ -793,7 +793,7 @@ describe("Mission API", () => {
|
|||||||
expect(missionStore.updateMission).toHaveBeenCalledWith(res.body.id, { autoAdvance: true });
|
expect(missionStore.updateMission).toHaveBeenCalledWith(res.body.id, { autoAdvance: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("watches mission immediately when created with autopilotEnabled true", async () => {
|
it("creates missions stopped even when autopilotEnabled is passed", async () => {
|
||||||
const missionAutopilot = createMockMissionAutopilot();
|
const missionAutopilot = createMockMissionAutopilot();
|
||||||
const { app } = buildApp({ missionAutopilot });
|
const { app } = buildApp({ missionAutopilot });
|
||||||
|
|
||||||
@@ -806,8 +806,10 @@ describe("Mission API", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
expect(missionAutopilot.watchMission).toHaveBeenCalledTimes(1);
|
expect(res.body.status).toBe("planning");
|
||||||
expect(missionAutopilot.watchMission).toHaveBeenCalledWith(res.body.id);
|
expect(res.body.autopilotEnabled).toBe(false);
|
||||||
|
expect(res.body.autoAdvance).toBe(false);
|
||||||
|
expect(missionAutopilot.watchMission).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ export function createMissionRouter(
|
|||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
catchTypedHandler(async (req, res) => {
|
catchTypedHandler(async (req, res) => {
|
||||||
const { title, description, autoAdvance, autopilotEnabled, baseBranch, branchStrategy } = req.body;
|
const { title, description, autoAdvance, baseBranch, branchStrategy } = req.body;
|
||||||
|
|
||||||
const validatedTitle = validateTitle(title);
|
const validatedTitle = validateTitle(title);
|
||||||
const validatedDescription = validateDescription(description);
|
const validatedDescription = validateDescription(description);
|
||||||
@@ -350,15 +350,8 @@ export function createMissionRouter(
|
|||||||
if (autoAdvance !== undefined) {
|
if (autoAdvance !== undefined) {
|
||||||
updates.autoAdvance = validateBoolean(autoAdvance, "autoAdvance");
|
updates.autoAdvance = validateBoolean(autoAdvance, "autoAdvance");
|
||||||
}
|
}
|
||||||
if (autopilotEnabled !== undefined) {
|
|
||||||
updates.autopilotEnabled = validateBoolean(autopilotEnabled, "autopilotEnabled");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(updates).length > 0) {
|
if (Object.keys(updates).length > 0) {
|
||||||
const updatedMission = missionStore.updateMission(mission.id, updates);
|
const updatedMission = missionStore.updateMission(mission.id, updates);
|
||||||
if (missionAutopilot && updatedMission.autopilotEnabled) {
|
|
||||||
missionAutopilot.watchMission(updatedMission.id);
|
|
||||||
}
|
|
||||||
res.status(201).json(updatedMission);
|
res.status(201).json(updatedMission);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user