FN-5960: add unlinked mission advisory reporting

Add explicit visibility for active missions that remain intentionally unlinked to goals.

- document the no-backfill decision with an engine-emitted unlinked mission advisory
- add a scheduler reporter that emits one deduped workflow insight for active missions without goal links and falls back to task logs when insight storage is unavailable
- cover the advisory reporter and scheduler integration with focused tests
- clear PR remediation errors when changing the Create Pull Request base branch

Files changed:
 docs/missions.md                                   |   2 +
 .../dashboard/app/components/PrCreateModal.tsx     |   2 +
 packages/engine/src/__tests__/scheduler.test.ts    |   8 +
 .../unlinked-missions-advisory-reporter.test.ts    | 170 +++++++++++++++++++++
 packages/engine/src/scheduler.ts                   |  18 +++
 .../src/unlinked-missions-advisory-reporter.ts     | 117 ++++++++++++++
 6 files changed, 317 insertions(+)

Fusion-Task-Id: FN-5960

Fusion-Task-Lineage: efdbb4f5-fbd5-4ed7-9b9e-58706041d993
This commit is contained in:
gsxdsm
2026-06-04 23:36:28 -07:00
parent fa36d68621
commit fb6b441fbe
6 changed files with 317 additions and 0 deletions

View File

@@ -395,6 +395,8 @@ export function PrCreateModal({
const handleBaseChange = useCallback(async (nextBase: string) => {
baseBranchTouchedRef.current = true;
setBaseBranch(nextBase);
setPushBranchError(null);
setResolveConflictError(null);
await loadPreflight(nextBase);
}, [loadPreflight]);

View File

@@ -19,6 +19,7 @@ import { MissionExecutionLoop } from "../mission-execution-loop.js";
const staleReporterReportMock = vi.fn();
const backlogPressureReporterReportMock = vi.fn();
const unlinkedMissionsAdvisoryReporterReportMock = vi.fn();
// Mock fs modules
vi.mock("node:fs", async (importOriginal) => {
@@ -62,6 +63,12 @@ vi.mock("../backlog-pressure-reporter.js", () => ({
})),
}));
vi.mock("../unlinked-missions-advisory-reporter.js", () => ({
UnlinkedMissionsAdvisoryReporter: vi.fn().mockImplementation(() => ({
report: unlinkedMissionsAdvisoryReporterReportMock,
})),
}));
// Helper to create mock tasks
function createMockTask(overrides: Partial<Task> = {}): Task {
return {
@@ -446,6 +453,7 @@ describe("Scheduler", () => {
beforeEach(() => {
staleReporterReportMock.mockReset().mockResolvedValue({ surfaced: 0 });
backlogPressureReporterReportMock.mockReset().mockResolvedValue({ alerted: false });
unlinkedMissionsAdvisoryReporterReportMock.mockReset().mockResolvedValue({ alerted: false });
});
// Helper to create mock MissionStore (shared across mission-related test suites)
function createMockMissionStore(overrides = {}) {

View File

@@ -0,0 +1,170 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { computeInsightFingerprint, type Mission, type TaskStore } from "@fusion/core";
import {
UNLINKED_MISSIONS_ADVISORY_KEY,
UNLINKED_MISSIONS_ADVISORY_TITLE,
UnlinkedMissionsAdvisoryReporter,
} from "../unlinked-missions-advisory-reporter.js";
function createMission(overrides: Partial<Mission> = {}): Mission {
return {
id: "M-001",
title: "Mission",
status: "active",
interviewState: "complete",
createdAt: "2026-06-03T00:00:00.000Z",
updatedAt: "2026-06-03T00:00:00.000Z",
...overrides,
} as Mission;
}
function createStore(params: {
missions?: Mission[];
goalIdsByMissionId?: Record<string, string[]>;
insightStore?: { upsertInsight: ReturnType<typeof vi.fn>; listInsights: ReturnType<typeof vi.fn> };
throwInsightStore?: boolean;
}): TaskStore {
const missionStore = {
listMissions: vi.fn().mockReturnValue(params.missions ?? []),
listGoalIdsForMission: vi.fn().mockImplementation((missionId: string) => params.goalIdsByMissionId?.[missionId] ?? []),
};
return {
getMissionStore: vi.fn().mockReturnValue(missionStore),
getInsightStore: vi.fn().mockImplementation(() => {
if (params.throwInsightStore) {
throw new Error("missing insight store");
}
return params.insightStore;
}),
logEntry: vi.fn().mockResolvedValue(undefined),
} as unknown as TaskStore;
}
describe("UnlinkedMissionsAdvisoryReporter", () => {
const logger = { warn: vi.fn(), error: vi.fn() };
beforeEach(() => {
vi.clearAllMocks();
});
it("returns none-unlinked when there are zero missions", async () => {
const insightStore = { upsertInsight: vi.fn(), listInsights: vi.fn().mockReturnValue([]) };
const store = createStore({ missions: [], insightStore });
const reporter = new UnlinkedMissionsAdvisoryReporter({ store, projectId: "/tmp/project", logger });
await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "none-unlinked" });
expect(insightStore.upsertInsight).not.toHaveBeenCalled();
});
it("emits exactly one advisory for active unlinked missions", async () => {
const insightStore = { upsertInsight: vi.fn(), listInsights: vi.fn().mockReturnValue([]) };
const store = createStore({
missions: [createMission({ id: "M-UNLINKED" })],
insightStore,
});
const reporter = new UnlinkedMissionsAdvisoryReporter({
store,
projectId: "/tmp/project",
logger,
now: () => Date.parse("2026-06-03T12:00:00.000Z"),
});
await expect(reporter.report()).resolves.toEqual({ alerted: true });
expect(insightStore.upsertInsight).toHaveBeenCalledTimes(1);
const input = insightStore.upsertInsight.mock.calls[0][1];
expect(input.title).toBe(UNLINKED_MISSIONS_ADVISORY_TITLE);
expect(input.fingerprint).toBe(computeInsightFingerprint(UNLINKED_MISSIONS_ADVISORY_TITLE, "workflow"));
expect(input.provenance.metadata).toMatchObject({
generator: "unlinked-missions-advisory-reporter",
advisoryKey: UNLINKED_MISSIONS_ADVISORY_KEY,
});
expect(JSON.parse(input.content)).toEqual({
unlinkedCount: 1,
missionIds: ["M-UNLINKED"],
detectedAt: "2026-06-03T12:00:00.000Z",
});
});
it("excludes active missions that already have linked goals", async () => {
const insightStore = { upsertInsight: vi.fn(), listInsights: vi.fn().mockReturnValue([]) };
const store = createStore({
missions: [createMission({ id: "M-LINKED" })],
goalIdsByMissionId: { "M-LINKED": ["G-001"] },
insightStore,
});
const reporter = new UnlinkedMissionsAdvisoryReporter({ store, projectId: "/tmp/project", logger });
await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "none-unlinked" });
expect(insightStore.upsertInsight).not.toHaveBeenCalled();
});
it("excludes archived unlinked missions", async () => {
const insightStore = { upsertInsight: vi.fn(), listInsights: vi.fn().mockReturnValue([]) };
const store = createStore({
missions: [createMission({ id: "M-ARCHIVED", status: "archived" })],
insightStore,
});
const reporter = new UnlinkedMissionsAdvisoryReporter({ store, projectId: "/tmp/project", logger });
await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "none-unlinked" });
expect(insightStore.upsertInsight).not.toHaveBeenCalled();
});
it("reports only the active unlinked subset for mixed mission states", async () => {
const insightStore = { upsertInsight: vi.fn(), listInsights: vi.fn().mockReturnValue([]) };
const store = createStore({
missions: [
createMission({ id: "M-UNLINKED-A" }),
createMission({ id: "M-LINKED" }),
createMission({ id: "M-ARCHIVED", status: "archived" }),
createMission({ id: "M-UNLINKED-B" }),
],
goalIdsByMissionId: { "M-LINKED": ["G-001"], "M-ARCHIVED": [] },
insightStore,
});
const reporter = new UnlinkedMissionsAdvisoryReporter({ store, projectId: "/tmp/project", logger });
await expect(reporter.report()).resolves.toEqual({ alerted: true });
const content = JSON.parse(insightStore.upsertInsight.mock.calls[0][1].content);
expect(content.unlinkedCount).toBe(2);
expect(content.missionIds).toEqual(["M-UNLINKED-A", "M-UNLINKED-B"]);
});
it("does not emit a second advisory when an existing advisory insight already exists", async () => {
const insightStore = {
upsertInsight: vi.fn(),
listInsights: vi.fn().mockReturnValue([
{
title: UNLINKED_MISSIONS_ADVISORY_TITLE,
updatedAt: "2026-06-03T12:00:00.000Z",
provenance: { metadata: { advisoryKey: UNLINKED_MISSIONS_ADVISORY_KEY } },
},
]),
};
const store = createStore({
missions: [createMission({ id: "M-UNLINKED" })],
insightStore,
});
const reporter = new UnlinkedMissionsAdvisoryReporter({ store, projectId: "/tmp/project", logger });
await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "already-reported" });
expect(insightStore.upsertInsight).not.toHaveBeenCalled();
});
it.each([
{ projectId: "", throwInsightStore: false },
{ projectId: "/tmp/project", throwInsightStore: true },
])("fails soft when insight infrastructure is unavailable %#", async ({ projectId, throwInsightStore }) => {
const store = createStore({
missions: [createMission({ id: "M-UNLINKED" })],
insightStore: { upsertInsight: vi.fn(), listInsights: vi.fn().mockReturnValue([]) },
throwInsightStore,
});
const reporter = new UnlinkedMissionsAdvisoryReporter({ store, projectId, logger });
await expect(reporter.report()).resolves.toEqual({ alerted: true });
expect(store.logEntry).toHaveBeenCalledTimes(1);
expect(store.logEntry).toHaveBeenCalledWith("M-UNLINKED", expect.stringContaining("[unlinked-missions-advisory]"));
});
});

View File

@@ -31,6 +31,7 @@ import { selectPermanentAgentForTask } from "./agent-assignment.js";
import type { AutoClaimSnapshotManager } from "./auto-claim-snapshot.js";
import { StaleTaskReporter } from "./stale-task-reporter.js";
import { BacklogPressureReporter } from "./backlog-pressure-reporter.js";
import { UnlinkedMissionsAdvisoryReporter } from "./unlinked-missions-advisory-reporter.js";
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
import { isWorkflowColumnsEnabled, DEFAULT_WORKFLOW_POOL_ID } from "@fusion/core";
import { runHoldReleaseSweep, type SlotReservation } from "./hold-release.js";
@@ -484,8 +485,10 @@ export class Scheduler {
private lastAutoClaimFingerprint = new Map<string, string>();
private readonly staleTaskReporter: StaleTaskReporter;
private readonly backlogPressureReporter: BacklogPressureReporter;
private readonly unlinkedMissionsAdvisoryReporter: UnlinkedMissionsAdvisoryReporter;
private lastStaleTaskReportAt = 0;
private lastBacklogPressureReportAt = 0;
private lastUnlinkedMissionsAdvisoryReportAt = 0;
private readonly lastHighOverlapFanoutWarningKey = new Map<string, string>();
/**
@@ -505,6 +508,11 @@ export class Scheduler {
projectId: this.store.getRootDir(),
logger: schedulerLog,
});
this.unlinkedMissionsAdvisoryReporter = new UnlinkedMissionsAdvisoryReporter({
store: this.store,
projectId: this.store.getRootDir(),
logger: schedulerLog,
});
/**
* Event-driven scheduling: when a task is created, trigger a scheduling
* pass immediately instead of waiting for the next poll interval.
@@ -1937,6 +1945,16 @@ export class Scheduler {
this.lastBacklogPressureReportAt = Date.now();
}
}
if (Date.now() - this.lastUnlinkedMissionsAdvisoryReportAt >= 60_000) {
try {
await this.unlinkedMissionsAdvisoryReporter.report();
} catch (error) {
schedulerLog.warn("Unlinked missions advisory reporter failed", error);
} finally {
this.lastUnlinkedMissionsAdvisoryReportAt = Date.now();
}
}
} catch (err) {
schedulerLog.error("Scheduling error:", err);
} finally {

View File

@@ -0,0 +1,117 @@
import { computeInsightFingerprint, type Mission, type TaskStore } from "@fusion/core";
import { createLogger } from "./logger.js";
const reporterLog = createLogger("unlinked-missions-advisory");
export const UNLINKED_MISSIONS_ADVISORY_TITLE = "Unlinked active missions need goal links";
export const UNLINKED_MISSIONS_ADVISORY_KEY = "unlinked_missions_advisory";
type UnlinkedMissionsAdvisoryReporterLogger = {
warn: (message: string, ...args: unknown[]) => void;
error?: (message: string, ...args: unknown[]) => void;
};
interface UnlinkedMissionsAdvisoryReporterOptions {
store: TaskStore;
projectId: string;
logger?: UnlinkedMissionsAdvisoryReporterLogger;
now?: () => number;
}
export class UnlinkedMissionsAdvisoryReporter {
private readonly store: TaskStore;
private readonly projectId: string;
private readonly logger: UnlinkedMissionsAdvisoryReporterLogger;
private readonly now: () => number;
constructor(options: UnlinkedMissionsAdvisoryReporterOptions) {
this.store = options.store;
this.projectId = options.projectId;
this.logger = options.logger ?? reporterLog;
this.now = options.now ?? (() => Date.now());
}
async report(): Promise<{ alerted: boolean; reason?: string }> {
try {
const missionStore = this.store.getMissionStore();
const missions = missionStore.listMissions();
const unlinkedActiveMissions: Mission[] = [];
for (const mission of missions) {
if (mission.status !== "active") {
continue;
}
if (missionStore.listGoalIdsForMission(mission.id).length > 0) {
continue;
}
unlinkedActiveMissions.push(mission);
}
if (unlinkedActiveMissions.length === 0) {
return { alerted: false, reason: "none-unlinked" };
}
const detectedAt = new Date(this.now()).toISOString();
const missionIds = unlinkedActiveMissions.map((mission) => mission.id);
const content = JSON.stringify({
unlinkedCount: missionIds.length,
missionIds,
detectedAt,
});
let insightStore;
try {
if (!this.projectId) {
throw new Error("empty projectId");
}
insightStore = this.store.getInsightStore();
} catch (error) {
await this.store.logEntry(
missionIds[0],
`[unlinked-missions-advisory] ${content}`,
);
this.logger.warn("[unlinked-missions-advisory] insight store unavailable; logged fallback payload", error);
return { alerted: true };
}
const existingInsights = insightStore.listInsights({
projectId: this.projectId,
category: "workflow",
status: "generated",
limit: 10,
});
const existing = existingInsights.find(
(insight) =>
insight.title === UNLINKED_MISSIONS_ADVISORY_TITLE &&
insight.provenance?.metadata?.advisoryKey === UNLINKED_MISSIONS_ADVISORY_KEY,
);
if (existing) {
return { alerted: false, reason: "already-reported" };
}
insightStore.upsertInsight(this.projectId, {
title: UNLINKED_MISSIONS_ADVISORY_TITLE,
content,
category: "workflow",
fingerprint: computeInsightFingerprint(UNLINKED_MISSIONS_ADVISORY_TITLE, "workflow"),
provenance: {
trigger: "schedule",
description:
"Advisory for active missions that still need explicit goal links after the no-backfill decision.",
relatedEntityIds: missionIds,
metadata: {
generator: "unlinked-missions-advisory-reporter",
advisoryKey: UNLINKED_MISSIONS_ADVISORY_KEY,
},
},
});
this.logger.warn(
`[unlinked-missions-advisory] advisory emitted for active missions without goal links: ${missionIds.join(",")}`,
);
return { alerted: true };
} catch (error) {
this.logger.error?.("[unlinked-missions-advisory] reporter failed", error);
return { alerted: false, reason: "error" };
}
}
}