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`


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Phil Larson
2026-08-11 13:57:36 -07:00
committed by GitHub
parent 9f24a517cf
commit 6cc15fd73d
16 changed files with 366 additions and 53 deletions

View File

@@ -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.

View File

@@ -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),

View File

@@ -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 });
}
});
});

View File

@@ -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", () => {

View File

@@ -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:

View File

@@ -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<MissionFeature[]> {
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<MissionFeature[]> {
const rows = await handle

View File

@@ -111,6 +111,7 @@ import {
listFeaturesByIds,
listFeatures,
listFeaturesForMilestone,
listFeaturesForMission,
listAllFeatures,
updateFeature,
deleteFeature,
@@ -694,17 +695,11 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
}
private async getMissionBlockedDescriptorsWithHandle(handle: QueryHandle, missionId: string, lockStops = false): Promise<MissionBlockerDescriptor[]> {
const allFeatures = await listAllFeatures(handle);
const featureMission = new Map<string, string>();
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<MissionStoreEvents> {
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<MissionStoreEvents> {
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<MissionStoreEvents> {
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;

View File

@@ -3008,6 +3008,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
break;
}
let ownsFeature = false;
this.db.transaction(() => {
// Update the validator run
this.db.prepare(`
@@ -3027,11 +3028,16 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
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<MissionStoreEvents> {
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<MissionStoreEvents> {
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(`

View File

@@ -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.

View File

@@ -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 (
<section className="mission-detail__reconcile-panel" aria-label={t("missions.reconcilePreview", "Reconcile preview")}>
@@ -791,7 +797,7 @@ function MissionReconcilePreview({
<span>{t("missions.reconcileBadgeRepairs", "Badge repairs: {{count}}", { count: result.badgeRepairs })}</span>
<span>{t("missions.reconcileTerminalRepairs", "Terminal repairs: {{count}}", { count: result.terminalRepairs })}</span>
</div>
{result.skippedReason === "archived" ? <p>{t("missions.reconcileArchived", "Mission is archived — nothing reconciled")}</p>
{missionIsArchived ? <p>{t("missions.reconcileArchived", "Mission is archived — nothing reconciled")}</p>
: isEmpty ? <p>{t("missions.reconcileUpToDate", "Already up to date")}</p>
: <ul className="mission-detail__reconcile-list">{planned.map((entry) => (
<li key={`${entry.featureId}:${entry.action}`}>

View File

@@ -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 () => {

View File

@@ -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);

View File

@@ -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

View File

@@ -25,10 +25,14 @@ export function hasTerminalReconcileCapability(missionStore: unknown): missionSt
}
export type MissionReconcileExtensionHook = (context: { feature: MissionFeature; task?: Task }) => Promise<void> | 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<string, unknown> | 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<boolean> {
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<Task[]> }).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<string, Task | null>();
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");

View File

@@ -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),

View File

@@ -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,