diff --git a/.changeset/fix-mission-followup-admission.md b/.changeset/fix-mission-followup-admission.md new file mode 100644 index 0000000000..6a85ab5ef2 --- /dev/null +++ b/.changeset/fix-mission-followup-admission.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Resume planned mission follow-ups after their source task completes or the task is rehomed. +category: fix +dev: Keeps source features active for live Decision-A descendants and prefers canonical feature links during admission. diff --git a/packages/engine/src/__tests__/mission-state-reconcile.test.ts b/packages/engine/src/__tests__/mission-state-reconcile.test.ts index e332975cc3..41b7e8c5f6 100644 --- a/packages/engine/src/__tests__/mission-state-reconcile.test.ts +++ b/packages/engine/src/__tests__/mission-state-reconcile.test.ts @@ -2,6 +2,147 @@ import { describe, expect, it, vi } from "vitest"; import { reconcileMissionState } from "../missions/mission-state-reconcile.js"; describe("reconcileMissionState", () => { + it("keeps a source feature active while an approved Decision-A follow-up is live", async () => { + const parent = { + id: "FN-1", title: "Delivery", column: "done", status: undefined, + missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:00:00.000Z", + }; + const followUp = { + id: "FN-2", title: "Follow-up", column: "todo", status: "queued", + missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:01:00.000Z", + sourceMetadata: { missionLineage: { missionId: "M-1", sliceId: "SL-1", featureId: "F-1" } }, + }; + const feature = { + id: "F-1", title: "Delivery", sliceId: "SL-1", taskId: parent.id, status: "done", + createdAt: "2026-08-11T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z", + }; + const updateFeatureStatus = vi.fn(); + const missionStore = { + listMissions: vi.fn().mockResolvedValue([{ id: "M-1", status: "complete" }]), + getMissionWithHierarchy: vi.fn().mockResolvedValue({ + id: "M-1", milestones: [{ slices: [{ id: "SL-1", features: [feature] }] }], + }), + listAssertionsForFeature: vi.fn().mockResolvedValue([]), + updateFeatureStatus, + }; + const taskStore = { + listTasks: vi.fn().mockResolvedValue([parent, followUp]), + getTask: vi.fn().mockResolvedValue(parent), + getLatestSpecDriftReport: vi.fn().mockResolvedValue(undefined), + }; + + await reconcileMissionState({ taskStore: taskStore as never, missionStore }, { source: "self-healing" }); + + expect(updateFeatureStatus).toHaveBeenCalledWith( + feature.id, + "in-progress", + { actor: { type: "system", id: "mission-reconcile", source: "mission-reconcile:self-healing" } }, + ); + }); + + it("does not retain a historical source after a same-slice follow-up rehome", async () => { + const parent = { + id: "FN-1", title: "Delivery", column: "done", status: undefined, + missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:00:00.000Z", + }; + const followUp = { + id: "FN-2", title: "Rehomed", column: "todo", status: "queued", + missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:01:00.000Z", + sourceMetadata: { missionLineage: { missionId: "M-1", sliceId: "SL-1", featureId: "F-1" } }, + }; + const sourceFeature = { + id: "F-1", title: "Delivery", sliceId: "SL-1", taskId: parent.id, status: "in-progress", + createdAt: "2026-08-11T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z", + }; + const currentFeature = { + id: "F-2", title: "Rehomed", sliceId: "SL-1", taskId: followUp.id, status: "triaged", + createdAt: "2026-08-11T00:01:00.000Z", updatedAt: "2026-08-11T00:01:00.000Z", + }; + const updateFeatureStatus = vi.fn(); + const missionStore = { + listMissions: vi.fn().mockResolvedValue([{ id: "M-1", status: "active" }]), + getMissionWithHierarchy: vi.fn().mockResolvedValue({ + id: "M-1", milestones: [{ slices: [{ id: "SL-1", features: [sourceFeature, currentFeature] }] }], + }), + listAssertionsForFeature: vi.fn().mockResolvedValue([]), + updateFeatureStatus, + }; + const tasks = new Map([[parent.id, parent], [followUp.id, followUp]]); + const taskStore = { + listTasks: vi.fn().mockResolvedValue([parent, followUp]), + getTask: vi.fn((taskId: string) => Promise.resolve(tasks.get(taskId))), + getLatestSpecDriftReport: vi.fn().mockResolvedValue(undefined), + }; + + await reconcileMissionState({ taskStore: taskStore as never, missionStore }, { source: "self-healing" }); + + expect(updateFeatureStatus).toHaveBeenCalledWith( + sourceFeature.id, + "done", + { actor: { type: "system", id: "mission-reconcile", source: "mission-reconcile:self-healing" } }, + ); + expect(updateFeatureStatus).not.toHaveBeenCalledWith( + sourceFeature.id, + "in-progress", + expect.anything(), + ); + }); + + it("allows a source feature to complete after every Decision-A follow-up reaches a custom terminal lane", async () => { + const parent = { + id: "FN-1", title: "Delivery", column: "shipped", status: undefined, + missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:00:00.000Z", + }; + const followUp = { + id: "FN-2", title: "Follow-up", column: "shipped", status: undefined, + missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:01:00.000Z", + sourceMetadata: { missionLineage: { missionId: "M-1", sliceId: "SL-1", featureId: "F-1" } }, + }; + const feature = { + id: "F-1", title: "Delivery", sliceId: "SL-1", taskId: parent.id, status: "in-progress", + createdAt: "2026-08-11T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z", + }; + const updateFeatureStatus = vi.fn(); + const missionStore = { + listMissions: vi.fn().mockResolvedValue([{ id: "M-1", status: "active" }]), + getMissionWithHierarchy: vi.fn().mockResolvedValue({ + id: "M-1", milestones: [{ slices: [{ id: "SL-1", features: [feature] }] }], + }), + listAssertionsForFeature: vi.fn().mockResolvedValue([]), + updateFeatureStatus, + }; + const taskStore = { + listTasks: vi.fn().mockResolvedValue([parent, followUp]), + getTask: vi.fn((taskId: string) => Promise.resolve(taskId === parent.id ? parent : followUp)), + getLatestSpecDriftReport: vi.fn().mockResolvedValue(undefined), + getTaskWorkflowSelectionsAsync: vi.fn().mockResolvedValue(new Map([ + [parent.id, { workflowId: "custom:delivery", stepIds: [] }], + [followUp.id, { workflowId: "custom:delivery", stepIds: [] }], + ])), + getTaskWorkflowSelectionAsync: vi.fn().mockResolvedValue({ workflowId: "custom:delivery", stepIds: [] }), + getWorkflowDefinition: vi.fn().mockResolvedValue({ + ir: { + version: "v2", id: "custom:delivery", nodes: [], edges: [], + columns: [ + { id: "todo", label: "Todo", traits: [{ trait: "hold" }] }, + { id: "shipped", label: "Shipped", traits: [{ trait: "complete" }] }, + { id: "stored", label: "Stored", traits: [{ trait: "archived" }] }, + ], + }, + }), + }; + + await reconcileMissionState({ taskStore: taskStore as never, missionStore }, { source: "self-healing" }); + + expect(updateFeatureStatus).toHaveBeenCalledWith( + feature.id, + "done", + { actor: { type: "system", id: "mission-reconcile", source: "mission-reconcile:self-healing" } }, + ); + expect(taskStore.getTaskWorkflowSelectionsAsync).toHaveBeenCalledOnce(); + expect(taskStore.getTaskWorkflowSelectionAsync).not.toHaveBeenCalledWith(followUp.id); + }); + it("retains the orthogonal alignment projection when lifecycle status is already current", async () => { const task = { id: "FN-1", title: "Delivery", column: "in-progress", status: "in-progress", diff --git a/packages/engine/src/__tests__/mission-symbol-admission.test.ts b/packages/engine/src/__tests__/mission-symbol-admission.test.ts index bf05e70b27..268d7d74c5 100644 --- a/packages/engine/src/__tests__/mission-symbol-admission.test.ts +++ b/packages/engine/src/__tests__/mission-symbol-admission.test.ts @@ -14,7 +14,7 @@ function task(overrides: Partial = {}): Task { function store(overrides: Partial<{ mission: Mission | undefined; milestone: Milestone | undefined; slice: Slice | undefined; feature: MissionFeature | undefined }> = {}) { const values = { mission, milestone, slice, feature, ...overrides }; return { - getFeatureByTaskId: async () => values.feature, + getFeatureByTaskId: async (taskId: string) => values.feature?.taskId === taskId ? values.feature : undefined, getFeature: async (id: string) => id === feature.id ? values.feature : undefined, getSlice: async () => values.slice, getMilestone: async () => values.milestone, @@ -53,6 +53,28 @@ describe("decideMissionSymbolAdmission", () => { }); }); + it("prefers a canonical feature link after a Decision-A follow-up is rehomed", async () => { + const currentSlice = { ...slice, id: "SL-2" }; + const currentFeature = { ...feature, id: "F-2", sliceId: currentSlice.id, taskId: "FN-2" }; + const rehomed = task({ + id: "FN-2", + sliceId: currentSlice.id, + declaredSymbols: ["pkg/a.ts#A"], + sourceMetadata: { missionLineage: { missionId: mission.id, sliceId: slice.id, featureId: feature.id } }, + }); + const missionStore = { + getFeatureByTaskId: async (taskId: string) => taskId === rehomed.id ? currentFeature : undefined, + getFeature: async (id: string) => id === feature.id ? feature : id === currentFeature.id ? currentFeature : undefined, + getSlice: async (id: string) => id === currentSlice.id ? currentSlice : id === slice.id ? slice : undefined, + getMilestone: async () => milestone, + getMission: async () => mission, + }; + + await expect(decideMissionSymbolAdmission(rehomed, missionStore as never)).resolves.toMatchObject({ + kind: "symbol-lock", feature: { id: currentFeature.id, taskId: rehomed.id }, + }); + }); + it("uses coarse fallback for non-mission and approved empty-symbol work", async () => { await expect(decideMissionSymbolAdmission(task({ missionId: undefined, sliceId: undefined }), store({ feature: undefined }))).resolves.toEqual({ kind: "coarse-fallback", reason: "non-mission" }); await expect(decideMissionSymbolAdmission(task({ declaredSymbols: [] }), store())).resolves.toEqual({ kind: "coarse-fallback", reason: "symbols-unresolvable" }); diff --git a/packages/engine/src/__tests__/workflow-work-scheduler.test.ts b/packages/engine/src/__tests__/workflow-work-scheduler.test.ts index eb70bc8a14..3de865e099 100644 --- a/packages/engine/src/__tests__/workflow-work-scheduler.test.ts +++ b/packages/engine/src/__tests__/workflow-work-scheduler.test.ts @@ -45,6 +45,34 @@ describe("claimDueWorkflowWorkItem", () => { expect(logEntry).toHaveBeenCalledWith("FN-1", expect.stringContaining("mission lineage blocked")); }); + it("claims a rehomed task through its canonical feature instead of stale follow-up lineage", async () => { + const acquireWorkflowWorkItemLease = vi.fn(() => item); + const acquireSymbolLocks = vi.fn(async () => ({ acquired: true as const, conflicts: [] })); + const result = await claimDueWorkflowWorkItem({ + listDueWorkflowWorkItems: () => [item], acquireWorkflowWorkItemLease, + getTask: async () => ({ + id: "FN-1", missionId: "M-1", sliceId: "SL-2", declaredSymbols: ["pkg/a.ts#A"], + sourceMetadata: { missionLineage: { missionId: "M-1", sliceId: "SL-OLD", featureId: "F-OLD" } }, + } as any), + getMissionStore: () => ({ + getFeatureByTaskId: async () => ({ id: "F-2", taskId: "FN-1", sliceId: "SL-2", status: "triaged" }), + getFeature: async () => ({ id: "F-OLD", taskId: "FN-OLD", sliceId: "SL-OLD", status: "done" }), + getSlice: async () => ({ id: "SL-2", milestoneId: "MS-1", status: "active" }), + getMilestone: async () => ({ id: "MS-1", missionId: "M-1", status: "active" }), + getMission: async () => ({ id: "M-1", status: "active" }), + } as any), + acquireSymbolLocks, + }, { leaseOwner: "worker", leaseDurationMs: 1000 }); + + expect(result).toMatchObject({ taskId: "FN-1", workItem: item }); + expect(acquireSymbolLocks).toHaveBeenCalledWith( + ["pkg/a.ts#a"], + { ownerTaskId: "FN-1", missionId: "M-1", featureId: "F-2", agentId: "worker" }, + expect.any(Number), + ); + expect(acquireWorkflowWorkItemLease).toHaveBeenCalledOnce(); + }); + it("releases an acquired symbol lock when the workflow lease races", async () => { const releaseSymbolLocks = vi.fn(async () => undefined); const result = await claimDueWorkflowWorkItem({ diff --git a/packages/engine/src/executor/lifecycle-columns.ts b/packages/engine/src/executor/lifecycle-columns.ts index 351c485fa1..e43882ab03 100644 --- a/packages/engine/src/executor/lifecycle-columns.ts +++ b/packages/engine/src/executor/lifecycle-columns.ts @@ -25,7 +25,7 @@ * which is the half-conversion shape: the correct target reached through a check that could * not see it. Each site now resolves once and uses the same value for both. */ -import type { TaskStore } from "@fusion/core"; +import type { TaskStore, WorkflowSelectionCache } from "@fusion/core"; import { resolveCompleteColumn, resolveLifecycleColumns, @@ -53,12 +53,12 @@ export async function resolveTerminalColumnsFor( store: TaskStore, taskId: string, /* - FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (#2787 review — greptile P2): - Optional CALLER-OWNED IR cache, matching the contract on `resolveTaskLifecycleColumns`. Sweeps that - call this once per card on a whole board must read one IR per WORKFLOW, not one per task; callers - resolving a single task pass nothing and are unaffected. + FNXC:WorkflowLifecycleColumns 2026-08-12-00:20: + Optional caller-owned IR and selection caches let sweeps read one IR per workflow and one + selection per task. Single-task callers pass neither and retain the original behavior. */ irCache?: Map>>, + selectionCache?: WorkflowSelectionCache, ): Promise { /* FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (PR #2568 review — greptile): @@ -82,7 +82,7 @@ export async function resolveTerminalColumnsFor( column, which is the failure the conversion exists to prevent. */ try { - const resolved = resolveTerminalColumns(await resolveWorkflowIrForTask(store, taskId, irCache)); + const resolved = resolveTerminalColumns(await resolveWorkflowIrForTask(store, taskId, irCache, selectionCache)); return [...new Set([...resolved, ...LEGACY_TERMINAL_COLUMNS])]; } catch { return LEGACY_TERMINAL_COLUMNS; diff --git a/packages/engine/src/missions/mission-feature-sync.ts b/packages/engine/src/missions/mission-feature-sync.ts index 85ae247200..cf53c5a7ae 100644 --- a/packages/engine/src/missions/mission-feature-sync.ts +++ b/packages/engine/src/missions/mission-feature-sync.ts @@ -51,6 +51,8 @@ export async function resolveMissionFeatureAlignment( export interface MissionFeatureSyncContext { hasLinkedAssertions?: boolean; + /** FNXC:MissionFollowupLifecycle 2026-08-12-00:20: Live Decision-A follow-ups keep their source feature active until the whole delivery boundary is terminal. */ + hasLiveLineageDescendants?: boolean; /* FNXC:WorkflowLifecycleColumns 2026-07-30-11:20 (U11): The task's resolved planner lanes (intake + hold). Supplied by the CALLER, which @@ -296,12 +298,17 @@ export async function reconcileMissionFeatureState( return { kind: "blocked", reason: blocker, alignment }; } - if (hasUnvalidatedAssertions) { + const pendingCompletionReason = context.hasLiveLineageDescendants === true + ? "lineage follow-ups" + : hasUnvalidatedAssertions + ? "assertion validation" + : undefined; + if (pendingCompletionReason) { if (feature.status !== "in-progress") { return { kind: "update", status: "in-progress", - reason: `task ${task.id} completed; awaiting assertion validation`, + reason: `task ${task.id} completed; awaiting ${pendingCompletionReason}`, alignment, }; } diff --git a/packages/engine/src/missions/mission-state-reconcile.ts b/packages/engine/src/missions/mission-state-reconcile.ts index 1edd004aaa..db5f4cdb47 100644 --- a/packages/engine/src/missions/mission-state-reconcile.ts +++ b/packages/engine/src/missions/mission-state-reconcile.ts @@ -1,8 +1,10 @@ -import type { MissionFeature, MissionFeatureRepairGroundTruth, MissionTransitionActor, Task, TaskStore } from "@fusion/core"; +import type { MissionFeature, MissionFeatureRepairGroundTruth, MissionTransitionActor, Task, TaskStore, WorkflowSelectionCache } from "@fusion/core"; import { TerminalTaskReconciliationError, resolveLifecycleColumns, resolveWorkflowIrForTask } from "@fusion/core"; import { createRunAuditor, generateSyntheticRunId } from "../util/run-audit.js"; +import { resolveTerminalColumnsFor } from "../executor/lifecycle-columns.js"; import { resolvePlannerLanesForTask } from "../planner-lane-resolution.js"; import { reconcileMissionFeatureState } from "./mission-feature-sync.js"; +import { parsePersistedMissionLineage } from "./mission-symbol-admission.js"; export type MissionReconcileSource = "startup" | "self-healing" | "autopilot" | "task-move" | "api" | "tool"; type TerminalCapability = { reconcileFeatureDoneWithTerminalTask(featureId: string, taskId: string): Promise }; @@ -45,6 +47,7 @@ function actorFor(source: MissionReconcileSource, supplied?: MissionTransitionAc return { type: "system", id: "mission-reconcile", source: `mission-reconcile:${source}` }; } function titleKey(sliceId: string, title: string): string { return `${sliceId}\0${title.trim().replace(/\s+/g, " ").toLowerCase()}`; } +function lineageKey(missionId: string, sliceId: string, featureId: string): string { return `${missionId}\0${sliceId}\0${featureId}`; } function hasRepairCapability(store: unknown): store is RepairCapability { return typeof (store as Record | null | undefined)?.repairFeatureValidationState === "function"; } @@ -84,15 +87,78 @@ export async function reconcileMissionState( // FNXC:MissionAutoReconcile 2026-08-11-05:20: TaskStore methods use their receiver; optional-capability probing must not detach listTasks from deps.taskStore. const liveTasks = listTasks ? await listTasks.call(deps.taskStore, { slim: true, includeArchived: false }) : []; const selectedIds = new Set(selected.map((mission) => mission.id)); - const byTitle = new Map(); - for (const task of liveTasks) { - if (!task.sliceId || !task.title || !task.missionId || !selectedIds.has(task.missionId)) continue; - const key = titleKey(task.sliceId, task.title); - byTitle.set(key, byTitle.has(key) ? null : task); - } + type MissionHierarchy = { milestones: Array<{ slices: Array<{ id: string; features: MissionFeature[] }> }> }; + /* + FNXC:MissionFollowupLifecycle 2026-08-12-00:20: + Persisted Decision-A lineage is provenance only. Index current hierarchy ownership before + projecting descendants so a canonically rehomed task cannot keep its former feature open, + including when both features share a slice or happen to reuse an id elsewhere. + */ + const selectedHierarchies: Array<{ mission: { id: string; status: string }; hierarchy: MissionHierarchy }> = []; + const canonicalTaskIds = new Set(); + const knownFeatureLineages = new Set(); for (const mission of selected) { - const hierarchy = await missionApi.getMissionWithHierarchy(mission.id) as { milestones: Array<{ slices: Array<{ id: string; features: MissionFeature[] }> }> } | undefined; + const hierarchy = await missionApi.getMissionWithHierarchy(mission.id) as MissionHierarchy | undefined; if (!hierarchy) continue; + selectedHierarchies.push({ mission, hierarchy }); + for (const slice of hierarchy.milestones.flatMap((milestone) => milestone.slices)) { + for (const feature of slice.features) { + knownFeatureLineages.add(lineageKey(mission.id, slice.id, feature.id)); + if (feature.taskId) canonicalTaskIds.add(feature.taskId); + } + } + } + const byTitle = new Map(); + const featuresWithLiveLineageDescendants = new Set(); + const lineageCandidates: Array<{ task: Task; key: string }> = []; + for (const task of liveTasks) { + if (!task.sliceId || !task.missionId || !selectedIds.has(task.missionId)) continue; + const lineage = parsePersistedMissionLineage(task); + if ( + lineage + && lineage.missionId === task.missionId + && lineage.sliceId === task.sliceId + && !canonicalTaskIds.has(task.id) + && knownFeatureLineages.has(lineageKey(lineage.missionId, lineage.sliceId, lineage.featureId)) + ) { + lineageCandidates.push({ task, key: lineageKey(lineage.missionId, lineage.sliceId, lineage.featureId) }); + } + if (task.title) { + const key = titleKey(task.sliceId, task.title); + byTitle.set(key, byTitle.has(key) ? null : task); + } + } + const terminalIrCache = new Map>>(); + const selectionCache: WorkflowSelectionCache = new Map(); + const lineageTaskIds = lineageCandidates.map(({ task }) => task.id); + if (lineageTaskIds.length > 0) { + let needsPerTaskFallback = !deps.taskStore.getTaskWorkflowSelectionsAsync; + try { + if (deps.taskStore.getTaskWorkflowSelectionsAsync) { + const selections = await deps.taskStore.getTaskWorkflowSelectionsAsync(lineageTaskIds); + for (const taskId of lineageTaskIds) selectionCache.set(taskId, selections.get(taskId)); + } + } catch { + needsPerTaskFallback = true; + } + if (needsPerTaskFallback) { + await Promise.all(lineageTaskIds.map(async (taskId) => { + try { selectionCache.set(taskId, await deps.taskStore.getTaskWorkflowSelectionAsync(taskId)); } catch { /* FNXC:MissionFollowupLifecycle 2026-08-12-00:20: Preserve fail-soft default workflow resolution after a selection read failure. */ } + })); + } + } + const lineageBatchSize = 8; + for (let index = 0; index < lineageCandidates.length; index += lineageBatchSize) { + const batch = lineageCandidates.slice(index, index + lineageBatchSize); + const live = await Promise.all(batch.map(async ({ task, key }) => ({ + key, + isLive: !task.deletedAt && !(await resolveTerminalColumnsFor(deps.taskStore, task.id, terminalIrCache, selectionCache)).includes(task.column), + }))); + for (const candidate of live) { + if (candidate.isLive) featuresWithLiveLineageDescendants.add(candidate.key); + } + } + for (const { mission, hierarchy } of selectedHierarchies) { result.missionsScanned++; for (const slice of hierarchy.milestones.flatMap((milestone) => milestone.slices)) { const featureTitleCounts = new Map(); @@ -122,7 +188,11 @@ export async function reconcileMissionState( if (!terminalCandidate && task) { const assertions = missionApi.listAssertionsForFeature ? await missionApi.listAssertionsForFeature(feature.id) : []; const plannerColumns = await resolvePlannerLanesForTask(deps.taskStore, task.id) ?? []; - const decision = await reconcileMissionFeatureState(deps.taskStore, task, feature, { hasLinkedAssertions: assertions.length > 0, plannerColumns }); + const decision = await reconcileMissionFeatureState(deps.taskStore, task, feature, { + hasLinkedAssertions: assertions.length > 0, + hasLiveLineageDescendants: featuresWithLiveLineageDescendants.has(lineageKey(mission.id, slice.id, feature.id)), + plannerColumns, + }); const needsRepair = feature.status === "blocked" || feature.loopState === "blocked" || feature.loopState === "needs_fix"; if (decision.kind === "update" && feature.status !== decision.status) { if (options.dryRun) result.planned!.push({ featureId: feature.id, action: "status" }); diff --git a/packages/engine/src/missions/mission-symbol-admission.ts b/packages/engine/src/missions/mission-symbol-admission.ts index 8699b8809c..8d0dc4bb96 100644 --- a/packages/engine/src/missions/mission-symbol-admission.ts +++ b/packages/engine/src/missions/mission-symbol-admission.ts @@ -35,9 +35,9 @@ type MissionReader = Pick< "getMission" | "getMilestone" | "getSlice" | "getFeature" | "getFeatureByTaskId" >; -type PersistedMissionLineage = { missionId: string; sliceId: string; featureId: string }; +export type PersistedMissionLineage = { missionId: string; sliceId: string; featureId: string }; -function parsePersistedMissionLineage(task: Task): PersistedMissionLineage | undefined { +export function parsePersistedMissionLineage(task: Task): PersistedMissionLineage | undefined { const candidate = task.sourceMetadata?.missionLineage; if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return undefined; const { missionId, sliceId, featureId } = candidate as Record; @@ -47,16 +47,26 @@ function parsePersistedMissionLineage(task: Task): PersistedMissionLineage | und } /** - * FNXC:MissionSymbolAdmission 2026-08-01-00:00: - * Decision-A follow-up tasks retain the source feature's scalar taskId and carry - * a separately validated sourceMetadata.missionLineage reference. Resolve that - * reference before the canonical link so scheduler admission and reconciliation - * preserve source ownership without treating a metadata-shaped value as proof. + * FNXC:MissionSymbolAdmission 2026-08-12-00:20: + * Decision-A follow-up tasks retain a separately validated + * sourceMetadata.missionLineage reference. Prefer a current canonical task link; + * use the persisted lineage only for genuinely unlinked follow-ups. */ export async function resolveMissionFeatureForTask( store: MissionReader, task: Task, ): Promise { + /* + FNXC:MissionFollowupAdmission 2026-08-12-00:20: + A task can begin as a Decision-A follow-up and later become the canonical task for a + different Feature. The canonical taskId link is then the current ownership record; + inherited metadata is historical provenance and must not shadow it. Keep the + persisted-lineage fallback fail-closed for genuine unlinked follow-ups. + */ + const canonical = await store.getFeatureByTaskId(task.id); + if (canonical) { + return !task.sliceId || canonical.sliceId === task.sliceId ? canonical : undefined; + } const persisted = parsePersistedMissionLineage(task); if (persisted) { const feature = await store.getFeature(persisted.featureId); @@ -65,7 +75,7 @@ export async function resolveMissionFeatureForTask( } return undefined; } - return await store.getFeatureByTaskId(task.id); + return undefined; } /**