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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user