From fb6b441fbed8bcec14398fe8967bbad1bfdc8de1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 23:36:28 -0700 Subject: [PATCH] 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 --- docs/missions.md | 2 + .../app/components/PrCreateModal.tsx | 2 + .../engine/src/__tests__/scheduler.test.ts | 8 + ...nlinked-missions-advisory-reporter.test.ts | 170 ++++++++++++++++++ packages/engine/src/scheduler.ts | 18 ++ .../unlinked-missions-advisory-reporter.ts | 117 ++++++++++++ 6 files changed, 317 insertions(+) create mode 100644 packages/engine/src/__tests__/unlinked-missions-advisory-reporter.test.ts create mode 100644 packages/engine/src/unlinked-missions-advisory-reporter.ts diff --git a/docs/missions.md b/docs/missions.md index d5b02f94d3..055ea02413 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -52,6 +52,8 @@ Mission ↔ goal links are created and removed deliberately as part of normal pl Mission Manager shows an **Unlinked** indicator on active mission cards when `linkedGoalCount` is zero. This is a read-only attention badge so operators can quickly find active missions that still need an explicit goal association. +The engine also emits a workflow insight with advisory key `unlinked_missions_advisory` when it first observes one or more active missions with zero goal links. The insight is advisory only, includes only the affected mission ids plus a count, and is deduped to one stable row so it does not spam on every scheduler heartbeat. + ### Task → Goal provenance When a mission feature is linked or triaged into a task, Fusion does **not** copy goal ids onto the task row. Instead, task goal provenance is always derived from the mission link owned by `MissionStore`: diff --git a/packages/dashboard/app/components/PrCreateModal.tsx b/packages/dashboard/app/components/PrCreateModal.tsx index 70535c09db..8397fa9075 100644 --- a/packages/dashboard/app/components/PrCreateModal.tsx +++ b/packages/dashboard/app/components/PrCreateModal.tsx @@ -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]); diff --git a/packages/engine/src/__tests__/scheduler.test.ts b/packages/engine/src/__tests__/scheduler.test.ts index 7a431a3286..4b4ef4fff5 100644 --- a/packages/engine/src/__tests__/scheduler.test.ts +++ b/packages/engine/src/__tests__/scheduler.test.ts @@ -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 { 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 = {}) { diff --git a/packages/engine/src/__tests__/unlinked-missions-advisory-reporter.test.ts b/packages/engine/src/__tests__/unlinked-missions-advisory-reporter.test.ts new file mode 100644 index 0000000000..3c555473ac --- /dev/null +++ b/packages/engine/src/__tests__/unlinked-missions-advisory-reporter.test.ts @@ -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 { + 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; + insightStore?: { upsertInsight: ReturnType; listInsights: ReturnType }; + 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]")); + }); +}); diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index 35afa2e55a..5a014383eb 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -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(); 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(); /** @@ -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 { diff --git a/packages/engine/src/unlinked-missions-advisory-reporter.ts b/packages/engine/src/unlinked-missions-advisory-reporter.ts new file mode 100644 index 0000000000..55aa9cc8b2 --- /dev/null +++ b/packages/engine/src/unlinked-missions-advisory-reporter.ts @@ -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" }; + } + } +}