FN-5754: re-triage stranded features in active mission slices

Ensure active mission slices recover stranded features by linking or auto-triaging them during scheduler and self-healing reconciliation.

- Extend mission feature reconciliation to auto-triage defined features in active slices when mission autopilot/auto-advance is enabled and no linked task can be found.
- Emit a new mission:stranded-feature-triaged audit event whenever stranded features are linked or triaged, and wire reconciliation into self-healing maintenance runs.
- Add regression coverage for scheduler and reliability interactions, and document the new backstop in AGENTS and mission docs.

Files changed:
 AGENTS.md                                          |   1 +
 docs/missions-completion-contract.md               |   1 +
 docs/missions.md                                   |   1 +
 .../mission-stranded-feature-retriage.test.ts      | 160 +++++++++++++++++++++
 packages/engine/src/__tests__/scheduler.test.ts    | 155 ++++++++++++++++++++
 packages/engine/src/run-audit.ts                   |   1 +
 packages/engine/src/runtimes/in-process-runtime.ts |   1 +
 packages/engine/src/scheduler.ts                   |  64 ++++++++-
 packages/engine/src/self-healing.ts                |  11 ++
 9 files changed, 394 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-5754

Fusion-Task-Lineage: 6b29754a-2fdf-4528-aa43-af294c4210df
This commit is contained in:
gsxdsm
2026-05-30 16:35:41 -07:00
parent 76e3cde532
commit 0e0b17f3c4
9 changed files with 394 additions and 1 deletions

View File

@@ -162,6 +162,7 @@ When `settings.autoMerge: false`, `in-review` is terminal-until-merged by a huma
- FN-5715 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` guards mission validation trigger continuity so done task completion and startup recovery both route assertion-linked features through validator runs before completion.
- FN-5738 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` extends mission-loop coverage so zero-assertion auto-pass deterministically advances to `loopState="passed"` and emits `validation_auto_passed_no_assertions` without duplicate recovery re-fire.
- FN-5741 backstop: `packages/engine/src/__tests__/reliability-interactions/merge-request-shadow-handoff.test.ts` guards Phase-1 write-only-shadow merge-request record + handoff-accepted marker seam (flag OFF = no-op, ON = shadow-only non-authoritative).
- FN-5754 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-stranded-feature-retriage.test.ts` guards startup/maintenance stranded-feature re-triage for active autopilot slices, including link-first dedupe, non-defined skip safety, non-autopilot no-op, idempotency, and `mission:stranded-feature-triaged` audit shape.
---

View File

@@ -81,6 +81,7 @@ Autopilot may advance only when each active-slice feature is resolved under this
- Feature with linked assertions: all linked assertions must pass.
- Feature with zero linked assertions: explicit auto-pass path completes it.
- Feature with failed/blocked validation: slice remains incomplete.
- Feature stranded without a task link in an active autopilot slice (`taskId == null`): startup + maintenance reconciliation must repair it (title-match link first, otherwise defined-status re-triage) so `allDone` remains reachable instead of stalling on never-triaged features.
Then:

View File

@@ -218,6 +218,7 @@ If validation cannot run (unexpected loop state, duplicate trigger, blocked vali
- `autopilotEnabled=true` → features in activated slices are automatically planned (converted to tasks)
- `autopilotEnabled=false`, `autoAdvance=true` → features are planned (legacy compat)
- Active autopilot slices are continuously reconciled on startup recovery and periodic maintenance: stranded features (`taskId == null`) are re-triaged idempotently, title-matched tasks are linked first, and successful link/triage repairs emit `mission:stranded-feature-triaged` run-audit events.
- `autopilotEnabled=false`, `autoAdvance=false` → manual slice activation only
**Slice progression (on slice completion):**

View File

@@ -0,0 +1,160 @@
import { describe, expect, it, vi } from "vitest";
import type { MissionFeature, TaskStore } from "@fusion/core";
import { Scheduler } from "../../scheduler.js";
function createTaskStore(tasks: any[] = []): TaskStore {
return {
listTasks: vi.fn(async () => tasks),
getTask: vi.fn(async (taskId: string) => tasks.find((task) => task.id === taskId)),
recordRunAuditEvent: vi.fn(async () => undefined),
getSettings: vi.fn(async () => ({})),
getRootDir: vi.fn(() => "/test/project"),
on: vi.fn(),
off: vi.fn(),
} as unknown as TaskStore;
}
function feature(overrides: Partial<MissionFeature>): MissionFeature {
return {
id: "F-001",
sliceId: "SL-001",
title: "Feature one",
status: "defined",
loopState: "idle",
implementationAttemptCount: 0,
validatorAttemptCount: 0,
taskId: undefined,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
} as MissionFeature;
}
describe("FN-5754 reliability: mission stranded feature retriage", () => {
it("triages stranded features in active autopilot slices and is idempotent", async () => {
const features = [feature({ id: "F-001" })];
const tasks: any[] = [];
const missionStore = {
listMissions: vi.fn(() => [{ id: "M-001", status: "active", autopilotEnabled: true }]),
getMissionWithHierarchy: vi.fn(() => ({
id: "M-001",
status: "active",
milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features }] }],
})),
triageFeature: vi.fn(async (featureId: string) => {
const taskId = `FN-${featureId}`;
tasks.push({ id: taskId, title: "Feature one", missionId: "M-001", sliceId: "SL-001", column: "todo", status: "queued" });
features[0] = { ...features[0], taskId, status: "triaged" };
return features[0];
}),
linkFeatureToTask: vi.fn(),
updateFeatureStatus: vi.fn(),
listAssertionsForFeature: vi.fn(() => []),
};
const store = createTaskStore(tasks);
const scheduler = new Scheduler(store, { missionStore: missionStore as any });
await scheduler.reconcileAllMissionFeatures();
await scheduler.reconcileAllMissionFeatures();
expect(missionStore.triageFeature).toHaveBeenCalledTimes(1);
expect(features[0].taskId).toBe("FN-F-001");
});
it("links title-matched existing tasks without recreating", async () => {
const tasks = [{ id: "FN-001", title: "Feature one", missionId: "M-001", sliceId: "SL-001", column: "todo", status: "queued" }];
const features = [feature({ id: "F-001", taskId: undefined, status: "triaged" })];
const missionStore = {
listMissions: vi.fn(() => [{ id: "M-001", status: "active", autopilotEnabled: true }]),
getMissionWithHierarchy: vi.fn(() => ({
id: "M-001",
status: "active",
milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features }] }],
})),
triageFeature: vi.fn(),
linkFeatureToTask: vi.fn((featureId: string, taskId: string) => {
features[0] = { ...features[0], id: featureId, taskId };
return features[0];
}),
updateFeatureStatus: vi.fn(),
listAssertionsForFeature: vi.fn(() => []),
};
const scheduler = new Scheduler(createTaskStore(tasks), { missionStore: missionStore as any });
await scheduler.reconcileAllMissionFeatures();
expect(missionStore.linkFeatureToTask).toHaveBeenCalledWith("F-001", "FN-001");
expect(missionStore.triageFeature).not.toHaveBeenCalled();
});
it("skips inconsistent non-defined stranded features without title match", async () => {
const features = [feature({ id: "F-001", status: "triaged", taskId: undefined })];
const missionStore = {
listMissions: vi.fn(() => [{ id: "M-001", status: "active", autopilotEnabled: true }]),
getMissionWithHierarchy: vi.fn(() => ({
id: "M-001",
status: "active",
milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features }] }],
})),
triageFeature: vi.fn(),
linkFeatureToTask: vi.fn(),
updateFeatureStatus: vi.fn(),
};
const scheduler = new Scheduler(createTaskStore([]), { missionStore: missionStore as any });
await scheduler.reconcileAllMissionFeatures();
expect(missionStore.triageFeature).not.toHaveBeenCalled();
expect(missionStore.linkFeatureToTask).not.toHaveBeenCalled();
});
it("leaves non-autopilot and blocked features untouched", async () => {
const autopilotOffFeature = feature({ id: "F-001", status: "defined", taskId: undefined });
const blockedFeature = feature({ id: "F-002", status: "blocked", taskId: undefined, title: "Blocked feature" });
const missionStore = {
listMissions: vi.fn(() => [
{ id: "M-001", status: "active", autopilotEnabled: false, autoAdvance: false },
{ id: "M-002", status: "active", autopilotEnabled: true },
]),
getMissionWithHierarchy: vi.fn((missionId: string) => missionId === "M-001"
? { id: missionId, status: "active", milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: [autopilotOffFeature] }] }] }
: { id: missionId, status: "active", milestones: [{ id: "MS-002", slices: [{ id: "SL-002", status: "active", features: [blockedFeature] }] }] }),
triageFeature: vi.fn(),
linkFeatureToTask: vi.fn(),
updateFeatureStatus: vi.fn(),
};
const scheduler = new Scheduler(createTaskStore([]), { missionStore: missionStore as any });
await scheduler.reconcileAllMissionFeatures();
expect(missionStore.triageFeature).not.toHaveBeenCalled();
expect(missionStore.linkFeatureToTask).not.toHaveBeenCalled();
});
it("emits mission:stranded-feature-triaged audit entries", async () => {
const features = [feature({ id: "F-001" })];
const tasks: any[] = [];
const store = createTaskStore(tasks);
const missionStore = {
listMissions: vi.fn(() => [{ id: "M-001", status: "active", autopilotEnabled: true }]),
getMissionWithHierarchy: vi.fn(() => ({
id: "M-001",
status: "active",
milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features }] }],
})),
triageFeature: vi.fn(async () => {
features[0] = { ...features[0], taskId: "FN-001", status: "triaged" };
tasks.push({ id: "FN-001", title: "Feature one", missionId: "M-001", sliceId: "SL-001", column: "todo", status: "queued" });
return features[0];
}),
linkFeatureToTask: vi.fn(),
updateFeatureStatus: vi.fn(),
listAssertionsForFeature: vi.fn(() => []),
};
const scheduler = new Scheduler(store, { missionStore: missionStore as any });
await scheduler.reconcileAllMissionFeatures();
expect((store.recordRunAuditEvent as any).mock.calls.some(([event]: any[]) => event.mutationType === "mission:stranded-feature-triaged" && event.metadata?.featureId === "F-001" && event.metadata?.taskId === "FN-001")).toBe(true);
});
});

View File

@@ -397,6 +397,7 @@ describe("Scheduler", () => {
title: "Linked feature",
status: "triaged",
})),
triageFeature: vi.fn(),
...overrides,
};
}
@@ -4487,6 +4488,160 @@ describe("Scheduler", () => {
expect(result).toBe(2);
});
it("triages defined stranded features for active autopilot slices", async () => {
const triagedFeature = {
id: "F-001",
sliceId: "SL-001",
title: "Feature one",
taskId: "FN-TRIAGED",
status: "triaged",
};
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(createMockTask({ id: "FN-TRIAGED", column: "todo" })),
});
const mockMissionStore = createMockMissionStore({
listMissions: vi.fn().mockReturnValue([{ id: "M-001", status: "active", autopilotEnabled: true }]),
getMissionWithHierarchy: vi.fn().mockReturnValue({
id: "M-001",
status: "active",
milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: [{ id: "F-001", sliceId: "SL-001", title: "Feature one", taskId: undefined, status: "defined" }] }] }],
}),
triageFeature: vi.fn().mockResolvedValue(triagedFeature),
updateFeatureStatus: vi.fn(),
});
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
const result = await scheduler.reconcileAllMissionFeatures();
expect(mockMissionStore.triageFeature).toHaveBeenCalledWith("F-001");
expect(result).toBe(1);
});
it("does not triage stranded features for non-autopilot missions", async () => {
const store = createMockStore({ getTask: vi.fn() });
const mockMissionStore = createMockMissionStore({
listMissions: vi.fn().mockReturnValue([{ id: "M-001", status: "active", autopilotEnabled: false, autoAdvance: false }]),
getMissionWithHierarchy: vi.fn().mockReturnValue({
id: "M-001",
status: "active",
milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: [{ id: "F-001", sliceId: "SL-001", title: "Feature one", taskId: undefined, status: "defined" }] }] }],
}),
triageFeature: vi.fn(),
updateFeatureStatus: vi.fn(),
});
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
const result = await scheduler.reconcileAllMissionFeatures();
expect(mockMissionStore.triageFeature).not.toHaveBeenCalled();
expect(result).toBe(0);
});
it("links title-matched stranded features instead of re-triaging", async () => {
const matchedTask = createMockTask({
id: "FN-1703",
title: "Feature one",
missionId: "M-001",
sliceId: "SL-001",
column: "todo",
} as Partial<Task>);
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([matchedTask]),
getTask: vi.fn().mockResolvedValue(matchedTask),
});
const mockMissionStore = createMockMissionStore({
listMissions: vi.fn().mockReturnValue([{ id: "M-001", status: "active", autopilotEnabled: true }]),
getMissionWithHierarchy: vi.fn().mockReturnValue({
id: "M-001",
status: "active",
milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: [{ id: "F-001", sliceId: "SL-001", title: "Feature one", taskId: undefined, status: "triaged" }] }] }],
}),
triageFeature: vi.fn(),
linkFeatureToTask: vi.fn().mockReturnValue({ id: "F-001", sliceId: "SL-001", title: "Feature one", taskId: "FN-1703", status: "triaged" }),
updateFeatureStatus: vi.fn(),
});
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
const result = await scheduler.reconcileAllMissionFeatures();
expect(mockMissionStore.linkFeatureToTask).toHaveBeenCalledWith("F-001", "FN-1703");
expect(mockMissionStore.triageFeature).not.toHaveBeenCalled();
expect(result).toBe(1);
});
it("skips inconsistent non-defined stranded features with no title match", async () => {
const store = createMockStore({ getTask: vi.fn() });
const mockMissionStore = createMockMissionStore({
listMissions: vi.fn().mockReturnValue([{ id: "M-001", status: "active", autopilotEnabled: true }]),
getMissionWithHierarchy: vi.fn().mockReturnValue({
id: "M-001",
status: "active",
milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: [{ id: "F-001", sliceId: "SL-001", title: "Feature one", taskId: undefined, status: "triaged" }] }] }],
}),
triageFeature: vi.fn(),
updateFeatureStatus: vi.fn(),
});
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
const result = await scheduler.reconcileAllMissionFeatures();
expect(mockMissionStore.triageFeature).not.toHaveBeenCalled();
expect(result).toBe(0);
});
it("skips blocked stranded features", async () => {
const store = createMockStore({ getTask: vi.fn() });
const mockMissionStore = createMockMissionStore({
listMissions: vi.fn().mockReturnValue([{ id: "M-001", status: "active", autopilotEnabled: true }]),
getMissionWithHierarchy: vi.fn().mockReturnValue({
id: "M-001",
status: "active",
milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: [{ id: "F-001", sliceId: "SL-001", title: "Feature one", taskId: undefined, status: "blocked" }] }] }],
}),
triageFeature: vi.fn(),
});
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
const result = await scheduler.reconcileAllMissionFeatures();
expect(mockMissionStore.triageFeature).not.toHaveBeenCalled();
expect(result).toBe(0);
});
it("emits stranded-feature audit events for triaged features", async () => {
const triagedFeature = {
id: "F-001",
sliceId: "SL-001",
title: "Feature one",
taskId: "FN-TRIAGED",
status: "triaged",
};
const recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
const store = createMockStore({
recordRunAuditEvent,
getTask: vi.fn().mockResolvedValue(createMockTask({ id: "FN-TRIAGED", column: "todo" })),
});
const mockMissionStore = createMockMissionStore({
listMissions: vi.fn().mockReturnValue([{ id: "M-001", status: "active", autopilotEnabled: true }]),
getMissionWithHierarchy: vi.fn().mockReturnValue({
id: "M-001",
status: "active",
milestones: [{ id: "MS-001", slices: [{ id: "SL-001", status: "active", features: [{ id: "F-001", sliceId: "SL-001", title: "Feature one", taskId: undefined, status: "defined" }] }] }],
}),
triageFeature: vi.fn().mockResolvedValue(triagedFeature),
});
const scheduler = new Scheduler(store, { missionStore: mockMissionStore as any });
await scheduler.reconcileAllMissionFeatures();
expect(recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({
domain: "database",
mutationType: "mission:stranded-feature-triaged",
target: "F-001",
metadata: expect.objectContaining({ missionId: "M-001", sliceId: "SL-001", featureId: "F-001", taskId: "FN-TRIAGED" }),
}));
});
it("skips features without taskId", async () => {
const store = createMockStore({
getTask: vi.fn(),

View File

@@ -414,6 +414,7 @@ export type DatabaseMutationType =
| "verification:followup-deduped"
/** Metadata: { kind, parentTaskId, newTaskId, signature, supersedesTaskId } */
| "verification:followup-created"
| "mission:stranded-feature-triaged"
| "task:auto-recover-branch-misbound"
| "task:auto-recover-misrouted-foreign-commit"
| "task:auto-recover-foreign-only-contamination"

View File

@@ -732,6 +732,7 @@ export class InProcessRuntime
getActiveMergeTaskId: () => this.activeMergeTaskIdProvider?.() ?? null,
leaseManager: this.leaseManager,
hasActiveAgentExecution: (agentId: string) => this.heartbeatMonitor?.getTrackedAgents().includes(agentId) ?? false,
reconcileAllMissionFeatures: async () => this.scheduler.reconcileAllMissionFeatures(),
chatStore: this.chatStore,
messageStore: this.messageStore,
restartDurableAgentHeartbeat: async (agentId: string, context: { reason: string; attempt: number }) => {

View File

@@ -2033,10 +2033,11 @@ export class Scheduler {
.filter((slice) => slice.status === "active");
for (const slice of activeSlices) {
const missionAutoTriageEnabled = mission.autopilotEnabled === true || mission.autoAdvance === true;
for (const feature of slice.features) {
let featureForReconciliation = feature;
let task: Task | undefined;
if (feature.taskId) {
task = await this.store.getTask(feature.taskId);
} else {
@@ -2050,6 +2051,36 @@ export class Scheduler {
featureForReconciliation = missionStore.linkFeatureToTask(feature.id, matchedTask.id);
task = matchedTask;
totalFixed++;
await this.emitStrandedFeatureTriageAudit(mission.id, slice.id, feature.id, matchedTask.id);
} else if (
missionAutoTriageEnabled
&& feature.status !== "blocked"
) {
if (feature.status === "defined") {
try {
featureForReconciliation = await missionStore.triageFeature(feature.id);
task = featureForReconciliation.taskId
? await this.store.getTask(featureForReconciliation.taskId)
: undefined;
totalFixed++;
if (featureForReconciliation.taskId) {
await this.emitStrandedFeatureTriageAudit(
mission.id,
slice.id,
featureForReconciliation.id,
featureForReconciliation.taskId,
);
}
} catch (error) {
schedulerLog.warn(
`Failed to triage stranded feature ${feature.id} during reconciliation: ${error instanceof Error ? error.message : String(error)}`,
);
}
} else {
schedulerLog.warn(
`Skipping stranded feature ${feature.id} with status ${feature.status}: no linked task and no title-matched task available`,
);
}
}
}
@@ -2095,6 +2126,37 @@ export class Scheduler {
return totalFixed;
}
private async emitStrandedFeatureTriageAudit(
missionId: string,
sliceId: string,
featureId: string,
taskId: string,
): Promise<void> {
const auditor = createRunAuditor(this.store, {
runId: generateSyntheticRunId("scheduler-mission-stranded-feature", featureId),
agentId: "scheduler",
taskId,
phase: "mission-stranded-feature-reconcile",
});
try {
await auditor.database({
type: "mission:stranded-feature-triaged",
target: featureId,
metadata: {
missionId,
sliceId,
featureId,
taskId,
},
});
} catch (error) {
schedulerLog.warn(
`Feature ${featureId} failed to emit stranded-feature triage audit: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
private getMissionFeatureTitleKey(sliceId: string, title: string): string {
return `${sliceId}\0${this.normalizeMissionFeatureTitle(title)}`;
}

View File

@@ -274,6 +274,8 @@ export interface SelfHealingOptions {
/** Optional notifier for board-stall unrecovered alerts. */
ntfyNotifier?: Pick<NtfyNotifier, "notifyBoardStallUnrecovered">;
getProjectId?: () => string;
/** Optional callback to reconcile active mission features during maintenance. */
reconcileAllMissionFeatures?: () => Promise<number>;
}
const APPROVED_TRIAGE_RECOVERY_GRACE_MS = 60_000;
@@ -1468,6 +1470,15 @@ export class SelfHealingManager {
} else {
// Batch 2 — Task recovery (operations are independent of each other)
const batch2Fns: Array<{ name: string; fn: () => Promise<unknown> }> = [
{
name: "reconcile-mission-features",
fn: async () => {
if (!this.options.reconcileAllMissionFeatures) {
return;
}
await this.options.reconcileAllMissionFeatures();
},
},
{ name: "recover-completed-tasks", fn: () => this.recoverCompletedTasks() },
{ name: "recover-stranded-completed-todo", fn: () => this.recoverStrandedCompletedTodoTasks() },
{ name: "recover-stale-incomplete-review", fn: () => this.recoverStaleIncompleteReviewTasks() },