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";
|
import { reconcileMissionState } from "../missions/mission-state-reconcile.js";
|
||||||
|
|
||||||
describe("reconcileMissionState", () => {
|
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 () => {
|
it("retains the orthogonal alignment projection when lifecycle status is already current", async () => {
|
||||||
const task = {
|
const task = {
|
||||||
id: "FN-1", title: "Delivery", column: "in-progress", status: "in-progress",
|
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 }> = {}) {
|
function store(overrides: Partial<{ mission: Mission | undefined; milestone: Milestone | undefined; slice: Slice | undefined; feature: MissionFeature | undefined }> = {}) {
|
||||||
const values = { mission, milestone, slice, feature, ...overrides };
|
const values = { mission, milestone, slice, feature, ...overrides };
|
||||||
return {
|
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,
|
getFeature: async (id: string) => id === feature.id ? values.feature : undefined,
|
||||||
getSlice: async () => values.slice,
|
getSlice: async () => values.slice,
|
||||||
getMilestone: async () => values.milestone,
|
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 () => {
|
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({ 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" });
|
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"));
|
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 () => {
|
it("releases an acquired symbol lock when the workflow lease races", async () => {
|
||||||
const releaseSymbolLocks = vi.fn(async () => undefined);
|
const releaseSymbolLocks = vi.fn(async () => undefined);
|
||||||
const result = await claimDueWorkflowWorkItem({
|
const result = await claimDueWorkflowWorkItem({
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
* which is the half-conversion shape: the correct target reached through a check that could
|
* 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.
|
* 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 {
|
import {
|
||||||
resolveCompleteColumn,
|
resolveCompleteColumn,
|
||||||
resolveLifecycleColumns,
|
resolveLifecycleColumns,
|
||||||
@@ -53,12 +53,12 @@ export async function resolveTerminalColumnsFor(
|
|||||||
store: TaskStore,
|
store: TaskStore,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
/*
|
/*
|
||||||
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (#2787 review — greptile P2):
|
FNXC:WorkflowLifecycleColumns 2026-08-12-00:20:
|
||||||
Optional CALLER-OWNED IR cache, matching the contract on `resolveTaskLifecycleColumns`. Sweeps that
|
Optional caller-owned IR and selection caches let sweeps read one IR per workflow and one
|
||||||
call this once per card on a whole board must read one IR per WORKFLOW, not one per task; callers
|
selection per task. Single-task callers pass neither and retain the original behavior.
|
||||||
resolving a single task pass nothing and are unaffected.
|
|
||||||
*/
|
*/
|
||||||
irCache?: Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>,
|
irCache?: Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>,
|
||||||
|
selectionCache?: WorkflowSelectionCache,
|
||||||
): Promise<readonly string[]> {
|
): Promise<readonly string[]> {
|
||||||
/*
|
/*
|
||||||
FNXC:WorkflowLifecycleColumns 2026-07-30-21:40 (PR #2568 review — greptile):
|
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.
|
column, which is the failure the conversion exists to prevent.
|
||||||
*/
|
*/
|
||||||
try {
|
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])];
|
return [...new Set([...resolved, ...LEGACY_TERMINAL_COLUMNS])];
|
||||||
} catch {
|
} catch {
|
||||||
return LEGACY_TERMINAL_COLUMNS;
|
return LEGACY_TERMINAL_COLUMNS;
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export async function resolveMissionFeatureAlignment(
|
|||||||
|
|
||||||
export interface MissionFeatureSyncContext {
|
export interface MissionFeatureSyncContext {
|
||||||
hasLinkedAssertions?: boolean;
|
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):
|
FNXC:WorkflowLifecycleColumns 2026-07-30-11:20 (U11):
|
||||||
The task's resolved planner lanes (intake + hold). Supplied by the CALLER, which
|
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 };
|
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") {
|
if (feature.status !== "in-progress") {
|
||||||
return {
|
return {
|
||||||
kind: "update",
|
kind: "update",
|
||||||
status: "in-progress",
|
status: "in-progress",
|
||||||
reason: `task ${task.id} completed; awaiting assertion validation`,
|
reason: `task ${task.id} completed; awaiting ${pendingCompletionReason}`,
|
||||||
alignment,
|
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 { TerminalTaskReconciliationError, resolveLifecycleColumns, resolveWorkflowIrForTask } from "@fusion/core";
|
||||||
import { createRunAuditor, generateSyntheticRunId } from "../util/run-audit.js";
|
import { createRunAuditor, generateSyntheticRunId } from "../util/run-audit.js";
|
||||||
|
import { resolveTerminalColumnsFor } from "../executor/lifecycle-columns.js";
|
||||||
import { resolvePlannerLanesForTask } from "../planner-lane-resolution.js";
|
import { resolvePlannerLanesForTask } from "../planner-lane-resolution.js";
|
||||||
import { reconcileMissionFeatureState } from "./mission-feature-sync.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";
|
export type MissionReconcileSource = "startup" | "self-healing" | "autopilot" | "task-move" | "api" | "tool";
|
||||||
type TerminalCapability = { reconcileFeatureDoneWithTerminalTask(featureId: string, taskId: string): Promise<MissionFeature> };
|
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}` };
|
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 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 {
|
function hasRepairCapability(store: unknown): store is RepairCapability {
|
||||||
return typeof (store as Record<string, unknown> | null | undefined)?.repairFeatureValidationState === "function";
|
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.
|
// 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 liveTasks = listTasks ? await listTasks.call(deps.taskStore, { slim: true, includeArchived: false }) : [];
|
||||||
const selectedIds = new Set(selected.map((mission) => mission.id));
|
const selectedIds = new Set(selected.map((mission) => mission.id));
|
||||||
const byTitle = new Map<string, Task | null>();
|
type MissionHierarchy = { milestones: Array<{ slices: Array<{ id: string; features: MissionFeature[] }> }> };
|
||||||
for (const task of liveTasks) {
|
/*
|
||||||
if (!task.sliceId || !task.title || !task.missionId || !selectedIds.has(task.missionId)) continue;
|
FNXC:MissionFollowupLifecycle 2026-08-12-00:20:
|
||||||
const key = titleKey(task.sliceId, task.title);
|
Persisted Decision-A lineage is provenance only. Index current hierarchy ownership before
|
||||||
byTitle.set(key, byTitle.has(key) ? null : task);
|
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) {
|
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;
|
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++;
|
result.missionsScanned++;
|
||||||
for (const slice of hierarchy.milestones.flatMap((milestone) => milestone.slices)) {
|
for (const slice of hierarchy.milestones.flatMap((milestone) => milestone.slices)) {
|
||||||
const featureTitleCounts = new Map<string, number>();
|
const featureTitleCounts = new Map<string, number>();
|
||||||
@@ -122,7 +188,11 @@ export async function reconcileMissionState(
|
|||||||
if (!terminalCandidate && task) {
|
if (!terminalCandidate && task) {
|
||||||
const assertions = missionApi.listAssertionsForFeature ? await missionApi.listAssertionsForFeature(feature.id) : [];
|
const assertions = missionApi.listAssertionsForFeature ? await missionApi.listAssertionsForFeature(feature.id) : [];
|
||||||
const plannerColumns = await resolvePlannerLanesForTask(deps.taskStore, task.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";
|
const needsRepair = feature.status === "blocked" || feature.loopState === "blocked" || feature.loopState === "needs_fix";
|
||||||
if (decision.kind === "update" && feature.status !== decision.status) {
|
if (decision.kind === "update" && feature.status !== decision.status) {
|
||||||
if (options.dryRun) result.planned!.push({ featureId: feature.id, action: "status" });
|
if (options.dryRun) result.planned!.push({ featureId: feature.id, action: "status" });
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ type MissionReader = Pick<
|
|||||||
"getMission" | "getMilestone" | "getSlice" | "getFeature" | "getFeatureByTaskId"
|
"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;
|
const candidate = task.sourceMetadata?.missionLineage;
|
||||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return undefined;
|
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return undefined;
|
||||||
const { missionId, sliceId, featureId } = candidate as Record<string, unknown>;
|
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:
|
* FNXC:MissionSymbolAdmission 2026-08-12-00:20:
|
||||||
* Decision-A follow-up tasks retain the source feature's scalar taskId and carry
|
* Decision-A follow-up tasks retain a separately validated
|
||||||
* a separately validated sourceMetadata.missionLineage reference. Resolve that
|
* sourceMetadata.missionLineage reference. Prefer a current canonical task link;
|
||||||
* reference before the canonical link so scheduler admission and reconciliation
|
* use the persisted lineage only for genuinely unlinked follow-ups.
|
||||||
* preserve source ownership without treating a metadata-shaped value as proof.
|
|
||||||
*/
|
*/
|
||||||
export async function resolveMissionFeatureForTask(
|
export async function resolveMissionFeatureForTask(
|
||||||
store: MissionReader,
|
store: MissionReader,
|
||||||
task: Task,
|
task: Task,
|
||||||
): Promise<MissionFeature | undefined> {
|
): 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);
|
const persisted = parsePersistedMissionLineage(task);
|
||||||
if (persisted) {
|
if (persisted) {
|
||||||
const feature = await store.getFeature(persisted.featureId);
|
const feature = await store.getFeature(persisted.featureId);
|
||||||
@@ -65,7 +75,7 @@ export async function resolveMissionFeatureForTask(
|
|||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return await store.getFeatureByTaskId(task.id);
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user