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:
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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]"));
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
117
packages/engine/src/unlinked-missions-advisory-reporter.ts
Normal file
117
packages/engine/src/unlinked-missions-advisory-reporter.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user