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:
@@ -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.
|
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
|
### 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`:
|
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`:
|
||||||
|
|||||||
@@ -395,6 +395,8 @@ export function PrCreateModal({
|
|||||||
const handleBaseChange = useCallback(async (nextBase: string) => {
|
const handleBaseChange = useCallback(async (nextBase: string) => {
|
||||||
baseBranchTouchedRef.current = true;
|
baseBranchTouchedRef.current = true;
|
||||||
setBaseBranch(nextBase);
|
setBaseBranch(nextBase);
|
||||||
|
setPushBranchError(null);
|
||||||
|
setResolveConflictError(null);
|
||||||
await loadPreflight(nextBase);
|
await loadPreflight(nextBase);
|
||||||
}, [loadPreflight]);
|
}, [loadPreflight]);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { MissionExecutionLoop } from "../mission-execution-loop.js";
|
|||||||
|
|
||||||
const staleReporterReportMock = vi.fn();
|
const staleReporterReportMock = vi.fn();
|
||||||
const backlogPressureReporterReportMock = vi.fn();
|
const backlogPressureReporterReportMock = vi.fn();
|
||||||
|
const unlinkedMissionsAdvisoryReporterReportMock = vi.fn();
|
||||||
|
|
||||||
// Mock fs modules
|
// Mock fs modules
|
||||||
vi.mock("node:fs", async (importOriginal) => {
|
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
|
// Helper to create mock tasks
|
||||||
function createMockTask(overrides: Partial<Task> = {}): Task {
|
function createMockTask(overrides: Partial<Task> = {}): Task {
|
||||||
return {
|
return {
|
||||||
@@ -446,6 +453,7 @@ describe("Scheduler", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
staleReporterReportMock.mockReset().mockResolvedValue({ surfaced: 0 });
|
staleReporterReportMock.mockReset().mockResolvedValue({ surfaced: 0 });
|
||||||
backlogPressureReporterReportMock.mockReset().mockResolvedValue({ alerted: false });
|
backlogPressureReporterReportMock.mockReset().mockResolvedValue({ alerted: false });
|
||||||
|
unlinkedMissionsAdvisoryReporterReportMock.mockReset().mockResolvedValue({ alerted: false });
|
||||||
});
|
});
|
||||||
// Helper to create mock MissionStore (shared across mission-related test suites)
|
// Helper to create mock MissionStore (shared across mission-related test suites)
|
||||||
function createMockMissionStore(overrides = {}) {
|
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 type { AutoClaimSnapshotManager } from "./auto-claim-snapshot.js";
|
||||||
import { StaleTaskReporter } from "./stale-task-reporter.js";
|
import { StaleTaskReporter } from "./stale-task-reporter.js";
|
||||||
import { BacklogPressureReporter } from "./backlog-pressure-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 { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
||||||
import { isWorkflowColumnsEnabled, DEFAULT_WORKFLOW_POOL_ID } from "@fusion/core";
|
import { isWorkflowColumnsEnabled, DEFAULT_WORKFLOW_POOL_ID } from "@fusion/core";
|
||||||
import { runHoldReleaseSweep, type SlotReservation } from "./hold-release.js";
|
import { runHoldReleaseSweep, type SlotReservation } from "./hold-release.js";
|
||||||
@@ -484,8 +485,10 @@ export class Scheduler {
|
|||||||
private lastAutoClaimFingerprint = new Map<string, string>();
|
private lastAutoClaimFingerprint = new Map<string, string>();
|
||||||
private readonly staleTaskReporter: StaleTaskReporter;
|
private readonly staleTaskReporter: StaleTaskReporter;
|
||||||
private readonly backlogPressureReporter: BacklogPressureReporter;
|
private readonly backlogPressureReporter: BacklogPressureReporter;
|
||||||
|
private readonly unlinkedMissionsAdvisoryReporter: UnlinkedMissionsAdvisoryReporter;
|
||||||
private lastStaleTaskReportAt = 0;
|
private lastStaleTaskReportAt = 0;
|
||||||
private lastBacklogPressureReportAt = 0;
|
private lastBacklogPressureReportAt = 0;
|
||||||
|
private lastUnlinkedMissionsAdvisoryReportAt = 0;
|
||||||
private readonly lastHighOverlapFanoutWarningKey = new Map<string, string>();
|
private readonly lastHighOverlapFanoutWarningKey = new Map<string, string>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -505,6 +508,11 @@ export class Scheduler {
|
|||||||
projectId: this.store.getRootDir(),
|
projectId: this.store.getRootDir(),
|
||||||
logger: schedulerLog,
|
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
|
* Event-driven scheduling: when a task is created, trigger a scheduling
|
||||||
* pass immediately instead of waiting for the next poll interval.
|
* pass immediately instead of waiting for the next poll interval.
|
||||||
@@ -1937,6 +1945,16 @@ export class Scheduler {
|
|||||||
this.lastBacklogPressureReportAt = Date.now();
|
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) {
|
} catch (err) {
|
||||||
schedulerLog.error("Scheduling error:", err);
|
schedulerLog.error("Scheduling error:", err);
|
||||||
} finally {
|
} 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