feat(core): reconcile archived mission evidence atomically (#2379)

## Summary

- add an atomic PostgreSQL operation that reconciles a mission feature
with a terminal delivery task
- support retained archived-task evidence without unarchiving or waking
mission automation
- reject missing, active, deleted-without-archive, and duplicate-linked
task evidence without partial mutation
- route the reconciliation endpoint through the transactional store
operation

## Root cause

Mission reconciliation previously relied on ordinary task-link and
lifecycle paths that cannot safely use retained archived task evidence.
That made historical delivery repair either impossible or vulnerable to
partial linkage and unintended mission-loop side effects.

## Scope

This PR contains the reusable product capability recovered from FX-001.
It intentionally does not perform the project-specific 69-row live data
mutation; that operational reconciliation was blocked by ambiguous
evidence and belongs outside the source change.

## Validation

- PostgreSQL mission-store tests: 23 passed
- dashboard reconciliation route tests: 3 passed
- `@fusion/core` typecheck
- `@fusion/dashboard` typecheck
- targeted ESLint
- strict changeset validation


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added safer reconciliation for completed mission features using
validated terminal task evidence.
* Supports eligible archived tasks without restoring or relinking them.
* Reconciliation is idempotent and updates related mission progress
consistently.

* **Bug Fixes**
* Prevented conflicting or invalid task evidence from changing mission
state.
  * Added atomic rollback when reconciliation encounters an error.
* Improved API responses for missing resources and reconciliation
conflicts.

* **Documentation**
* Expanded reconciliation safety, error, idempotency, and
duplicate-cleanup guidance.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: fusion-merge-train <merge-train@topkoli.local>
Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
flexi767
2026-07-22 22:46:08 +02:00
committed by GitHub
parent 56efd7488e
commit 8e6985aed3
8 changed files with 442 additions and 49 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Reconcile completed mission features safely against archived delivery tasks.
category: fix
dev: Adds an atomic PostgreSQL terminal-evidence repair path with conflict and archive-tombstone validation.

View File

@@ -386,21 +386,26 @@ Use this endpoint when a feature's delivery task has already shipped and is now
**Safety gate behavior:**
- Validates `featureId` and requires a non-empty string `taskId`.
- Looks up the feature and the delivery task in the request's scoped project store.
- Only allows reconciliation when the delivery task column is `done` or `archived`.
- If feature has no `taskId`, the endpoint links it first, then marks feature status `done` via `updateFeatureStatus` (which recomputes slice status).
- If feature already has a different `taskId`, returns `409` (conflict).
- Resolves the feature and terminal delivery evidence in the request's scoped PostgreSQL project.
- Accepts either a live, non-deleted task in `done`, or a supported archived task represented by both its retained soft-deleted `column=archived` project-task tombstone and its project-scoped cold archive snapshot. A deletion without both archive representations is not delivery evidence.
- Atomically validates conflicts, writes the canonical feature→task link, marks the feature `done`, updates the live task's reverse mission/slice link when applicable, and recomputes slice/milestone rollups. Any validation or write failure rolls back the complete operation.
- Archived evidence remains archived and read-only: reconciliation intentionally leaves its retained task tombstone without a writable reverse backlink rather than resurrecting the task.
- Repeating the same terminal task against the same completed feature is idempotent. A feature linked to another task, or a task linked to another feature, returns `409` without mutation.
- The repair path never enters ordinary triage/implementation state, changes loop attempts, creates or moves a task, activates/watches the mission, or changes mission `status`, `autopilotEnabled`, or `autoAdvance`.
<!-- FNXC:MissionReconciliation 2026-07-20-08:34: Operators need a supported atomic terminal-evidence repair because unarchive/move workarounds enter ordinary task lifecycle observers and can wake a parked mission or generate duplicate work. -->
**Safe duplicate cleanup:** preserve the first `409`; verify through supported APIs that the current linked task is generated duplicate work with no unique delivery or lineage value; call `POST /api/missions/features/:featureId/unlink-task`; archive only the proven duplicate through the supported task archive API; then call `reconcile-done` with the canonical terminal task. Never overwrite a mismatched link, unarchive/move canonical delivery evidence, or use direct storage edits. If the duplicate is ambiguous, leave it untouched and escalate for evidence.
**How this differs from `PATCH /api/missions/features/:featureId`:**
- `PATCH` keeps the execution-status guard and rejects `done`/`triaged`/`in-progress`/`blocked` when no linked task exists.
- `reconcile-done` is a dedicated, evidence-gated path for shipped work where the delivery task is already terminal.
- `reconcile-done` is a dedicated, evidence-gated transaction for already-shipped work. It is not a shortcut for active work or mission execution.
**Error responses:**
- `400` — invalid feature ID format or missing/empty `taskId`.
- `404` — feature not found or delivery task not found.
- `409` — feature/task mismatch or delivery task is not in `done`/`archived` (use normal PATCH/triage/link flow for active work).
- `404` — feature not found or no task/archive evidence exists for the supplied task ID.
- `409` — feature/task mismatch, task already linked to another feature, nonterminal task, or a deleted/archived task lacking the supported retained tombstone plus cold snapshot. Every `409` leaves feature, task, rollups, and mission controls unchanged.
## Validation Contract Lifecycle

View File

@@ -13,8 +13,8 @@
* gate (test:pg-gate).
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
import { sql } from "drizzle-orm";
import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from "vitest";
import { eq, sql } from "drizzle-orm";
import {
pgDescribe,
@@ -244,6 +244,111 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
expect(unlinked.status).toBe("defined");
});
/*
FNXC:MissionReconciliation 2026-07-20-08:34:
Regression coverage exercises every terminal-evidence representation through the real PostgreSQL store. Reconciliation must never route through ordinary triage linking, mutate loop attempts or mission controls, or partially commit when the transaction fails.
*/
it("atomically reconciles live done evidence and remains idempotent", async () => {
const m = missions();
const mission = await m.createMission({ title: "Parked repair" });
const milestone = await m.addMilestone(mission.id, { title: "MS" });
const slice = await m.addSlice(milestone.id, { title: "SL" });
const feature = await m.addFeature(slice.id, { title: "Delivered" });
const task = await h.store().createTask({ description: "shipped", column: "done" });
const taskCount = (await h.store().listTasks()).length;
const reconciled = await m.reconcileFeatureDoneWithTerminalTask(feature.id, task.id);
expect(reconciled).toMatchObject({ taskId: task.id, status: "done", loopState: "idle", implementationAttemptCount: 0 });
expect(await m.getSlice(slice.id)).toMatchObject({ status: "complete" });
expect(await m.getMilestone(milestone.id)).toMatchObject({ status: "complete" });
expect(await m.getMission(mission.id)).toMatchObject({ status: "planning", autopilotEnabled: false, autoAdvance: false });
expect(await h.store().getTask(task.id)).toMatchObject({ missionId: mission.id, sliceId: slice.id, column: "done" });
expect((await h.store().listTasks()).length).toBe(taskCount);
const firstUpdatedAt = reconciled.updatedAt;
const idempotent = await m.reconcileFeatureDoneWithTerminalTask(feature.id, task.id);
expect(idempotent.updatedAt).toBe(firstUpdatedAt);
expect(idempotent).toEqual(reconciled);
const duplicate = await m.addFeature(slice.id, { title: "Corrupt duplicate" });
await m.updateFeature(duplicate.id, { taskId: task.id });
await expect(m.reconcileFeatureDoneWithTerminalTask(feature.id, task.id)).rejects.toMatchObject({ code: "TASK_FEATURE_CONFLICT" });
expect(await m.getFeature(feature.id)).toEqual(reconciled);
});
it("accepts a supported archived tombstone without resurrecting or back-linking it", async () => {
const m = missions();
const mission = await m.createMission({ title: "Archived repair" });
const milestone = await m.addMilestone(mission.id, { title: "MS" });
const slice = await m.addSlice(milestone.id, { title: "SL" });
const feature = await m.addFeature(slice.id, { title: "Archived delivery" });
const task = await h.store().createTask({ description: "archived shipped work", column: "done" });
await h.store().archiveTask(task.id, { cleanup: false });
const reconciled = await m.reconcileFeatureDoneWithTerminalTask(feature.id, task.id);
expect(reconciled).toMatchObject({ taskId: task.id, status: "done", loopState: "idle", implementationAttemptCount: 0 });
expect(await h.store().getTask(task.id)).toMatchObject({ column: "archived" });
const tombstones = await h.layer().db
.select({ column: schema.project.tasks.column, deletedAt: schema.project.tasks.deletedAt, missionId: schema.project.tasks.missionId, sliceId: schema.project.tasks.sliceId })
.from(schema.project.tasks)
.where(eq(schema.project.tasks.id, task.id));
expect(tombstones).toEqual([{ column: "archived", deletedAt: expect.any(String), missionId: null, sliceId: null }]);
expect(await m.getMission(mission.id)).toMatchObject({ status: "planning", autopilotEnabled: false, autoAdvance: false });
});
it("rejects missing, nonterminal, invalid-deleted, feature mismatch, and duplicate task links without mutation", async () => {
const m = missions();
const mission = await m.createMission({ title: "Guarded repair" });
const milestone = await m.addMilestone(mission.id, { title: "MS" });
const slice = await m.addSlice(milestone.id, { title: "SL" });
const [feature, other] = await Promise.all([
m.addFeature(slice.id, { title: "Canonical" }),
m.addFeature(slice.id, { title: "Other" }),
]);
const nonterminal = await h.store().createTask({ description: "active", column: "todo" });
const invalidDeleted = await h.store().createTask({ description: "deleted without archive", column: "done" });
await h.layer().db.update(schema.project.tasks).set({ deletedAt: new Date().toISOString() })
.where(eq(schema.project.tasks.id, invalidDeleted.id));
const linkedTask = await h.store().createTask({ description: "already linked", column: "done" });
await m.reconcileFeatureDoneWithTerminalTask(other.id, linkedTask.id);
await expect(m.reconcileFeatureDoneWithTerminalTask(feature.id, "FN-MISSING")).rejects.toMatchObject({ code: "TASK_NOT_FOUND" });
await expect(m.reconcileFeatureDoneWithTerminalTask(feature.id, nonterminal.id)).rejects.toMatchObject({ code: "TASK_NOT_TERMINAL" });
await expect(m.reconcileFeatureDoneWithTerminalTask(feature.id, invalidDeleted.id)).rejects.toMatchObject({ code: "TASK_ARCHIVE_INVALID" });
await expect(m.reconcileFeatureDoneWithTerminalTask(feature.id, linkedTask.id)).rejects.toMatchObject({ code: "TASK_FEATURE_CONFLICT" });
const canonicalTask = await h.store().createTask({ description: "canonical", column: "done" });
await m.linkFeatureToTask(feature.id, nonterminal.id);
await expect(m.reconcileFeatureDoneWithTerminalTask(feature.id, canonicalTask.id)).rejects.toMatchObject({ code: "FEATURE_TASK_CONFLICT" });
expect(await m.getFeature(feature.id)).toMatchObject({ taskId: nonterminal.id, status: "triaged", loopState: "implementing" });
expect(await m.getMission(mission.id)).toMatchObject({ autopilotEnabled: false, autoAdvance: false });
});
it("rolls back feature linkage and rollups when reconciliation fails after its writes", async () => {
const m = missions();
const mission = await m.createMission({ title: "Rollback repair" });
const milestone = await m.addMilestone(mission.id, { title: "MS" });
const slice = await m.addSlice(milestone.id, { title: "SL" });
const feature = await m.addFeature(slice.id, { title: "Rollback" });
const task = await h.store().createTask({ description: "done", column: "done" });
const layer = h.layer();
const original = layer.transactionImmediate.bind(layer);
const transaction = vi.spyOn(layer, "transactionImmediate").mockImplementation(async (callback) => original(async (tx) => {
await callback(tx);
throw new Error("injected post-write failure");
}));
await expect(m.reconcileFeatureDoneWithTerminalTask(feature.id, task.id)).rejects.toThrow("injected post-write failure");
transaction.mockRestore();
expect(await m.getFeature(feature.id)).toMatchObject({ taskId: undefined, status: "defined", loopState: "idle", implementationAttemptCount: 0 });
expect(await m.getSlice(slice.id)).toMatchObject({ status: "pending" });
expect(await m.getMilestone(milestone.id)).toMatchObject({ status: "planning" });
expect(await h.store().getTask(task.id)).toMatchObject({ missionId: undefined, sliceId: undefined });
});
it("addContractAssertion appears in listContractAssertions", async () => {
const m = missions();
const mission = await m.createMission({ title: "Asserted" });

View File

@@ -42,7 +42,7 @@
* These helpers are the production MissionStore persistence path and program
* against AsyncDataLayer rather than a synchronous SQLite database.
*/
import { and, asc, desc, eq, inArray, sql, type AnyColumn, type SQL } from "drizzle-orm";
import { and, asc, desc, eq, inArray, ne, sql, type AnyColumn, type SQL } from "drizzle-orm";
import * as schema from "./postgres/schema/index.js";
import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js";
import { normalizeMissionAssertionType } from "./mission-types.js";
@@ -1050,6 +1050,24 @@ export async function deleteFeature(handle: QueryHandle, id: string): Promise<bo
return result.length > 0;
}
/** Return a different feature already using the task, if one exists. */
export async function getConflictingFeatureByTaskId(
handle: QueryHandle,
taskId: string,
featureId: string,
): Promise<MissionFeature | undefined> {
const rows = await handle
.select(featureColumns)
.from(schema.project.missionFeatures)
.where(and(
missionProjectScope(schema.project.missionFeatures.projectId),
eq(schema.project.missionFeatures.taskId, taskId),
ne(schema.project.missionFeatures.id, featureId),
))
.limit(1);
return rows[0] ? rowToFeature(rows[0] as FeatureRow) : undefined;
}
/** Get a feature by its linked taskId (null if no feature is linked). */
export async function getFeatureByTaskId(handle: QueryHandle, taskId: string): Promise<MissionFeature | undefined> {
const rows = await handle
@@ -2053,6 +2071,46 @@ export async function listLiveLinkedTaskIds(handle: QueryHandle, taskIds: string
return new Set(rows.map((row) => row.id));
}
export type TerminalTaskEvidence =
| { kind: "done"; id: string; column: "done" }
| { kind: "archived"; id: string; column: "archived" }
| { kind: "nonterminal"; id: string; column: string }
| { kind: "invalid-deleted"; id: string; column?: string }
| { kind: "missing" };
/**
* FNXC:MissionReconciliation 2026-07-20-08:34:
* Terminal evidence repair must distinguish a supported archive (the retained archived task tombstone plus its project-scoped cold snapshot) from an arbitrary soft/hard deletion. Read both representations on the caller's transaction handle so validation and feature linkage share one snapshot.
*/
export async function getTerminalTaskEvidence(handle: QueryHandle, taskId: string): Promise<TerminalTaskEvidence> {
const taskRows = await handle
.select({
id: schema.project.tasks.id,
column: schema.project.tasks.column,
deletedAt: schema.project.tasks.deletedAt,
})
.from(schema.project.tasks)
.where(and(missionProjectScope(schema.project.tasks.projectId), eq(schema.project.tasks.id, taskId)))
.limit(1);
const archiveRows = await handle
.select({ id: schema.archive.archivedTasks.id })
.from(schema.archive.archivedTasks)
.where(and(missionProjectScope(schema.archive.archivedTasks.projectId), eq(schema.archive.archivedTasks.id, taskId)))
.limit(1);
const task = taskRows[0];
const hasArchiveSnapshot = archiveRows.length > 0;
if (!task) return hasArchiveSnapshot ? { kind: "invalid-deleted", id: taskId } : { kind: "missing" };
if (task.deletedAt === null && task.column === "done") return { kind: "done", id: task.id, column: "done" };
if (task.deletedAt !== null && task.column === "archived" && hasArchiveSnapshot) {
return { kind: "archived", id: task.id, column: "archived" };
}
if (task.deletedAt !== null || task.column === "archived") {
return { kind: "invalid-deleted", id: task.id, column: task.column };
}
return { kind: "nonterminal", id: task.id, column: task.column };
}
/** Get a live (non-deleted) task's id + column, or undefined. */
export async function getLiveTaskById(handle: QueryHandle, taskId: string): Promise<{ id: string; column: string } | undefined> {
const rows = await handle

View File

@@ -98,7 +98,9 @@ import {
updateFeature,
deleteFeature,
getFeatureByTaskId,
getConflictingFeatureByTaskId,
unlinkFeatureFromTaskId,
getTerminalTaskEvidence,
getMaxEventSeq,
insertMissionEvent,
countMissionEvents,
@@ -180,6 +182,24 @@ import {
* Validator-run and generated-fix events are emitted after their PostgreSQL
* transactions commit, matching the synchronous store's observable contract.
*/
export type TerminalTaskReconciliationErrorCode =
| "FEATURE_NOT_FOUND"
| "TASK_NOT_FOUND"
| "TASK_NOT_TERMINAL"
| "TASK_ARCHIVE_INVALID"
| "FEATURE_TASK_CONFLICT"
| "TASK_FEATURE_CONFLICT";
export class TerminalTaskReconciliationError extends Error {
constructor(
public readonly code: TerminalTaskReconciliationErrorCode,
message: string,
) {
super(message);
this.name = "TerminalTaskReconciliationError";
}
}
export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
private idSequence = 0;
private readonly milestonesMissingStructuredAssertions = new Set<string>();
@@ -915,6 +935,92 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
return updated;
}
/**
* FNXC:MissionReconciliation 2026-07-20-08:34:
* Shipped-delivery repair is a dedicated transaction, not ordinary feature linking. It accepts only a live done row or the supported retained archived tombstone+cold snapshot, preserves conflict guards, leaves loop attempts and mission run controls untouched, and updates only the live task backlink because archived evidence must never be resurrected.
*/
async reconcileFeatureDoneWithTerminalTask(featureId: string, taskId: string): Promise<MissionFeature> {
const outcome = await this.layer.transactionImmediate(async (tx) => {
const feature = await getFeature(tx, featureId);
if (!feature) {
throw new TerminalTaskReconciliationError("FEATURE_NOT_FOUND", `Feature ${featureId} not found`);
}
if (feature.taskId && feature.taskId !== taskId) {
throw new TerminalTaskReconciliationError(
"FEATURE_TASK_CONFLICT",
`Feature ${featureId} is already linked to ${feature.taskId}; cannot reconcile against ${taskId}`,
);
}
const evidence = await getTerminalTaskEvidence(tx, taskId);
if (evidence.kind === "missing") {
throw new TerminalTaskReconciliationError("TASK_NOT_FOUND", `Delivery task ${taskId} not found`);
}
if (evidence.kind === "nonterminal") {
throw new TerminalTaskReconciliationError(
"TASK_NOT_TERMINAL",
`Delivery task ${taskId} must be in done or supported archived state, not ${evidence.column}`,
);
}
if (evidence.kind === "invalid-deleted") {
throw new TerminalTaskReconciliationError(
"TASK_ARCHIVE_INVALID",
`Delivery task ${taskId} is deleted or archived without a valid retained tombstone and archive snapshot`,
);
}
const taskFeature = await getConflictingFeatureByTaskId(tx, taskId, featureId);
if (taskFeature) {
throw new TerminalTaskReconciliationError(
"TASK_FEATURE_CONFLICT",
`Delivery task ${taskId} is already linked to feature ${taskFeature.id}`,
);
}
const slice = await getSlice(tx, feature.sliceId);
if (!slice) throw new TerminalTaskReconciliationError("FEATURE_NOT_FOUND", `Slice ${feature.sliceId} not found`);
const milestone = await getMilestone(tx, slice.milestoneId);
if (!milestone) throw new TerminalTaskReconciliationError("FEATURE_NOT_FOUND", `Milestone ${slice.milestoneId} not found`);
const mission = await getMission(tx, milestone.missionId);
if (!mission) throw new TerminalTaskReconciliationError("FEATURE_NOT_FOUND", `Mission ${milestone.missionId} not found`);
const now = new Date().toISOString();
const featureChanged = feature.taskId !== taskId || feature.status !== "done";
const reconciledFeature: MissionFeature = featureChanged
? { ...feature, taskId, status: "done", updatedAt: now }
: feature;
if (featureChanged) await updateFeature(tx, reconciledFeature);
if (evidence.kind === "done") {
await setTaskMissionLinkage(tx, taskId, mission.id, slice.id);
}
const sliceStatus = await this.computeSliceStatusWithHandle(tx, slice.id);
const reconciledSlice = slice.status === sliceStatus ? slice : { ...slice, status: sliceStatus, updatedAt: now };
if (reconciledSlice !== slice) await updateSlice(tx, reconciledSlice);
const milestoneStatus = await this.computeMilestoneStatusWithHandle(tx, milestone.id);
const reconciledMilestone = milestone.status === milestoneStatus
? milestone
: { ...milestone, status: milestoneStatus, updatedAt: now };
if (reconciledMilestone !== milestone) await updateMilestone(tx, reconciledMilestone);
return {
feature: reconciledFeature,
featureChanged,
linked: feature.taskId !== taskId,
slice: reconciledSlice !== slice ? reconciledSlice : undefined,
milestone: reconciledMilestone !== milestone ? reconciledMilestone : undefined,
};
});
if (outcome.featureChanged) this.emit("feature:updated", outcome.feature);
if (outcome.linked) this.emit("feature:linked", { feature: outcome.feature, taskId });
if (outcome.slice) this.emit("slice:updated", outcome.slice);
if (outcome.milestone) this.emit("milestone:updated", outcome.milestone);
return outcome.feature;
}
async linkFeatureToTask(featureId: string, taskId: string): Promise<MissionFeature> {
const feature = await getFeature(this.db, featureId);
if (!feature) throw new Error(`Feature ${featureId} not found`);
@@ -1761,10 +1867,14 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
// ════════════════ STATUS ROLLUP ════════════════
async computeSliceStatus(sliceId: string): Promise<SliceStatus> {
const features = await listFeatures(this.db, sliceId);
return this.computeSliceStatusWithHandle(this.db, sliceId);
}
private async computeSliceStatusWithHandle(handle: QueryHandle, sliceId: string): Promise<SliceStatus> {
const features = await listFeatures(handle, sliceId);
if (features.length === 0) return "pending";
/* FNXC:MissionStatusPerformance 2026-07-14-18:45: Slice reconciliation loads assertion membership for the whole feature set once; status rollups must not issue one assertion query per feature. */
const featureIdsWithAssertions = await listFeatureIdsWithAssertions(this.db, features.map((feature) => feature.id));
const featureIdsWithAssertions = await listFeatureIdsWithAssertions(handle, features.map((feature) => feature.id));
let allDone = true;
for (const feature of features) {
if (feature.status !== "done") { allDone = false; break; }
@@ -1781,7 +1891,11 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
}
async computeMilestoneStatus(milestoneId: string): Promise<MilestoneStatus> {
const slices = await listSlices(this.db, milestoneId);
return this.computeMilestoneStatusWithHandle(this.db, milestoneId);
}
private async computeMilestoneStatusWithHandle(handle: QueryHandle, milestoneId: string): Promise<MilestoneStatus> {
const slices = await listSlices(handle, milestoneId);
if (slices.length === 0) return "planning";
const allComplete = slices.every((s) => s.status === "complete");
if (allComplete) return "complete";

View File

@@ -1727,7 +1727,8 @@ export type {
} from "./mission-types.js";
export { MissionStore } from "./mission-store.js";
export type { MissionStoreEvents, MissionSummary } from "./mission-store.js";
export { AsyncMissionStore } from "./async-mission-store.js";
export { AsyncMissionStore, TerminalTaskReconciliationError } from "./async-mission-store.js";
export type { TerminalTaskReconciliationErrorCode } from "./async-mission-store.js";
export { AsyncIdeationStore } from "./async-ideation-store.js";
export { IDEATION_SESSION_STATUSES, IDEATION_CANDIDATE_ORIGINS } from "./ideation-types.js";
export type { IdeationSessionStatus, IdeationCandidateOrigin, IdeationSession, IdeationCandidate, IdeationSessionCreateInput, IdeationCandidateCreateInput, IdeationCandidateUpdateInput, IdeationConvergeInput, IdeationSessionWithCandidates } from "./ideation-types.js";

View File

@@ -14,7 +14,13 @@
import { Router, type Request, type Response, type NextFunction } from "express";
import { AsyncLocalStorage } from "node:async_hooks";
import { TaskStore, resolvePlanningSettingsModel, AgentStore, THINKING_LEVELS } from "@fusion/core";
import {
TaskStore,
resolvePlanningSettingsModel,
AgentStore,
THINKING_LEVELS,
TerminalTaskReconciliationError,
} from "@fusion/core";
import type { Goal, Settings, ThinkingLevel } from "@fusion/core";
import { listEligibleExecutorAgents, resolvePlanningThinkingLevel } from "@fusion/engine";
import {
@@ -2735,48 +2741,30 @@ export function createMissionRouter(
throw badRequest("Invalid feature ID format");
}
const existing = await missionStore.getFeature(featureId);
if (!existing) {
throw notFound("Feature not found");
}
if (typeof taskId !== "string" || !taskId.trim()) {
throw badRequest("taskId is required and must be a non-empty string");
}
const normalizedTaskId = taskId.trim();
if (existing.taskId && existing.taskId !== normalizedTaskId) {
throw conflict(
`Feature ${featureId} is already linked to ${existing.taskId}; cannot reconcile against ${normalizedTaskId}`
);
}
const { store: scopedStore } = await getProjectContext(req);
let task: Awaited<ReturnType<typeof scopedStore.getTask>>;
const scopedMissionStore = scopedStore.getMissionStore();
if (!("reconcileFeatureDoneWithTerminalTask" in scopedMissionStore)) {
throw internalError("Terminal-task reconciliation requires the PostgreSQL mission store");
}
/*
FNXC:MissionReconciliation 2026-07-20-08:34:
Route validation stays project-scoped, but all terminal-evidence checks, mismatch guards, linkage, and rollups belong to one store transaction. Never pre-link or move/unarchive a shipped task here because those ordinary lifecycle paths can wake a parked mission.
*/
try {
task = await scopedStore.getTask(normalizedTaskId);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
if (errMsg.includes("not found")) {
throw notFound("Delivery task not found");
}
throw err;
const feature = await scopedMissionStore.reconcileFeatureDoneWithTerminalTask(featureId, normalizedTaskId);
res.json(feature);
} catch (error: unknown) {
if (!(error instanceof TerminalTaskReconciliationError)) throw error;
if (error.code === "FEATURE_NOT_FOUND") throw notFound("Feature not found");
if (error.code === "TASK_NOT_FOUND") throw notFound("Delivery task not found");
throw conflict(error.message);
}
if (task.column !== "done" && task.column !== "archived") {
throw conflict(
`Delivery task ${normalizedTaskId} must be in done or archived to reconcile feature to done. ` +
"Use PATCH /api/missions/features/:featureId or triage/link-task endpoints for active work."
);
}
if (!existing.taskId) {
await missionStore.linkFeatureToTask(featureId, normalizedTaskId);
}
const feature = await missionStore.updateFeatureStatus(featureId, "done");
res.json(feature);
})
);

View File

@@ -0,0 +1,115 @@
// @vitest-environment node
/*
FNXC:MissionReconciliation 2026-07-20-08:34:
The HTTP regression fixture uses the real PostgreSQL archive path because a mocked `column:"archived"` task cannot prove the retained tombstone+cold-snapshot contract. Success must preserve the parked mission and loop state; every expected domain rejection must be a 4xx with no partial feature, task, or rollup mutation.
*/
import { afterEach, beforeEach, expect, it } from "vitest";
import express from "express";
import { TaskStore } from "@fusion/core";
import {
createTaskStoreForTest,
pgDescribe,
type PgTestHarness,
} from "../../../../core/src/__test-utils__/pg-test-harness.js";
import { createApiRoutes } from "../../routes.js";
import { request as REQUEST } from "../../test-request.js";
pgDescribe("mission reconcile-done route", () => {
let harness: PgTestHarness;
let store: TaskStore;
let app: express.Express;
beforeEach(async () => {
harness = await createTaskStoreForTest();
store = harness.store;
app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
});
afterEach(async () => {
await harness.teardown();
});
const post = (featureId: string, body: unknown) => REQUEST(
app,
"POST",
`/api/missions/features/${featureId}/reconcile-done`,
JSON.stringify(body),
{ "content-type": "application/json" },
);
async function createFeature(title = "Delivered") {
const missionStore = store.getMissionStore();
const mission = await missionStore.createMission({ title: "Parked mission" });
const milestone = await missionStore.addMilestone(mission.id, { title: "Milestone" });
const slice = await missionStore.addSlice(milestone.id, { title: "Slice" });
const feature = await missionStore.addFeature(slice.id, { title });
return { missionStore, mission, milestone, slice, feature };
}
it("reconciles a normally archived task atomically without mission-loop side effects", async () => {
const { missionStore, mission, milestone, slice, feature } = await createFeature();
const task = await store.createTask({ description: "shipped delivery", column: "done" });
await store.archiveTask(task.id, { cleanup: false });
const taskCount = (await store.listTasks()).length;
const response = await post(feature.id, { taskId: task.id });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ id: feature.id, taskId: task.id, status: "done", loopState: "idle", implementationAttemptCount: 0 });
expect(await missionStore.getSlice(slice.id)).toMatchObject({ status: "complete" });
expect(await missionStore.getMilestone(milestone.id)).toMatchObject({ status: "complete" });
expect(await missionStore.getMission(mission.id)).toMatchObject({ status: "planning", autopilotEnabled: false, autoAdvance: false });
expect(await store.getTask(task.id)).toMatchObject({ column: "archived" });
expect((await store.listTasks()).length).toBe(taskCount);
expect(await store.getTask(task.id)).toMatchObject({
column: "archived",
missionId: undefined,
sliceId: undefined,
});
const idempotent = await post(feature.id, { taskId: task.id });
expect(idempotent.status).toBe(200);
expect(idempotent.body).toEqual(response.body);
});
it("returns stable 400/404 responses for malformed and unknown inputs", async () => {
const { feature } = await createFeature();
expect((await post(feature.id, {})).status).toBe(400);
expect((await post(feature.id, { taskId: " " })).status).toBe(400);
expect((await post("not-a-feature", { taskId: "FN-1" })).status).toBe(400);
expect((await post("F-MISSING", { taskId: "FN-1" })).status).toBe(404);
expect((await post(feature.id, { taskId: "FN-MISSING" })).status).toBe(404);
});
it("maps every terminal-evidence conflict to 409 without mutation", async () => {
const { missionStore, mission, slice, feature } = await createFeature("Canonical");
const other = await missionStore.addFeature(slice.id, { title: "Other" });
const active = await store.createTask({ description: "active", column: "todo" });
const invalidDeleted = await store.createTask({ description: "invalid deleted", column: "done" });
await store.deleteTask(invalidDeleted.id);
const duplicate = await store.createTask({ description: "duplicate link", column: "done" });
await missionStore.reconcileFeatureDoneWithTerminalTask(other.id, duplicate.id);
const before = await missionStore.getFeature(feature.id);
for (const taskId of [active.id, invalidDeleted.id, duplicate.id]) {
const response = await post(feature.id, { taskId });
expect(response.status).toBe(409);
expect(await missionStore.getFeature(feature.id)).toEqual(before);
expect(await missionStore.getMission(mission.id)).toMatchObject({ status: "planning", autopilotEnabled: false, autoAdvance: false });
}
const canonical = await store.createTask({ description: "canonical", column: "done" });
await missionStore.linkFeatureToTask(feature.id, active.id);
const linkedBefore = await missionStore.getFeature(feature.id);
const mismatch = await post(feature.id, { taskId: canonical.id });
expect(mismatch.status).toBe(409);
expect(await missionStore.getFeature(feature.id)).toEqual(linkedBefore);
expect(await store.getTask(canonical.id)).toMatchObject({ missionId: undefined, sliceId: undefined });
});
});