fix(engine): resume planned mission follow-ups
This commit is contained in:
7
.changeset/fix-mission-followup-admission.md
Normal file
7
.changeset/fix-mission-followup-admission.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user