FN-8845: persist deterministic spec alignment

Persist approved-plan drift alignment on linked mission features.

- Store spec alignment across PostgreSQL and SQLite mission feature projections
- Reconcile and render durable alignment instead of browser-side task joins
- Preserve migration identities and cover drift persistence regressions

Files changed:
 docs/architecture.md                               |  2 +
 docs/missions.md                                   |  2 +-
 .../core/src/__tests__/planner/spec-lock.test.ts   |  1 +
 .../src/__tests__/postgres/schema-applier.test.ts  |  9 ++++-
 .../async-stores/async-mission-store-queries.ts    |  5 +++
 packages/core/src/missions/mission-store.ts        |  9 ++++-
 packages/core/src/missions/mission-types.ts        | 10 +++++
 packages/core/src/planner/spec-lock.ts             |  7 +++-
 .../0053_mission_feature_spec_alignment.sql        |  3 ++
 packages/core/src/postgres/schema-applier.ts       | 13 +++++-
 packages/core/src/postgres/schema/project.ts       |  2 +
 packages/dashboard/app/api/missions/missions.ts    |  2 +
 .../dashboard/app/components/MissionManager.tsx    | 40 ++++++------------
 packages/dashboard/app/components/mission-types.ts |  2 +
 .../src/__tests__/plan-approval-status.pg.test.ts  |  4 +-
 .../src/__tests__/mission-feature-sync.test.ts     | 37 ++++++++++++++++-
 .../src/__tests__/spec-drift-reconciler.test.ts    | 14 +++++++
 packages/engine/src/missions/mission-autopilot.ts  | 17 +++++---
 .../engine/src/missions/mission-feature-sync.ts    | 47 +++++++++++++++++++++-
 packages/engine/src/project-engine.ts              |  3 +-
 packages/engine/src/scheduler.ts                   | 40 +++++++++++-------
 packages/engine/src/spec-drift-reconciler.ts       |  5 +++
 22 files changed, 215 insertions(+), 59 deletions(-)

Fusion-Task-Id: FN-8845

Fusion-Task-Lineage: d4a30472-f1c3-41ba-a61b-3f2be1ad32ab

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-10 11:50:56 -07:00
parent 963dba6f80
commit 00ddafd5fe
22 changed files with 215 additions and 59 deletions

View File

@@ -2406,3 +2406,5 @@ FN-8864 records project-scoped agent activity in `project.agent_activity_events`
Writers use the `AsyncDataLayer` outbox seam because AgentStore and approval stores can run outside a TaskStore process. Callers provide attribution claims only; the append boundary probes the current project roster before emitting `agent` attribution, without exemptions. `lane` and `actor` are never org-map nodes. Metadata is deny-by-default: it contains only closed enums (with `unlisted`/`custom` fallbacks), generated Fusion identifiers, counts, booleans, and SHAs—never freeform text.
Paused-safe housekeeping retains at most 30 days and 50,000 rows per project. The [Agent activity API contract](agent-activity-contract.md) defines the inspectable `GET /api/agent-activity` wire, cursor, and continuation behavior. SSE tails durable rows in ascending seq pages, advances only after sending, serializes reentrant drains, and emits a bounded truncation marker for oversized backlogs; in-process events are latency nudges only, preserving reconnect correctness across processes.
Scheduler and autopilot mission reconciliation persist the evaluated alignment on linked mission features, including no-delivery-transition outcomes, so roadmap readers consume a durable projection rather than recomputing task reports in the browser.

View File

@@ -768,7 +768,7 @@ Every automatic suppression appends one visible `validation memoized` activity e
## Spec alignment
A linked task may expose a separate spec alignment signal: `on-plan`, `diverged-needs-review`, `diverged-relocked-approved`, or `unavailable`. This signal is independent of feature delivery and validation status; it never marks a feature done, blocks a task, or substitutes for assertion validation. Archived tasks retain their task-visible lock history but follow the existing unlink behavior and do not recreate a feature projection.
A linked task may expose a separate spec alignment signal: `on-plan`, `diverged-needs-review`, `diverged-relocked-approved`, or `unavailable`. This signal is independent of feature delivery and validation status; it never marks a feature done, blocks a task, or substitutes for assertion validation. Archived tasks retain their task-visible lock history but follow the existing unlink behavior and do not recreate a feature projection. Scheduler and autopilot reconciliation persist the current deterministic projection on each linked feature even when delivery status does not change, and Mission Manager renders that retained projection.
`fn_feature_set_status` preserves the linked-task guard: `triaged`, `in-progress`, `done`, and `blocked` require a linked task; link an existing task with `fn_feature_link_task` or triage the feature first. Feature status writes emit `feature_status_changed` atomically with the row write at every production writer: engine and pi tools, dashboard REST repairs, scheduler work, linking/claiming, terminal-task reconciliation, validator reuse, and superseded-fix reconciliation. Feature and mission status events use one total, size-capped metadata builder, which persists only ids-only actor fields (`type`, `id`, `source`; never `displayName`) and an optional redacted, byte-bounded reason.

View File

@@ -9,6 +9,7 @@ const lock = (current = evidence()) => ({ version: 1, acceptedAt: "2026-08-09T07
describe("spec lock canonicalization", () => {
it("normalizes Mission whitespace but preserves a structural Mission rewrite", () => {
expect(canonicalizePlan(prompt).sections.mission.hash).toBe(canonicalizePlan(prompt.replace("Build a safe widget.", " Build a safe widget. ")).sections.mission.hash);
expect(canonicalizePlan(prompt).sections.mission.hash).toBe(canonicalizePlan(prompt.replace("Build a safe widget.", "Build a\n\n safe\twidget.")).sections.mission.hash);
expect(canonicalizePlan(prompt).sections.mission.hash).not.toBe(canonicalizePlan(prompt.replace("safe", "different")).sections.mission.hash);
});

View File

@@ -95,6 +95,7 @@ import {
SPEC_LOCK_DRIFT_REPORT_VERSION,
SPEC_LOCK_SOURCE_REVISION_BIGINT_VERSION,
MEMORY_RECALL_RECORDS_VERSION,
MISSION_FEATURE_SPEC_ALIGNMENT_VERSION,
} from "../../postgres/schema-applier.js";
import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js";
import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js";
@@ -123,7 +124,8 @@ describe("schema-applier: immutable migration identities", () => {
expect(SPEC_LOCK_DRIFT_REPORT_VERSION).toBe("0050");
expect(SPEC_LOCK_SOURCE_REVISION_BIGINT_VERSION).toBe("0051");
expect(MEMORY_RECALL_RECORDS_VERSION).toBe("0052");
expect(SCHEMA_BASELINE_VERSION).toBe("0052");
expect(MISSION_FEATURE_SPEC_ALIGNMENT_VERSION).toBe("0053");
expect(SCHEMA_BASELINE_VERSION).toBe("0053");
});
it("keeps monitor and approval isolation assigned to version 0003", () => {
@@ -1791,6 +1793,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
SPEC_LOCK_DRIFT_REPORT_VERSION,
SPEC_LOCK_SOURCE_REVISION_BIGINT_VERSION,
MEMORY_RECALL_RECORDS_VERSION,
MISSION_FEATURE_SPEC_ALIGNMENT_VERSION,
]);
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
});
@@ -1869,6 +1872,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
SPEC_LOCK_DRIFT_REPORT_VERSION,
SPEC_LOCK_SOURCE_REVISION_BIGINT_VERSION,
MEMORY_RECALL_RECORDS_VERSION,
MISSION_FEATURE_SPEC_ALIGNMENT_VERSION,
]);
});
@@ -2080,6 +2084,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
SPEC_LOCK_DRIFT_REPORT_VERSION,
SPEC_LOCK_SOURCE_REVISION_BIGINT_VERSION,
MEMORY_RECALL_RECORDS_VERSION,
MISSION_FEATURE_SPEC_ALIGNMENT_VERSION,
]);
});
@@ -2172,6 +2177,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
SPEC_LOCK_DRIFT_REPORT_VERSION,
SPEC_LOCK_SOURCE_REVISION_BIGINT_VERSION,
MEMORY_RECALL_RECORDS_VERSION,
MISSION_FEATURE_SPEC_ALIGNMENT_VERSION,
]);
});
@@ -2264,6 +2270,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
SPEC_LOCK_DRIFT_REPORT_VERSION,
SPEC_LOCK_SOURCE_REVISION_BIGINT_VERSION,
MEMORY_RECALL_RECORDS_VERSION,
MISSION_FEATURE_SPEC_ALIGNMENT_VERSION,
]);
});
});

View File

@@ -175,6 +175,7 @@ interface FeatureRow {
description: string | null;
acceptanceCriteria: string | null;
status: string;
specAlignment: string | null;
createdAt: string;
updatedAt: string;
loopState: string | null;
@@ -340,6 +341,7 @@ const featureColumns = {
description: schema.project.missionFeatures.description,
acceptanceCriteria: schema.project.missionFeatures.acceptanceCriteria,
status: schema.project.missionFeatures.status,
specAlignment: schema.project.missionFeatures.specAlignment,
createdAt: schema.project.missionFeatures.createdAt,
updatedAt: schema.project.missionFeatures.updatedAt,
loopState: schema.project.missionFeatures.loopState,
@@ -510,6 +512,7 @@ function rowToFeature(row: FeatureRow): MissionFeature {
description: row.description ?? undefined,
acceptanceCriteria: row.acceptanceCriteria ?? undefined,
status: row.status as FeatureStatus,
specAlignment: row.specAlignment as MissionFeature["specAlignment"],
createdAt: row.createdAt,
updatedAt: row.updatedAt,
loopState: (row.loopState as FeatureLoopState) || "idle",
@@ -2019,6 +2022,7 @@ export async function upsertFeature(handle: QueryHandle, feature: MissionFeature
description: feature.description ?? null,
acceptanceCriteria: feature.acceptanceCriteria ?? null,
status: feature.status,
specAlignment: feature.specAlignment ?? null,
createdAt: feature.createdAt,
updatedAt: feature.updatedAt,
loopState: feature.loopState ?? "idle",
@@ -2046,6 +2050,7 @@ export async function upsertFeature(handle: QueryHandle, feature: MissionFeature
description: sql`excluded.description`,
acceptanceCriteria: sql`excluded.acceptance_criteria`,
status: sql`excluded.status`,
specAlignment: sql`excluded.spec_alignment`,
updatedAt: sql`excluded.updated_at`,
loopState: sql`excluded.loop_state`,
implementationAttemptCount: sql`excluded.implementation_attempt_count`,

View File

@@ -326,6 +326,7 @@ interface FeatureRow {
description: string | null;
acceptanceCriteria: string | null;
status: string;
specAlignment: string | null;
createdAt: string;
updatedAt: string;
loopState: string | null;
@@ -624,6 +625,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
description: row.description || undefined,
acceptanceCriteria: row.acceptanceCriteria || undefined,
status: row.status as FeatureStatus,
specAlignment: row.specAlignment as import("./mission-types.js").MissionFeatureSpecAlignment || undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
loopState: (row.loopState as import("./mission-types.js").FeatureLoopState) || "idle",
@@ -2217,8 +2219,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
};
this.db.prepare(`
INSERT INTO mission_features (id, sliceId, title, description, acceptanceCriteria, status, loopState, implementationAttemptCount, validatorAttemptCount, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO mission_features (id, sliceId, title, description, acceptanceCriteria, status, specAlignment, loopState, implementationAttemptCount, validatorAttemptCount, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
feature.id,
feature.sliceId,
@@ -2226,6 +2228,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
feature.description ?? null,
feature.acceptanceCriteria ?? null,
feature.status,
feature.specAlignment ?? null,
feature.loopState ?? "idle",
feature.implementationAttemptCount ?? 0,
feature.validatorAttemptCount ?? 0,
@@ -2302,6 +2305,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
description = ?,
acceptanceCriteria = ?,
status = ?,
specAlignment = ?,
taskId = ?,
loopState = ?,
implementationAttemptCount = ?,
@@ -2317,6 +2321,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
updated.description ?? null,
updated.acceptanceCriteria ?? null,
updated.status,
updated.specAlignment ?? null,
updated.taskId ?? null,
updated.loopState ?? "idle",
updated.implementationAttemptCount ?? 0,

View File

@@ -495,6 +495,14 @@ export interface ResearchFeatureProvenance {
export type ImplementationStopReason = "budget-exhausted" | "operator-intervention";
/**
* FNXC:SpecLockMissionAlignment 2026-08-10-16:17:
* Alignment is a persisted roadmap projection from the linked task's deterministic drift report.
* It stays separate from delivery and validation lifecycle state, so divergence never fabricates
* completion or blocks work.
*/
export type MissionFeatureSpecAlignment = "on-plan" | "diverged-needs-review" | "diverged-relocked-approved" | "unavailable";
export interface MissionFeature {
/** Unique identifier (e.g., "F-J6K9AB-G7H3") */
id: string;
@@ -510,6 +518,8 @@ export interface MissionFeature {
acceptanceCriteria?: string;
/** Current lifecycle status */
status: FeatureStatus;
/** Orthogonal, machine-derived alignment of the linked task's current plan and execution. */
specAlignment?: MissionFeatureSpecAlignment;
/** Durable lineage when this canonical feature came from Fusion Research. */
researchProvenance?: ResearchFeatureProvenance;
/** ISO-8601 timestamp of creation */

View File

@@ -61,7 +61,12 @@ const sections: Array<{ key: SpecLockSection; headings: string[]; required: bool
];
const hash = (value: string): string => createHash("sha256").update(value, "utf8").digest("hex");
const normalizeText = (value: string): string => value.replace(/\r\n?/g, "\n").replace(/[ \t]+/g, " ").trim();
/*
FNXC:SpecLock 2026-08-10-16:34:
Mission prose is structurally hashed, not line-layout hashed. Collapse every whitespace run so a
cosmetic paragraph reflow cannot create a deterministic plan-deviation finding.
*/
const normalizeText = (value: string): string => value.replace(/\s+/g, " ").trim();
const normalizeListItems = (value: string): string[] => value.split("\n")
.map((line) => line.replace(/^\s*(?:[-*+]\s+|\d+[.)]\s+)/, "").replace(/[ \t]+/g, " ").trim())
.filter(Boolean);

View File

@@ -0,0 +1,3 @@
-- FNXC:SpecLockMissionAlignment 2026-08-10-16:17: persist the deterministic task drift projection independently of mission delivery state, so production reconciliation does not discard divergence.
ALTER TABLE project.mission_features
ADD COLUMN IF NOT EXISTS spec_alignment text;

View File

@@ -60,7 +60,8 @@ capacity-model table drop that landed while this PR was open.
/** FNXC:AgentActivityStream 2026-08-09-21:32: 0049 follows the landed 0048 GitHub check-state migration so upgraded projects receive the durable activity outbox. */
/* FNXC:SpecLock 2026-08-09-18:17: 0050 stores immutable plan history and 0051 widens source revisions before Date.now()-based writes. */
/* FNXC:MemoryRecall 2026-08-10-11:03: Explicit baseline registration prevents the recall migration from being silently skipped. */
export const SCHEMA_BASELINE_VERSION = "0052";
/* FNXC:SpecLockMissionAlignment 2026-08-10-16:17: advance the schema ceiling so SQLite and PostgreSQL feature projections retain reconciled drift alignment. */
export const SCHEMA_BASELINE_VERSION = "0053";
/** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */
export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
const INITIAL_SCHEMA_VERSION = "0000";
@@ -207,6 +208,8 @@ export const SPEC_LOCK_DRIFT_REPORT_VERSION = "0050";
export const SPEC_LOCK_SOURCE_REVISION_BIGINT_VERSION = "0051";
/** FNXC:MemoryRecall 2026-08-10-11:03: explicit migration bookkeeping for project recall rows. */
export const MEMORY_RECALL_RECORDS_VERSION = "0052";
/** FNXC:SpecLockMissionAlignment 2026-08-10-16:17: durable feature alignment is registered after all existing migration identities. */
export const MISSION_FEATURE_SPEC_ALIGNMENT_VERSION = "0053";
/** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */
export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained";
@@ -433,6 +436,7 @@ const AGENT_ACTIVITY_EVENTS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0049_fn_8864_
const SPEC_LOCK_DRIFT_REPORT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0050_spec_lock_drift_report.sql");
const SPEC_LOCK_SOURCE_REVISION_BIGINT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0051_spec_lock_source_revision_bigint.sql");
const MEMORY_RECALL_RECORDS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0052_fn_8922_memory_recall_records.sql");
const MISSION_FEATURE_SPEC_ALIGNMENT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0053_mission_feature_spec_alignment.sql");
/**
* Ensure the migration bookkeeping table exists. Lives in the public schema so
@@ -555,6 +559,7 @@ export async function applySchemaBaseline(
const specLockDriftReportAlreadyApplied = applied.includes(SPEC_LOCK_DRIFT_REPORT_VERSION);
const specLockSourceRevisionBigintAlreadyApplied = applied.includes(SPEC_LOCK_SOURCE_REVISION_BIGINT_VERSION);
const memoryRecallRecordsAlreadyApplied = applied.includes(MEMORY_RECALL_RECORDS_VERSION);
const missionFeatureSpecAlignmentAlreadyApplied = applied.includes(MISSION_FEATURE_SPEC_ALIGNMENT_VERSION);
assertBinaryNotOlderThanDatabase(applied);
let schemaChanged = false;
@@ -1219,6 +1224,12 @@ export async function applySchemaBaseline(
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${MEMORY_RECALL_RECORDS_VERSION}) ON CONFLICT (version) DO NOTHING`);
schemaChanged = true;
}
if (!missionFeatureSpecAlignmentAlreadyApplied) {
const migrationSql = await readFile(MISSION_FEATURE_SPEC_ALIGNMENT_MIGRATION_PATH, "utf8");
await tx.execute(sql.raw(migrationSql));
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${MISSION_FEATURE_SPEC_ALIGNMENT_VERSION}) ON CONFLICT (version) DO NOTHING`);
schemaChanged = true;
}
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
});
}

View File

@@ -1614,6 +1614,8 @@ export const missionFeatures = projectSchema.table("mission_features", {
// fixed to text to match the SQLite TEXT column and MissionStore semantics.
acceptanceCriteria: text("acceptance_criteria"),
status: text("status").notNull(),
// FNXC:SpecLockMissionAlignment 2026-08-10-16:17: retain task drift projection independently of feature delivery status so roadmap readers share reconciliation's durable result.
specAlignment: text("spec_alignment"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
// FNXC:MissionStore 2026-06-24-08:20:

View File

@@ -119,6 +119,8 @@ export interface MissionFeature {
description?: string;
acceptanceCriteria?: string;
status: FeatureStatus;
/** Deterministic spec-lock projection persisted by mission reconciliation. */
specAlignment?: "on-plan" | "diverged-needs-review" | "diverged-relocked-approved" | "unavailable";
createdAt: string;
updatedAt: string;
}

View File

@@ -103,7 +103,6 @@ import {
fetchMissionInterviewDrafts,
discardMissionInterviewDraft,
fetchTaskDetail,
fetchSpecLock,
apiGetBranchGroup,
api,
type AiSessionSummary,
@@ -1273,33 +1272,20 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
branch, member count, or PR state into the current mission.
*/
/*
FNXC:SpecLockMissionAlignment 2026-08-09-08:25:
FN-8845 keeps delivery status and spec alignment independent. Mission cards obtain the
persisted task report instead of inferring alignment from a task column, so an archived or
unlinked feature never gains a fabricated roadmap projection.
FNXC:SpecLockMissionAlignment 2026-08-10-16:17:
FN-8845 keeps delivery status and spec alignment independent. Mission reconciliation persists
its deterministic projection on linked features; render that shared state rather than performing
browser-only task-report joins that can disagree with periodic/autopilot reconciliation. An
unlinked or archived feature remains unavailable and never receives a fabricated projection.
*/
const [featureSpecAlignments, setFeatureSpecAlignments] = useState<Record<string, DriftAlignment>>({});
useEffect(() => {
let cancelled = false;
const linkedFeatures = selectedMission?.milestones.flatMap((milestone) =>
milestone.slices.flatMap((slice) => slice.features.flatMap((feature) => feature.taskId ? [feature] : [])),
) ?? [];
setFeatureSpecAlignments({});
if (!isActive || linkedFeatures.length === 0) return () => { cancelled = true; };
void Promise.all(linkedFeatures.map(async (feature) => {
try {
const evidence = await fetchSpecLock(feature.taskId!, projectId);
return [feature.id, evidence.report?.alignment ?? "unavailable"] as const;
} catch {
return [feature.id, "unavailable"] as const;
}
})).then((entries) => {
if (!cancelled) setFeatureSpecAlignments(Object.fromEntries(entries));
});
return () => { cancelled = true; };
}, [isActive, projectId, selectedMission]);
const featureSpecAlignments = useMemo<Record<string, DriftAlignment>>(() => Object.fromEntries(
(selectedMission?.milestones ?? []).flatMap((milestone) => milestone.slices.flatMap((slice) =>
slice.features.map((feature) => [
feature.id,
feature.taskId ? (feature.specAlignment ?? "unavailable") : "unavailable",
] as const),
)),
), [selectedMission]);
useEffect(() => {
let cancelled = false;

View File

@@ -90,6 +90,8 @@ export interface MissionFeature {
description?: string;
acceptanceCriteria?: string;
status: FeatureStatus;
/** Deterministic plan/execution projection retained by mission reconciliation. */
specAlignment?: "on-plan" | "diverged-needs-review" | "diverged-relocked-approved" | "unavailable";
createdAt: string;
updatedAt: string;
/** Current loop state for the execution loop (idle, implementing, validating, needs_fix, passed, blocked) */

View File

@@ -446,7 +446,9 @@ pgDescribe("plan approval status persistence", () => {
const afterReplace = await store.getTask(task.id);
expect(afterReplace.approvedPlanFingerprint).toBeUndefined();
expect((await store.getLatestSpecDriftReport(task.id))?.alignment).toBe("unavailable");
// FNXC:SpecLock 2026-08-10-16:40: A dependency replacement invalidates admission but remains
// structurally comparable to the retained lock, so the roadmap receives divergence—not silence.
expect((await store.getLatestSpecDriftReport(task.id))?.alignment).toBe("diverged-needs-review");
await store.updateTask(task.id, { status: "awaiting-approval" });
expect((await request(createApp(), "POST", `/api/tasks/${task.id}/approve-plan`)).status).toBe(200);

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { projectMissionFeatureAlignment, reconcileMissionFeatureState, resolveMissionFeatureAlignment } from "../missions/mission-feature-sync.js";
import { describe, expect, it, vi } from "vitest";
import { persistMissionFeatureReconciliation, projectMissionFeatureAlignment, publishPersistedMissionFeatureAlignment, reconcileMissionFeatureState, resolveMissionFeatureAlignment } from "../missions/mission-feature-sync.js";
describe("reconcileMissionFeatureState", () => {
it("projects persisted drift separately from delivery status", async () => {
@@ -11,6 +11,39 @@ describe("reconcileMissionFeatureState", () => {
.resolves.toBe("unavailable");
});
it("publishes a persisted report without changing feature delivery status", async () => {
const updateFeature = vi.fn();
const getFeatureByTaskId = vi.fn().mockResolvedValue({ id: "F-1", status: "in-progress", specAlignment: "on-plan" });
const updated = await publishPersistedMissionFeatureAlignment(
{ getMissionStore: () => ({ getFeatureByTaskId, updateFeature }) } as never,
"FN-1",
{ alignment: "diverged-needs-review" },
);
expect(updated).toBe(true);
expect(updateFeature).toHaveBeenCalledWith("F-1", { specAlignment: "diverged-needs-review" });
});
it("persists alignment when delivery status is unchanged", async () => {
const updateFeature = vi.fn();
await expect(persistMissionFeatureReconciliation(
{ updateFeature },
{ id: "F-1", specAlignment: "on-plan" },
{ kind: "noop", alignment: "diverged-needs-review" },
)).resolves.toBe(true);
expect(updateFeature).toHaveBeenCalledWith("F-1", { specAlignment: "diverged-needs-review" });
await expect(persistMissionFeatureReconciliation(
{ updateFeature },
{ id: "F-1", specAlignment: "diverged-needs-review" },
{ kind: "update", status: "done", reason: "task completed", alignment: "diverged-relocked-approved" },
)).resolves.toBe(true);
expect(updateFeature).toHaveBeenLastCalledWith("F-1", {
status: "done",
specAlignment: "diverged-relocked-approved",
});
});
it("keeps assertion validation as the completion gate for research-derived features", async () => {
const decision = await reconcileMissionFeatureState(
{ getTask: async () => undefined } as never,

View File

@@ -14,6 +14,20 @@ describe("SpecDriftReconciler", () => {
expect(report?.findings).toContainEqual(expect.objectContaining({ kind: "scope-creep", path: "src/outside.ts" }));
expect(persisted).toHaveLength(1);
});
it("projects mission alignment after the report is durably persisted", async () => {
const persisted: string[] = [];
const projected: string[] = [];
const reconciler = new SpecDriftReconciler({
snapshot: async () => ({ latestLock: lock, currentPlan: evidence, approvedPlanFingerprint: "approved", modifiedFiles: ["src/outside.ts"] }),
persist: async (_taskId, report) => { persisted.push(report.alignment); },
onPersisted: async (_taskId, report) => { projected.push(report.alignment); },
});
await reconciler.reconcile("FN-MISSION");
expect(persisted).toEqual(["diverged-needs-review"]);
expect(projected).toEqual(persisted);
});
it("retries a failed persistence write without waiting for restart", async () => {
vi.useFakeTimers();
let attempts = 0;

View File

@@ -49,7 +49,7 @@ import type {
*/
type AutopilotMissionStore = MissionStore | AsyncMissionStore;
import { autopilotLog } from "../logger.js";
import { reconcileMissionFeatureState } from "./mission-feature-sync.js";
import { persistMissionFeatureReconciliation, reconcileMissionFeatureState } from "./mission-feature-sync.js";
import { isOperatorActionableAgentError } from "../errors/transient-error-detector.js";
import { resolvePlannerLanesForTask } from "../planner-lane-resolution.js";
@@ -975,6 +975,16 @@ export class MissionAutopilot {
plannerColumns: await resolvePlannerLanesForTask(this.taskStore as never, task.id),
});
/*
FNXC:SpecLockMissionAlignment 2026-08-10-16:17:
Persist the evaluator's orthogonal projection on every reconciliation outcome. Previously
this loop calculated alignment only to discard it, leaving the mission roadmap stale when
delivery status did not also need a transition.
*/
if (await persistMissionFeatureReconciliation(this.missionStore, feature, reconciliation)) {
fixedCount++;
}
if (reconciliation.kind === "failure") {
await this.handleTaskFailure(feature.taskId);
fixedCount++;
@@ -985,11 +995,6 @@ export class MissionAutopilot {
autopilotLog.warn(`Skipping feature ${feature.id} reconciliation — ${reconciliation.reason}`);
continue;
}
if (reconciliation.kind === "update") {
await this.missionStore.updateFeatureStatus(feature.id, reconciliation.status);
fixedCount++;
}
}
}

View File

@@ -1,4 +1,4 @@
import type { DriftAlignment, MissionFeature, Task, TaskStore } from "@fusion/core";
import type { DriftAlignment, DriftReport, MissionFeature, Task, TaskStore } from "@fusion/core";
import { getTaskCompletionBlockerForStore } from "../execution/task-completion.js";
import { resolveLifecycleColumns, resolveTaskLifecycleColumns, resolveWorkflowIrForTask } from "@fusion/core";
@@ -13,6 +13,24 @@ export function projectMissionFeatureAlignment(report: { alignment: DriftAlignme
return report?.alignment ?? "unavailable";
}
/**
* FNXC:SpecLockMissionAlignment 2026-08-10-16:40:
* Report persistence, rather than a later task move, is the authoritative alignment event. Update
* only the linked feature's orthogonal field here; delivery status remains owned by the existing
* scheduler and autopilot reconciliation paths.
*/
export async function publishPersistedMissionFeatureAlignment(
taskStore: Pick<TaskStore, "getMissionStore">,
taskId: string,
report: Pick<DriftReport, "alignment">,
): Promise<boolean> {
const missionStore = taskStore.getMissionStore();
const feature = await missionStore.getFeatureByTaskId(taskId);
if (!feature || feature.specAlignment === report.alignment) return false;
await missionStore.updateFeature(feature.id, { specAlignment: report.alignment });
return true;
}
/**
* FNXC:SpecLockMissionAlignment 2026-08-09-19:51:
* Mission delivery reconciliation consumes the retained report instead of deriving scope state
@@ -66,10 +84,35 @@ export type MissionFeatureSyncDecision =
| { kind: "update"; status: MissionFeatureSyncTargetStatus; reason: string; alignment: DriftAlignment }
| { kind: "noop"; alignment: DriftAlignment };
/**
* FNXC:SpecLockMissionAlignment 2026-08-10-16:17:
* Every production mission reconciler must publish the evaluated alignment even when the delivery
* state is unchanged. Centralizing this write prevents event-driven and periodic consumers from
* silently calculating a report and then dropping the roadmap projection.
*/
export async function persistMissionFeatureReconciliation(
missionStore: Pick<{ updateFeature(id: string, updates: Partial<MissionFeature>): unknown }, "updateFeature">,
feature: Pick<MissionFeature, "id" | "specAlignment">,
decision: MissionFeatureSyncDecision,
): Promise<boolean> {
if (decision.kind === "update") {
await missionStore.updateFeature(feature.id, {
status: decision.status,
specAlignment: decision.alignment,
});
return true;
}
if (feature.specAlignment !== decision.alignment) {
await missionStore.updateFeature(feature.id, { specAlignment: decision.alignment });
return true;
}
return false;
}
export async function reconcileMissionFeatureState(
taskStore: Pick<TaskStore, "getTask" | "getLatestSpecDriftReport"> & Parameters<typeof resolveTaskLifecycleColumns>[0],
task: Task,
feature: Pick<MissionFeature, "id" | "status" | "lastValidatorStatus">,
feature: Pick<MissionFeature, "id" | "status" | "lastValidatorStatus" | "specAlignment">,
context: MissionFeatureSyncContext = {},
): Promise<MissionFeatureSyncDecision> {
const alignment = await resolveMissionFeatureAlignment(taskStore, task.id);

View File

@@ -54,6 +54,7 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { InProcessRuntime } from "./runtimes/in-process-runtime.js";
import { createStoreSpecDriftRepository, SpecDriftReconciler } from "./spec-drift-reconciler.js";
import { publishPersistedMissionFeatureAlignment } from "./missions/mission-feature-sync.js";
import type { WorktreePool } from "./worktree/worktree-pool.js";
import type { ProjectRuntimeConfig } from "./project/project-runtime.js";
import { PrMonitor } from "./merge/pr-monitor.js";
@@ -955,7 +956,7 @@ export class ProjectEngine {
inline latest-report snapshot. Full append-only history preserves re-locked divergence, while
the report identity fence intentionally cannot detect an incorrect alignment value.
*/
this.specDriftReconciler = new SpecDriftReconciler(createStoreSpecDriftRepository(store));
this.specDriftReconciler = new SpecDriftReconciler(createStoreSpecDriftRepository(store, async (taskId, report) => { await publishPersistedMissionFeatureAlignment(store, taskId, report); }));
/*
FNXC:SpecDrift 2026-08-09-18:32:
Startup repair alone leaves a long-running engine blind to direct task mutations and workflow

View File

@@ -30,7 +30,7 @@ import { planTaskWorktreePath, resolveTaskWorkingBranch } from "./worktree/workt
import { schedulerLog } from "./logger.js";
import { createRepeatSuppressedLog } from "./util/repeat-suppressed-log.js";
import { type PrMonitor, type PrComment } from "./merge/pr-monitor.js";
import { reconcileMissionFeatureState } from "./missions/mission-feature-sync.js";
import { persistMissionFeatureReconciliation, reconcileMissionFeatureState } from "./missions/mission-feature-sync.js";
import { resolveDedicatedPlannerColumnsForTask, resolvePlannerLanesForTask } from "./planner-lane-resolution.js";
import { evaluateSpecStaleness, getPromptPath } from "./execution/spec-staleness.js";
import { resolveEffectiveNode, type EffectiveNode } from "./project/effective-node.js";
@@ -3171,6 +3171,22 @@ export class Scheduler {
},
);
const sliceIdBeforeUpdate = feature.sliceId;
/*
FNXC:SpecLockMissionAlignment 2026-08-10-16:17:
Scheduler move reconciliation is a production consumer of deterministic drift. Persist its
orthogonal projection even when delivery status is unchanged, or the evaluated alignment is
discarded before Mission Manager can render the roadmap's actual state.
*/
if (await persistMissionFeatureReconciliation(missionStore, feature, reconciliation)) {
if (reconciliation.kind === "update") {
schedulerLog.log(
`Feature ${feature.id} marked ${reconciliation.status} (${reconciliation.reason})`,
);
}
}
if (reconciliation.kind === "blocked") {
schedulerLog.warn(`Task ${taskId} mission update blocked — ${reconciliation.reason}`);
return;
@@ -3181,15 +3197,6 @@ export class Scheduler {
return;
}
const sliceIdBeforeUpdate = feature.sliceId;
if (reconciliation.kind === "update") {
await missionStore.updateFeatureStatus(feature.id, reconciliation.status);
schedulerLog.log(
`Feature ${feature.id} marked ${reconciliation.status} (${reconciliation.reason})`,
);
}
/*
FNXC:WorkflowLifecycleColumns 2026-07-30-20:40 (fleet — mission completion advance):
"Did this task just COMPLETE?" resolved from the destination column's own trait.
@@ -3554,6 +3561,15 @@ export class Scheduler {
hasLinkedAssertions,
});
/*
FNXC:SpecLockMissionAlignment 2026-08-10-16:17:
Periodic reconciliation must retain the same drift projection as event-driven moves;
otherwise a quiet task's alignment disappears until an unrelated status transition.
*/
if (await persistMissionFeatureReconciliation(missionStore, featureForReconciliation, reconciliation)) {
totalFixed++;
}
if (reconciliation.kind === "failure") {
if (this.options.onTaskFailed) {
await this.options.onTaskFailed(task.id);
@@ -3569,10 +3585,6 @@ export class Scheduler {
continue;
}
if (reconciliation.kind === "update") {
await missionStore.updateFeatureStatus(featureForReconciliation.id, reconciliation.status);
totalFixed++;
}
}
}
}

View File

@@ -10,6 +10,7 @@ export interface SpecDriftSnapshot {
export interface SpecDriftRepository {
snapshot(taskId: string): Promise<SpecDriftSnapshot>;
persist(taskId: string, report: DriftReport): Promise<void>;
onPersisted?(taskId: string, report: DriftReport): Promise<void>;
}
const RETRY_DELAY_MS = 1_000;
@@ -23,6 +24,7 @@ const RETRY_DELAY_MS = 1_000;
*/
export function createStoreSpecDriftRepository(
store: Pick<TaskStore, "getTask" | "getLatestSpecLock" | "getLatestCurrentPlanEvidence" | "listSpecDriftReports" | "appendSpecDriftReport">,
onPersisted?: SpecDriftRepository["onPersisted"],
): SpecDriftRepository {
return {
snapshot: async (taskId) => {
@@ -41,6 +43,7 @@ export function createStoreSpecDriftRepository(
};
},
persist: async (taskId, report) => { await store.appendSpecDriftReport(taskId, report); },
onPersisted,
};
}
@@ -84,6 +87,8 @@ export class SpecDriftReconciler {
const report = evaluateSpecDrift(snapshot);
if (this.stopped) return undefined;
await this.repository.persist(taskId, report);
/* FNXC:SpecLockMissionAlignment 2026-08-10-16:40: publish mission alignment only after the report is durable, so failed projection retries retained evidence. */
await this.repository.onPersisted?.(taskId, report);
const retry = this.retryTimers.get(taskId);
if (retry) clearTimeout(retry);
this.retryTimers.delete(taskId);