From 6cc15fd73d0c1c4c452893eba9a57c07dd021bb1 Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Tue, 11 Aug 2026 13:57:36 -0700 Subject: [PATCH] fix(ci): restore clean-main CLI and lifecycle gates (#3420) ## Summary - Complete the isolated `@fusion/core` mock used by the experiment-finalize extension suite - Classify three intentional physical/synthetic lifecycle literals introduced on current main - Re-record the strict lifecycle census baseline with zero unexamined guards ## Test plan - `pnpm --filter @runfusion/fusion exec vitest run src/__tests__/extension-experiment-finalize.test.ts --silent=passed-only --reporter=dot` - `pnpm --filter @fusion/core exec vitest run src/__tests__/task-intake-owner-resolver.test.ts --silent=passed-only --reporter=dot` - `pnpm --filter @fusion/engine exec vitest run --project engine-default src/__tests__/mission-feature-sync-lanes.test.ts --silent=passed-only --reporter=dot` - `pnpm check:lifecycle-columns` - `node scripts/check-mock-completeness.mjs` ## Summary by CodeRabbit - **Bug Fixes** - Improved mission reconciliation previews for task links, specification alignment, and lifecycle updates. - Prevented stale or superseded validation runs from overwriting current feature status or ownership. - Improved blocked-feature diagnostics and archived-task handling across workflow configurations. - **Documentation** - Clarified validation, assignment checks, and mission synchronization behavior. - **Tests** - Expanded coverage for reconciliation previews and validator ownership scenarios. - **Chores** - Updated lifecycle baseline data for known archived-task cases. --- .changeset/fix-mission-reconcile-preview.md | 7 ++ .../extension-experiment-finalize.test.ts | 3 + .../cli/src/__tests__/package-config.test.ts | 26 ++++-- ...mission-store.sync-loop-transition.test.ts | 26 ++++++ .../postgres/mission-store.pg.test.ts | 87 ++++++++++++++++++ .../async-mission-store-queries.ts | 24 +++++ .../src/async-stores/async-mission-store.ts | 84 +++++++++++------ packages/core/src/missions/mission-store.ts | 21 +++-- .../src/tasks/task-intake-owner-resolver.ts | 2 + .../app/components/MissionManager.tsx | 10 ++- .../__tests__/mission-state-reconcile.test.ts | 90 +++++++++++++++++++ ...ling-stranded-continuation-reclaim.test.ts | 8 ++ .../src/missions/mission-feature-sync.ts | 2 + .../src/missions/mission-state-reconcile.ts | 22 +++-- packages/engine/src/self-healing.ts | 2 - .../lib/lifecycle-column-census-baseline.json | 5 ++ 16 files changed, 366 insertions(+), 53 deletions(-) create mode 100644 .changeset/fix-mission-reconcile-preview.md diff --git a/.changeset/fix-mission-reconcile-preview.md b/.changeset/fix-mission-reconcile-preview.md new file mode 100644 index 0000000000..bac6b360a2 --- /dev/null +++ b/.changeset/fix-mission-reconcile-preview.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix mission reconciliation, blocked diagnostics, and superseded validator ownership. +category: fix +dev: Corrects dry-run actions, binds TaskStore access, scopes blocked-feature reads, and guards stale run completion. diff --git a/packages/cli/src/__tests__/extension-experiment-finalize.test.ts b/packages/cli/src/__tests__/extension-experiment-finalize.test.ts index 55f8fb02ea..57feb9b5c3 100644 --- a/packages/cli/src/__tests__/extension-experiment-finalize.test.ts +++ b/packages/cli/src/__tests__/extension-experiment-finalize.test.ts @@ -53,6 +53,9 @@ vi.mock("@fusion/core", () => ({ })), COLUMNS: [], COLUMN_LABELS: {}, + // FNXC:CliTests 2026-08-11-02:58: extension.ts builds mission and feature status schemas during tool registration. + MISSION_STATUSES: ["planning", "active", "blocked", "complete", "archived"], + FEATURE_STATUSES: ["defined", "triaged", "in-progress", "done", "blocked"], validateNodeOverrideChange: vi.fn(), RESEARCH_RUN_STATUSES: [], isResearchExperimentalEnabled: vi.fn(() => true), diff --git a/packages/cli/src/__tests__/package-config.test.ts b/packages/cli/src/__tests__/package-config.test.ts index d5a18d473f..2153fd3a9c 100644 --- a/packages/cli/src/__tests__/package-config.test.ts +++ b/packages/cli/src/__tests__/package-config.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; -import { readFileSync } from "node:fs"; +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { builtinModules } from "node:module"; import { execFileSync } from "node:child_process"; @@ -536,12 +537,21 @@ describe("shipped agent skills", () => { expect(cli.files).toContain("skill/**"); expect(applyPrepackTransform(cli).files).toContain("skill/**"); const packageDir = join(workspaceRoot, "packages", "cli"); - const packed = JSON.parse(execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { - cwd: packageDir, - encoding: "utf8", - })) as Array<{ files: Array<{ path: string }> }>; - const packedPaths = new Set(packed[0]!.files.map((file) => file.path)); - expect(packedPaths).toContain("skill/fusion/SKILL.md"); - expect(packedPaths).toContain("skill/computer-use/SKILL.md"); + const packFixture = mkdtempSync(join(tmpdir(), "fusion-cli-packlist-")); + try { + /* Keep npm's real packlist semantics without making this focused manifest test scan the + * multi-megabyte built CLI and dashboard bundles. Source presence is still proven by cpSync. */ + writeFileSync(join(packFixture, "package.json"), JSON.stringify(cli)); + cpSync(join(packageDir, "skill"), join(packFixture, "skill"), { recursive: true }); + const packed = JSON.parse(execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { + cwd: packFixture, + encoding: "utf8", + })) as Array<{ files: Array<{ path: string }> }>; + const packedPaths = new Set(packed[0]!.files.map((file) => file.path)); + expect(packedPaths).toContain("skill/fusion/SKILL.md"); + expect(packedPaths).toContain("skill/computer-use/SKILL.md"); + } finally { + rmSync(packFixture, { recursive: true, force: true }); + } }); }); diff --git a/packages/core/src/__tests__/mission-store.sync-loop-transition.test.ts b/packages/core/src/__tests__/mission-store.sync-loop-transition.test.ts index 82b5d8d991..98ab40ca4d 100644 --- a/packages/core/src/__tests__/mission-store.sync-loop-transition.test.ts +++ b/packages/core/src/__tests__/mission-store.sync-loop-transition.test.ts @@ -40,6 +40,32 @@ describe("MissionStore synchronous loop transitions", () => { }); expect(updateFeature).toHaveBeenCalledWith(feature.id, { loopState: "implementing" }); }); + + it("records a superseded validator completion without overwriting the replacement owner", () => { + const db = { + transaction: (callback: () => void) => callback(), + prepare: vi.fn().mockReturnValue({ get: vi.fn().mockReturnValue(undefined), run: vi.fn() }), + bumpLastModified: vi.fn(), + } as unknown as Database; + const store = new MissionStore("/tmp/fusion-mission-store-test", db); + const run = { + id: "VR-OLD", featureId: "F-RECOVERY", milestoneId: "MS-1", sliceId: "SL-1", + status: "running", implementationAttempt: 0, validatorAttempt: 1, + startedAt: "2026-08-10T00:00:00.000Z", createdAt: "2026-08-10T00:00:00.000Z", updatedAt: "2026-08-10T00:00:00.000Z", + } as const; + const completed = { ...run, status: "failed" as const, completedAt: "2026-08-11T00:00:00.000Z" }; + const feature = { + id: run.featureId, sliceId: run.sliceId, title: "Owned by replacement", status: "in-progress", + loopState: "validating", lastValidatorRunId: "VR-NEW", + createdAt: "2026-08-10T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z", + } as MissionFeature; + vi.spyOn(store, "getValidatorRun").mockReturnValueOnce(run).mockReturnValue(completed); + vi.spyOn(store, "getFeature").mockReturnValue(feature); + const updateFeature = vi.spyOn(store, "updateFeature"); + + expect(store.completeValidatorRun(run.id, "failed")).toEqual(completed); + expect(updateFeature).not.toHaveBeenCalled(); + }); }); describe("MissionStore serial slice admission", () => { 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 6736f227c9..eec8b94799 100644 --- a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts @@ -959,6 +959,93 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => { await expect(m.startManualValidatorRun(feature.id)).resolves.toMatchObject({ outcome: "started" }); }); + /* + FNXC:MissionValidation 2026-08-11-05:26: + A replacement validator owns feature state across every stale-run terminal surface: manual admission after an old engine run, engine fallback after an old manual run, and the stale reaper. Historical runs still become terminal records but cannot overwrite the replacement's validating state. + */ + it("keeps replacement validator ownership when superseded runs complete or are reaped", async () => { + const m = missions(); + const mission = await m.createMission({ title: "Superseded validator ownership" }); + const milestone = await m.addMilestone(mission.id, { title: "MS" }); + const slice = await m.addSlice(milestone.id, { title: "SL" }); + const staleAt = new Date(Date.now() - 7 * 60 * 60 * 1000).toISOString(); + const ageRun = async (runId: string) => { + await h.layer().transactionImmediate(async (tx) => { + await tx.update(schema.project.missionValidatorRuns) + .set({ startedAt: staleAt }) + .where(eq(schema.project.missionValidatorRuns.id, runId)); + }); + }; + + const manualReplacementFeature = await m.addFeature(slice.id, { title: "Manual replacement" }); + const oldEngineRun = await m.startValidatorRun(manualReplacementFeature.id, "task_completion"); + await ageRun(oldEngineRun.id); + const manualReplacement = await m.startManualValidatorRun(manualReplacementFeature.id); + expect(manualReplacement).toMatchObject({ outcome: "started" }); + await m.completeValidatorRun(oldEngineRun.id, "failed"); + expect(await m.getValidatorRun(oldEngineRun.id)).toMatchObject({ + status: "failed", + completedAt: expect.any(String), + }); + expect(await m.getFeature(manualReplacementFeature.id)).toMatchObject({ + lastValidatorRunId: manualReplacement.run.id, + loopState: "validating", + }); + + const fallbackReplacementFeature = await m.addFeature(slice.id, { title: "Fallback replacement" }); + const oldManual = await m.startManualValidatorRun(fallbackReplacementFeature.id); + expect(oldManual).toMatchObject({ outcome: "started" }); + await ageRun(oldManual.run.id); + const fallbackReplacement = await m.startValidatorRun(fallbackReplacementFeature.id, "task_completion"); + await m.completeValidatorRun(oldManual.run.id, "passed"); + expect(await m.getValidatorRun(oldManual.run.id)).toMatchObject({ + status: "passed", + completedAt: expect.any(String), + }); + expect(await m.getFeature(fallbackReplacementFeature.id)).toMatchObject({ + lastValidatorRunId: fallbackReplacement.id, + loopState: "validating", + }); + + const reaperReplacementFeature = await m.addFeature(slice.id, { title: "Reaper replacement" }); + const oldReapTarget = await m.startValidatorRun(reaperReplacementFeature.id, "task_completion"); + await ageRun(oldReapTarget.id); + const reaperReplacement = await m.startManualValidatorRun(reaperReplacementFeature.id); + expect(reaperReplacement).toMatchObject({ outcome: "started" }); + await m.reapValidatorRun(oldReapTarget.id, "stale owner"); + expect(await m.getValidatorRun(oldReapTarget.id)).toMatchObject({ + status: "error", + completedAt: expect.any(String), + }); + expect(await m.getFeature(reaperReplacementFeature.id)).toMatchObject({ + lastValidatorRunId: reaperReplacement.run.id, + loopState: "validating", + }); + + const terminalMissionFeature = await m.addFeature(slice.id, { title: "Terminal mission guard" }); + const terminalMissionRun = await m.startValidatorRun(terminalMissionFeature.id, "task_completion"); + const layer = h.layer(); + const originalTransaction = layer.transactionImmediate.bind(layer); + let archiveBeforeReapTransaction = true; + const transaction = vi.spyOn(layer, "transactionImmediate").mockImplementation(async (callback) => { + if (archiveBeforeReapTransaction) { + archiveBeforeReapTransaction = false; + await m.updateMission(mission.id, { status: "archived" }); + } + return originalTransaction(callback); + }); + try { + await m.reapValidatorRun(terminalMissionRun.id, "mission became terminal"); + } finally { + transaction.mockRestore(); + } + expect(await m.getMission(mission.id)).toMatchObject({ status: "archived" }); + expect(await m.getFeature(terminalMissionFeature.id)).toMatchObject({ + lastValidatorRunId: terminalMissionRun.id, + loopState: "validating", + }); + }); + it("refuses automatic-after-manual admission across the fingerprint-less boundary", async () => { /* FNXC:MissionValidation 2026-08-11-05:38: diff --git a/packages/core/src/async-stores/async-mission-store-queries.ts b/packages/core/src/async-stores/async-mission-store-queries.ts index 90d18ee317..caa63ccdcf 100644 --- a/packages/core/src/async-stores/async-mission-store-queries.ts +++ b/packages/core/src/async-stores/async-mission-store-queries.ts @@ -1044,6 +1044,30 @@ export async function listFeaturesForMilestone(handle: QueryHandle, milestoneId: return rows.map((row) => rowToFeature(row as FeatureRow)); } +/** + * FNXC:MissionBlockedRepair 2026-08-11-05:25: + * Blocked-mission diagnostics are mission-scoped and may run inside the clear transaction. Resolve the requested hierarchy in one joined query so unrelated project features cannot lengthen that transaction. + */ +export async function listFeaturesForMission(handle: QueryHandle, missionId: string): Promise { + const rows = await handle + .select(featureColumns) + .from(schema.project.missionFeatures) + .innerJoin(schema.project.slices, and( + eq(schema.project.slices.projectId, schema.project.missionFeatures.projectId), + eq(schema.project.slices.id, schema.project.missionFeatures.sliceId), + )) + .innerJoin(schema.project.milestones, and( + eq(schema.project.milestones.projectId, schema.project.slices.projectId), + eq(schema.project.milestones.id, schema.project.slices.milestoneId), + )) + .where(and( + missionProjectScope(schema.project.missionFeatures.projectId), + eq(schema.project.milestones.missionId, missionId), + )) + .orderBy(asc(schema.project.missionFeatures.createdAt)); + return rows.map((row) => rowToFeature(row as FeatureRow)); +} + /** List ALL features across all slices, ordered by createdAt ASC. */ export async function listAllFeatures(handle: QueryHandle): Promise { const rows = await handle diff --git a/packages/core/src/async-stores/async-mission-store.ts b/packages/core/src/async-stores/async-mission-store.ts index d63e17f7d9..0544456c8e 100644 --- a/packages/core/src/async-stores/async-mission-store.ts +++ b/packages/core/src/async-stores/async-mission-store.ts @@ -111,6 +111,7 @@ import { listFeaturesByIds, listFeatures, listFeaturesForMilestone, + listFeaturesForMission, listAllFeatures, updateFeature, deleteFeature, @@ -694,17 +695,11 @@ export class AsyncMissionStore extends EventEmitter { } private async getMissionBlockedDescriptorsWithHandle(handle: QueryHandle, missionId: string, lockStops = false): Promise { - const allFeatures = await listAllFeatures(handle); - const featureMission = new Map(); - for (const feature of allFeatures) { - const slice = await getSlice(handle, feature.sliceId); - const milestone = slice ? await getMilestone(handle, slice.milestoneId) : undefined; - if (milestone) featureMission.set(feature.id, milestone.missionId); - } + const features = await listFeaturesForMission(handle, missionId); const stopsQuery = handle.select().from(schema.project.missionLineageStops) .where(and(eq(schema.project.missionLineageStops.projectId, missionProjectId()), eq(schema.project.missionLineageStops.missionId, missionId))); const stops = lockStops ? await stopsQuery.for("update") : await stopsQuery; - const roots = allFeatures.filter((feature) => featureMission.get(feature.id) === missionId && !feature.generatedFromFeatureId && feature.loopState === "blocked"); + const roots = features.filter((feature) => !feature.generatedFromFeatureId && feature.loopState === "blocked"); return classifyMissionResumeBlockers({ rootFeatures: roots, lineageStops: stops, missionId }).blockers; } @@ -1721,6 +1716,11 @@ export class AsyncMissionStore extends EventEmitter { const rows = await tx.select({ column: schema.project.tasks.column, updatedAt: schema.project.tasks.updatedAt, deletedAt: schema.project.tasks.deletedAt }) .from(schema.project.tasks).where(and(eq(schema.project.tasks.projectId, missionProjectId()), eq(schema.project.tasks.id, fence.taskId))).for("update"); const task = rows[0]; + /* + FNXC:MissionValidationRepair 2026-08-11-03:04 DELIBERATE-LITERAL: + The locked verifier must match the producer's physical legacy-row predicate; renamed + archive lanes remain live until archival soft-deletes them. + */ const liveness = task && !task.deletedAt && task.column !== "archived" ? "live" : "absent"; if (fence.taskLiveness === "live") { if (liveness !== "live" || task!.column !== fence.taskColumn || task!.updatedAt !== fence.taskUpdatedAt) throw new RepairGroundTruthStaleError(featureId); @@ -2038,24 +2038,35 @@ export class AsyncMissionStore extends EventEmitter { const run = await getValidatorRun(this.db, runId); if (!run) throw new Error(`Validator run ${runId} not found`); if (run.status !== "running") throw new Error(`Validator run ${runId} is not in 'running' status`); - const feature = await getFeature(this.db, run.featureId); - if (!feature) throw new Error(`Feature ${run.featureId} not found`); const now = new Date().toISOString(); const loopState: FeatureLoopState = result === "passed" ? "passed" : result === "failed" ? "needs_fix" : result === "blocked" ? "blocked" : "validating"; const updatedRun: MissionValidatorRun = { ...run, status: result, summary, blockedReason, completedAt: now, updatedAt: now }; - const won = await this.layer.transactionImmediate(async (tx) => { + /* + FNXC:MissionValidation 2026-08-11-05:26: + A validator run becomes historical when a newer admission replaces feature.lastValidatorRunId. Complete the historical run, but only the current owner may project loop state or trigger passed-run reconciliation. + */ + const completion = await this.layer.transactionImmediate(async (tx) => { + await tx.select().from(schema.project.missionFeatures).where(and( + eq(schema.project.missionFeatures.projectId, missionProjectId()), + eq(schema.project.missionFeatures.id, run.featureId), + )).for("update"); + const feature = await getFeature(tx, run.featureId); + if (!feature) throw new Error(`Feature ${run.featureId} not found`); const winner = await transitionRunningValidatorRun(tx, updatedRun); - if (!winner) return false; - await updateFeature(tx, { ...feature, loopState, lastValidatorStatus: result, updatedAt: now }); - return true; + if (!winner) return { won: false, ownsFeature: false, feature }; + const ownsFeature = feature.lastValidatorRunId === run.id; + if (ownsFeature) await updateFeature(tx, { ...feature, loopState, lastValidatorStatus: result, updatedAt: now }); + return { won: true, ownsFeature, feature }; }); - if (!won) return (await getValidatorRun(this.db, runId)) ?? updatedRun; - const updatedFeature = await getFeature(this.db, feature.id); - if (updatedFeature) this.emit("feature:updated", updatedFeature); - await this.recomputeSliceStatus(feature.sliceId); + if (!completion.won) return (await getValidatorRun(this.db, runId)) ?? updatedRun; + if (completion.ownsFeature) { + const updatedFeature = await getFeature(this.db, completion.feature.id); + if (updatedFeature) this.emit("feature:updated", updatedFeature); + await this.recomputeSliceStatus(completion.feature.sliceId); + } const durationMs = Math.max(0, Date.parse(now) - Date.parse(run.startedAt)); this.emit("validator-run:completed", updatedRun, result, durationMs); - if (result === "passed") await this.reconcileSupersededGeneratedFixFeatures(feature.sliceId); + if (result === "passed" && completion.ownsFeature) await this.reconcileSupersededGeneratedFixFeatures(completion.feature.sliceId); return updatedRun; } @@ -2098,18 +2109,35 @@ export class AsyncMissionStore extends EventEmitter { if (!mission) throw new Error(`Mission ${milestone.missionId} not found`); const now = new Date().toISOString(); const updatedRun: MissionValidatorRun = { ...run, status: "error", summary: reason, completedAt: now, updatedAt: now }; - const shouldUpdateFeature = mission.status !== "archived" && mission.status !== "complete" && feature.status !== "done"; - const won = await this.layer.transactionImmediate(async (tx) => { + const reaped = await this.layer.transactionImmediate(async (tx) => { + await tx.select().from(schema.project.missionFeatures).where(and( + eq(schema.project.missionFeatures.projectId, missionProjectId()), + eq(schema.project.missionFeatures.id, run.featureId), + )).for("update"); + const currentFeature = await getFeature(tx, run.featureId); + if (!currentFeature) throw new Error(`Feature ${run.featureId} not found`); const winner = await transitionRunningValidatorRun(tx, updatedRun); - if (!winner) return false; - if (shouldUpdateFeature) await updateFeature(tx, { ...feature, loopState: "needs_fix", lastValidatorStatus: "error", updatedAt: now }); - return true; + if (!winner) return { won: false, updatedFeature: false, feature: currentFeature }; + const ownsFeature = currentFeature.lastValidatorRunId === run.id; + /* + FNXC:MissionValidation 2026-08-11-05:54: + Reaper eligibility must use mission state protected by the same transaction as the feature update. A mission that becomes archived or complete after the preflight read must not be reopened by a stale validator reap. + */ + await tx.select().from(schema.project.missions).where(and( + eq(schema.project.missions.projectId, missionProjectId()), + eq(schema.project.missions.id, mission.id), + )).for("update"); + const currentMission = await getMission(tx, mission.id); + if (!currentMission) throw new Error(`Mission ${mission.id} not found`); + const shouldUpdateFeature = ownsFeature && currentMission.status !== "archived" && currentMission.status !== "complete" && currentFeature.status !== "done"; + if (shouldUpdateFeature) await updateFeature(tx, { ...currentFeature, loopState: "needs_fix", lastValidatorStatus: "error", updatedAt: now }); + return { won: true, updatedFeature: shouldUpdateFeature, feature: currentFeature }; }); - if (!won) return (await getValidatorRun(this.db, runId)) ?? updatedRun; - if (shouldUpdateFeature) { - const updatedFeature = await getFeature(this.db, feature.id); + if (!reaped.won) return (await getValidatorRun(this.db, runId)) ?? updatedRun; + if (reaped.updatedFeature) { + const updatedFeature = await getFeature(this.db, reaped.feature.id); if (updatedFeature) this.emit("feature:updated", updatedFeature); - await this.recomputeSliceStatus(feature.sliceId); + await this.recomputeSliceStatus(reaped.feature.sliceId); } this.emit("validator-run:completed", updatedRun, "error", Math.max(0, Date.parse(now) - Date.parse(run.startedAt))); return updatedRun; diff --git a/packages/core/src/missions/mission-store.ts b/packages/core/src/missions/mission-store.ts index b96cb3f467..93a88332c7 100644 --- a/packages/core/src/missions/mission-store.ts +++ b/packages/core/src/missions/mission-store.ts @@ -3008,6 +3008,7 @@ export class MissionStore extends EventEmitter { break; } + let ownsFeature = false; this.db.transaction(() => { // Update the validator run this.db.prepare(` @@ -3027,11 +3028,16 @@ export class MissionStore extends EventEmitter { runId, ); - // Update the feature's loop state and lastValidatorStatus - this.updateFeature(run.featureId, { - loopState: featureLoopState, - lastValidatorStatus: featureLastValidatorStatus, - }); + const currentFeature = this.getFeature(run.featureId); + if (!currentFeature) throw new Error(`Feature ${run.featureId} not found`); + ownsFeature = currentFeature.lastValidatorRunId === run.id; + // FNXC:MissionValidation 2026-08-11-05:26: Keep SQLite parity with PostgreSQL: historical completions become terminal records but cannot overwrite the newer validator owner's feature state. + if (ownsFeature) { + this.updateFeature(run.featureId, { + loopState: featureLoopState, + lastValidatorStatus: featureLastValidatorStatus, + }); + } }); this.db.bumpLastModified(); @@ -3040,7 +3046,7 @@ export class MissionStore extends EventEmitter { const updatedRun = this.getValidatorRun(runId)!; this.emit("validator-run:completed", updatedRun, result, durationMs); - if (result === "passed") { + if (result === "passed" && ownsFeature) { const passedFeature = this.getFeature(run.featureId); if (passedFeature) { this.reconcileSupersededGeneratedFixFeatures(passedFeature.sliceId); @@ -3211,7 +3217,8 @@ export class MissionStore extends EventEmitter { const startedAtMs = new Date(run.startedAt).getTime(); const completedAtMs = new Date(completedAt).getTime(); const durationMs = Math.max(0, completedAtMs - startedAtMs); - const shouldUpdateFeature = mission.status !== "archived" && mission.status !== "complete" && feature.status !== "done"; + // FNXC:MissionValidation 2026-08-11-05:26: Reaping a superseded SQLite run must not reopen the feature owned by its newer validator run. + const shouldUpdateFeature = feature.lastValidatorRunId === run.id && mission.status !== "archived" && mission.status !== "complete" && feature.status !== "done"; this.db.transaction(() => { this.db.prepare(` diff --git a/packages/core/src/tasks/task-intake-owner-resolver.ts b/packages/core/src/tasks/task-intake-owner-resolver.ts index de347e3b69..eff9941201 100644 --- a/packages/core/src/tasks/task-intake-owner-resolver.ts +++ b/packages/core/src/tasks/task-intake-owner-resolver.ts @@ -123,6 +123,8 @@ export function resolveTaskIntakeOwner(input: ResolveTaskIntakeOwnerInput): Task const explicit = byId.get(input.explicitAssigneeId); /* FNXC:IntakeOwnership 2026-08-11-02:04: + DELIBERATE-LITERAL: this synthetic task-like value asks only whether an explicitly selected + agent may perform implementation work; it is not a persisted task query or move target. FN-8843 duplicated an executor-only role check here, contradicting explicit-routing policy and the executorRoleOverride contract so every explicitly assigned engineer or operator override failed before insert. The shared policy now owns the explicit role decision; binding and pool routing remain executor-only. diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index 3a9c108d06..901c236e7a 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -782,7 +782,13 @@ function MissionReconcilePreview({ }) { const planned = result.planned ?? []; const isEmpty = planned.length === 0 && result.statusUpdates === 0 && result.badgeRepairs === 0 && result.terminalRepairs === 0; - const canApply = result.skippedReason !== "archived" && !isEmpty && planned.length > 0; + /* + FNXC:MissionReconcileUI 2026-08-11-07:37 DELIBERATE-LITERAL: + The reconciliation API's `skippedReason` discriminant describes mission state; it is not a task lifecycle column or move target. + Hoist the check so preview copy and apply eligibility share one interpretation of the archived-mission response. + */ + const missionIsArchived = result.skippedReason === "archived"; + const canApply = !missionIsArchived && !isEmpty && planned.length > 0; return (
@@ -791,7 +797,7 @@ function MissionReconcilePreview({ {t("missions.reconcileBadgeRepairs", "Badge repairs: {{count}}", { count: result.badgeRepairs })} {t("missions.reconcileTerminalRepairs", "Terminal repairs: {{count}}", { count: result.terminalRepairs })} - {result.skippedReason === "archived" ?

{t("missions.reconcileArchived", "Mission is archived — nothing reconciled")}

+ {missionIsArchived ?

{t("missions.reconcileArchived", "Mission is archived — nothing reconciled")}

: isEmpty ?

{t("missions.reconcileUpToDate", "Already up to date")}

:
    {planned.map((entry) => (
  • diff --git a/packages/engine/src/__tests__/mission-state-reconcile.test.ts b/packages/engine/src/__tests__/mission-state-reconcile.test.ts index 1c3c1e8ca4..e332975cc3 100644 --- a/packages/engine/src/__tests__/mission-state-reconcile.test.ts +++ b/packages/engine/src/__tests__/mission-state-reconcile.test.ts @@ -35,6 +35,96 @@ describe("reconcileMissionState", () => { { specAlignment: "diverged-needs-review" }, { actor: { type: "system", id: "mission-reconcile", source: "mission-reconcile:self-healing" } }, ); + + updateFeature.mockClear(); + const dryRun = await reconcileMissionState( + { taskStore: taskStore as never, missionStore }, + { source: "self-healing", dryRun: true }, + ); + expect(dryRun.planned).toEqual([{ featureId: "F-1", action: "spec-alignment" }]); + expect(updateFeature).not.toHaveBeenCalled(); + }); + + it("does not preview spec alignment when the store cannot apply that mutation", async () => { + const task = { + id: "FN-1", title: "Delivery", column: "in-progress", status: "in-progress", + missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:00:00.000Z", + }; + const feature = { + id: "F-1", title: "Delivery", sliceId: "SL-1", taskId: "FN-1", status: "in-progress", + specAlignment: "on-plan", createdAt: "2026-08-11T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z", + }; + 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: vi.fn(), + }; + const taskStore = { + listTasks: vi.fn().mockResolvedValue([task]), + getTask: vi.fn().mockResolvedValue(task), + getLatestSpecDriftReport: vi.fn().mockResolvedValue({ alignment: "diverged-needs-review" }), + }; + + const result = await reconcileMissionState( + { taskStore: taskStore as never, missionStore }, + { source: "self-healing", dryRun: true }, + ); + + expect(result.planned).toEqual([]); + expect(result).toMatchObject({ missionsScanned: 1, featuresScanned: 1, failures: 0 }); + expect(taskStore.getLatestSpecDriftReport).toHaveBeenCalled(); + }); + + it("preserves the TaskStore receiver while listing reconciliation candidates", async () => { + const taskStore = { + async listTasks(this: unknown, options: unknown) { + expect(this).toBe(taskStore); + expect(options).toEqual({ slim: true, includeArchived: false }); + return []; + }, + }; + const missionStore = { listMissions: vi.fn().mockResolvedValue([]) }; + + await expect(reconcileMissionState( + { taskStore: taskStore as never, missionStore }, + { source: "startup" }, + )).resolves.toMatchObject({ missionsScanned: 0, failures: 0 }); + }); + + it("previews unique title repair as a link without mutating the feature", async () => { + const task = { + id: "FN-1", title: "Delivery", column: "in-progress", status: "in-progress", + missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:00:00.000Z", + }; + const feature = { + id: "F-1", title: "Delivery", sliceId: "SL-1", status: "defined", + createdAt: "2026-08-11T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z", + }; + const linkFeatureToTask = 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([]), + linkFeatureToTask, + updateFeatureStatus: vi.fn(), + }; + const taskStore = { + listTasks: vi.fn().mockResolvedValue([task]), + getLatestSpecDriftReport: vi.fn().mockResolvedValue(undefined), + }; + + const result = await reconcileMissionState( + { taskStore: taskStore as never, missionStore }, + { source: "task-move", dryRun: true }, + ); + + expect(result.planned).toContainEqual({ featureId: "F-1", action: "link" }); + expect(linkFeatureToTask).not.toHaveBeenCalled(); }); it("does not title-link a task when duplicate features make ownership ambiguous", async () => { diff --git a/packages/engine/src/__tests__/self-healing-stranded-continuation-reclaim.test.ts b/packages/engine/src/__tests__/self-healing-stranded-continuation-reclaim.test.ts index 9330d751e5..b5ae2dc1d8 100644 --- a/packages/engine/src/__tests__/self-healing-stranded-continuation-reclaim.test.ts +++ b/packages/engine/src/__tests__/self-healing-stranded-continuation-reclaim.test.ts @@ -124,6 +124,14 @@ describe("reconcileStrandedWorkflowContinuations", () => { } }); + it("does not treat default column ids as terminal for a custom workflow", async () => { + const { manager, transitions } = harness([item()], { column: "done" }); + resolveTaskLifecycleColumnsMock.mockResolvedValue({ complete: "shipped", archived: "retired" }); + + await expect(manager.reconcileStrandedWorkflowContinuations()).resolves.toBe(1); + expect(transitions[0]?.state).toBe("runnable"); + }); + it("retires a row whose task no longer resolves at all", async () => { const { manager, transitions } = harness([item({ taskId: "FN-DELETED" })]); await expect(manager.reconcileStrandedWorkflowContinuations()).resolves.toBe(1); diff --git a/packages/engine/src/missions/mission-feature-sync.ts b/packages/engine/src/missions/mission-feature-sync.ts index e8ac476504..85ae247200 100644 --- a/packages/engine/src/missions/mission-feature-sync.ts +++ b/packages/engine/src/missions/mission-feature-sync.ts @@ -129,6 +129,8 @@ export async function resolveFeatureRepairTargets( const task = await taskStore.getTask(feature.taskId).catch(() => null); /* FNXC:MissionValidationRepair 2026-08-11-02:05: + DELIBERATE-LITERAL: this fence describes the physical legacy row that the locked core + verifier can inspect, not the task's workflow-resolved archive lane. The repair fence may call a link absent only for physical states the core transaction can verify without resolving a workflow: a missing/soft-deleted task or the legacy literal archived row. A renamed archived lifecycle lane remains a live row until archival soft-deletes it; classifying diff --git a/packages/engine/src/missions/mission-state-reconcile.ts b/packages/engine/src/missions/mission-state-reconcile.ts index c9b9372b68..1edd004aaa 100644 --- a/packages/engine/src/missions/mission-state-reconcile.ts +++ b/packages/engine/src/missions/mission-state-reconcile.ts @@ -25,10 +25,14 @@ export function hasTerminalReconcileCapability(missionStore: unknown): missionSt } export type MissionReconcileExtensionHook = (context: { feature: MissionFeature; task?: Task }) => Promise | void; +/** + * FNXC:MissionAutoReconcile 2026-08-11-05:20: + * Dry-run plans are operator-facing mutation previews. Keep task linking and spec-alignment writes distinct from lifecycle status updates so preview mode names the same mutation kind that apply mode performs. + */ export interface MissionReconcilePassResult { missionsScanned: number; featuresScanned: number; statusUpdates: number; badgeRepairs: number; badgeRepairsSkipped: number; terminalRepairs: number; terminalSkipped: number; conflicts: number; - failures: number; skippedReason?: "archived"; planned?: Array<{ featureId: string; action: "status" | "terminal-done" | "badge-clear" }>; + failures: number; skippedReason?: "archived"; planned?: Array<{ featureId: string; action: "status" | "link" | "spec-alignment" | "terminal-done" | "badge-clear" }>; } export interface ReconcileMissionStateDeps { taskStore: TaskStore; missionStore: object; @@ -44,6 +48,10 @@ function titleKey(sliceId: string, title: string): string { return `${sliceId}\0 function hasRepairCapability(store: unknown): store is RepairCapability { return typeof (store as Record | null | undefined)?.repairFeatureValidationState === "function"; } +/* +FNXC:MissionAutoReconcile 2026-08-11-04:57 DELIBERATE-LITERAL: +The physical archived lane remains a compatibility terminal signal before workflow resolution; custom workflows are checked against their resolved archived lane immediately afterward. +*/ async function isArchivedTask(taskStore: TaskStore, task: Task): Promise { if (task.deletedAt || task.column === "archived") return true; const ir = await resolveWorkflowIrForTask(taskStore, task.id).catch(() => undefined); @@ -73,7 +81,8 @@ export async function reconcileMissionState( if (options.missionId && requested?.status === "archived") return { ...result, skippedReason: "archived" }; const selected = (missions as Array<{ id: string; status: string }>).filter((mission) => mission.status !== "archived"); const listTasks = (deps.taskStore as unknown as { listTasks?: (options: { slim: boolean; includeArchived: boolean }) => Promise }).listTasks; - const liveTasks = listTasks ? await listTasks({ slim: true, includeArchived: false }) : []; + // FNXC:MissionAutoReconcile 2026-08-11-05:20: TaskStore methods use their receiver; optional-capability probing must not detach listTasks from deps.taskStore. + const liveTasks = listTasks ? await listTasks.call(deps.taskStore, { slim: true, includeArchived: false }) : []; const selectedIds = new Set(selected.map((mission) => mission.id)); const byTitle = new Map(); for (const task of liveTasks) { @@ -106,7 +115,7 @@ export async function reconcileMissionState( normalized title in this slice. Do not attach a moved task to an arbitrary duplicate feature; an operator must make ambiguous roadmap ownership explicit. */ - if (options.dryRun) result.planned!.push({ featureId: feature.id, action: "status" }); + if (options.dryRun) result.planned!.push({ featureId: feature.id, action: "link" }); else feature = await missionApi.linkFeatureToTask(feature.id, task.id); } const terminalCandidate = Boolean(explicitTaskId && task && await isArchivedTask(deps.taskStore, task)); @@ -124,10 +133,11 @@ export async function reconcileMissionState( Reconciliation has always projected spec alignment independently of delivery status. Preserve that projection even when the lifecycle decision is a no-op, while routing its write through the same explicit actor boundary as every automatic reconcile mutation. + Dry-run previews must also require that write capability so they never promise an unavailable mutation. */ - if (feature.specAlignment !== decision.alignment) { - if (options.dryRun) result.planned!.push({ featureId: feature.id, action: "status" }); - else if (missionApi.updateFeature) await missionApi.updateFeature(feature.id, { specAlignment: decision.alignment }, { actor }); + if (feature.specAlignment !== decision.alignment && missionApi.updateFeature) { + if (options.dryRun) result.planned!.push({ featureId: feature.id, action: "spec-alignment" }); + else await missionApi.updateFeature(feature.id, { specAlignment: decision.alignment }, { actor }); } const wip = task.column !== undefined && !plannerColumns.includes(task.column) && decision.kind === "update" && decision.status === "in-progress"; const complete = decision.kind === "update" && decision.status === "done" && (!assertions.length || feature.lastValidatorStatus === "passed"); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index a031df6c57..ca3e4deace 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -7844,8 +7844,6 @@ export class SelfHealingManager extends SelfHealingGitEvidence { task.deletedAt != null || task.column === archivedColumn || task.column === doneColumn - || task.column === "archived" - || task.column === "done" ), taskPaused: task?.userPaused === true || task?.paused === true, live: live(item.taskId), diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index c42761d3ea..0dc3e17009 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -45,6 +45,7 @@ "packages/core/src/agents/live-agent-count.ts\u0000done": 1, "packages/core/src/async-stores/async-mission-store-queries.ts\u0000archived": 1, "packages/core/src/async-stores/async-mission-store-queries.ts\u0000done": 1, + "packages/core/src/async-stores/async-mission-store.ts\u0000archived": 1, "packages/core/src/eval/eval-signal-collector.ts\u0000archived": 1, "packages/core/src/missions/mission-store.ts\u0000archived": 1, "packages/core/src/store.ts\u0000archived": 1, @@ -63,6 +64,7 @@ "packages/core/src/task-store/task-update.ts\u0000in-review": 1, "packages/core/src/task-store/update-task-deps.ts\u0000archived": 1, "packages/core/src/task-store/update-task-deps.ts\u0000done": 1, + "packages/core/src/tasks/task-intake-owner-resolver.ts\u0000todo": 1, "packages/core/src/tasks/task-move-disposer.ts\u0000in-progress": 1, "packages/core/src/tasks/task-move-disposer.ts\u0000todo": 1, "packages/dashboard/app/components/command-center/liveSnapshotMetrics.ts\u0000in-progress": 1, @@ -71,6 +73,7 @@ "packages/dashboard/app/components/command-center/MissionControlPanel.tsx\u0000todo": 1, "packages/dashboard/app/components/DocumentsView.tsx\u0000archived": 1, "packages/dashboard/app/components/DocumentsView.tsx\u0000done": 1, + "packages/dashboard/app/components/MissionManager.tsx\u0000archived": 1, "packages/dashboard/app/components/RoutineEditor.tsx\u0000triage": 1, "packages/dashboard/app/components/ScheduleForm.tsx\u0000triage": 1, "packages/dashboard/app/components/ScheduleStepsEditor.tsx\u0000triage": 1, @@ -106,6 +109,8 @@ "packages/engine/src/execution/hold-release.ts\u0000done": 1, "packages/engine/src/execution/hold-release.ts\u0000in-review": 1, "packages/engine/src/executor/transition-review-addressing.ts\u0000in-progress": 1, + "packages/engine/src/missions/mission-feature-sync.ts\u0000archived": 1, + "packages/engine/src/missions/mission-state-reconcile.ts\u0000archived": 1, "packages/engine/src/project-engine.ts\u0000in-review": 1, "packages/engine/src/scheduler.ts\u0000todo": 1, "packages/engine/src/self-healing.ts\u0000archived": 1,