fix(engine): resume planned mission follow-ups

This commit is contained in:
gsxdsm
2026-08-11 17:53:40 -07:00
parent b095ddeb69
commit 167e17c697
8 changed files with 311 additions and 26 deletions

View File

@@ -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.

View File

@@ -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",

View File

@@ -14,7 +14,7 @@ function task(overrides: Partial<Task> = {}): 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" });

View File

@@ -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({

View File

@@ -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<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>,
selectionCache?: WorkflowSelectionCache,
): Promise<readonly string[]> {
/*
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;

View File

@@ -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,
};
}

View File

@@ -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<MissionFeature> };
@@ -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<string, unknown> | 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<string, Task | null>();
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<string>();
const knownFeatureLineages = new Set<string>();
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<string, Task | null>();
const featuresWithLiveLineageDescendants = new Set<string>();
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<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>();
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<string, number>();
@@ -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" });

View File

@@ -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<string, unknown>;
@@ -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<MissionFeature | undefined> {
/*
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;
}
/**