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:
gsxdsm
2026-04-09 18:10:29 -07:00
parent 7d599a5387
commit b682898caf
7 changed files with 395 additions and 101 deletions

View File

@@ -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"),
);

View File

@@ -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", () => {

View File

@@ -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, {

View File

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