From 8e6985aed32ea750373568c511a786a13a61da1d Mon Sep 17 00:00:00 2001 From: flexi767 <96955327+flexi767@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:46:08 +0200 Subject: [PATCH] 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 ## 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. --------- Co-authored-by: fusion-merge-train Co-authored-by: Fusion --- .../fn-991-archived-mission-reconciliation.md | 7 + docs/missions.md | 19 ++- .../postgres/mission-store.pg.test.ts | 109 +++++++++++++++- .../core/src/async-mission-store-queries.ts | 60 ++++++++- packages/core/src/async-mission-store.ts | 120 +++++++++++++++++- packages/core/src/index.ts | 3 +- packages/dashboard/src/mission-routes.ts | 58 ++++----- .../mission-reconcile-done-route.test.ts | 115 +++++++++++++++++ 8 files changed, 442 insertions(+), 49 deletions(-) create mode 100644 .changeset/fn-991-archived-mission-reconciliation.md create mode 100644 packages/dashboard/src/routes/__tests__/mission-reconcile-done-route.test.ts diff --git a/.changeset/fn-991-archived-mission-reconciliation.md b/.changeset/fn-991-archived-mission-reconciliation.md new file mode 100644 index 0000000000..c39c687541 --- /dev/null +++ b/.changeset/fn-991-archived-mission-reconciliation.md @@ -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. diff --git a/docs/missions.md b/docs/missions.md index 4285c7cdeb..84526dbc34 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -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`. + + +**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 diff --git a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts index 7c48aa0d4a..8e704945d8 100644 --- a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts @@ -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" }); diff --git a/packages/core/src/async-mission-store-queries.ts b/packages/core/src/async-mission-store-queries.ts index df913e2b3a..52b609c70d 100644 --- a/packages/core/src/async-mission-store-queries.ts +++ b/packages/core/src/async-mission-store-queries.ts @@ -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 0; } +/** Return a different feature already using the task, if one exists. */ +export async function getConflictingFeatureByTaskId( + handle: QueryHandle, + taskId: string, + featureId: string, +): Promise { + 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 { 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 { + 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 diff --git a/packages/core/src/async-mission-store.ts b/packages/core/src/async-mission-store.ts index f7e3233d56..0f96eb9c1b 100644 --- a/packages/core/src/async-mission-store.ts +++ b/packages/core/src/async-mission-store.ts @@ -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 { private idSequence = 0; private readonly milestonesMissingStructuredAssertions = new Set(); @@ -915,6 +935,92 @@ export class AsyncMissionStore extends EventEmitter { 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 { + 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 { 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 { // ════════════════ STATUS ROLLUP ════════════════ async computeSliceStatus(sliceId: string): Promise { - const features = await listFeatures(this.db, sliceId); + return this.computeSliceStatusWithHandle(this.db, sliceId); + } + + private async computeSliceStatusWithHandle(handle: QueryHandle, sliceId: string): Promise { + 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 { } async computeMilestoneStatus(milestoneId: string): Promise { - const slices = await listSlices(this.db, milestoneId); + return this.computeMilestoneStatusWithHandle(this.db, milestoneId); + } + + private async computeMilestoneStatusWithHandle(handle: QueryHandle, milestoneId: string): Promise { + const slices = await listSlices(handle, milestoneId); if (slices.length === 0) return "planning"; const allComplete = slices.every((s) => s.status === "complete"); if (allComplete) return "complete"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a14a309f0a..78cea9f0d3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index b10944fb73..54dd059715 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -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>; + 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); }) ); diff --git a/packages/dashboard/src/routes/__tests__/mission-reconcile-done-route.test.ts b/packages/dashboard/src/routes/__tests__/mission-reconcile-done-route.test.ts new file mode 100644 index 0000000000..2f6284af7b --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/mission-reconcile-done-route.test.ts @@ -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 }); + }); +});