diff --git a/docs/architecture.md b/docs/architecture.md index de7f829daa..efa00b1256 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/missions.md b/docs/missions.md index 589fb9ae6d..3942a83631 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -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. diff --git a/packages/core/src/__tests__/planner/spec-lock.test.ts b/packages/core/src/__tests__/planner/spec-lock.test.ts index 7d1c010e53..e8db9fb043 100644 --- a/packages/core/src/__tests__/planner/spec-lock.test.ts +++ b/packages/core/src/__tests__/planner/spec-lock.test.ts @@ -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); }); diff --git a/packages/core/src/__tests__/postgres/schema-applier.test.ts b/packages/core/src/__tests__/postgres/schema-applier.test.ts index 83ef029dd0..ca14d81500 100644 --- a/packages/core/src/__tests__/postgres/schema-applier.test.ts +++ b/packages/core/src/__tests__/postgres/schema-applier.test.ts @@ -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, ]); }); }); diff --git a/packages/core/src/async-stores/async-mission-store-queries.ts b/packages/core/src/async-stores/async-mission-store-queries.ts index 7747f29eae..90d18ee317 100644 --- a/packages/core/src/async-stores/async-mission-store-queries.ts +++ b/packages/core/src/async-stores/async-mission-store-queries.ts @@ -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`, diff --git a/packages/core/src/missions/mission-store.ts b/packages/core/src/missions/mission-store.ts index be0cc837c9..c3b0883bcf 100644 --- a/packages/core/src/missions/mission-store.ts +++ b/packages/core/src/missions/mission-store.ts @@ -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 { 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 { }; 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 { 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 { description = ?, acceptanceCriteria = ?, status = ?, + specAlignment = ?, taskId = ?, loopState = ?, implementationAttemptCount = ?, @@ -2317,6 +2321,7 @@ export class MissionStore extends EventEmitter { updated.description ?? null, updated.acceptanceCriteria ?? null, updated.status, + updated.specAlignment ?? null, updated.taskId ?? null, updated.loopState ?? "idle", updated.implementationAttemptCount ?? 0, diff --git a/packages/core/src/missions/mission-types.ts b/packages/core/src/missions/mission-types.ts index 1336f37862..2d510c846f 100644 --- a/packages/core/src/missions/mission-types.ts +++ b/packages/core/src/missions/mission-types.ts @@ -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 */ diff --git a/packages/core/src/planner/spec-lock.ts b/packages/core/src/planner/spec-lock.ts index 885bbc2040..6deb468091 100644 --- a/packages/core/src/planner/spec-lock.ts +++ b/packages/core/src/planner/spec-lock.ts @@ -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); diff --git a/packages/core/src/postgres/migrations/0053_mission_feature_spec_alignment.sql b/packages/core/src/postgres/migrations/0053_mission_feature_spec_alignment.sql new file mode 100644 index 0000000000..d0c07520c1 --- /dev/null +++ b/packages/core/src/postgres/migrations/0053_mission_feature_spec_alignment.sql @@ -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; diff --git a/packages/core/src/postgres/schema-applier.ts b/packages/core/src/postgres/schema-applier.ts index 6a4d766c6b..c1ad96e3ad 100644 --- a/packages/core/src/postgres/schema-applier.ts +++ b/packages/core/src/postgres/schema-applier.ts @@ -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 }; }); } diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 4df5e269b7..10481f6893 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -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: diff --git a/packages/dashboard/app/api/missions/missions.ts b/packages/dashboard/app/api/missions/missions.ts index 58106baa9d..c911bad9e9 100644 --- a/packages/dashboard/app/api/missions/missions.ts +++ b/packages/dashboard/app/api/missions/missions.ts @@ -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; } diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index c96f211da4..0396911cad 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -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>({}); - - 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>(() => 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; diff --git a/packages/dashboard/app/components/mission-types.ts b/packages/dashboard/app/components/mission-types.ts index ac80b76a14..5487651575 100644 --- a/packages/dashboard/app/components/mission-types.ts +++ b/packages/dashboard/app/components/mission-types.ts @@ -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) */ diff --git a/packages/dashboard/src/__tests__/plan-approval-status.pg.test.ts b/packages/dashboard/src/__tests__/plan-approval-status.pg.test.ts index 8b4d5f8623..bef072dcb6 100644 --- a/packages/dashboard/src/__tests__/plan-approval-status.pg.test.ts +++ b/packages/dashboard/src/__tests__/plan-approval-status.pg.test.ts @@ -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); diff --git a/packages/engine/src/__tests__/mission-feature-sync.test.ts b/packages/engine/src/__tests__/mission-feature-sync.test.ts index cceea316fd..25e9f19946 100644 --- a/packages/engine/src/__tests__/mission-feature-sync.test.ts +++ b/packages/engine/src/__tests__/mission-feature-sync.test.ts @@ -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, diff --git a/packages/engine/src/__tests__/spec-drift-reconciler.test.ts b/packages/engine/src/__tests__/spec-drift-reconciler.test.ts index 33cddca955..56276b0a4a 100644 --- a/packages/engine/src/__tests__/spec-drift-reconciler.test.ts +++ b/packages/engine/src/__tests__/spec-drift-reconciler.test.ts @@ -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; diff --git a/packages/engine/src/missions/mission-autopilot.ts b/packages/engine/src/missions/mission-autopilot.ts index 4a30d9ff7a..7742d155c5 100644 --- a/packages/engine/src/missions/mission-autopilot.ts +++ b/packages/engine/src/missions/mission-autopilot.ts @@ -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++; - } } } diff --git a/packages/engine/src/missions/mission-feature-sync.ts b/packages/engine/src/missions/mission-feature-sync.ts index 4435f7b5e4..98ca79fa07 100644 --- a/packages/engine/src/missions/mission-feature-sync.ts +++ b/packages/engine/src/missions/mission-feature-sync.ts @@ -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, + taskId: string, + report: Pick, +): Promise { + 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): unknown }, "updateFeature">, + feature: Pick, + decision: MissionFeatureSyncDecision, +): Promise { + 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 & Parameters[0], task: Task, - feature: Pick, + feature: Pick, context: MissionFeatureSyncContext = {}, ): Promise { const alignment = await resolveMissionFeatureAlignment(taskStore, task.id); diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 017383ddf4..b2512b0c32 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -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 diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index 24b94cc36d..a3cec11801 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -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++; - } } } } diff --git a/packages/engine/src/spec-drift-reconciler.ts b/packages/engine/src/spec-drift-reconciler.ts index ccae80f668..2703bf4dff 100644 --- a/packages/engine/src/spec-drift-reconciler.ts +++ b/packages/engine/src/spec-drift-reconciler.ts @@ -10,6 +10,7 @@ export interface SpecDriftSnapshot { export interface SpecDriftRepository { snapshot(taskId: string): Promise; persist(taskId: string, report: DriftReport): Promise; + onPersisted?(taskId: string, report: DriftReport): Promise; } const RETRY_DELAY_MS = 1_000; @@ -23,6 +24,7 @@ const RETRY_DELAY_MS = 1_000; */ export function createStoreSpecDriftRepository( store: Pick, + 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);