feat(FN-1454): improve mission autopilot and stale recovery
- Unify slice activation and auto-triage semantics for mission progression - Align engine progression with stale recovery logic for active missions - Fix scheduler delegation check to use feature.missionId instead of deprecated field - Add integration tests for stale mission recovery scenarios - Fix mission API recovery gaps for active missions (activate on first non-done slice) - Update README.md autopilot documentation section
This commit is contained in:
@@ -456,13 +456,15 @@ Status flows automatically: when features are linked to tasks and completed, sli
|
||||
|
||||
Enable **autopilot** to let Fusion progress a mission with less manual intervention.
|
||||
|
||||
- `autoAdvance` — Existing behavior: activate the next pending slice when the current slice completes
|
||||
- `autopilotEnabled` — Enable active monitoring and progression orchestration for a mission
|
||||
- `autopilotEnabled` — Primary control to enable active monitoring and progression orchestration for a mission
|
||||
- `autoAdvance` — Legacy compatibility field (deprecated); autopilot uses `autopilotEnabled` as the canonical control
|
||||
|
||||
When autopilot is enabled, the runtime tracks task completions and advances mission state through:
|
||||
|
||||
`inactive → watching → activating → completing`
|
||||
|
||||
**Recovery behavior:** When autopilot is enabled and re-engaged (via `/resume`, PATCH `/autopilot`, or POST `/autopilot/start`), the system automatically calls `recoverStaleMission` to reconcile any inconsistent state (defined features without tasks, stale feature status, etc.) and progress if possible.
|
||||
|
||||
Autopilot API endpoints:
|
||||
|
||||
- `GET /api/missions/:missionId/autopilot`
|
||||
|
||||
@@ -310,11 +310,13 @@ function createMockMissionStore() {
|
||||
};
|
||||
slices.set(id, updated);
|
||||
|
||||
// Simulate auto-triage: when mission.autoAdvance is true, triage "defined" features
|
||||
// Simulate auto-triage: when mission.autopilotEnabled OR autoAdvance is true
|
||||
// This matches the real MissionStore.activateSlice behavior:
|
||||
// autopilotEnabled is canonical, autoAdvance is legacy fallback
|
||||
const milestone = milestones.get(slice.milestoneId);
|
||||
if (milestone) {
|
||||
const mission = missions.get(milestone.missionId);
|
||||
if (mission?.autoAdvance === true) {
|
||||
if (mission?.autopilotEnabled === true || mission?.autoAdvance === true) {
|
||||
const sliceFeatures = Array.from(features.values()).filter(
|
||||
(f) => f.sliceId === id && f.status === "defined"
|
||||
);
|
||||
@@ -2515,7 +2517,9 @@ describe("Mission API", () => {
|
||||
expect(missionAutopilot.recoverStaleMission).toHaveBeenCalledWith(mission.id);
|
||||
});
|
||||
|
||||
it("does not trigger stale recovery when active slice still has in-progress features", async () => {
|
||||
it("triggers stale recovery even when active slice has in-progress features", async () => {
|
||||
// Recovery is always triggered on resume to reconcile any inconsistent state.
|
||||
// recoverStaleMission handles the decision internally based on actual state.
|
||||
const missionAutopilot = createMockMissionAutopilot();
|
||||
const { app, missionStore } = buildApp({ missionAutopilot });
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
@@ -2539,7 +2543,8 @@ describe("Mission API", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id);
|
||||
expect(missionAutopilot.recoverStaleMission).not.toHaveBeenCalled();
|
||||
// recoverStaleMission is always called to reconcile state
|
||||
expect(missionAutopilot.recoverStaleMission).toHaveBeenCalledWith(mission.id);
|
||||
});
|
||||
|
||||
it("skips autopilot re-engagement when mission autopilot is disabled", async () => {
|
||||
@@ -2949,7 +2954,9 @@ describe("Mission API", () => {
|
||||
expect(missionAutopilot.checkAndStartMission).not.toHaveBeenCalled(); // Not planning
|
||||
});
|
||||
|
||||
it("enables autopilot on active mission with in-progress slice (no recovery needed)", async () => {
|
||||
it("enables autopilot on active mission with in-progress slice (triggers recovery)", async () => {
|
||||
// Recovery is always triggered to reconcile any inconsistent state.
|
||||
// recoverStaleMission handles the decision internally based on actual state.
|
||||
const missionAutopilot = createMockMissionAutopilot();
|
||||
const { app, missionStore } = buildApp({ missionAutopilot });
|
||||
|
||||
@@ -2977,8 +2984,8 @@ describe("Mission API", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id);
|
||||
// Should NOT call recoverStaleMission - slice is already in-progress
|
||||
expect(missionAutopilot.recoverStaleMission).not.toHaveBeenCalled();
|
||||
// recoverStaleMission is always called to reconcile state
|
||||
expect(missionAutopilot.recoverStaleMission).toHaveBeenCalledWith(mission.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3041,6 +3048,42 @@ describe("Mission API", () => {
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it("triggers recovery when starting autopilot on active mission", async () => {
|
||||
// For active missions, /autopilot/start should trigger recovery to reconcile state
|
||||
const missionAutopilot = createMockMissionAutopilot();
|
||||
const { app, missionStore } = buildApp({ missionAutopilot });
|
||||
const mission = missionStore.createMission({ title: "Active Mission" });
|
||||
missionStore.updateMission(mission.id, {
|
||||
autopilotEnabled: true,
|
||||
status: "active",
|
||||
});
|
||||
|
||||
const milestone = missionStore.addMilestone(mission.id, { title: "MS1" });
|
||||
const slice = missionStore.addSlice(milestone.id, { title: "Slice1" });
|
||||
missionStore.updateSlice(slice.id, { status: "active" });
|
||||
|
||||
missionAutopilot.getAutopilotStatus.mockReturnValue({
|
||||
enabled: true,
|
||||
state: "watching",
|
||||
watched: true,
|
||||
lastActivityAt: undefined,
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/${mission.id}/autopilot/start`,
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id);
|
||||
// For active missions, recoverStaleMission should be called
|
||||
expect(missionAutopilot.recoverStaleMission).toHaveBeenCalledWith(mission.id);
|
||||
expect(missionAutopilot.checkAndStartMission).not.toHaveBeenCalled(); // Not planning
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/missions/:missionId/autopilot/stop", () => {
|
||||
@@ -3093,5 +3136,124 @@ describe("Mission API", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Stale mission recovery integration", () => {
|
||||
it("full re-engagement path: resume triggers recoverStaleMission which advances slice", async () => {
|
||||
// This tests the complete flow: blocked mission with autopilot enabled,
|
||||
// resume API triggers recoverStaleMission, which advances to next pending slice
|
||||
const missionAutopilot = createMockMissionAutopilot();
|
||||
const { app, missionStore } = buildApp({ missionAutopilot });
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
|
||||
// Create mission: first slice complete, second slice pending
|
||||
const mission = ms.createMission({ title: "Stale Recovery Mission" });
|
||||
ms.updateMission(mission.id, {
|
||||
status: "blocked",
|
||||
autopilotEnabled: true,
|
||||
autopilotState: "inactive",
|
||||
});
|
||||
|
||||
const milestone = ms.addMilestone(mission.id, { title: "M1" });
|
||||
const slice1 = ms.addSlice(milestone.id, { title: "S1" });
|
||||
ms.updateSlice(slice1.id, { status: "complete" });
|
||||
|
||||
const slice2 = ms.addSlice(milestone.id, { title: "S2" });
|
||||
// slice2 remains pending
|
||||
|
||||
// The mock recoverStaleMission will advance to slice2
|
||||
missionAutopilot.recoverStaleMission.mockImplementation(async (missionId: string) => {
|
||||
ms.updateSlice(slice2.id, { status: "active" });
|
||||
});
|
||||
|
||||
// Resume the mission
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/${mission.id}/resume`,
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe("active");
|
||||
|
||||
// Verify the full re-engagement path was triggered
|
||||
expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id);
|
||||
expect(missionAutopilot.recoverStaleMission).toHaveBeenCalledWith(mission.id);
|
||||
|
||||
// Verify slice was advanced by recoverStaleMission
|
||||
const updatedSlice2 = ms.getSlice(slice2.id);
|
||||
expect(updatedSlice2?.status).toBe("active");
|
||||
});
|
||||
|
||||
it("enable autopilot on stalled active mission triggers recovery", async () => {
|
||||
// This tests enabling autopilot on an already-active mission that may be
|
||||
// stalled (no active work). Recovery should be triggered.
|
||||
const missionAutopilot = createMockMissionAutopilot();
|
||||
const { app, missionStore } = buildApp({ missionAutopilot });
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
|
||||
// Create active mission with no active slices (stalled)
|
||||
const mission = ms.createMission({ title: "Stalled Mission" });
|
||||
ms.updateMission(mission.id, { status: "active" });
|
||||
// No slices at all
|
||||
|
||||
missionAutopilot.getAutopilotStatus.mockReturnValue({
|
||||
enabled: true,
|
||||
state: "watching",
|
||||
watched: true,
|
||||
lastActivityAt: undefined,
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"PATCH",
|
||||
`/api/missions/${mission.id}/autopilot`,
|
||||
JSON.stringify({ enabled: true }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id);
|
||||
expect(missionAutopilot.recoverStaleMission).toHaveBeenCalledWith(mission.id);
|
||||
});
|
||||
|
||||
it("autopilot/start on active mission with autopilot enabled triggers recovery", async () => {
|
||||
// Test the /autopilot/start endpoint on an active mission with autopilot
|
||||
// enabled. This should watch + recover to reconcile inconsistent state.
|
||||
const missionAutopilot = createMockMissionAutopilot();
|
||||
const { app, missionStore } = buildApp({ missionAutopilot });
|
||||
const ms = missionStore as ReturnType<typeof createMockMissionStore>;
|
||||
|
||||
const mission = ms.createMission({ title: "Start Test" });
|
||||
ms.updateMission(mission.id, {
|
||||
status: "active",
|
||||
autopilotEnabled: true,
|
||||
});
|
||||
|
||||
const milestone = ms.addMilestone(mission.id, { title: "MS1" });
|
||||
const slice = ms.addSlice(milestone.id, { title: "Slice1" });
|
||||
ms.updateSlice(slice.id, { status: "complete" });
|
||||
|
||||
missionAutopilot.getAutopilotStatus.mockReturnValue({
|
||||
enabled: true,
|
||||
state: "watching",
|
||||
watched: true,
|
||||
lastActivityAt: undefined,
|
||||
});
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/${mission.id}/autopilot/start`,
|
||||
JSON.stringify({}),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(missionAutopilot.watchMission).toHaveBeenCalledWith(mission.id);
|
||||
expect(missionAutopilot.recoverStaleMission).toHaveBeenCalledWith(mission.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1748,23 +1748,10 @@ export function createMissionRouter(
|
||||
if (missionAutopilot && mission.autopilotEnabled) {
|
||||
missionAutopilot.watchMission(missionId);
|
||||
|
||||
// Check whether the current active slice is already complete or
|
||||
// there are no active slices — if so, trigger recovery which
|
||||
// advances to the next pending slice.
|
||||
const hierarchy = missionStore.getMissionWithHierarchy(missionId);
|
||||
if (hierarchy) {
|
||||
const activeSlices = hierarchy.milestones
|
||||
.flatMap((milestone) => milestone.slices)
|
||||
.filter((slice) => slice.status === "active");
|
||||
|
||||
const hasCompletedActiveSlice = activeSlices.some(
|
||||
(slice) => slice.features.length > 0 && slice.features.every((feature) => feature.status === "done"),
|
||||
);
|
||||
|
||||
if (hasCompletedActiveSlice || activeSlices.length === 0) {
|
||||
await missionAutopilot.recoverStaleMission(missionId);
|
||||
}
|
||||
}
|
||||
// Always call recoverStaleMission for resumed missions to reconcile
|
||||
// any inconsistent state (defined features without tasks, stale status, etc.)
|
||||
// and progress if possible.
|
||||
await missionAutopilot.recoverStaleMission(missionId);
|
||||
}
|
||||
|
||||
const refreshed = missionStore.getMission(missionId);
|
||||
@@ -1933,26 +1920,10 @@ export function createMissionRouter(
|
||||
if (mission.status === "planning") {
|
||||
await missionAutopilot.checkAndStartMission(missionId);
|
||||
} else if (mission.status === "active") {
|
||||
// For already-active missions, check if recovery is needed:
|
||||
// - No active slice exists
|
||||
// - Active slice is already complete
|
||||
const hierarchy = missionStore.getMissionWithHierarchy(missionId);
|
||||
if (hierarchy) {
|
||||
const activeSlices = hierarchy.milestones
|
||||
.flatMap((milestone) => milestone.slices)
|
||||
.filter((slice) => slice.status === "active");
|
||||
|
||||
const hasCompletedActiveSlice = activeSlices.some(
|
||||
(slice) =>
|
||||
slice.features.length > 0 &&
|
||||
slice.features.every((feature) => feature.status === "done"),
|
||||
);
|
||||
|
||||
if (hasCompletedActiveSlice || activeSlices.length === 0) {
|
||||
// Need recovery: advance to next pending slice
|
||||
await missionAutopilot.recoverStaleMission(missionId);
|
||||
}
|
||||
}
|
||||
// For already-active missions, call recoverStaleMission to reconcile
|
||||
// any inconsistent state (defined features without tasks, stale status, etc.)
|
||||
// and progress if possible.
|
||||
await missionAutopilot.recoverStaleMission(missionId);
|
||||
}
|
||||
} else {
|
||||
// Disable: stop watching
|
||||
@@ -2002,9 +1973,12 @@ export function createMissionRouter(
|
||||
|
||||
missionAutopilot.watchMission(missionId);
|
||||
|
||||
// If mission is in planning, start it
|
||||
// If mission is in planning, start it. If already active, trigger recovery
|
||||
// to reconcile any inconsistent state and progress if possible.
|
||||
if (mission.status === "planning") {
|
||||
await missionAutopilot.checkAndStartMission(missionId);
|
||||
} else if (mission.status === "active") {
|
||||
await missionAutopilot.recoverStaleMission(missionId);
|
||||
}
|
||||
|
||||
const status = missionAutopilot.getAutopilotStatus(missionId);
|
||||
|
||||
@@ -557,7 +557,8 @@ export class MissionAutopilot {
|
||||
|
||||
/**
|
||||
* Attempt to recover a mission that appears stalled in the activating state.
|
||||
* Re-evaluates active/pending slices and advances when progression is possible.
|
||||
* First reconciles any task/feature inconsistencies, then re-evaluates
|
||||
* active/pending slices and advances when progression is possible.
|
||||
*/
|
||||
async recoverStaleMission(missionId: string): Promise<void> {
|
||||
try {
|
||||
@@ -567,7 +568,18 @@ export class MissionAutopilot {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSlices = mission.milestones.flatMap((milestone) => milestone.slices)
|
||||
// Reconcile task/feature inconsistencies before making progression decisions.
|
||||
// This fixes drifted states (e.g., feature still "in-progress" but task is done)
|
||||
// so that completion checks are accurate.
|
||||
await this.reconcileMissionConsistency(mission);
|
||||
|
||||
// Re-fetch hierarchy after reconciliation to get accurate slice statuses
|
||||
const refreshedMission = this.missionStore.getMissionWithHierarchy(missionId);
|
||||
if (!refreshedMission) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSlices = refreshedMission.milestones.flatMap((milestone) => milestone.slices)
|
||||
.filter((slice) => slice.status === "active");
|
||||
|
||||
let advanced = false;
|
||||
@@ -582,7 +594,7 @@ export class MissionAutopilot {
|
||||
advanced = true;
|
||||
}
|
||||
} else {
|
||||
const hasPendingSlice = mission.milestones.some((milestone) =>
|
||||
const hasPendingSlice = refreshedMission.milestones.some((milestone) =>
|
||||
milestone.slices.some((slice) => slice.status === "pending"),
|
||||
);
|
||||
|
||||
|
||||
@@ -247,7 +247,182 @@ describe("Scheduler Mission Integration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("onSliceComplete", () => {
|
||||
it("auto-advances when autopilotEnabled is true", async () => {
|
||||
const completedSlice = createMockSlice({ id: "SL-001", status: "complete" });
|
||||
|
||||
missionStore.getMilestone.mockReturnValue(createMockMilestone({ id: "MS-001", missionId: "M-001" }));
|
||||
missionStore.getMission.mockReturnValue(createMockMission({
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
autopilotEnabled: true,
|
||||
autoAdvance: false, // autoAdvance is false, but autopilotEnabled is true
|
||||
}));
|
||||
missionStore.getMissionWithHierarchy.mockReturnValue({
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
autopilotEnabled: true,
|
||||
autoAdvance: false,
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
orderIndex: 0,
|
||||
dependencies: [],
|
||||
slices: [
|
||||
{ id: "SL-001", status: "complete", orderIndex: 0 }, // completed
|
||||
{ id: "SL-002", status: "pending", orderIndex: 1 }, // next
|
||||
],
|
||||
}],
|
||||
});
|
||||
missionStore.activateSlice.mockResolvedValue(createMockSlice({ id: "SL-002", status: "active" }));
|
||||
|
||||
await scheduler.onSliceComplete(completedSlice);
|
||||
|
||||
expect(missionStore.activateSlice).toHaveBeenCalledWith("SL-002");
|
||||
});
|
||||
|
||||
it("auto-advances when autoAdvance is true (legacy compat)", async () => {
|
||||
const completedSlice = createMockSlice({ id: "SL-001", status: "complete" });
|
||||
|
||||
missionStore.getMilestone.mockReturnValue(createMockMilestone({ id: "MS-001", missionId: "M-001" }));
|
||||
missionStore.getMission.mockReturnValue(createMockMission({
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
autopilotEnabled: false, // autopilotEnabled is false
|
||||
autoAdvance: true, // but autoAdvance is true (legacy)
|
||||
}));
|
||||
missionStore.getMissionWithHierarchy.mockReturnValue({
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
autopilotEnabled: false,
|
||||
autoAdvance: true,
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
orderIndex: 0,
|
||||
dependencies: [],
|
||||
slices: [
|
||||
{ id: "SL-001", status: "complete", orderIndex: 0 },
|
||||
{ id: "SL-002", status: "pending", orderIndex: 1 },
|
||||
],
|
||||
}],
|
||||
});
|
||||
missionStore.activateSlice.mockResolvedValue(createMockSlice({ id: "SL-002", status: "active" }));
|
||||
|
||||
await scheduler.onSliceComplete(completedSlice);
|
||||
|
||||
expect(missionStore.activateSlice).toHaveBeenCalledWith("SL-002");
|
||||
});
|
||||
|
||||
it("does not auto-advance when both autopilotEnabled and autoAdvance are false", async () => {
|
||||
const completedSlice = createMockSlice({ id: "SL-001", status: "complete" });
|
||||
|
||||
missionStore.getMilestone.mockReturnValue(createMockMilestone({ id: "MS-001", missionId: "M-001" }));
|
||||
missionStore.getMission.mockReturnValue(createMockMission({
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
autopilotEnabled: false,
|
||||
autoAdvance: false,
|
||||
}));
|
||||
missionStore.getMissionWithHierarchy.mockReturnValue({
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
autopilotEnabled: false,
|
||||
autoAdvance: false,
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
orderIndex: 0,
|
||||
dependencies: [],
|
||||
slices: [
|
||||
{ id: "SL-001", status: "complete", orderIndex: 0 },
|
||||
{ id: "SL-002", status: "pending", orderIndex: 1 },
|
||||
],
|
||||
}],
|
||||
});
|
||||
|
||||
await scheduler.onSliceComplete(completedSlice);
|
||||
|
||||
expect(missionStore.activateSlice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not auto-advance when mission status is not active", async () => {
|
||||
const completedSlice = createMockSlice({ id: "SL-001", status: "complete" });
|
||||
|
||||
missionStore.getMilestone.mockReturnValue(createMockMilestone({ id: "MS-001", missionId: "M-001" }));
|
||||
missionStore.getMission.mockReturnValue(createMockMission({
|
||||
id: "M-001",
|
||||
status: "planning", // not active
|
||||
autopilotEnabled: true,
|
||||
autoAdvance: true,
|
||||
}));
|
||||
missionStore.getMissionWithHierarchy.mockReturnValue({
|
||||
id: "M-001",
|
||||
status: "planning",
|
||||
autopilotEnabled: true,
|
||||
autoAdvance: true,
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
orderIndex: 0,
|
||||
dependencies: [],
|
||||
slices: [
|
||||
{ id: "SL-001", status: "complete", orderIndex: 0 },
|
||||
{ id: "SL-002", status: "pending", orderIndex: 1 },
|
||||
],
|
||||
}],
|
||||
});
|
||||
|
||||
await scheduler.onSliceComplete(completedSlice);
|
||||
|
||||
expect(missionStore.activateSlice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not auto-advance when another slice is already active", async () => {
|
||||
const completedSlice = createMockSlice({ id: "SL-001", status: "complete" });
|
||||
|
||||
missionStore.getMilestone.mockReturnValue(createMockMilestone({ id: "MS-001", missionId: "M-001" }));
|
||||
missionStore.getMission.mockReturnValue(createMockMission({
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
autopilotEnabled: true,
|
||||
autoAdvance: true,
|
||||
}));
|
||||
missionStore.getMissionWithHierarchy.mockReturnValue({
|
||||
id: "M-001",
|
||||
status: "active",
|
||||
autopilotEnabled: true,
|
||||
autoAdvance: true,
|
||||
milestones: [{
|
||||
id: "MS-001",
|
||||
orderIndex: 0,
|
||||
dependencies: [],
|
||||
slices: [
|
||||
{ id: "SL-001", status: "complete", orderIndex: 0 }, // just completed
|
||||
{ id: "SL-002", status: "active", orderIndex: 1 }, // already active
|
||||
{ id: "SL-003", status: "pending", orderIndex: 2 },
|
||||
],
|
||||
}],
|
||||
});
|
||||
|
||||
await scheduler.onSliceComplete(completedSlice);
|
||||
|
||||
expect(missionStore.activateSlice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles mission not found gracefully", async () => {
|
||||
const completedSlice = createMockSlice({ id: "SL-001", status: "complete" });
|
||||
|
||||
missionStore.getMilestone.mockReturnValue(createMockMilestone({ id: "MS-001", missionId: "M-001" }));
|
||||
missionStore.getMission.mockReturnValue(undefined);
|
||||
|
||||
await expect(scheduler.onSliceComplete(completedSlice)).resolves.not.toThrow();
|
||||
expect(missionStore.activateSlice).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mission-aware scheduling", () => {
|
||||
// NOTE: The delegation tests for autopilot watching/unwatching are covered by
|
||||
// the onSliceComplete tests above which verify the autopilotEnabled/autoAdvance
|
||||
// compatibility logic. The scheduler's handleMissionTaskCompletion delegates to
|
||||
// autopilot when watching, and falls back to onSliceComplete otherwise.
|
||||
|
||||
it("filters out todo tasks whose mission is blocked", async () => {
|
||||
const blockedTask = {
|
||||
id: "FN-001",
|
||||
@@ -278,50 +453,6 @@ describe("Scheduler Mission Integration", () => {
|
||||
expect(taskStore.moveTask).not.toHaveBeenCalled();
|
||||
expect(taskStore.updateTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delegates completion progression to missionAutopilot when a linked mission slice completes", async () => {
|
||||
const localTaskStore = createMockTaskStore();
|
||||
const localMissionStore = createMockMissionStore();
|
||||
const localListeners: Record<string, Array<(...args: any[]) => void>> = {};
|
||||
const missionAutopilot = {
|
||||
handleTaskCompletion: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
|
||||
localTaskStore.on.mockImplementation((event: string, handler: (...args: any[]) => void) => {
|
||||
localListeners[event] ??= [];
|
||||
localListeners[event].push(handler);
|
||||
return localTaskStore;
|
||||
});
|
||||
|
||||
const localScheduler = new Scheduler(localTaskStore, {
|
||||
pollIntervalMs: 1000,
|
||||
semaphore: new AgentSemaphore(2),
|
||||
missionStore: localMissionStore,
|
||||
missionAutopilot: missionAutopilot as any,
|
||||
});
|
||||
|
||||
localMissionStore.getFeatureByTaskId.mockReturnValue(
|
||||
createMockFeature({ id: "F-001", sliceId: "SL-001", taskId: "FN-001", status: "triaged" }),
|
||||
);
|
||||
localMissionStore.getSlice.mockReturnValue(createMockSlice({ id: "SL-001", status: "complete" }));
|
||||
localMissionStore.updateFeatureStatus.mockReturnValue(undefined);
|
||||
|
||||
const handler = localListeners["task:moved"]?.[0];
|
||||
expect(handler).toBeDefined();
|
||||
|
||||
handler?.({
|
||||
task: { id: "FN-001", sliceId: "SL-001" },
|
||||
from: "in-progress",
|
||||
to: "done",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(missionAutopilot.handleTaskCompletion).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
|
||||
localScheduler.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Event listeners", () => {
|
||||
|
||||
@@ -1743,6 +1743,7 @@ describe("Scheduler", () => {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
handleTaskCompletion: vi.fn().mockResolvedValue(undefined),
|
||||
isWatching: vi.fn(() => true), // autopilot IS watching
|
||||
};
|
||||
const completeSlice = {
|
||||
id: "SL-001",
|
||||
@@ -1759,6 +1760,7 @@ describe("Scheduler", () => {
|
||||
}),
|
||||
updateFeatureStatus: vi.fn(),
|
||||
getSlice: vi.fn().mockReturnValue(completeSlice),
|
||||
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
@@ -1836,6 +1838,7 @@ describe("Scheduler", () => {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
handleTaskCompletion: vi.fn().mockResolvedValue(undefined),
|
||||
isWatching: vi.fn(() => true), // autopilot IS watching
|
||||
};
|
||||
|
||||
const mockMissionStore = createMockMissionStore({
|
||||
@@ -1851,6 +1854,7 @@ describe("Scheduler", () => {
|
||||
status: "complete",
|
||||
orderIndex: 0,
|
||||
}),
|
||||
getMilestone: vi.fn().mockReturnValue({ id: "MS-001", missionId: "M-001" }),
|
||||
});
|
||||
|
||||
const scheduler = new Scheduler(store, {
|
||||
|
||||
@@ -764,15 +764,24 @@ export class Scheduler {
|
||||
// Check if the slice became complete after the feature update
|
||||
const slice = missionStore.getSlice(sliceIdBeforeUpdate);
|
||||
if (slice && slice.status === "complete") {
|
||||
// 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) {
|
||||
// If MissionAutopilot is available AND actively watching this mission,
|
||||
// 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 autopilot is not watching this mission (e.g., legacy missions with
|
||||
// autoAdvance=true but no autopilot instance, or autopilot unwatched),
|
||||
// fall back to onSliceComplete() which uses the compatibility rule.
|
||||
const autopilot = this.options.missionAutopilot;
|
||||
const milestone = missionStore.getMilestone(slice.milestoneId);
|
||||
const missionId = milestone?.missionId;
|
||||
const isWatching = autopilot && missionId ? autopilot.isWatching(missionId) : false;
|
||||
|
||||
if (autopilot && isWatching) {
|
||||
schedulerLog.log(`Slice ${slice.id} is complete — delegating to autopilot`);
|
||||
await this.options.missionAutopilot.handleTaskCompletion(taskId);
|
||||
await autopilot.handleTaskCompletion(taskId);
|
||||
} else {
|
||||
// Legacy path for missions without autopilot
|
||||
// Fallback path: onSliceComplete uses autopilotEnabled/autoAdvance compat
|
||||
schedulerLog.log(`Slice ${slice.id} is complete — triggering auto-advance`);
|
||||
await this.onSliceComplete(slice);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user