FN-8252: rescue quarantined engine tests
Restore non-mechanical engine coverage with PostgreSQL-safe test fixtures and awaited overseer audit writes. - migrate eligible engine tests to shared PostgreSQL harnesses and restore their Vitest coverage - harden mission and advisory reporting paths for async persistence and observable failures - await the production planner-overseer audit callback and verify the start() wiring preserves persistence Files changed: .../__tests__/mission-autopilot-end-to-end.test.ts | 27 ++-- .../engine/src/__tests__/mission-autopilot.test.ts | 4 +- .../planner-overseer-intervention-wiring.test.ts | 39 +++--- .../engine/src/__tests__/project-engine.test.ts | 138 ++++++++++++++++----- .../unlinked-missions-advisory-reporter.pg.test.ts | 51 ++++++++ .../unlinked-missions-advisory-reporter.test.ts | 20 ++- packages/engine/src/mission-execution-loop.ts | 27 ++-- packages/engine/src/project-engine.ts | 41 +++--- .../src/unlinked-missions-advisory-reporter.ts | 23 ++-- packages/engine/vitest.config.ts | 6 +- scripts/lib/test-quarantine.json | 27 +--- 11 files changed, 260 insertions(+), 143 deletions(-) Fusion-Task-Id: FN-8252 Fusion-Task-Lineage: 4f86ce7e-11a2-4704-a5d1-00e0a8c1448e Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -72,6 +72,7 @@ function makeHarness({
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
|
||||
});
|
||||
|
||||
let missionStore: any;
|
||||
const taskStore = {
|
||||
getTask: vi.fn(async () => task),
|
||||
getRootDir: vi.fn(() => "/tmp"),
|
||||
@@ -83,11 +84,13 @@ function makeHarness({
|
||||
moveTask: vi.fn(async () => undefined),
|
||||
logEntry: vi.fn(async () => undefined),
|
||||
recordRunAuditEvent: vi.fn(async () => undefined),
|
||||
updateSettings: vi.fn(async () => ({})),
|
||||
getMissionStore: vi.fn(() => missionStore),
|
||||
on,
|
||||
off: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
const missionStore: any = {
|
||||
missionStore = {
|
||||
getMission: vi.fn(() => mission),
|
||||
listMissions: vi.fn(() => [mission]),
|
||||
updateMission: vi.fn((_id: string, updates: any) => ({ ...mission, ...updates })),
|
||||
@@ -121,8 +124,10 @@ function makeHarness({
|
||||
}
|
||||
return next;
|
||||
}),
|
||||
listAssertionsForFeature: vi.fn(() => (withAssertions ? [{ id: "CA-1", milestoneId: milestone.id, title: "assert", assertion: "works", status: "pending", orderIndex: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }] : [])),
|
||||
startValidatorRun: vi.fn(() => ({ id: "VR-001", featureId: feature.id, milestoneId: milestone.id, sliceId: "SL-001", status: "running", triggerType: "task_completion", implementationAttempt: 1, validatorAttempt: 1, startedAt: new Date().toISOString(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() })),
|
||||
listAssertionsForFeature: vi.fn(async () => (withAssertions ? [{ id: "CA-1", milestoneId: milestone.id, title: "assert", assertion: "works", status: "pending", orderIndex: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }] : [])),
|
||||
ensureFeatureAssertionLinked: vi.fn(async () => (withAssertions ? [{ id: "CA-1", milestoneId: milestone.id, title: "generated assert", assertion: "works", status: "pending", orderIndex: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }] : [])),
|
||||
listGoalIdsForMission: vi.fn(async () => []),
|
||||
startValidatorRun: vi.fn(async () => ({ id: "VR-001", featureId: feature.id, milestoneId: milestone.id, sliceId: "SL-001", status: "running", triggerType: "task_completion", implementationAttempt: 1, validatorAttempt: 1, startedAt: new Date().toISOString(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() })),
|
||||
completeValidatorRun: vi.fn(),
|
||||
recordValidatorFailures: vi.fn(),
|
||||
createGeneratedFixFeature: vi.fn(),
|
||||
@@ -137,9 +142,10 @@ function makeHarness({
|
||||
const f = missionStore.getFeature(featureId);
|
||||
if (f?.taskId) await autopilot.handleTaskCompletion(f.taskId);
|
||||
} } });
|
||||
vi.spyOn(loop as any, "runValidation").mockImplementation(
|
||||
runValidationImpl ?? (async () => ({ status: "pass", assertions: [], summary: "ok" })),
|
||||
);
|
||||
vi.spyOn(loop as any, "runValidation").mockImplementation(async () => ({
|
||||
result: await (runValidationImpl ?? (async () => ({ status: "pass" as const, assertions: [], summary: "ok" })))(),
|
||||
inspection: { workspaceStale: false },
|
||||
}));
|
||||
loop.start();
|
||||
|
||||
const scheduler = new Scheduler(taskStore, {
|
||||
@@ -159,8 +165,6 @@ function makeHarness({
|
||||
task.column = to;
|
||||
const callbacks = handlers.get("task:moved") ?? [];
|
||||
await Promise.all(callbacks.map((cb) => cb({ task, from: "in-progress", to })));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
scheduler.start();
|
||||
@@ -174,10 +178,11 @@ describe("mission autopilot end-to-end wiring", () => {
|
||||
|
||||
const processSpy = vi.spyOn(h.loop, "processTaskOutcome");
|
||||
await h.emitTaskMoved("done");
|
||||
await vi.waitFor(() => expect(h.missionStore.getFeatureByTaskId("FN-001")?.status).toBe("done"));
|
||||
|
||||
expect(h.missionStore.getFeatureByTaskId("FN-001")?.status).toBe("done");
|
||||
expect(processSpy).toHaveBeenCalledWith("FN-001");
|
||||
expect(h.missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion");
|
||||
expect(h.missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion", "FN-001");
|
||||
expect(h.missionStore.completeValidatorRun).toHaveBeenCalledWith("VR-001", "passed", "ok");
|
||||
expect(h.slices.get("SL-001").status).toBe("complete");
|
||||
expect(h.activateSpy).toHaveBeenCalledWith("M-001");
|
||||
@@ -189,6 +194,7 @@ describe("mission autopilot end-to-end wiring", () => {
|
||||
const h = makeHarness({ withAssertions: false });
|
||||
|
||||
await h.emitTaskMoved("done");
|
||||
await vi.waitFor(() => expect(h.slices.get("SL-001").status).toBe("complete"));
|
||||
|
||||
expect(h.missionStore.startValidatorRun).not.toHaveBeenCalled();
|
||||
expect(h.slices.get("SL-001").status).toBe("complete");
|
||||
@@ -202,7 +208,7 @@ describe("mission autopilot end-to-end wiring", () => {
|
||||
|
||||
await h.loop.processTaskOutcome("FN-001");
|
||||
|
||||
expect(h.missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion");
|
||||
expect(h.missionStore.startValidatorRun).toHaveBeenCalledWith("F-001", "task_completion", "FN-001");
|
||||
expect(h.missionStore.completeValidatorRun).toHaveBeenCalledWith("VR-001", "passed", "ok");
|
||||
expect(h.missionStore.getFeature("F-001")?.status).toBe("done");
|
||||
expect(h.slices.get("SL-001").status).toBe("complete");
|
||||
@@ -239,6 +245,7 @@ describe("mission autopilot end-to-end wiring", () => {
|
||||
});
|
||||
|
||||
await h.emitTaskMoved("done");
|
||||
await vi.waitFor(() => expect(h.missionStore.completeValidatorRun).toHaveBeenCalledWith("VR-001", "error", "runtime unavailable"));
|
||||
|
||||
expect(h.missionStore.completeValidatorRun).toHaveBeenCalledWith("VR-001", "error", "runtime unavailable");
|
||||
expect(h.missionStore.logMissionEvent).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1223,7 +1223,7 @@ describe("MissionAutopilot", () => {
|
||||
expect(ap.isWatching(mission.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not clear watched state when normalization is accidentally called for an active mission", () => {
|
||||
it("does not clear watched state when normalization is accidentally called for an active mission", async () => {
|
||||
const mission = createMockMission({
|
||||
id: "M-ACTIVE",
|
||||
status: "active",
|
||||
@@ -1237,7 +1237,7 @@ describe("MissionAutopilot", () => {
|
||||
ap.watchMission("M-ACTIVE");
|
||||
store.updateMission.mockClear();
|
||||
store.logMissionEvent.mockClear();
|
||||
(ap as any).normalizeCompleteMissionAutopilotState("M-ACTIVE", "test");
|
||||
await (ap as any).normalizeCompleteMissionAutopilotState("M-ACTIVE", "test");
|
||||
|
||||
expect(ap.isWatching("M-ACTIVE")).toBe(true);
|
||||
expect(store.updateMission).not.toHaveBeenCalled();
|
||||
|
||||
@@ -23,11 +23,9 @@
|
||||
* FN-7840 removes the only real-tick producer of advisory merger awaiting-confirmation interventions, so this live-wiring harness now proves suppression at the source rather than request/resolution emission for an unreachable advisory pending-confirmation path.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
|
||||
import { TaskStore, getPlannerInterventionTimeline, type Task } from "@fusion/core";
|
||||
import { createSharedPgTaskStoreTestHarness, pgDescribe } from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import { ProjectEngine } from "../project-engine.js";
|
||||
import { PlannerOverseerMonitor, type OverseerStageObservation } from "../planner-overseer.js";
|
||||
import { PlannerRecoveryController, type PlannerRecoveryHandlers, type PlannerRecoverySnapshotProvider } from "../planner-recovery-controller.js";
|
||||
@@ -36,12 +34,12 @@ interface EngineOverseerInternals {
|
||||
plannerObservationEmitDedup: Map<string, string>;
|
||||
plannerEscalationEmitDedup: Set<string>;
|
||||
buildPlannerRecoveryHandlers(store: TaskStore): PlannerRecoveryHandlers;
|
||||
emitOverseerObservationDeduped(store: TaskStore, observation: OverseerStageObservation): void;
|
||||
emitOverseerObservationDeduped(store: TaskStore, observation: OverseerStageObservation): Promise<void>;
|
||||
emitOverseerEscalationDeduped(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
decision: { watchedStage: string | null; reason: string; attemptCount: number; attemptLimit: number; sourceLinks: unknown[] },
|
||||
): void;
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
/** Extracts the real `ProjectEngine` prototype methods FN-7551 wired without running its heavy constructor/`start()`. */
|
||||
@@ -57,7 +55,7 @@ function wireRealEngineOverseer(store: TaskStore) {
|
||||
const internals = makeEngineInternals();
|
||||
const monitor = new PlannerOverseerMonitor({
|
||||
store,
|
||||
onObservation: (observation) => internals.emitOverseerObservationDeduped(store, observation),
|
||||
onObservation: async (observation) => internals.emitOverseerObservationDeduped(store, observation),
|
||||
});
|
||||
const handlers = internals.buildPlannerRecoveryHandlers(store);
|
||||
const controllerFromMonitor = new PlannerRecoveryController({ snapshotProvider: monitor, handlers });
|
||||
@@ -91,24 +89,17 @@ function observation(overrides: Partial<OverseerStageObservation> = {}): Oversee
|
||||
};
|
||||
}
|
||||
|
||||
describe("FN-7551 — overseer decision points populate the intervention timeline via the live wiring", () => {
|
||||
let rootDir: string;
|
||||
let globalDir: string;
|
||||
pgDescribe("FN-7551 — overseer decision points populate the intervention timeline via the live wiring", () => {
|
||||
const harness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_overseer_wiring" });
|
||||
let store: TaskStore;
|
||||
|
||||
beforeAll(harness.beforeAll);
|
||||
beforeEach(async () => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "fn-7551-engine-root-"));
|
||||
globalDir = mkdtempSync(join(tmpdir(), "fn-7551-engine-global-"));
|
||||
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.stopWatching();
|
||||
store.close();
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
rmSync(globalDir, { recursive: true, force: true });
|
||||
await harness.beforeEach();
|
||||
store = harness.store();
|
||||
});
|
||||
afterEach(harness.afterEach);
|
||||
afterAll(harness.afterAll);
|
||||
|
||||
async function seedTask(column: "in-progress" | "in-review" = "in-progress"): Promise<Task> {
|
||||
const task = await store.createTask({ title: "T", description: "d" });
|
||||
@@ -245,9 +236,9 @@ describe("FN-7551 — overseer decision points populate the intervention timelin
|
||||
sourceLinks: [],
|
||||
};
|
||||
|
||||
emitEscalation(task.id, exhaustedDecision);
|
||||
emitEscalation(task.id, exhaustedDecision);
|
||||
emitEscalation(task.id, exhaustedDecision);
|
||||
await emitEscalation(task.id, exhaustedDecision);
|
||||
await emitEscalation(task.id, exhaustedDecision);
|
||||
await emitEscalation(task.id, exhaustedDecision);
|
||||
|
||||
const timeline = await getPlannerInterventionTimeline(store, task.id);
|
||||
const escalations = timeline.filter((e) => e.action === "escalate");
|
||||
|
||||
@@ -82,7 +82,7 @@ vi.mock("../cron-runner.js", () => {
|
||||
// dispatch onto runAiMerge (merger-ai.js). project-engine no longer imports
|
||||
// aiMergeTask; the merge seam these tests mock/assert is now runAiMerge.
|
||||
vi.mock("../merger.js", () => ({
|
||||
sweepStaleAutostashes: vi.fn(async () => undefined),
|
||||
sweepStaleAutostashes: vi.fn(async () => ({ dropped: 0, retained: 0 })),
|
||||
VerificationError: class VerificationError extends Error {},
|
||||
}));
|
||||
|
||||
@@ -205,6 +205,7 @@ vi.mock("../runtimes/in-process-runtime.js", () => ({
|
||||
stop: mocks.runtimeStop,
|
||||
resumeAfterUnpause: mocks.runtimeResumeAfterUnpause,
|
||||
getTaskStore: () => mocks.currentStore,
|
||||
getPluginRunner: vi.fn(() => undefined),
|
||||
getAgentStore: vi.fn(),
|
||||
getMessageStore: vi.fn(),
|
||||
getRoutineStore: vi.fn(),
|
||||
@@ -246,6 +247,7 @@ function createMockStore(initialSettings: Record<string, unknown>) {
|
||||
return structuredClone(settings);
|
||||
}),
|
||||
logEntry: vi.fn(async () => undefined),
|
||||
getAsyncLayer: vi.fn(() => ({ kind: "test-async-layer" })),
|
||||
/*
|
||||
FNXC:OverlapSelfHealing 2026-06-26-12:00:
|
||||
ProjectEngine's broad TaskStore fake is shared by maintenance wiring tests, so it carries clearStaleBlockedBy's overlap-path seam even when individual cases only exercise merge orchestration.
|
||||
@@ -404,6 +406,7 @@ describe("ProjectEngine notification ownership wiring", () => {
|
||||
const engine = createEngine({ skipNotifier: false, projectId: "proj_for_notifier" });
|
||||
|
||||
await engine.start();
|
||||
await vi.waitFor(() => expect(mocks.notifierStart).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(NotificationService).toHaveBeenCalledTimes(1);
|
||||
expect(OAuthAlertStateStore).toHaveBeenCalledTimes(1);
|
||||
@@ -443,6 +446,7 @@ describe("ProjectEngine notification ownership wiring", () => {
|
||||
|
||||
await engine.start();
|
||||
await engine.start();
|
||||
await vi.waitFor(() => expect(mocks.notifierStart).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Root cause guard: if ProjectEngine.start is called more than once, it should not
|
||||
// wire a second NotificationService/NtfyNotifier pair for the same store.
|
||||
@@ -498,6 +502,47 @@ describe("ProjectEngine notification ownership wiring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectEngine planner overseer observation wiring", () => {
|
||||
it("awaits the audit persistence promise through the monitor registered by start()", async () => {
|
||||
const mockStore = createMockStore(baseSettings);
|
||||
mocks.currentStore = mockStore.store;
|
||||
const engine = createEngine();
|
||||
let releasePersistence!: () => void;
|
||||
const persistence = new Promise<void>((resolve) => {
|
||||
releasePersistence = resolve;
|
||||
});
|
||||
const engineInternals = engine as unknown as {
|
||||
emitOverseerObservationDeduped: (store: unknown, observation: unknown) => Promise<void>;
|
||||
};
|
||||
const emitSpy = vi.spyOn(engineInternals, "emitOverseerObservationDeduped").mockReturnValue(persistence);
|
||||
|
||||
await engine.start();
|
||||
try {
|
||||
const monitor = engine.getPlannerOverseer();
|
||||
expect(monitor).toBeDefined();
|
||||
|
||||
/*
|
||||
FNXC:PlannerOversight 2026-07-17-16:35:
|
||||
ProjectEngine.start() must return the audit-persistence Promise from its
|
||||
monitor callback. Awaiting only a test-local callback can hide dropped
|
||||
PostgreSQL audit writes in the production wiring.
|
||||
*/
|
||||
const observed = monitor!.observeTask({ id: "FN-observation", column: "in-progress" } as Task, "autonomous");
|
||||
await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledOnce());
|
||||
|
||||
let settled = false;
|
||||
void observed.then(() => { settled = true; });
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
releasePersistence();
|
||||
await expect(observed).resolves.toEqual(expect.objectContaining({ taskId: "FN-observation" }));
|
||||
} finally {
|
||||
await engine.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectEngine accessors", () => {
|
||||
it("returns configured project id", () => {
|
||||
const engine = new ProjectEngine(
|
||||
@@ -565,6 +610,7 @@ describe("ProjectEngine auto-summarize wiring", () => {
|
||||
const engine = createEngine();
|
||||
|
||||
await engine.start();
|
||||
await vi.waitFor(() => expect(mocks.syncScheduledEvalBatchAutomation).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(mocks.syncInsightExtractionAutomation).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.syncAutoSummarizeAutomation).toHaveBeenCalledTimes(1);
|
||||
@@ -602,6 +648,7 @@ describe("ProjectEngine auto-summarize wiring", () => {
|
||||
const engine = createEngine();
|
||||
|
||||
await engine.start();
|
||||
await vi.waitFor(() => expect(mocks.syncAutoSummarizeAutomation).toHaveBeenCalledTimes(1));
|
||||
mocks.syncAutoSummarizeAutomation.mockClear();
|
||||
|
||||
const previous = { ...baseSettings };
|
||||
@@ -644,6 +691,7 @@ describe("ProjectEngine memory dreams wiring", () => {
|
||||
const engine = createEngine();
|
||||
|
||||
await engine.start();
|
||||
await vi.waitFor(() => expect(mocks.syncMemoryDreamsAutomation).toHaveBeenCalledTimes(1));
|
||||
|
||||
const cronRunnerStartOrder = mocks.cronRunnerStart.mock.invocationCallOrder[0];
|
||||
expect(mocks.syncMemoryDreamsAutomation).toHaveBeenCalledTimes(1);
|
||||
@@ -660,6 +708,7 @@ describe("ProjectEngine memory dreams wiring", () => {
|
||||
const engine = createEngine();
|
||||
|
||||
await engine.start();
|
||||
await vi.waitFor(() => expect(mocks.syncMemoryDreamsAutomation).toHaveBeenCalledTimes(1));
|
||||
mocks.syncMemoryDreamsAutomation.mockClear();
|
||||
|
||||
const previous = { ...baseSettings };
|
||||
@@ -682,6 +731,7 @@ describe("ProjectEngine memory dreams wiring", () => {
|
||||
const engine = createEngine();
|
||||
|
||||
await engine.start();
|
||||
await vi.waitFor(() => expect(mocks.syncMemoryDreamsAutomation).toHaveBeenCalledTimes(1));
|
||||
mocks.syncMemoryDreamsAutomation.mockClear();
|
||||
|
||||
const previous = { ...baseSettings };
|
||||
@@ -703,6 +753,7 @@ describe("ProjectEngine memory dreams wiring", () => {
|
||||
const engine = createEngine();
|
||||
|
||||
await engine.start();
|
||||
await vi.waitFor(() => expect(mocks.syncMemoryDreamsAutomation).toHaveBeenCalledTimes(1));
|
||||
mocks.syncMemoryDreamsAutomation.mockClear();
|
||||
|
||||
const previous = { ...baseSettings };
|
||||
@@ -725,6 +776,9 @@ describe("ProjectEngine memory dreams wiring", () => {
|
||||
const engine = createEngine();
|
||||
|
||||
await expect(engine.start()).resolves.toBeUndefined();
|
||||
await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Memory dreams automation startup sync failed"),
|
||||
));
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Memory dreams automation startup sync failed"),
|
||||
@@ -745,6 +799,7 @@ describe("ProjectEngine memory dreams wiring", () => {
|
||||
const engine = createEngine();
|
||||
|
||||
await engine.start();
|
||||
await vi.waitFor(() => expect(mocks.syncMemoryDreamsAutomation).toHaveBeenCalledTimes(1));
|
||||
warnSpy.mockClear();
|
||||
mocks.syncMemoryDreamsAutomation.mockRejectedValueOnce(new Error("dream settings sync failed"));
|
||||
|
||||
@@ -2568,16 +2623,19 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
||||
|
||||
it("startup merge sweep skips paused in-review tasks", async () => {
|
||||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||||
mockStore.store.listTasks.mockResolvedValueOnce([
|
||||
const inReviewTasks = [
|
||||
{ id: "FN-paused", column: "in-review", paused: true, mergeRetries: 0, status: null },
|
||||
{ id: "FN-ready", column: "in-review", paused: false, mergeRetries: 0, status: null },
|
||||
]);
|
||||
];
|
||||
// Critical stale-status cleanup reads first; deferred startup then evaluates eligibility.
|
||||
mockStore.store.listTasks.mockResolvedValueOnce([]).mockResolvedValueOnce(inReviewTasks);
|
||||
mocks.currentStore = mockStore.store;
|
||||
const engine = createEngine();
|
||||
const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void };
|
||||
const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge");
|
||||
|
||||
await engine.start();
|
||||
await vi.waitFor(() => expect(enqueueSpy).toHaveBeenCalledWith("FN-ready"));
|
||||
|
||||
expect(enqueueSpy).toHaveBeenCalledWith("FN-ready");
|
||||
expect(enqueueSpy).not.toHaveBeenCalledWith("FN-paused");
|
||||
@@ -2588,7 +2646,7 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
||||
it("startup merge sweep enqueues shared-group members when autoMerge is false", async () => {
|
||||
const mockStore = createMockStore({ ...baseSettings, autoMerge: false });
|
||||
mockStore.store.getBranchGroup.mockReturnValue({ id: "BG-5819", status: "open", branchName: "fusion/groups/bg-5819" });
|
||||
mockStore.store.listTasks.mockResolvedValueOnce([
|
||||
const inReviewTasks = [
|
||||
{
|
||||
id: "FN-shared",
|
||||
column: "in-review",
|
||||
@@ -2598,13 +2656,16 @@ describe("ProjectEngine paused in-review auto-merge behavior", () => {
|
||||
branchContext: { assignmentMode: "shared", groupId: "BG-5819", source: "planning" },
|
||||
},
|
||||
{ id: "FN-plain", column: "in-review", paused: false, mergeRetries: 0, status: null },
|
||||
]);
|
||||
];
|
||||
// Critical stale-status cleanup reads first; deferred startup then evaluates eligibility.
|
||||
mockStore.store.listTasks.mockResolvedValueOnce([]).mockResolvedValueOnce(inReviewTasks);
|
||||
mocks.currentStore = mockStore.store;
|
||||
const engine = createEngine();
|
||||
const privateEngine = engine as unknown as { internalEnqueueMerge: (taskId: string) => void };
|
||||
const enqueueSpy = vi.spyOn(privateEngine, "internalEnqueueMerge");
|
||||
|
||||
await engine.start();
|
||||
await vi.waitFor(() => expect(enqueueSpy).toHaveBeenCalledWith("FN-shared"));
|
||||
|
||||
expect(enqueueSpy).toHaveBeenCalledWith("FN-shared");
|
||||
expect(enqueueSpy).not.toHaveBeenCalledWith("FN-plain");
|
||||
@@ -3074,6 +3135,7 @@ describe("ProjectEngine swallowed error hardening", () => {
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await vi.waitFor(() => expect(mockStore.store.listTasks).toHaveBeenCalledTimes(2));
|
||||
|
||||
mockStore.store.getSettings.mockRejectedValueOnce(new Error("db locked"));
|
||||
|
||||
@@ -3105,12 +3167,18 @@ describe("ProjectEngine swallowed error hardening", () => {
|
||||
it("warns when startup merge sweep fails", async () => {
|
||||
const mockStore = createMockStore({ ...baseSettings, autoMerge: true });
|
||||
mocks.currentStore = mockStore.store;
|
||||
mockStore.store.listTasks.mockRejectedValueOnce(new Error("connection lost"));
|
||||
// The critical stale-status read precedes the deferred enqueue sweep.
|
||||
mockStore.store.listTasks
|
||||
.mockResolvedValueOnce([])
|
||||
.mockRejectedValueOnce(new Error("connection lost"));
|
||||
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Auto-merge startup enqueue failed: connection lost"),
|
||||
));
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Auto-merge startup sweep failed"));
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Auto-merge startup enqueue failed: connection lost"));
|
||||
|
||||
await engine.stop();
|
||||
});
|
||||
@@ -3121,6 +3189,7 @@ describe("ProjectEngine swallowed error hardening", () => {
|
||||
mocks.currentStore = mockStore.store;
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await vi.waitFor(() => expect(mockStore.store.listTasks).toHaveBeenCalledTimes(2));
|
||||
warnSpy.mockClear();
|
||||
|
||||
mockStore.store.listTasks.mockRejectedValueOnce(new Error("sweep db error"));
|
||||
@@ -3138,6 +3207,7 @@ describe("ProjectEngine swallowed error hardening", () => {
|
||||
mocks.currentStore = mockStore.store;
|
||||
const engine = createEngine();
|
||||
await engine.start();
|
||||
await vi.waitFor(() => expect(mockStore.store.listTasks).toHaveBeenCalledTimes(2));
|
||||
warnSpy.mockClear();
|
||||
|
||||
mockStore.store.getSettings
|
||||
@@ -3498,49 +3568,49 @@ describe("ProjectEngine stale mergeActive rescue (FN-3900)", () => {
|
||||
});
|
||||
|
||||
describe("allowInReviewMergeProcessing per-task autoMerge override", () => {
|
||||
const gate = (task: Partial<Task>, settings: { autoMerge: boolean }, branchGroup: { status: "open" | "finalized" | "abandoned" } | null = null) =>
|
||||
(createEngine() as any).allowInReviewMergeProcessing(task, settings, { getBranchGroup: vi.fn(() => branchGroup) }) as boolean;
|
||||
const gate = (task: Partial<Task>, settings: { autoMerge: boolean }, branchGroup: { status: "open" | "finalized" | "abandoned" } | null = null): Promise<boolean> =>
|
||||
(createEngine() as any).allowInReviewMergeProcessing(task, settings, { getBranchGroup: vi.fn(() => branchGroup) });
|
||||
|
||||
it("lets an explicit per-task autoMerge:true through when the global setting is off", () => {
|
||||
expect(gate({ autoMerge: true }, { autoMerge: false })).toBe(true);
|
||||
it("lets an explicit per-task autoMerge:true through when the global setting is off", async () => {
|
||||
await expect(gate({ autoMerge: true }, { autoMerge: false })).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("blocks tasks without a per-task override when the global setting is off", () => {
|
||||
expect(gate({}, { autoMerge: false })).toBe(false);
|
||||
expect(gate({ autoMerge: false }, { autoMerge: false })).toBe(false);
|
||||
it("blocks tasks without a per-task override when the global setting is off", async () => {
|
||||
await expect(gate({}, { autoMerge: false })).resolves.toBe(false);
|
||||
await expect(gate({ autoMerge: false }, { autoMerge: false })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("keeps everything flowing when the global setting is on — explicit autoMerge:false is parked manual-required downstream", () => {
|
||||
expect(gate({}, { autoMerge: true })).toBe(true);
|
||||
expect(gate({ autoMerge: false }, { autoMerge: true })).toBe(true);
|
||||
it("keeps everything flowing when the global setting is on — explicit autoMerge:false is parked manual-required downstream", async () => {
|
||||
await expect(gate({}, { autoMerge: true })).resolves.toBe(true);
|
||||
await expect(gate({ autoMerge: false }, { autoMerge: true })).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("still exempts live shared-branch-group member integration when the global setting is off", () => {
|
||||
expect(gate(
|
||||
it("still exempts live shared-branch-group member integration when the global setting is off", async () => {
|
||||
await expect(gate(
|
||||
{ branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] },
|
||||
{ autoMerge: false },
|
||||
{ status: "open" },
|
||||
)).toBe(true);
|
||||
)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing", null],
|
||||
["finalized", { status: "finalized" as const }],
|
||||
["abandoned", { status: "abandoned" as const }],
|
||||
])("blocks shared-branch-group member integration for %s groups when global autoMerge is off", (_label, branchGroup) => {
|
||||
expect(gate(
|
||||
])("blocks shared-branch-group member integration for %s groups when global autoMerge is off", async (_label, branchGroup) => {
|
||||
await expect(gate(
|
||||
{ branchContext: { assignmentMode: "shared", groupId: "grp-1" } as Task["branchContext"] },
|
||||
{ autoMerge: false },
|
||||
branchGroup,
|
||||
)).toBe(false);
|
||||
)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["api", { sourceType: "api" }],
|
||||
["user-created", { sourceType: undefined }],
|
||||
["engine-created", { sourceType: "unknown", sourceMetadata: { fusionBranchContext: { assignmentMode: "shared", groupId: "grp-1", source: "mission" } } }],
|
||||
])("applies the dissolved-group manual hold regardless of %s provenance", (_label, provenance) => {
|
||||
expect(gate(
|
||||
])("applies the dissolved-group manual hold regardless of %s provenance", async (_label, provenance) => {
|
||||
await expect(gate(
|
||||
{
|
||||
...provenance,
|
||||
autoMerge: undefined,
|
||||
@@ -3548,7 +3618,7 @@ describe("allowInReviewMergeProcessing per-task autoMerge override", () => {
|
||||
},
|
||||
{ autoMerge: false },
|
||||
null,
|
||||
)).toBe(false);
|
||||
)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3589,21 +3659,21 @@ describe("enqueueEligibleInReviewTasks honors per-task autoMerge override (share
|
||||
const enqueueSpy = vi
|
||||
.spyOn(engine, "internalEnqueueMerge")
|
||||
.mockImplementation(() => true);
|
||||
const run = (tasks: Task[], settings: { autoMerge: boolean }): number =>
|
||||
engine.enqueueEligibleInReviewTasks(tasks, settings) as number;
|
||||
const run = (tasks: Task[], settings: { autoMerge: boolean }): Promise<number> =>
|
||||
engine.enqueueEligibleInReviewTasks(tasks, settings);
|
||||
return { engine, enqueueSpy, run };
|
||||
};
|
||||
|
||||
it("enqueues an in-review task with autoMerge:true even when the global setting is off", () => {
|
||||
it("enqueues an in-review task with autoMerge:true even when the global setting is off", async () => {
|
||||
const { enqueueSpy, run } = setup();
|
||||
const count = run([inReview("FN-override", { autoMerge: true })], { autoMerge: false });
|
||||
const count = await run([inReview("FN-override", { autoMerge: true })], { autoMerge: false });
|
||||
expect(count).toBe(1);
|
||||
expect(enqueueSpy).toHaveBeenCalledWith("FN-override");
|
||||
});
|
||||
|
||||
it("does not enqueue a sibling task without an override in the same sweep when the global setting is off", () => {
|
||||
it("does not enqueue a sibling task without an override in the same sweep when the global setting is off", async () => {
|
||||
const { enqueueSpy, run } = setup();
|
||||
const count = run(
|
||||
const count = await run(
|
||||
[inReview("FN-override", { autoMerge: true }), inReview("FN-plain")],
|
||||
{ autoMerge: false },
|
||||
);
|
||||
@@ -3612,9 +3682,9 @@ describe("enqueueEligibleInReviewTasks honors per-task autoMerge override (share
|
||||
expect(enqueueSpy).not.toHaveBeenCalledWith("FN-plain");
|
||||
});
|
||||
|
||||
it("still enqueues a task with autoMerge:false when the global setting is on (parked manual-required downstream)", () => {
|
||||
it("still enqueues a task with autoMerge:false when the global setting is on (parked manual-required downstream)", async () => {
|
||||
const { enqueueSpy, run } = setup();
|
||||
const count = run([inReview("FN-explicit-false", { autoMerge: false })], { autoMerge: true });
|
||||
const count = await run([inReview("FN-explicit-false", { autoMerge: false })], { autoMerge: true });
|
||||
expect(count).toBe(1);
|
||||
expect(enqueueSpy).toHaveBeenCalledWith("FN-explicit-false");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
FNXC:UnlinkedMissionsAdvisory 2026-07-17-16:25:
|
||||
The scheduler must persist the same active-unlinked advisory through PostgreSQL
|
||||
as it does through the synchronous MissionStore. This integration test uses the
|
||||
canonical TaskStore harness so query ordering and insight deduplication remain
|
||||
real backend behavior rather than a structural mock.
|
||||
*/
|
||||
import { expect, it } from "vitest";
|
||||
import type { AsyncGoalStore, AsyncMissionStore } from "@fusion/core";
|
||||
import { createTaskStoreForTest, pgDescribe } from "../../../core/src/__test-utils__/pg-test-harness.js";
|
||||
import {
|
||||
UNLINKED_MISSIONS_ADVISORY_TITLE,
|
||||
UnlinkedMissionsAdvisoryReporter,
|
||||
} from "../unlinked-missions-advisory-reporter.js";
|
||||
|
||||
pgDescribe("UnlinkedMissionsAdvisoryReporter PostgreSQL", () => {
|
||||
it("persists one advisory for only active unlinked missions without duplicating it", async () => {
|
||||
const harness = await createTaskStoreForTest({ prefix: "fusion_unlinked_advisory" });
|
||||
try {
|
||||
const missions = harness.store.getMissionStore() as AsyncMissionStore;
|
||||
const goals = harness.store.getGoalStore() as AsyncGoalStore;
|
||||
const linked = await missions.createMission({ title: "Linked" });
|
||||
const unlinked = await missions.createMission({ title: "Unlinked" });
|
||||
const archived = await missions.createMission({ title: "Archived" });
|
||||
const goal = await goals.createGoal({ title: "Goal" });
|
||||
await missions.updateMission(linked.id, { status: "active" });
|
||||
await missions.updateMission(unlinked.id, { status: "active" });
|
||||
await missions.linkGoal(linked.id, goal.id);
|
||||
await missions.updateMission(archived.id, { status: "archived" });
|
||||
|
||||
const projectId = "pg-reporter";
|
||||
const reporter = new UnlinkedMissionsAdvisoryReporter({
|
||||
store: harness.store,
|
||||
projectId,
|
||||
now: () => Date.parse("2026-07-17T16:25:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(reporter.report()).resolves.toEqual({ alerted: true });
|
||||
await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "already-reported" });
|
||||
|
||||
const insights = await harness.store.getInsightStore().listInsights({
|
||||
projectId,
|
||||
category: "workflow",
|
||||
});
|
||||
expect(insights.filter((insight) => insight.title === UNLINKED_MISSIONS_ADVISORY_TITLE)).toHaveLength(1);
|
||||
expect(JSON.parse(insights[0]!.content ?? "{}").missionIds).toEqual([unlinked.id]);
|
||||
} finally {
|
||||
await harness.teardown();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -23,10 +23,12 @@ function createStore(params: {
|
||||
goalIdsByMissionId?: Record<string, string[]>;
|
||||
insightStore?: { upsertInsight: ReturnType<typeof vi.fn>; listInsights: ReturnType<typeof vi.fn> };
|
||||
throwInsightStore?: boolean;
|
||||
asyncMissionStore?: boolean;
|
||||
}): TaskStore {
|
||||
const resolve = <T>(value: T) => params.asyncMissionStore ? Promise.resolve(value) : value;
|
||||
const missionStore = {
|
||||
listMissions: vi.fn().mockReturnValue(params.missions ?? []),
|
||||
listGoalIdsForMission: vi.fn().mockImplementation((missionId: string) => params.goalIdsByMissionId?.[missionId] ?? []),
|
||||
listMissions: vi.fn().mockImplementation(() => resolve(params.missions ?? [])),
|
||||
listGoalIdsForMission: vi.fn().mockImplementation((missionId: string) => resolve(params.goalIdsByMissionId?.[missionId] ?? [])),
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -86,6 +88,20 @@ describe("UnlinkedMissionsAdvisoryReporter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("supports promise-returning PostgreSQL-shaped mission stores", async () => {
|
||||
const insightStore = { upsertInsight: vi.fn().mockResolvedValue(undefined), listInsights: vi.fn().mockResolvedValue([]) };
|
||||
const store = createStore({
|
||||
missions: [createMission({ id: "M-ASYNC" })],
|
||||
insightStore,
|
||||
asyncMissionStore: true,
|
||||
});
|
||||
const reporter = new UnlinkedMissionsAdvisoryReporter({ store, projectId: "/tmp/project", logger });
|
||||
|
||||
await expect(reporter.report()).resolves.toEqual({ alerted: true });
|
||||
expect(insightStore.upsertInsight).toHaveBeenCalledTimes(1);
|
||||
expect(store.getMissionStore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("excludes active missions that already have linked goals", async () => {
|
||||
const insightStore = { upsertInsight: vi.fn(), listInsights: vi.fn().mockReturnValue([]) };
|
||||
const store = createStore({
|
||||
|
||||
@@ -528,18 +528,27 @@ export class MissionExecutionLoop extends EventEmitter {
|
||||
* dispatch of the validation result.
|
||||
*/
|
||||
private async runFeatureValidation(feature: MissionFeature): Promise<void> {
|
||||
// Lazily guarantee a linked assertion before validation so every feature
|
||||
// is evaluated by the validator even when legacy data is missing links.
|
||||
let assertions = await this.missionStore.listAssertionsForFeature(feature.id);
|
||||
if (assertions.length === 0) {
|
||||
loopLog.log(`Feature ${feature.id} has no linked assertions; lazily ensuring store-managed assertion linkage`);
|
||||
assertions = await this.missionStore.ensureFeatureAssertionLinked(feature.id);
|
||||
}
|
||||
|
||||
// Mark feature as being validated
|
||||
/*
|
||||
FNXC:MissionValidation 2026-07-17-16:40:
|
||||
Claim validation before any asynchronous assertion lookup. Concurrent task
|
||||
completion events must share one validator run, including the lazy-link path.
|
||||
*/
|
||||
this.activeValidations.add(feature.id);
|
||||
|
||||
try {
|
||||
// Lazily guarantee a linked assertion before validation so every feature
|
||||
// is evaluated by the validator even when legacy data is missing links.
|
||||
let assertions = await this.missionStore.listAssertionsForFeature(feature.id);
|
||||
if (assertions.length === 0) {
|
||||
loopLog.log(`Feature ${feature.id} has no linked assertions; lazily ensuring store-managed assertion linkage`);
|
||||
assertions = await this.missionStore.ensureFeatureAssertionLinked(feature.id);
|
||||
}
|
||||
if (assertions.length === 0) {
|
||||
// FNXC:MissionValidation 2026-07-17-16:45: no-assertion features remain a valid completion path when linkage cannot derive an assertion.
|
||||
await this.handleValidationPass(feature.id, undefined, "No assertions linked to feature");
|
||||
return;
|
||||
}
|
||||
|
||||
loopLog.log(`Running internal validation for feature ${feature.id} — no board task created (policy: docs/missions.md)`);
|
||||
|
||||
// FNXC:MissionValidation 2026-07-16-12:00:
|
||||
|
||||
@@ -750,6 +750,14 @@ export class ProjectEngine {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:EngineShutdown 2026-07-17-16:35:
|
||||
stop() is a hard lifecycle boundary: public merge requests must remain
|
||||
rejected after it settles. Reset the shutdown guard only when a subsequent
|
||||
start begins a new lifecycle, never at the end of stop().
|
||||
*/
|
||||
this.shuttingDown = false;
|
||||
|
||||
// 1. Start the core runtime (TaskStore, Scheduler, Executor, Triage, etc.)
|
||||
await this.runtime.start();
|
||||
|
||||
@@ -849,9 +857,7 @@ export class ProjectEngine {
|
||||
// through the FN-7520 façade for each real observation the monitor
|
||||
// records, using the real TaskStore. Best-effort — never throws (the
|
||||
// monitor already swallows callback errors around `onObservation`).
|
||||
onObservation: (observation) => {
|
||||
this.emitOverseerObservationDeduped(store, observation);
|
||||
},
|
||||
onObservation: (observation) => this.emitOverseerObservationDeduped(store, observation),
|
||||
});
|
||||
// FN-7512: bounded autonomous-recovery dispatcher, wired to the existing
|
||||
// steering-comment API + store retry/re-enqueue path only — no new
|
||||
@@ -1362,7 +1368,6 @@ export class ProjectEngine {
|
||||
await this.runtime.stop();
|
||||
|
||||
this.started = false;
|
||||
this.shuttingDown = false;
|
||||
runtimeLog.log(`ProjectEngine stopped for ${this.config.projectId}`);
|
||||
}
|
||||
|
||||
@@ -1641,9 +1646,9 @@ export class ProjectEngine {
|
||||
* try/catch-degrade-to-no-op contract every FN-7512/FN-7513/FN-7514
|
||||
* handler already follows).
|
||||
*/
|
||||
private emitOverseerInterventionSafe(fn: () => void): void {
|
||||
private async emitOverseerInterventionSafe(fn: () => unknown | Promise<unknown>): Promise<void> {
|
||||
try {
|
||||
fn();
|
||||
await fn();
|
||||
} catch (err) {
|
||||
runtimeLog.warn(`Failed to emit overseer intervention: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
@@ -1658,7 +1663,7 @@ export class ProjectEngine {
|
||||
* `(stage, signal)` pair emits. Best-effort: any store/façade failure is
|
||||
* swallowed so it never breaks `PlannerOverseerMonitor#observeTask`/the poll.
|
||||
*/
|
||||
private emitOverseerObservationDeduped(store: TaskStore, observation: import("./planner-overseer.js").OverseerStageObservation): void {
|
||||
private async emitOverseerObservationDeduped(store: TaskStore, observation: import("./planner-overseer.js").OverseerStageObservation): Promise<void> {
|
||||
try {
|
||||
const dedupKey = `${observation.stage}:${observation.signal}`;
|
||||
const last = this.plannerObservationEmitDedup.get(observation.taskId);
|
||||
@@ -1666,7 +1671,7 @@ export class ProjectEngine {
|
||||
return;
|
||||
}
|
||||
this.plannerObservationEmitDedup.set(observation.taskId, dedupKey);
|
||||
emitOverseerObservation({
|
||||
await emitOverseerObservation({
|
||||
store,
|
||||
taskId: observation.taskId,
|
||||
stage: observation.stage,
|
||||
@@ -1690,18 +1695,18 @@ export class ProjectEngine {
|
||||
* clears the dedup entry and may escalate again in a future exhaustion).
|
||||
* Best-effort; never throws out of the poll.
|
||||
*/
|
||||
private emitOverseerEscalationDeduped(
|
||||
private async emitOverseerEscalationDeduped(
|
||||
store: TaskStore,
|
||||
taskId: string,
|
||||
decision: { watchedStage: PlannerOversightStage | null; reason: string; attemptCount: number; attemptLimit: number; sourceLinks: ReadonlyArray<{ kind: string; ref: string; url?: string }> },
|
||||
): void {
|
||||
): Promise<void> {
|
||||
if (!decision.watchedStage) return;
|
||||
const dedupKey = `${taskId}::${decision.watchedStage}`;
|
||||
if (this.plannerEscalationEmitDedup.has(dedupKey)) {
|
||||
return;
|
||||
}
|
||||
this.plannerEscalationEmitDedup.add(dedupKey);
|
||||
this.emitOverseerInterventionSafe(() =>
|
||||
await this.emitOverseerInterventionSafe(() =>
|
||||
emitOverseerEscalation({
|
||||
store,
|
||||
taskId,
|
||||
@@ -1744,7 +1749,7 @@ export class ProjectEngine {
|
||||
// comment succeeds, through the real store, so the timeline reflects
|
||||
// the same guidance the agent actually saw.
|
||||
// FNXC:PlannerOversight 2026-07-13-23:05: tag lifecycle source for timeline vs session-advisor.
|
||||
this.emitOverseerInterventionSafe(() =>
|
||||
await this.emitOverseerInterventionSafe(() =>
|
||||
emitOverseerSteering({
|
||||
store,
|
||||
taskId: task.id,
|
||||
@@ -1759,7 +1764,7 @@ export class ProjectEngine {
|
||||
await store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine" } as Parameters<TaskStore["moveTask"]>[2]);
|
||||
// FN-7551: the attempt just dispatched — record it as attemptCount + 1
|
||||
// (decision.attemptCount is the count BEFORE this dispatch).
|
||||
this.emitOverseerInterventionSafe(() =>
|
||||
await this.emitOverseerInterventionSafe(() =>
|
||||
emitOverseerRetry({
|
||||
store,
|
||||
taskId: task.id,
|
||||
@@ -1777,7 +1782,7 @@ export class ProjectEngine {
|
||||
? `[planner-oversight] targeted-fix requested: ${decision.reason} (source: ${sourceRef})`
|
||||
: `[planner-oversight] targeted-fix requested: ${decision.reason}`;
|
||||
await store.addSteeringComment(task.id, text, "agent");
|
||||
this.emitOverseerInterventionSafe(() =>
|
||||
await this.emitOverseerInterventionSafe(() =>
|
||||
emitOverseerRecoveryAttempt({
|
||||
store,
|
||||
taskId: task.id,
|
||||
@@ -1810,7 +1815,7 @@ export class ProjectEngine {
|
||||
requestConfirmation: async (task, request) => {
|
||||
const text = `[planner-oversight] merge checkpoint (${request.sideEffectClass}): ${request.reason}`;
|
||||
await store.addSteeringComment(task.id, text, "agent");
|
||||
this.emitOverseerInterventionSafe(() =>
|
||||
await this.emitOverseerInterventionSafe(() =>
|
||||
emitOverseerConfirmation({
|
||||
store,
|
||||
taskId: task.id,
|
||||
@@ -1827,7 +1832,7 @@ export class ProjectEngine {
|
||||
// the timeline shows both the request and its resolution. Never touches
|
||||
// the approve/deny execution path itself.
|
||||
onConfirmationResolved: async (taskId, request, resolution) => {
|
||||
this.emitOverseerInterventionSafe(() =>
|
||||
await this.emitOverseerInterventionSafe(() =>
|
||||
emitOverseerConfirmation({
|
||||
store,
|
||||
taskId,
|
||||
@@ -2629,7 +2634,7 @@ export class ProjectEngine {
|
||||
}
|
||||
|
||||
private internalEnqueueMerge(taskId: string): boolean {
|
||||
if (this.shuttingDown) return false;
|
||||
if (this.shuttingDown || !this.started) return false;
|
||||
if (this.mergeActive.has(taskId)) {
|
||||
// Distinguish "actually being processed" (queued or active) from a
|
||||
// leaked entry. Reconcile leaks immediately so recovery paths and fresh
|
||||
@@ -2854,7 +2859,7 @@ export class ProjectEngine {
|
||||
// event — emit exactly one `escalate` entry per (taskId, stage)
|
||||
// while the stage remains exhausted across subsequent polls.
|
||||
if (decision?.exhausted && decision.watchedStage) {
|
||||
this.emitOverseerEscalationDeduped(store, task.id, decision);
|
||||
await this.emitOverseerEscalationDeduped(store, task.id, decision);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computeInsightFingerprint, MissionStore, type Mission, type TaskStore } from "@fusion/core";
|
||||
import { computeInsightFingerprint, type AsyncMissionStore, type Mission, type MissionStore, type TaskStore } from "@fusion/core";
|
||||
import { createLogger } from "./logger.js";
|
||||
|
||||
const reporterLog = createLogger("unlinked-missions-advisory");
|
||||
@@ -32,24 +32,21 @@ export class UnlinkedMissionsAdvisoryReporter {
|
||||
|
||||
async report(): Promise<{ alerted: boolean; reason?: string }> {
|
||||
try {
|
||||
// FNXC:MissionStore 2026-06-27-15:40:
|
||||
// This reporter reads the MissionStore synchronously. In PG backend mode
|
||||
// getMissionStore() returns the AsyncMissionStore (CRUD-only, not an
|
||||
// EventEmitter); guard with instanceof and degrade gracefully — the advisory
|
||||
// is sync-mode only this unit.
|
||||
const resolvedMissionStore = this.store.getMissionStore();
|
||||
if (!(resolvedMissionStore instanceof MissionStore)) {
|
||||
return { alerted: false, reason: "mission-store-async-unsupported" };
|
||||
}
|
||||
const missionStore = resolvedMissionStore;
|
||||
const missions = missionStore.listMissions();
|
||||
/*
|
||||
FNXC:UnlinkedMissionsAdvisory 2026-07-17-16:20:
|
||||
Scheduled advisory behavior must be identical for the synchronous MissionStore
|
||||
and PostgreSQL AsyncMissionStore. Both expose the same mission and goal-link
|
||||
queries, so await their union rather than disabling a production scheduler path.
|
||||
*/
|
||||
const missionStore: MissionStore | AsyncMissionStore = this.store.getMissionStore();
|
||||
const missions = await missionStore.listMissions();
|
||||
const unlinkedActiveMissions: Mission[] = [];
|
||||
|
||||
for (const mission of missions) {
|
||||
if (mission.status !== "active") {
|
||||
continue;
|
||||
}
|
||||
if (missionStore.listGoalIdsForMission(mission.id).length > 0) {
|
||||
if ((await missionStore.listGoalIdsForMission(mission.id)).length > 0) {
|
||||
continue;
|
||||
}
|
||||
unlinkedActiveMissions.push(mission);
|
||||
|
||||
@@ -295,6 +295,7 @@ export default defineConfig({
|
||||
// SQLite-path gate test evicted + quarantined (see engine-core comment + ledger).
|
||||
"node_modules/**",
|
||||
"dist/**",
|
||||
// FNXC:PgMigrationQuarantine 2026-07-17-16:50: FN-8252 rescued the semantic async-store holdouts through production PostgreSQL seams and current async harnesses; retain only the remaining paired quarantines.
|
||||
// FNXC:PgMigrationQuarantine 2026-07-14-08:00:
|
||||
// FNXC:WorkflowStepInstancePersistence 2026-07-16-20:35: FN-8157 restores this PG-backed foreach suite through async store persistence, so it must execute in engine-default.
|
||||
// VAL-REMOVAL-005 deleted the SQLite Database class. These engine-default files fail
|
||||
@@ -302,11 +303,7 @@ export default defineConfig({
|
||||
// getDatabase, walCheckpoint) that throw/return-empty in backend mode, or have mock
|
||||
// drift from the async-satellite cutover. Quarantined on sight per AGENTS.md.
|
||||
"src/__tests__/backlog-pressure-reporter.test.ts",
|
||||
"src/__tests__/mission-autopilot.test.ts",
|
||||
"src/__tests__/mission-factory-parity.integration.test.ts",
|
||||
"src/__tests__/planner-overseer-intervention-wiring.test.ts",
|
||||
"src/__tests__/project-engine.test.ts",
|
||||
"src/__tests__/unlinked-missions-advisory-reporter.test.ts",
|
||||
"src/__tests__/workflow-graph-task-runner.test.ts",
|
||||
"src/__tests__/agent-tools-intake-column.test.ts",
|
||||
"src/__tests__/agent-workflow-tools-exposure.test.ts",
|
||||
@@ -316,7 +313,6 @@ export default defineConfig({
|
||||
"src/__tests__/group-merge-coordinator.test.ts",
|
||||
"src/__tests__/hybrid-executor-multi-node-routing.test.ts",
|
||||
"src/__tests__/merger-cwd-fallback-removed.test.ts",
|
||||
"src/__tests__/mission-autopilot-end-to-end.test.ts",
|
||||
"src/__tests__/routine-runner.test.ts",
|
||||
"src/__tests__/self-healing-meta-archive-guards.test.ts",
|
||||
"src/__tests__/triage-token-usage.test.ts",
|
||||
|
||||
@@ -1,36 +1,16 @@
|
||||
{
|
||||
"$comment": "Flaky-test quarantine ledger (deletion ratchet — see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date — the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config is the enforcement.",
|
||||
"$comment": "Flaky-test quarantine ledger (deletion ratchet \u2014 see AGENTS.md 'Flaky tests: quarantine on sight' and docs/testing.md 'Quarantine ledger and the deletion ratchet'). A test observed failing without a corresponding real bug is quarantined ON SIGHT: add an entry here AND a matching one-line `exclude` entry in that package's vitest config, in the same commit. Every entry needs a non-empty `reason` (link the failing run) and a `quarantinedAt` date \u2014 the entry expires 14 days later, at which point the test file is DELETED unless someone rescues it with evidence it catches real regressions plus a root-cause fix (never appeasement). There is deliberately no loader module and no automation around this file: it is a dated record, the vitest config is the enforcement.",
|
||||
"entries": [
|
||||
{
|
||||
"file": "packages/engine/src/__tests__/backlog-pressure-reporter.test.ts",
|
||||
"reason": "VAL-REMOVAL-005 PG migration: fails due to SQLite removal or sync API incompatibility in backend mode. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29344576232. Mirrored in packages/engine/vitest.config.ts.",
|
||||
"quarantinedAt": "2026-07-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/engine/src/__tests__/mission-autopilot.test.ts",
|
||||
"reason": "VAL-REMOVAL-005 PG migration: fails due to SQLite removal or sync API incompatibility in backend mode. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29344576232. Mirrored in packages/engine/vitest.config.ts.",
|
||||
"quarantinedAt": "2026-07-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/engine/src/__tests__/mission-factory-parity.integration.test.ts",
|
||||
"reason": "VAL-REMOVAL-005 PG migration: fails due to SQLite removal or sync API incompatibility in backend mode. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29344576232. Mirrored in packages/engine/vitest.config.ts.",
|
||||
"quarantinedAt": "2026-07-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/engine/src/__tests__/planner-overseer-intervention-wiring.test.ts",
|
||||
"reason": "VAL-REMOVAL-005 PG migration: fails due to SQLite removal or sync API incompatibility in backend mode. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29344576232. Mirrored in packages/engine/vitest.config.ts.",
|
||||
"quarantinedAt": "2026-07-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/engine/src/__tests__/project-engine.test.ts",
|
||||
"reason": "VAL-REMOVAL-005 PG migration: fails due to SQLite removal or sync API incompatibility in backend mode. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29344576232. Mirrored in packages/engine/vitest.config.ts.",
|
||||
"quarantinedAt": "2026-07-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/engine/src/__tests__/unlinked-missions-advisory-reporter.test.ts",
|
||||
"reason": "VAL-REMOVAL-005 PG migration: fails due to SQLite removal or sync API incompatibility in backend mode. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29344576232. Mirrored in packages/engine/vitest.config.ts.",
|
||||
"quarantinedAt": "2026-07-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/engine/src/__tests__/workflow-graph-task-runner.test.ts",
|
||||
"reason": "VAL-REMOVAL-005 PG migration: fails due to SQLite removal or sync API incompatibility in backend mode. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29344576232. Mirrored in packages/engine/vitest.config.ts.",
|
||||
@@ -76,11 +56,6 @@
|
||||
"reason": "VAL-REMOVAL-005 PG migration: fails due to SQLite removal or sync API incompatibility in backend mode. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29344576232. Mirrored in packages/engine/vitest.config.ts.",
|
||||
"quarantinedAt": "2026-07-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/engine/src/__tests__/mission-autopilot-end-to-end.test.ts",
|
||||
"reason": "VAL-REMOVAL-005 PG migration: fails due to SQLite removal or sync API incompatibility in backend mode. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29344576232. Mirrored in packages/engine/vitest.config.ts.",
|
||||
"quarantinedAt": "2026-07-14"
|
||||
},
|
||||
{
|
||||
"file": "packages/engine/src/__tests__/routine-runner.test.ts",
|
||||
"reason": "VAL-REMOVAL-005 PG migration: fails due to SQLite removal or sync API incompatibility in backend mode. Failing run: https://github.com/Runfusion/Fusion/actions/runs/29344576232. Mirrored in packages/engine/vitest.config.ts.",
|
||||
|
||||
Reference in New Issue
Block a user