FN-8970: add mission reconcile controls

Add a dry-run mission reconciliation control with explicit apply feedback.

- Add the dashboard reconcile API client and exported result type.
- Present preview, apply, empty, and archived reconciliation states in Mission Manager.
- Guard reconcile requests across direct selection and target-mission deep-link changes.
- Cover reconcile interactions, stale requests, and deep-link intent updates.

Files changed:
 .changeset/fn-8970-mission-reconcile-control.md    |   7 +
 docs/missions.md                                   |   6 +
 packages/dashboard/app/api/legacy.ts               |   2 +
 packages/dashboard/app/api/missions/missions.ts    |  31 +++
 packages/dashboard/app/components/MissionManager.css    |  48 +++++
 packages/dashboard/app/components/MissionManager.tsx    | 184 +++++++++++++-
 packages/dashboard/app/components/__tests__/MissionManager.mobile-css.test.ts    |   8 +
 packages/dashboard/app/components/__tests__/MissionManager.reconcile.test.tsx    | 277 +++++++++++++++++++++
 8 files changed, 557 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-8970

Fusion-Task-Lineage: 62802129-a7ea-43cb-8068-5ce2c2c21b77

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-11 00:23:10 -07:00
parent 79502c67a0
commit 3eec1338c5
8 changed files with 557 additions and 6 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a Mission Manager "Reconcile now" control with a dry-run preview before applying.
category: feature
dev: Thin client over POST /api/missions/:missionId/reconcile; new reconcileMission API wrapper in the dashboard app.

View File

@@ -775,6 +775,12 @@ Mission hierarchy operations are available with the same project-scoped `Mission
The scheduler startup and self-healing maintenance passes, mission autopilot, task moves, and `fn_mission_reconcile({ id?, dryRun? })` use one idempotent reconciliation authority. `POST /api/missions/:missionId/reconcile` exposes the same pass; `dryRun: true` returns planned changes without mutation. Automatic writes are attributed to `mission-reconcile:<startup|self-healing|autopilot|task-move>` and API/tool calls retain their operator or agent actor.
### Mission Manager reconcile control
Mission detail includes **Reconcile now** for an on-demand operator pass. It first requests a zero-write dry-run preview and lists the server-returned planned feature actions. **Apply reconcile** is a separate explicit action; a failed apply leaves that preview available to retry. Archived missions report as skipped and offer no apply action.
Selection changes discard reconcile responses silently, including responses arriving before the newly selected mission detail finishes loading. Leaving a mission also releases its busy and preview state so the next mission is immediately actionable. While a new mission detail is loading, the retained previous header's reconcile controls are inert (disabled and handler-refused), preventing reconciliation of the mission just left.
Correction scans every non-archived mission and slice but never activates or triages work. It maps deterministic task lifecycle lanes, failure state, and assertion validation to feature status, repairs stale validation badges when the store supports its fenced repair primitive, and uses explicit task links only to reconcile shipped archived delivery through the store's `terminal-task-reconcile` attribution. A bounded `mission:reconcile-pass` audit event records IDs, source enums, and counters only. Git history, GitHub polling, FR-41 receipts, and FN-8845 spec-lock drift are deliberately deferred extension inputs.
For example, activate a ready work unit with `fn_slice_activate({ id: "SL-…" })`. Link it to live work with `fn_feature_link_task({ featureId: "F-…", taskId: "FN-…" })`. Linking delegates to `MissionStore.linkFeatureToTask()`: it verifies the task is a live row in the same project, changes the feature to `triaged`, and records the mission/slice linkage on the task. Archived, deleted, missing, and other-project tasks are rejected.

View File

@@ -1101,6 +1101,7 @@ export {
triggerValidation,
VALIDATION_ALREADY_RUNNING,
repairFeatureValidation,
reconcileMission,
unlinkFeatureFromAssertion,
unlinkFeatureFromTask,
updateAssertion,
@@ -1130,6 +1131,7 @@ export type {
MissionEventsResponse,
MissionFeature,
MissionFeatureLoopSnapshot,
MissionReconcilePassResult,
MissionStatus,
MissionSummary,
MissionValidatorRun,

View File

@@ -609,6 +609,37 @@ export function triggerValidation(featureId: string, projectId?: string): Promis
});
}
/*
FNXC:MissionReconcileControl 2026-08-11-06:49:
The dashboard is a thin client over the single server reconcile authority. It renders the
returned plan verbatim and must never re-derive reconcile decisions in browser code.
*/
export interface MissionReconcilePassResult {
missionsScanned: number;
featuresScanned: number;
statusUpdates: number;
badgeRepairs: number;
badgeRepairsSkipped: number;
terminalRepairs: number;
terminalSkipped: number;
conflicts: number;
failures: number;
skippedReason?: "archived";
planned?: Array<{ featureId: string; action: "status" | "terminal-done" | "badge-clear" }>;
}
/** Preview or apply the server-owned mission reconciliation pass. */
export function reconcileMission(
missionId: string,
options: { dryRun?: boolean } | undefined,
projectId?: string,
): Promise<MissionReconcilePassResult> {
return api<MissionReconcilePassResult>(withProjectId(`/missions/${encodeURIComponent(missionId)}/reconcile`, projectId), {
method: "POST",
body: JSON.stringify({ dryRun: options?.dryRun === true }),
});
}
/** Repair a stale feature validation state using server-resolved ground truth. */
export function repairFeatureValidation(
featureId: string,

View File

@@ -1111,6 +1111,44 @@ Narrow/mobile Missions puts Plan New Mission at the bottom of the list, using th
color: var(--text-muted);
}
.mission-detail__reconcile-panel {
display: flex;
flex-direction: column;
gap: var(--space-sm);
width: 100%;
padding: var(--space-sm);
border: var(--border) solid;
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-warning) 10%, var(--surface));
color: var(--text);
}
.mission-detail__reconcile-summary,
.mission-detail__reconcile-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-sm);
}
.mission-detail__reconcile-summary {
font-size: var(--font-size-sm);
color: var(--text-muted);
}
.mission-detail__reconcile-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin: 0;
padding-inline-start: var(--space-lg);
font-size: var(--font-size-sm);
}
.mission-detail__reconcile-actions {
justify-content: flex-start;
}
.mission-detail__management-actions {
display: inline-flex;
align-items: center;
@@ -2798,6 +2836,16 @@ Generated fix features can carry the same stale blocked state as ordinary rows.
min-height: 36px;
}
.mission-detail__run-controls > [data-testid="mission-reconcile-now"],
.mission-detail__reconcile-actions .mission-btn {
min-width: 36px;
min-height: 36px;
}
.mission-detail__reconcile-panel {
max-width: 100%;
}
/* Prevent iOS zoom on focus */
.mission-form-card input[type="text"],
.mission-form-card textarea,

View File

@@ -107,6 +107,7 @@ import {
triggerValidation,
VALIDATION_ALREADY_RUNNING,
repairFeatureValidation,
reconcileMission,
fetchValidationLoopState,
fetchValidationRuns,
fetchValidationRun,
@@ -119,6 +120,7 @@ import {
api,
type AiSessionSummary,
type BranchGroupSummary,
type MissionReconcilePassResult,
} from "../api";
import type { AutopilotState, MissionInterviewDraftSummary } from "./mission-types";
import { readCache, SWR_CACHE_KEYS, writeCache } from "../utils/swrCache";
@@ -756,6 +758,58 @@ function normalizeMissionHierarchy(mission: MissionWithHierarchy): MissionWithHi
FNXC:MissionValidationRepair 2026-08-11-00:07:
Both feature presentations must expose the same narrowly-scoped escape from a stale validation badge. This renders only actions allowed by the core predicate, so a live validation or implementation cycle is never pre-empted and the execution loop remains unable to escape blocked on its own.
*/
/*
FNXC:MissionReconcileControl 2026-08-11-06:49:
Preview content is the dry-run result from the server authority, not a browser-derived plan.
Nothing mutates until the operator explicitly applies this panel.
*/
function MissionReconcilePreview({
result,
featureTitles,
busy,
disabled,
onApply,
onDismiss,
t,
}: {
result: MissionReconcilePassResult;
featureTitles: Map<string, string>;
busy: "preview" | "apply" | null;
disabled: boolean;
onApply: () => void;
onDismiss: () => void;
t: ReturnType<typeof useTranslation>["t"];
}) {
const planned = result.planned ?? [];
const isEmpty = planned.length === 0 && result.statusUpdates === 0 && result.badgeRepairs === 0 && result.terminalRepairs === 0;
const canApply = result.skippedReason !== "archived" && !isEmpty && planned.length > 0;
return (
<section className="mission-detail__reconcile-panel" aria-label={t("missions.reconcilePreview", "Reconcile preview")}>
<div className="mission-detail__reconcile-summary">
<span>{t("missions.reconcileStatusUpdates", "Status updates: {{count}}", { count: result.statusUpdates })}</span>
<span>{t("missions.reconcileBadgeRepairs", "Badge repairs: {{count}}", { count: result.badgeRepairs })}</span>
<span>{t("missions.reconcileTerminalRepairs", "Terminal repairs: {{count}}", { count: result.terminalRepairs })}</span>
</div>
{result.skippedReason === "archived" ? <p>{t("missions.reconcileArchived", "Mission is archived — nothing reconciled")}</p>
: isEmpty ? <p>{t("missions.reconcileUpToDate", "Already up to date")}</p>
: <ul className="mission-detail__reconcile-list">{planned.map((entry) => (
<li key={`${entry.featureId}:${entry.action}`}>
{featureTitles.get(entry.featureId) ?? entry.featureId} — {entry.action}
</li>
))}</ul>}
<div className="mission-detail__reconcile-actions">
{canApply && <button className="mission-btn mission-btn--primary mission-btn--sm" onClick={onApply} disabled={busy !== null || disabled} data-testid="mission-reconcile-apply">
{busy === "apply" ? <Loader2 className="spinner" /> : <Check />}<span>{t("missions.applyReconcile", "Apply reconcile")}</span>
</button>}
<button className="mission-btn mission-btn--ghost mission-btn--sm" onClick={onDismiss} disabled={busy !== null}>
<span>{t("common.dismiss", "Dismiss")}</span>
</button>
</div>
</section>
);
}
function FeatureValidationRepairActions({
feature,
busy,
@@ -813,6 +867,9 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const initialMissions = readCache<MissionWithSummary[]>(missionsCacheKey);
const [missions, setMissions] = useState<MissionWithSummary[]>(() => (Array.isArray(initialMissions) ? initialMissions : []));
const [selectedMission, setSelectedMission] = useState<MissionWithHierarchy | null>(null);
const [selectedMissionIntentId, setSelectedMissionIntentId] = useState<string | null>(null);
const [reconcileBusy, setReconcileBusy] = useState<"preview" | "apply" | null>(null);
const [reconcilePreview, setReconcilePreview] = useState<{ missionId: string; result: MissionReconcilePassResult } | null>(null);
const [selectedMissionBranchGroup, setSelectedMissionBranchGroup] = useState<BranchGroupSummary | null>(null);
const [loading, setLoading] = useState(!(Array.isArray(initialMissions) && initialMissions.length > 0));
const hasHydratedRef = useRef(Array.isArray(initialMissions) && initialMissions.length > 0);
@@ -1113,6 +1170,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const missionEventsRef = useRef<MissionEvent[]>([]);
const missionsRef = useRef<MissionWithSummary[]>([]);
const selectedMissionRef = useRef<MissionWithHierarchy | null>(null);
// Intent changes synchronously on list clicks while committed detail intentionally lags its fetch.
const selectedMissionIntentRef = useRef<string | null>(null);
const selectedMilestoneIdRef = useRef<string | null>(null);
/*
FNXC:MissionBranchGroupDetail 2026-08-08-16:58:
@@ -1122,6 +1181,19 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
*/
const missionDetailRequestGenerationRef = useRef(0);
/*
FNXC:MissionReconcileControl 2026-08-11-06:49:
Reconcile responses are generation-guarded on success, rejection, and cleanup. A late loser
cannot alter a new mission, while a selection boundary owns synchronous busy/preview release.
*/
const reconcileRequestGenerationRef = useRef(0);
const invalidateReconcileRequests = useCallback((nextIntentMissionId: string | null) => {
reconcileRequestGenerationRef.current += 1;
selectedMissionIntentRef.current = nextIntentMissionId;
setSelectedMissionIntentId(nextIntentMissionId);
setReconcileBusy(null);
setReconcilePreview(null);
}, []);
/*
FNXC:MissionBranchGroupDetail 2026-08-08-17:07:
Returning to the mission list, deleting the selected mission, hiding this
inline view, or unmounting also changes selection. Invalidate in-flight
@@ -1130,7 +1202,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
*/
const invalidateMissionDetailRequests = useCallback(() => {
missionDetailRequestGenerationRef.current += 1;
}, []);
invalidateReconcileRequests(null);
}, [invalidateReconcileRequests]);
// FNXC:MilestoneValidationFreshness 2026-08-01-20:42: Rollup and telemetry responses share one per-milestone generation so an older request cannot restore a repaired failed badge, while a newer failure remains valid.
const validationRequestGenerationRef = useRef(new MilestoneValidationFreshnessCoordinator());
const activeTabRef = useRef<"structure" | "activity">("structure");
@@ -1162,6 +1235,15 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}, [eventsTotal, missions, selectedMission?.eventCount, selectedMission?.id]);
const displayedMissionEvents = useMemo(() => [...missionEvents].reverse(), [missionEvents]);
const reconcileFeatureTitles = useMemo(() => new Map(
(selectedMission?.milestones ?? []).flatMap((milestone) => milestone.slices.flatMap((slice) =>
slice.features.map((feature) => [feature.id, feature.title] as const),
)),
), [selectedMission]);
useEffect(() => {
setReconcilePreview(null);
}, [selectedMission?.id]);
// Keep latest state available to long-lived SSE handlers without reconnect churn.
missionsRef.current = missions;
@@ -1273,6 +1355,14 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}, [addToast, projectId, t]);
const loadMissionDetail = useCallback(async (missionId: string) => {
/*
FNXC:MissionReconcileControl 2026-08-11-07:20:
Every detail-load entry point, including `targetMissionId` deep links, is a selection boundary.
Update intent before fetching so a new deep-linked mission can reconcile once it commits.
*/
if (selectedMissionIntentRef.current !== missionId) {
invalidateReconcileRequests(missionId);
}
const requestGeneration = ++missionDetailRequestGenerationRef.current;
try {
setDetailLoading(true);
@@ -1283,6 +1373,10 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
const data = normalizeMissionHierarchy(payload as MissionWithHierarchy);
if (selectedMissionIntentRef.current === null) {
selectedMissionIntentRef.current = data.id;
setSelectedMissionIntentId(data.id);
}
setSelectedMission(data);
if (data.milestones.length > 0) {
const firstMilestoneId = data.milestones[0].id;
@@ -1336,7 +1430,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
setDetailLoading(false);
}
}
}, [addToast, loadAssertionsForMilestone, loadValidationRollup, projectId]);
}, [addToast, invalidateReconcileRequests, loadAssertionsForMilestone, loadValidationRollup, projectId]);
/*
FNXC:MissionBranchGroupDetail 2026-08-08-16:11:
@@ -2867,7 +2961,57 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
}, [addToast, loadMissionDetail, loadMissions, projectId]);
const isReconcileRequestStale = useCallback((generation: number, missionId: string) =>
generation !== reconcileRequestGenerationRef.current || selectedMissionRef.current?.id !== missionId,
[]);
const handleReconcilePreview = useCallback(async (missionId: string) => {
// The committed selectedMission is not an intent signal during a detail-load switch.
if (missionId !== selectedMissionIntentRef.current) return;
const generation = ++reconcileRequestGenerationRef.current;
setReconcileBusy("preview");
try {
const result = await reconcileMission(missionId, { dryRun: true }, projectId);
if (isReconcileRequestStale(generation, missionId)) return;
setReconcilePreview({ missionId, result });
} catch (err) {
if (isReconcileRequestStale(generation, missionId)) return;
addToast(getErrorMessage(err) || t("missions.reconcilePreviewFailed", "Failed to preview reconcile"), "error");
} finally {
if (!isReconcileRequestStale(generation, missionId)) setReconcileBusy(null);
}
}, [addToast, isReconcileRequestStale, projectId, t]);
const handleReconcileApply = useCallback(async (missionId: string) => {
// Refuse an apply dispatched from an old header in the synchronous selection window.
if (missionId !== selectedMissionIntentRef.current) return;
const generation = ++reconcileRequestGenerationRef.current;
setReconcileBusy("apply");
try {
const result = await reconcileMission(missionId, { dryRun: false }, projectId);
if (isReconcileRequestStale(generation, missionId)) return;
addToast(t("missions.reconcileApplied", "Reconciled: {{status}} status, {{badge}} badge, {{terminal}} terminal repairs", {
status: result.statusUpdates, badge: result.badgeRepairs, terminal: result.terminalRepairs,
}), "success");
setReconcilePreview(null);
await loadMissionDetail(missionId);
if (isReconcileRequestStale(generation, missionId)) return;
void loadMissions();
} catch (err) {
if (isReconcileRequestStale(generation, missionId)) return;
addToast(getErrorMessage(err) || t("missions.reconcileApplyFailed", "Failed to apply reconcile"), "error");
} finally {
if (!isReconcileRequestStale(generation, missionId)) setReconcileBusy(null);
}
}, [addToast, isReconcileRequestStale, loadMissionDetail, loadMissions, projectId, t]);
const handleSelectMission = useCallback((mission: Mission) => {
/*
FNXC:MissionReconcileControl 2026-08-11-06:49:
Detail refs lag a direct mission switch until its fetch commits. Record operator intent and
invalidate/release synchronously so the retained old header cannot reconcile an abandoned mission.
*/
invalidateReconcileRequests(mission.id);
setActiveTab("structure");
setSelectedMilestoneId(null);
setValidationTelemetry(null);
@@ -2876,7 +3020,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
setEventsFilter("all");
setExpandedEventMetadata(new Set());
loadMissionDetail(mission.id);
}, [loadMissionDetail]);
}, [invalidateReconcileRequests, loadMissionDetail]);
const handleBackToList = useCallback(() => {
invalidateMissionDetailRequests();
@@ -2908,9 +3052,15 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
useEffect(() => {
if (!isActive) {
/*
FNXC:MissionReconcileControl 2026-08-11-06:49:
Inline Mission Manager stays mounted when hidden. Treat that visibility boundary like a
deselection so an in-flight reconcile cannot toast or update hidden, abandoned detail.
*/
invalidateMissionDetailRequests();
previousMobileDetailVisibleRef.current = false;
}
}, [isActive]);
}, [invalidateMissionDetailRequests, isActive]);
useEffect(() => {
const isMobileDetailVisible = isActive && isMobile && Boolean(selectedMission);
@@ -3248,6 +3398,28 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<div className="mission-detail__actions">
<div className="mission-detail__run-controls">
<button
className="mission-btn mission-btn--ghost"
onClick={() => handleReconcilePreview(selectedMission.id)}
title={t("missions.reconcileNow", "Reconcile now")}
aria-label={t("missions.reconcileNow", "Reconcile now")}
data-testid="mission-reconcile-now"
disabled={reconcileBusy !== null || selectedMissionIntentId !== selectedMission.id}
>
{reconcileBusy === "preview" ? <Loader2 className="spinner" /> : <RefreshCw />}
<span>{t("missions.reconcileNow", "Reconcile now")}</span>
</button>
{reconcilePreview?.missionId === selectedMission.id && (
<MissionReconcilePreview
result={reconcilePreview.result}
featureTitles={reconcileFeatureTitles}
busy={reconcileBusy}
disabled={selectedMissionIntentId !== selectedMission.id}
onApply={() => handleReconcileApply(selectedMission.id)}
onDismiss={() => setReconcilePreview(null)}
t={t}
/>
)}
{selectedMission.status === "active" && (
<button
className="mission-btn mission-btn--danger"
@@ -5508,7 +5680,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<Loader2 size={24} className="spinner" />
<span>{t("missions.loadingMissions", "Loading missions...")}</span>
</div>
) : detailLoading ? (
) : detailLoading && !selectedMission ? (
<div className="mission-manager__loading">
<Loader2 size={24} className="spinner" />
<span>{t("missions.loadingMissionDetails", "Loading mission details...")}</span>
@@ -5572,7 +5744,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
)}
<div className="mission-manager__detail-pane">
{detailLoading ? (
{detailLoading && !selectedMission ? (
<div className="mission-manager__loading">
<Loader2 size={24} className="spinner" />
<span>{t("missions.loadingMissionDetails", "Loading mission details...")}</span>

View File

@@ -26,6 +26,14 @@ describe("MissionManager mobile styles", () => {
expect(section).toContain("min-height: 36px;");
});
it("keeps reconcile preview controls touch-sized", () => {
const section = getMissionMobileSection(loadAllAppCss());
expect(section).toContain('.mission-detail__run-controls > [data-testid="mission-reconcile-now"]');
expect(section).toContain(".mission-detail__reconcile-actions .mission-btn");
expect(section).toContain("min-width: 36px;");
expect(section).toContain("min-height: 36px;");
});
it("adds responsive tab and activity layout rules", () => {
const css = loadAllAppCss();
const section = getMissionMobileSection(css);

View File

@@ -0,0 +1,277 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ConfirmDialogProvider } from "../../hooks/useConfirm";
import { MissionManager } from "../MissionManager";
const fetchMissions = vi.fn();
const fetchMission = vi.fn();
const reconcileMission = vi.fn();
const fetchMissionsHealth = vi.fn();
const fetchMissionEvents = vi.fn();
const fetchAssertions = vi.fn();
const fetchMilestoneValidation = vi.fn();
const fetchMilestoneValidationTelemetry = vi.fn();
const fetchValidationLoopState = vi.fn();
const fetchValidationRuns = vi.fn();
const fetchAiSessions = vi.fn();
const fetchAiSession = vi.fn();
const fetchMissionInterviewDrafts = vi.fn();
vi.mock("../../hooks/useNavigationHistory", () => ({ useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }) }));
vi.mock("../../sse-bus", () => ({ subscribeSse: vi.fn(() => vi.fn()) }));
vi.mock("../MissionInterviewModal", () => ({ MissionInterviewModal: () => null }));
vi.mock("../MilestoneSliceInterviewModal", () => ({ MilestoneSliceInterviewModal: () => null }));
vi.mock("../../api", async (original) => ({
...(await original<typeof import("../../api")>()),
fetchMissions: (...args: unknown[]) => fetchMissions(...args),
fetchMission: (...args: unknown[]) => fetchMission(...args),
reconcileMission: (...args: unknown[]) => reconcileMission(...args),
fetchMissionsHealth: (...args: unknown[]) => fetchMissionsHealth(...args),
fetchMissionEvents: (...args: unknown[]) => fetchMissionEvents(...args),
fetchAssertions: (...args: unknown[]) => fetchAssertions(...args),
fetchMilestoneValidation: (...args: unknown[]) => fetchMilestoneValidation(...args),
fetchMilestoneValidationTelemetry: (...args: unknown[]) => fetchMilestoneValidationTelemetry(...args),
fetchValidationLoopState: (...args: unknown[]) => fetchValidationLoopState(...args),
fetchValidationRuns: (...args: unknown[]) => fetchValidationRuns(...args),
fetchAiSessions: (...args: unknown[]) => fetchAiSessions(...args),
fetchAiSession: (...args: unknown[]) => fetchAiSession(...args),
fetchMissionInterviewDrafts: (...args: unknown[]) => fetchMissionInterviewDrafts(...args),
fetchGoals: vi.fn().mockResolvedValue([]),
api: vi.fn().mockResolvedValue({ goals: [] }),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
}));
function mission(id = "M-1") {
return {
id, title: id === "M-1" ? "Mission one" : "Mission two", description: "", status: "active", interviewState: "completed",
createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z",
milestones: [{ id: `${id}-MS`, missionId: id, title: "Milestone", status: "active", interviewState: "completed", orderIndex: 0, dependencies: [], createdAt: "", updatedAt: "", slices: [{ id: `${id}-SL`, milestoneId: `${id}-MS`, title: "Slice", status: "active", orderIndex: 0, dependencies: [], createdAt: "", updatedAt: "", features: [{ id: "F-1", title: "Feature title", status: "in-progress", createdAt: "", updatedAt: "" }] }] }],
};
}
const result = (planned = [{ featureId: "F-1", action: "status" as const }]) => ({ missionsScanned: 1, featuresScanned: 1, statusUpdates: 1, badgeRepairs: 0, badgeRepairsSkipped: 0, terminalRepairs: 0, terminalSkipped: 0, conflicts: 0, failures: 0, planned });
/*
FNXC:MissionReconcileControl 2026-08-11-06:49:
Exercise the rendered control rather than an exported handler so dry-run gating, explicit apply,
and selection-boundary response suppression remain user-visible contracts.
*/
describe("MissionManager reconcile control", () => {
beforeEach(() => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1280 });
window.dispatchEvent(new Event("resize"));
vi.clearAllMocks();
fetchMissions.mockResolvedValue([mission("M-1"), mission("M-2")].map((item) => ({ ...item, milestones: [] })));
fetchMission.mockImplementation(async (id: string) => mission(id));
fetchMissionsHealth.mockResolvedValue({}); fetchMissionEvents.mockResolvedValue([]); fetchAssertions.mockResolvedValue([]);
fetchMilestoneValidation.mockResolvedValue(null); fetchMilestoneValidationTelemetry.mockResolvedValue({ rollup: null, validationTelemetry: { validationRounds: [], totalRuns: 0 }, validationContract: null, fixFeatures: [] });
fetchValidationLoopState.mockResolvedValue(null); fetchValidationRuns.mockResolvedValue([]); fetchAiSessions.mockResolvedValue([]); fetchAiSession.mockResolvedValue(null); fetchMissionInterviewDrafts.mockResolvedValue([]);
});
afterEach(cleanup);
function renderManager(addToast = vi.fn(), targetMissionId = "M-1") {
const rendered = render(<ConfirmDialogProvider><MissionManager isOpen isInline onClose={() => {}} addToast={addToast} projectId="p1" targetMissionId={targetMissionId} /></ConfirmDialogProvider>);
return { ...rendered, addToast };
}
it.each([1280, 640])("previews at %ipx without applying", async (width) => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: width }); window.dispatchEvent(new Event("resize"));
reconcileMission.mockResolvedValue(result()); renderManager();
await screen.findByText("Feature title"); fireEvent.click(screen.getByTestId("mission-reconcile-now"));
await screen.findByText(/Feature title — status/);
expect(reconcileMission).toHaveBeenCalledTimes(1);
expect(reconcileMission).toHaveBeenCalledWith("M-1", { dryRun: true }, "p1");
});
it("applies only after explicit confirmation and refreshes", async () => {
reconcileMission.mockResolvedValueOnce(result()).mockResolvedValueOnce({ ...result(), planned: undefined });
const { container, addToast } = renderManager(); await screen.findByText("Feature title");
fireEvent.click(screen.getByTestId("mission-reconcile-now")); await screen.findByTestId("mission-reconcile-apply");
fireEvent.click(screen.getByTestId("mission-reconcile-apply"));
await waitFor(() => expect(reconcileMission).toHaveBeenLastCalledWith("M-1", { dryRun: false }, "p1"));
await waitFor(() => expect(container.querySelector(".mission-detail__reconcile-panel")).toBeNull());
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Reconciled:"), "success");
});
it("keeps preview for an apply failure", async () => {
reconcileMission.mockResolvedValueOnce(result()).mockRejectedValueOnce(new Error("apply failed"));
const { addToast } = renderManager(); await screen.findByText("Feature title");
fireEvent.click(screen.getByTestId("mission-reconcile-now")); await screen.findByTestId("mission-reconcile-apply");
fireEvent.click(screen.getByTestId("mission-reconcile-apply"));
await waitFor(() => expect(addToast).toHaveBeenCalledWith("apply failed", "error"));
expect(screen.getByTestId("mission-reconcile-apply")).toBeEnabled();
});
it("renders empty and archived dry runs without an apply action", async () => {
reconcileMission.mockResolvedValueOnce({ ...result([]), statusUpdates: 0 }).mockResolvedValueOnce({ ...result([]), statusUpdates: 0, skippedReason: "archived" });
renderManager(); await screen.findByText("Feature title"); fireEvent.click(screen.getByTestId("mission-reconcile-now"));
await screen.findByText("Already up to date"); expect(screen.queryByTestId("mission-reconcile-apply")).toBeNull();
fireEvent.click(screen.getByText("Dismiss")); fireEvent.click(screen.getByTestId("mission-reconcile-now"));
await screen.findByText("Mission is archived — nothing reconciled"); expect(screen.queryByTestId("mission-reconcile-apply")).toBeNull();
});
it("keeps the affordance out of mission list controls", async () => {
reconcileMission.mockResolvedValue(result()); const { container } = renderManager(); await screen.findByText("Feature title");
expect(screen.getAllByTestId("mission-reconcile-now")).toHaveLength(1);
expect(container.querySelector(".mission-list__item-run-controls [data-testid=mission-reconcile-now]")).toBeNull();
});
function deferred<T>() {
let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
async function openM2() {
fireEvent.click(screen.getByRole("button", { name: "Open mission Mission two" }));
await screen.findByText("Mission two");
}
it("updates reconcile intent when a target mission deep link changes", async () => {
reconcileMission.mockResolvedValue(result());
const { rerender } = renderManager();
await screen.findByText("Mission one");
rerender(<ConfirmDialogProvider><MissionManager isOpen isInline onClose={() => {}} addToast={vi.fn()} projectId="p1" targetMissionId="M-2" /></ConfirmDialogProvider>);
await screen.findByText("Mission two");
expect(screen.getByTestId("mission-reconcile-now")).toBeEnabled();
fireEvent.click(screen.getByTestId("mission-reconcile-now"));
await waitFor(() => expect(reconcileMission).toHaveBeenCalledWith("M-2", { dryRun: true }, "p1"));
});
it("makes the retained header inert and refuses an attribute-stripped preview click during a switch", async () => {
const m2 = deferred<ReturnType<typeof mission>>();
fetchMission.mockImplementation((id: string) => id === "M-2" ? m2.promise : Promise.resolve(mission(id)));
const { container } = renderManager(); await screen.findByText("Feature title");
fireEvent.click(screen.getByRole("button", { name: "Open mission Mission two" }));
const button = screen.getByTestId("mission-reconcile-now") as HTMLButtonElement;
expect(button).toBeDisabled();
// Remove the DOM gate without a React render: this click reaches the production handler.
button.removeAttribute("disabled"); button.disabled = false;
await act(async () => { fireEvent.click(button); });
expect(reconcileMission).not.toHaveBeenCalled();
expect(container.querySelector(".mission-detail__reconcile-panel")).toBeNull();
m2.resolve(mission("M-2")); await screen.findByText("Mission two");
expect(screen.getByTestId("mission-reconcile-now")).toBeEnabled();
});
it("refuses a same-batch retained-header click while switching missions", async () => {
const m2 = deferred<ReturnType<typeof mission>>();
fetchMission.mockImplementation((id: string) => id === "M-2" ? m2.promise : Promise.resolve(mission(id)));
renderManager(); await screen.findByText("Feature title");
const row = screen.getByRole("button", { name: "Open mission Mission two" });
const button = screen.getByTestId("mission-reconcile-now");
await act(async () => { fireEvent.click(row); fireEvent.click(button); });
expect(reconcileMission).not.toHaveBeenCalledWith("M-1", expect.anything(), "p1");
m2.resolve(mission("M-2")); await screen.findByText("Mission two");
});
it("silently discards preview resolution and rejection in the pre-commit switch window", async () => {
const preview = deferred<ReturnType<typeof result>>(); const m2 = deferred<ReturnType<typeof mission>>();
reconcileMission.mockReturnValue(preview.promise);
fetchMission.mockImplementation((id: string) => id === "M-2" ? m2.promise : Promise.resolve(mission(id)));
const { addToast, container } = renderManager(); await screen.findByText("Feature title");
fireEvent.click(screen.getByTestId("mission-reconcile-now")); await waitFor(() => expect(reconcileMission).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "Open mission Mission two" }));
preview.resolve(result()); await act(async () => {});
expect(container.querySelector(".mission-detail__reconcile-panel")).toBeNull(); expect(addToast).not.toHaveBeenCalled();
m2.resolve(mission("M-2")); await screen.findByText("Mission two");
const rejected = deferred<ReturnType<typeof result>>(); reconcileMission.mockReturnValueOnce(rejected.promise);
fireEvent.click(screen.getByTestId("mission-reconcile-now")); await waitFor(() => expect(reconcileMission).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole("button", { name: "Open mission Mission one" }));
rejected.reject(new Error("stale preview")); await act(async () => {});
expect(addToast).not.toHaveBeenCalledWith("stale preview", "error");
});
it("refuses a same-batch retained-panel apply click so no write reaches the abandoned mission", async () => {
const m2 = deferred<ReturnType<typeof mission>>();
reconcileMission.mockResolvedValue(result());
fetchMission.mockImplementation((id: string) => id === "M-2" ? m2.promise : Promise.resolve(mission(id)));
renderManager(); await screen.findByText("Feature title");
fireEvent.click(screen.getByTestId("mission-reconcile-now")); await screen.findByTestId("mission-reconcile-apply");
const row = screen.getByRole("button", { name: "Open mission Mission two" });
const apply = screen.getByTestId("mission-reconcile-apply");
// The panel is synchronously released after the row event; this same-batch click is the
// reachable production path that still invokes the old handler before React re-renders it.
await act(async () => { fireEvent.click(row); fireEvent.click(apply); });
expect(reconcileMission).not.toHaveBeenCalledWith("M-1", { dryRun: false }, "p1");
m2.resolve(mission("M-2")); await screen.findByText("Mission two");
});
it("releases abandoned busy state but does not let a stale finally clobber a newer request", async () => {
const oldRequest = deferred<ReturnType<typeof result>>(); const newRequest = deferred<ReturnType<typeof result>>();
reconcileMission.mockReturnValueOnce(oldRequest.promise).mockReturnValueOnce(newRequest.promise);
renderManager(); await screen.findByText("Feature title");
fireEvent.click(screen.getByTestId("mission-reconcile-now"));
expect(screen.getByTestId("mission-reconcile-now")).toBeDisabled();
await openM2();
// The boundary, rather than the old request's finally, releases M-2 immediately.
expect(screen.getByTestId("mission-reconcile-now")).toBeEnabled();
fireEvent.click(screen.getByTestId("mission-reconcile-now"));
expect(screen.getByTestId("mission-reconcile-now")).toBeDisabled();
oldRequest.resolve(result()); await act(async () => {});
expect(screen.getByTestId("mission-reconcile-now")).toBeDisabled();
newRequest.resolve(result()); await screen.findByText(/Feature title — status/);
expect(screen.getByTestId("mission-reconcile-now")).toBeEnabled();
});
it("does not refresh or toast when an abandoned apply resolves or rejects", async () => {
const apply = deferred<ReturnType<typeof result>>();
reconcileMission.mockResolvedValueOnce(result()).mockReturnValueOnce(apply.promise);
const { addToast } = renderManager(); await screen.findByText("Feature title");
fireEvent.click(screen.getByTestId("mission-reconcile-now")); await screen.findByTestId("mission-reconcile-apply");
fireEvent.click(screen.getByTestId("mission-reconcile-apply")); await openM2();
const callsBefore = fetchMission.mock.calls.filter(([id]) => id === "M-1").length;
apply.resolve({ ...result(), planned: undefined }); await act(async () => {});
expect(addToast).not.toHaveBeenCalledWith(expect.stringContaining("Reconciled:"), "success");
expect(fetchMission.mock.calls.filter(([id]) => id === "M-1")).toHaveLength(callsBefore);
const rejected = deferred<ReturnType<typeof result>>(); reconcileMission.mockResolvedValueOnce(result()).mockReturnValueOnce(rejected.promise);
fireEvent.click(screen.getByTestId("mission-reconcile-now")); await screen.findByTestId("mission-reconcile-apply");
fireEvent.click(screen.getByTestId("mission-reconcile-apply"));
fireEvent.click(screen.getByRole("button", { name: "Open mission Mission one" }));
rejected.reject(new Error("stale apply")); await act(async () => {});
expect(addToast).not.toHaveBeenCalledWith("stale apply", "error");
});
it("prevents duplicate preview and apply requests while either request is busy", async () => {
const preview = deferred<ReturnType<typeof result>>(); const apply = deferred<ReturnType<typeof result>>();
reconcileMission.mockReturnValueOnce(preview.promise).mockReturnValueOnce(apply.promise);
renderManager(); await screen.findByText("Feature title"); const button = screen.getByTestId("mission-reconcile-now");
fireEvent.click(button); fireEvent.click(button); fireEvent.click(button);
expect(reconcileMission).toHaveBeenCalledTimes(1); expect(button).toBeDisabled();
preview.resolve(result()); await screen.findByTestId("mission-reconcile-apply");
const applyButton = screen.getByTestId("mission-reconcile-apply"); fireEvent.click(applyButton); fireEvent.click(applyButton); fireEvent.click(applyButton);
expect(reconcileMission).toHaveBeenCalledTimes(2); expect(applyButton).toBeDisabled();
apply.resolve({ ...result(), planned: undefined }); await waitFor(() => expect(screen.queryByTestId("mission-reconcile-apply")).toBeNull());
});
it("renders unknown and duplicate planned features without key warnings", async () => {
const error = vi.spyOn(console, "error").mockImplementation(() => {});
reconcileMission.mockResolvedValue(result([{ featureId: "F-missing", action: "status" }, { featureId: "F-1", action: "status" }, { featureId: "F-1", action: "badge-clear" }]));
renderManager(); await screen.findByText("Feature title"); fireEvent.click(screen.getByTestId("mission-reconcile-now"));
await screen.findByText(/F-missing — status/); expect(screen.getAllByText(/F-1|Feature title/).length).toBeGreaterThan(0);
expect(screen.getByText(/Feature title — badge-clear/)).toBeTruthy();
expect(error).not.toHaveBeenCalledWith(expect.stringMatching(/unique "key"/i)); error.mockRestore();
});
it("discards a pending reconcile after deselect and unmount without React warnings", async () => {
const request = deferred<ReturnType<typeof result>>(); reconcileMission.mockReturnValue(request.promise);
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const rendered = renderManager(); await screen.findByText("Feature title"); fireEvent.click(screen.getByTestId("mission-reconcile-now"));
fireEvent.click(screen.getByTestId("mission-back-btn")); request.resolve(result()); await act(async () => {});
expect(screen.queryByTestId("mission-reconcile-apply")).toBeNull();
rendered.unmount(); expect(error.mock.calls.join(" ")).not.toMatch(/state update on an unmounted|not wrapped in act/i); error.mockRestore();
});
it("does not update or warn after preview or apply promises settle following unmount", async () => {
const preview = deferred<ReturnType<typeof result>>(); const error = vi.spyOn(console, "error").mockImplementation(() => {});
reconcileMission.mockReturnValueOnce(preview.promise); const first = renderManager(); await screen.findByText("Feature title");
fireEvent.click(screen.getByTestId("mission-reconcile-now")); first.unmount(); preview.reject(new Error("gone")); await act(async () => {});
cleanup();
const apply = deferred<ReturnType<typeof result>>();
reconcileMission.mockResolvedValueOnce(result()).mockReturnValueOnce(apply.promise);
const second = renderManager(); await screen.findByText("Feature title"); fireEvent.click(screen.getByTestId("mission-reconcile-now")); await screen.findByTestId("mission-reconcile-apply");
fireEvent.click(screen.getByTestId("mission-reconcile-apply")); second.unmount(); apply.resolve({ ...result(), planned: undefined }); await act(async () => {});
expect(error.mock.calls.join(" ")).not.toMatch(/state update on an unmounted|not wrapped in act/i); error.mockRestore();
});
});