FN-8948: reconcile mission state from task ground truth

Centralize mission and feature state repair around deterministic task lifecycle evidence.

- Add a reusable mission reconciliation authority with task-move, API, tool, and maintenance callers.
- Repair unambiguous title links while preserving terminal, validation, and audit safeguards.
- Route scheduler moves through the authority and reject duplicate feature-title ownership.
- Expose reconcile controls and document the operator workflow.

Files changed:
 .changeset/fn-8948-mission-auto-reconcile.md       |   7 +
 AGENTS.md                                          |   1 +
 docs/missions.md                                   |   8 +-
 packages/cli/skill/fusion/SKILL.md                 |   2 +-
 .../cli/skill/fusion/references/extension-tools.md |   9 +
 .../skill/fusion/references/fusion-capabilities.md |   1 +
 .../extension-experiment-finalize.test.ts          |   2 +
 .../__tests__/extension-gitlab-tracking.test.ts    |   2 +
 .../cli/src/__tests__/extension-web-fetch.test.ts  |   2 +
 packages/cli/src/extension.ts                      |  14 +
 packages/dashboard/src/mission-routes.ts           |  24 +-
 .../src/__tests__/agent-mission-tools.test.ts      |   2 +-
 .../engine/src/__tests__/mission-autopilot.test.ts |   8 +-
 .../src/__tests__/mission-state-reconcile.test.ts  |  67 +++++
 packages/engine/src/agent-tools.ts                 |   6 +
 .../engine/src/execution/gating-classifications.ts |   1 +
 packages/engine/src/index.ts                       |   6 +
 packages/engine/src/missions/index.ts              |   8 +-
 packages/engine/src/missions/mission-autopilot.ts  | 106 +------
 .../engine/src/missions/mission-feature-sync.ts    |   1 +
 .../engine/src/missions/mission-state-reconcile.ts | 169 +++++++++++
 packages/engine/src/runtimes/in-process-runtime.ts |   4 +-
 packages/engine/src/scheduler.ts                   | 331 +++++----------------
 packages/engine/src/util/run-audit.ts              |   2 +
 24 files changed, 426 insertions(+), 357 deletions(-)

Fusion-Task-Id: FN-8948
Fusion-Task-Lineage: 0fbccef3-eeac-46d2-b3d8-aca679b3657e
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-10 20:32:26 -07:00
parent 33d4fa46c5
commit cef07527f6
24 changed files with 425 additions and 356 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Reconcile mission feature state automatically from delivery task ground truth.
category: feature
dev: Adds fn_mission_reconcile, a scoped REST endpoint, and bounded reconcile audit events.

View File

@@ -276,6 +276,7 @@ Scoped exception (FN-5819/FN-8823): while project auto-merge is On, shared-branc
### Run Audit
- Store-open provenance: every `TaskStore.init()` emits `store:open` with ids/paths-only metadata (`pid`, `ppid`, `execPath`, `entry`, `cwd`, `nodeVersion`). Purpose: attribute shared-DB mutations to the process that opened the store (the FN-7910 Ideas-evacuation writer was unidentifiable without it). Tests reading unfiltered `runAuditEvents` must filter out `store:open` rather than assert exact counts.
- FN-8948: `mission:reconcile-pass` records a bounded automatic reconciliation result. Metadata contains optional mission ID, source enum, and scan/write/skip/conflict/failure counters only; never roadmap prose, titles, reasons, or secrets.
- FN-7158: agent performance reflections emit `reflection:generated`, `reflection:skipped`, and `reflection:failed` with ids/counts/outcomes-only metadata; never persist reflection prose or prompt text in run-audit.
- FN-7528: a deterministic, non-LLM post-task performance capture (`AgentReflectionService.captureTaskPerformance`) runs once per completed task and emits `reflection:captured` with ids/counts/outcomes-only metadata (`retryReworkCount?`, `filesTouchedCount?`, `packagesTouchedCount?`, `verificationFileScoped?`, `durationMs?`); never persists `verificationScopeReason` free-text or summary prose in run-audit.
- FN-7787: `createResolvedAgentSession` enriches `session:runtime-resolved` with `noModelResolved: true` and `runtimeBuiltInFallbackModel` when a non-mock/non-test session reaches runtime creation without a complete provider/model pair; this is a visibility signal for runtime built-in fallback usage, not a fabricated model-resolution verdict.

View File

@@ -755,7 +755,13 @@ See also: [Multi-Project](./multi-project.md) and [Task Management](./task-manag
Mission hierarchy operations are available with the same project-scoped `MissionStore` contract in the pi extension, engine-managed executor/triage/heartbeat agents, and provider-backed dashboard chat. The surface is `fn_mission_list`, `fn_mission_show`, `fn_mission_create`, `fn_mission_update`, `fn_mission_set_status`, `fn_mission_delete`, `fn_milestone_add`, `fn_milestone_update`, `fn_milestone_delete`, `fn_slice_add`, `fn_slice_activate`, `fn_slice_delete`, `fn_feature_add`, `fn_feature_update`, `fn_feature_set_status`, `fn_feature_repair_validation`, `fn_feature_delete`, and `fn_feature_link_task`.
`fn_mission_list` and `fn_mission_show` are positively classified read-only. All other hierarchy operations, including `fn_feature_repair_validation`, mutate persisted project data and remain subject to the engine action gate and permanent-agent permission policy; they are never treated as unknown or exempt tools.
`fn_mission_list` and `fn_mission_show` are positively classified read-only. All other hierarchy operations, including `fn_feature_repair_validation` and `fn_mission_reconcile`, mutate persisted project data and remain subject to the engine action gate and permanent-agent permission policy; they are never treated as unknown or exempt tools.
## Automatic mission reconciliation
The scheduler startup and self-healing maintenance passes, mission autopilot, task moves, and `fn_mission_reconcile({ id?, dryRun? })` use one idempotent reconciliation authority. `POST /api/missions/:missionId/reconcile` exposes the same pass; `dryRun: true` returns planned changes without mutation. Automatic writes are attributed to `mission-reconcile:<startup|self-healing|autopilot|task-move>` and API/tool calls retain their operator or agent actor.
Correction scans every non-archived mission and slice but never activates or triages work. It maps deterministic task lifecycle lanes, failure state, and assertion validation to feature status, repairs stale validation badges when the store supports its fenced repair primitive, and uses explicit task links only to reconcile shipped archived delivery through the store's `terminal-task-reconcile` attribution. A bounded `mission:reconcile-pass` audit event records IDs, source enums, and counters only. Git history, GitHub polling, FR-41 receipts, and FN-8845 spec-lock drift are deliberately deferred extension inputs.
For example, activate a ready work unit with `fn_slice_activate({ id: "SL-…" })`. Link it to live work with `fn_feature_link_task({ featureId: "F-…", taskId: "FN-…" })`. Linking delegates to `MissionStore.linkFeatureToTask()`: it verifies the task is a live row in the same project, changes the feature to `triaged`, and records the mission/slice linkage on the task. Archived, deleted, missing, and other-project tasks are rejected.

View File

@@ -30,7 +30,7 @@ Mission → Milestone → Slice → Feature → Task
- **Task tools** — `fn_task_create`, `fn_task_update`, `fn_task_list`, `fn_task_show`, `fn_task_logs_read`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`, `fn_task_retry`, `fn_task_bypass_review`, `fn_task_duplicate`, `fn_task_refine`, `fn_task_archive`, `fn_task_unarchive`, `fn_task_delete`, `fn_task_browse_gitlab_project_issues`, `fn_task_import_gitlab_project_issues`, `fn_task_browse_gitlab_group_issues`, `fn_task_import_gitlab_group_issues`, `fn_task_browse_gitlab_merge_requests`, `fn_task_import_gitlab_merge_requests`, `fn_task_plan`
- **Workflow tools** — `fn_workflow_list`, `fn_workflow_get`, `fn_workflow_validate`, `fn_workflow_create`, `fn_workflow_update`, `fn_workflow_delete`, `fn_workflow_settings`, `fn_trait_list`, `fn_workflow_select`, `fn_workflow_step_resume`
- **GitHub tools** — `fn_task_import_github`, `fn_task_import_github_issue`, `fn_task_browse_github_issues`
- **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_list_goals`, `fn_mission_link_goal`, `fn_mission_unlink_goal`, `fn_mission_backfill_assertions`, `fn_mission_delete`, `fn_mission_set_status`, `fn_mission_update`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_feature_delete`, `fn_slice_delete`, `fn_milestone_delete`, `fn_slice_activate`, `fn_feature_link_task`, `fn_feature_set_status`, `fn_feature_repair_validation`, `fn_feature_update`, `fn_milestone_update`
- **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_list_goals`, `fn_mission_link_goal`, `fn_mission_unlink_goal`, `fn_mission_backfill_assertions`, `fn_mission_delete`, `fn_mission_set_status`, `fn_mission_update`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_feature_delete`, `fn_slice_delete`, `fn_milestone_delete`, `fn_slice_activate`, `fn_feature_link_task`, `fn_feature_set_status`, `fn_mission_reconcile`, `fn_feature_repair_validation`, `fn_feature_update`, `fn_milestone_update`
- **Goal tools** — `fn_goal_list`, `fn_goal_create`, `fn_goal_archive`, `fn_goal_show`
- **Agent tools** — `fn_agent_stop`, `fn_agent_start`, `fn_agent_create`, `fn_agent_update`, `fn_agent_set_instructions`, `fn_agent_delete`, `fn_list_agents`, `fn_delegate_task`, `fn_agent_show`, `fn_agent_org_chart`
- **Skills tools** — `fn_skills_search`, `fn_skills_install`

View File

@@ -525,6 +525,15 @@ Set a feature lifecycle status.
| `status` | union | ✓ | |
| `reason` | string | — | |
### fn_mission_reconcile
Reconcile mission state against deterministic delivery ground truth.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | — | |
| `dryRun` | boolean | — | |
### fn_feature_repair_validation
Clear a stale validation badge or re-run validation.

View File

@@ -77,6 +77,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
| `fn_slice_activate` | Activate a pending slice for implementation. Sets status to 'active' and enables task linking for its features. |
| `fn_feature_link_task` | Link a feature to a fn task for implementation. Updates the feature status to 'triaged' and associates it with the task. If the target task is not on the active board (for example archived, deleted, or never created), the tool returns a clear validation error indicating that only active tasks can be linked. |
| `fn_feature_set_status` | Set a feature lifecycle status. |
| `fn_mission_reconcile` | Reconcile mission state against deterministic delivery ground truth. |
| `fn_feature_repair_validation` | Clear a stale validation badge or re-run validation. |
| `fn_feature_update` | Update an existing feature's title, description, or acceptance criteria. Partial patches leave untouched fields intact. |
| `fn_milestone_update` | Update an existing milestone's title, description, or acceptance criteria (the structured pass/fail bar, distinct from verification's free-form how-to-confirm notes). Partial patches leave untouched fields intact. |

View File

@@ -90,6 +90,8 @@ vi.mock("@fusion/engine", () => ({
fetchWebContent: vi.fn(),
// FNXC:MissionValidationRepair 2026-08-11-02:35: FN-8947 adds feature-repair target resolution to extension.ts, so isolated engine mocks must export it.
resolveFeatureRepairTargets: vi.fn(),
// FNXC:MissionAutoReconcile 2026-08-11-03:27: FN-8948 exposes the mission reconciliation authority through extension.ts.
reconcileMissionState: vi.fn(),
emitGoalRetrievalAudit: vi.fn(),
createWorkflowAuthoringTools: vi.fn(() => ({})),
workflowListParams: {},

View File

@@ -45,6 +45,8 @@ vi.mock("@fusion/engine", () => ({
fetchWebContent: vi.fn(),
// FNXC:MissionValidationRepair 2026-08-11-02:35: FN-8947 adds feature-repair target resolution to extension.ts, so isolated engine mocks must export it.
resolveFeatureRepairTargets: vi.fn(),
// FNXC:MissionAutoReconcile 2026-08-11-03:27: FN-8948 exposes the mission reconciliation authority through extension.ts.
reconcileMissionState: vi.fn(),
assertNoSecretPlaintext: vi.fn(),
emitGoalRetrievalAudit: vi.fn(),
createWorkflowAuthoringTools: vi.fn(() => ({})),

View File

@@ -24,6 +24,8 @@ vi.mock("@fusion/engine", () => ({
fetchWebContent: fetchWebContentMock,
// FNXC:MissionValidationRepair 2026-08-11-02:35: FN-8947 adds feature-repair target resolution to extension.ts, so isolated engine mocks must export it.
resolveFeatureRepairTargets: vi.fn(),
// FNXC:MissionAutoReconcile 2026-08-11-03:27: FN-8948 exposes the mission reconciliation authority through extension.ts.
reconcileMissionState: vi.fn(),
emitGoalRetrievalAudit: vi.fn(),
createWorkflowAuthoringTools: vi.fn(() => ({})),
workflowListParams: {},

View File

@@ -85,6 +85,7 @@ import {
evaluateAgentActionGate,
resolveGateOutcome,
resolveFeatureRepairTargets,
reconcileMissionState,
} from "@fusion/engine";
import * as dashboard from "@fusion/dashboard";
import { resolve, relative, isAbsolute, sep, basename, extname, join } from "node:path";
@@ -5028,6 +5029,19 @@ export default function kbExtension(pi: ExtensionAPI) {
},
});
// ── fn_mission_reconcile ─────────────────────────────────────────
pi.registerTool({
name: "fn_mission_reconcile", label: "fn: Reconcile Mission", description: "Reconcile mission state against deterministic delivery ground truth.",
parameters: Type.Object({ id: Type.Optional(Type.String()), dryRun: Type.Optional(Type.Boolean()) }),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
try {
const store = await getStore(ctx.cwd);
const result = await reconcileMissionState({ taskStore: store, missionStore: store.getMissionStore() }, { missionId: params.id, dryRun: params.dryRun === true, source: "tool", actor: missionTransitionActor(ctx) });
return { content: [{ type: "text", text: `Reconciled ${params.id ?? "project"}` }], details: result };
} catch (error) { const message = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: message }], isError: true, details: { error: message } }; }
},
});
// ── fn_feature_repair_validation ──────────────────────────────────
/* FNXC:MissionValidationRepair 2026-08-10-17:20: CLI mirrors the engine tool so either agent surface performs the same fenced, single-retry repair rather than bypassing store validation. */
pi.registerTool({

View File

@@ -28,7 +28,7 @@ import {
RepairValidatorRunInFlightError,
} from "@fusion/core";
import type { AsyncMissionStore, Goal, Settings, ThinkingLevel } from "@fusion/core";
import { resolveFeatureRepairTargets, resolvePlanningThinkingLevel } from "@fusion/engine";
import { hasTerminalReconcileCapability, reconcileMissionState, resolveFeatureRepairTargets, resolvePlanningThinkingLevel } from "@fusion/engine";
import {
getScopedStore as resolveScopedRequestStore,
getProjectContext as resolveSharedProjectContext,
@@ -2900,6 +2900,26 @@ export function createMissionRouter(
})
);
/**
* POST /api/missions/:missionId/reconcile
* Reconcile deterministic delivery ground truth without requiring a PostgreSQL-only capability.
*/
router.post(
"/:missionId/reconcile",
catchTypedHandler(async (req, res) => {
const { missionId } = req.params;
if (!validateMissionId(missionId)) throw badRequest("Invalid mission ID format");
const { store: scopedStore } = await getProjectContext(req);
const missionStore = scopedStore.getMissionStore();
if (!await missionStore.getMission(missionId)) throw notFound("Mission not found");
const result = await reconcileMissionState(
{ taskStore: scopedStore, missionStore },
{ missionId, source: "api", actor: DASHBOARD_MISSION_ACTOR, dryRun: req.body?.dryRun === true },
);
res.json(result);
}),
);
/**
* POST /api/missions/features/:featureId/reconcile-done
* Reconcile feature completion against a shipped delivery task.
@@ -2921,7 +2941,7 @@ export function createMissionRouter(
const normalizedTaskId = taskId.trim();
const { store: scopedStore } = await getProjectContext(req);
const scopedMissionStore = scopedStore.getMissionStore();
if (!("reconcileFeatureDoneWithTerminalTask" in scopedMissionStore)) {
if (!hasTerminalReconcileCapability(scopedMissionStore)) {
throw internalError("Terminal-task reconciliation requires the PostgreSQL mission store");
}

View File

@@ -12,7 +12,7 @@ describe("createMissionTools", () => {
it("exposes the complete hierarchy surface with read and mutation names", () => {
const store = { getMissionStore: vi.fn() } as never;
expect(createMissionTools(store).map((tool) => tool.name)).toEqual([
"fn_mission_list", "fn_mission_show", "fn_mission_create", "fn_mission_update", "fn_mission_set_status", "fn_mission_delete",
"fn_mission_list", "fn_mission_show", "fn_mission_create", "fn_mission_update", "fn_mission_set_status", "fn_mission_delete", "fn_mission_reconcile",
"fn_milestone_add", "fn_milestone_update", "fn_milestone_delete", "fn_slice_add", "fn_slice_activate",
"fn_slice_delete", "fn_feature_add", "fn_feature_update", "fn_feature_repair_validation", "fn_feature_set_status", "fn_feature_delete", "fn_feature_link_task", "fn_research_promote_finding",
]);

View File

@@ -854,7 +854,7 @@ describe("MissionAutopilot", () => {
await (autopilot as any).runHealthCheck();
expect(missionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done");
expect(missionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done", expect.objectContaining({ actor: expect.objectContaining({ source: "mission-reconcile:autopilot" }) }));
autopilot.stop();
});
@@ -909,7 +909,7 @@ describe("MissionAutopilot", () => {
await (autopilot as any).runHealthCheck();
expect(missionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "in-progress");
expect(missionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "in-progress", expect.objectContaining({ actor: expect.objectContaining({ source: "mission-reconcile:autopilot" }) }));
autopilot.stop();
});
@@ -930,7 +930,7 @@ describe("MissionAutopilot", () => {
await (autopilot as any).runHealthCheck();
expect(missionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "triaged");
expect(missionStore.updateFeatureStatus).toHaveBeenCalledWith("F-001", "triaged", expect.objectContaining({ actor: expect.objectContaining({ source: "mission-reconcile:autopilot" }) }));
autopilot.stop();
});
@@ -1299,7 +1299,7 @@ describe("MissionAutopilot", () => {
await ap.recoverMissions(store as any);
expect(store.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done");
expect(store.updateFeatureStatus).toHaveBeenCalledWith("F-001", "done", expect.objectContaining({ actor: expect.objectContaining({ source: "mission-reconcile:autopilot" }) }));
});
it("advances slices when active slice features are already done", async () => {

View File

@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
import { reconcileMissionState } from "../missions/mission-state-reconcile.js";
describe("reconcileMissionState", () => {
it("retains the orthogonal alignment projection when lifecycle status is already current", 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 updateFeature = vi.fn().mockResolvedValue(feature);
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(),
updateFeature,
};
const taskStore = {
listTasks: vi.fn().mockResolvedValue([task]),
getTask: vi.fn().mockResolvedValue(task),
getLatestSpecDriftReport: vi.fn().mockResolvedValue({ alignment: "diverged-needs-review" }),
};
await reconcileMissionState({ taskStore: taskStore as never, missionStore }, { source: "self-healing" });
expect(missionStore.updateFeatureStatus).not.toHaveBeenCalled();
expect(updateFeature).toHaveBeenCalledWith(
"F-1",
{ specAlignment: "diverged-needs-review" },
{ actor: { type: "system", id: "mission-reconcile", source: "mission-reconcile:self-healing" } },
);
});
it("does not title-link a task when duplicate features make ownership ambiguous", 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 features = ["F-1", "F-2"].map((id) => ({
id, 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 }] }],
}),
linkFeatureToTask,
updateFeatureStatus: vi.fn(),
};
const taskStore = {
listTasks: vi.fn().mockResolvedValue([task]),
getLatestSpecDriftReport: vi.fn().mockResolvedValue(undefined),
};
await reconcileMissionState({ taskStore: taskStore as never, missionStore }, { source: "task-move" });
expect(linkFeatureToTask).not.toHaveBeenCalled();
});
});

View File

@@ -35,6 +35,7 @@ import { recordRetry } from "./errors/retry-burned-logger.js";
import { acquireWorkspaceRepoWorktree, WorkspaceRepoAcquireBusyError } from "./worktree/worktree-acquisition.js";
import { validateCodeNodeSources } from "./execution/code-node-runner.js";
import { resolveFeatureRepairTargets } from "./missions/mission-feature-sync.js";
import { reconcileMissionState } from "./missions/mission-state-reconcile.js";
// ── Tool parameter schemas (canonical definitions) ────────────────────────
@@ -4378,6 +4379,7 @@ export const missionCreateParams = Type.Object({
export const missionUpdateParams = Type.Object({ id: Type.String(), title: Type.Optional(Type.String()), description: Type.Optional(Type.String()) });
export const missionDeleteParams = Type.Object({ id: Type.String() });
export const missionSetStatusParams = Type.Object({ id: Type.String(), status: Type.Union(fusionCore.MISSION_STATUSES.map((status) => Type.Literal(status))), reason: Type.Optional(Type.String()) });
export const missionReconcileParams = Type.Object({ id: Type.Optional(Type.String()), dryRun: Type.Optional(Type.Boolean()) });
export const milestoneAddParams = Type.Object({ missionId: Type.String(), title: Type.String(), description: Type.Optional(Type.String()) });
export const milestoneUpdateParams = Type.Object({ id: Type.String(), title: Type.Optional(Type.String()), description: Type.Optional(Type.String()), acceptanceCriteria: Type.Optional(Type.String()) });
export const milestoneDeleteParams = Type.Object({ milestoneId: Type.String(), force: Type.Optional(Type.Boolean()) });
@@ -4526,6 +4528,10 @@ export function createMissionTools(store: TaskStore, context: MissionToolActorCo
return missionToolResult(`Set ${mission.id} status to ${mission.status}`, { mission });
}),
tool("fn_mission_delete", "Delete Mission", "Delete a mission and its hierarchy.", missionDeleteParams, async ({ id }) => { await store.getMissionStore().deleteMission(id); return missionToolResult(`Deleted ${id}`, { missionId: id }); }),
tool("fn_mission_reconcile", "Reconcile Mission", "Reconcile mission state against deterministic delivery ground truth.", missionReconcileParams, async (p) => {
const result = await reconcileMissionState({ taskStore: store, missionStore: store.getMissionStore() }, { missionId: p.id, dryRun: p.dryRun === true, source: "tool", actor });
return missionToolResult(`Reconciled ${p.id ?? "project"}`, result as unknown as Record<string, unknown>);
}),
tool("fn_milestone_add", "Add Milestone", "Add a milestone to a mission.", milestoneAddParams, async (p) => { const milestone = await store.getMissionStore().addMilestone(p.missionId, { title: p.title.trim(), description: optionalText(p.description) }); return missionToolResult(`Added ${milestone.id}`, { milestone }); }),
tool("fn_milestone_update", "Update Milestone", "Partially update a milestone.", milestoneUpdateParams, async (p) => { const updates = updateFields(p, ["title", "description", "acceptanceCriteria"]); if (!Object.keys(updates).length) return missionToolResult("No fields to update", {}, true); const milestone = await store.getMissionStore().updateMilestone(p.id, updates); return missionToolResult(`Updated ${milestone.id}`, { milestone }); }),
tool("fn_milestone_delete", "Delete Milestone", "Delete a milestone and descendants.", milestoneDeleteParams, async (p) => { await store.getMissionStore().deleteMilestone(p.milestoneId, p.force === true); return missionToolResult(`Deleted ${p.milestoneId}`, { milestoneId: p.milestoneId }); }),

View File

@@ -95,6 +95,7 @@ const PERMANENT_TASK_AGENT_ONLY_TOOLS = [
"fn_mission_delete",
"fn_mission_update",
"fn_mission_set_status",
"fn_mission_reconcile",
"fn_mission_backfill_assertions",
"fn_milestone_add",
"fn_slice_add",

View File

@@ -339,6 +339,12 @@ export { MeshLeaseManager, type MeshLeaseManagerOptions, type LeaseRecoveryConte
export { MissionAutopilot, type MissionAutopilotOptions } from "./missions/mission-autopilot.js";
export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./missions/mission-execution-loop.js";
export { resolveFeatureRepairTargets } from "./missions/mission-feature-sync.js";
export {
reconcileMissionState,
hasTerminalReconcileCapability,
type MissionReconcileSource,
type MissionReconcilePassResult,
} from "./missions/mission-state-reconcile.js";
// FNXC:MergerUnification 2026-06-22-00:00: @deprecated must sit on aiMergeTask's own
// export so IDE/type-aware tooling flags only aiMergeTask, not the helpers it shares with
// runAiMerge (those are NOT deprecated). A single @deprecated on the multi-member block

View File

@@ -2,4 +2,10 @@
* FNXC:CodeOrganization 2026-07-22-18:00:
* Domain folder for engine/missions. Import modules by path.
*/
export {};
export {
reconcileMissionState,
hasTerminalReconcileCapability,
type MissionReconcileSource,
type MissionReconcilePassResult,
type ReconcileMissionStateDeps,
} from "./mission-state-reconcile.js";

View File

@@ -20,6 +20,7 @@
*/
import { AsyncMissionStore, resolveTaskLifecycleColumns } from "@fusion/core";
import { reconcileMissionState } from "./mission-state-reconcile.js";
import type {
TaskStore,
MissionStore,
@@ -49,9 +50,7 @@ import type {
*/
type AutopilotMissionStore = MissionStore | AsyncMissionStore;
import { autopilotLog } from "../logger.js";
import { persistMissionFeatureReconciliation, reconcileMissionFeatureState } from "./mission-feature-sync.js";
import { isOperatorActionableAgentError } from "../errors/transient-error-detector.js";
import { resolvePlannerLanesForTask } from "../planner-lane-resolution.js";
/** Maximum retry attempts for slice activation failures. */
const MAX_RETRY_ATTEMPTS = 3;
@@ -910,95 +909,22 @@ export class MissionAutopilot {
private async reconcileMissionConsistency(
mission: MissionWithHierarchy,
): Promise<number> {
if (!mission) {
return 0;
if (!mission) return 0;
const completeSlices = mission.milestones.flatMap((milestone) => milestone.slices).filter((slice) => slice.status === "complete");
if (completeSlices.some((slice) => slice.features.some((feature) => feature.status !== "done"))) {
await this.recomputeMissionStatusChain(mission.id);
return 1;
}
const activeSlices = mission.milestones
.flatMap((milestone) => milestone.slices)
.filter((slice) => slice.status === "active");
const completeSlices = mission.milestones
.flatMap((milestone) => milestone.slices)
.filter((slice) => slice.status === "complete");
// Process complete slices: check for stale "defined" features.
// Features with status "defined" should never exist in a "complete" slice.
// If found, recompute the status chain to fix the stale state.
if (completeSlices.length > 0) {
for (const slice of completeSlices) {
const definedFeatures = slice.features.filter((f) => f.status !== "done");
if (definedFeatures.length > 0) {
autopilotLog.warn(
`Slice ${slice.id} is marked complete but has ${definedFeatures.length} feature(s) not done; recomputing status chain`,
{
missionId: mission.id,
sliceId: slice.id,
definedFeatureIds: definedFeatures.map((f) => f.id),
},
);
await this.logMissionEventSafe(
mission.id,
"error",
`Slice ${slice.id} has stale "complete" status (${definedFeatures.length} feature(s) not done); recomputing status chain`,
{ source: "reconcileMissionConsistency" },
);
await this.recomputeMissionStatusChain(mission.id);
// One recompute is sufficient for all complete slices; break after first hit
return 1;
}
}
const result = await reconcileMissionState({ taskStore: this.taskStore, missionStore: this.missionStore }, {
missionId: mission.id,
source: "autopilot",
});
for (const feature of mission.milestones.flatMap((milestone) => milestone.slices).flatMap((slice) => slice.features)) {
if (!feature.taskId) continue;
const task = await this.taskStore.getTask(feature.taskId);
if (task?.status === "failed" || task?.error) await this.handleTaskFailure(feature.taskId);
}
if (activeSlices.length === 0) {
return 0;
}
let fixedCount = 0;
for (const slice of activeSlices) {
for (const feature of slice.features) {
if (!feature.taskId) {
continue;
}
const task = await this.taskStore.getTask(feature.taskId);
if (!task) {
continue;
}
const hasLinkedAssertions = typeof this.missionStore.listAssertionsForFeature === "function"
? (await this.missionStore.listAssertionsForFeature(feature.id)).length > 0
: false;
const reconciliation = await reconcileMissionFeatureState(this.taskStore, task, feature, {
hasLinkedAssertions,
plannerColumns: await resolvePlannerLanesForTask(this.taskStore as never, task.id),
});
/*
FNXC:SpecLockMissionAlignment 2026-08-10-16:17:
Persist the evaluator's orthogonal projection on every reconciliation outcome. Previously
this loop calculated alignment only to discard it, leaving the mission roadmap stale when
delivery status did not also need a transition.
*/
if (await persistMissionFeatureReconciliation(this.missionStore, feature, reconciliation)) {
fixedCount++;
}
if (reconciliation.kind === "failure") {
await this.handleTaskFailure(feature.taskId);
fixedCount++;
continue;
}
if (reconciliation.kind === "blocked") {
autopilotLog.warn(`Skipping feature ${feature.id} reconciliation — ${reconciliation.reason}`);
continue;
}
}
}
return fixedCount;
return result.statusUpdates + result.badgeRepairs + result.terminalRepairs;
}
// ── Helpers ────────────────────────────────────────────────────────

View File

@@ -42,6 +42,7 @@ export async function resolveMissionFeatureAlignment(
): Promise<DriftAlignment> {
if (!taskId) return "unavailable";
try {
if (typeof taskStore.getLatestSpecDriftReport !== "function") return "unavailable";
return projectMissionFeatureAlignment(await taskStore.getLatestSpecDriftReport(taskId));
} catch {
return "unavailable";

View File

@@ -0,0 +1,169 @@
import type { MissionFeature, MissionFeatureRepairGroundTruth, MissionTransitionActor, Task, TaskStore } from "@fusion/core";
import { TerminalTaskReconciliationError, resolveLifecycleColumns, resolveWorkflowIrForTask } from "@fusion/core";
import { createRunAuditor, generateSyntheticRunId } from "../util/run-audit.js";
import { resolvePlannerLanesForTask } from "../planner-lane-resolution.js";
import { reconcileMissionFeatureState } from "./mission-feature-sync.js";
export type MissionReconcileSource = "startup" | "self-healing" | "autopilot" | "task-move" | "api" | "tool";
type TerminalCapability = { reconcileFeatureDoneWithTerminalTask(featureId: string, taskId: string): Promise<MissionFeature> };
type RepairCapability = { repairFeatureValidationState(featureId: string, input: Record<string, unknown>): Promise<unknown> };
type MissionApi = {
getMission?(id: string): Promise<unknown>; listMissions(): Promise<unknown[]>; getMissionWithHierarchy(id: string): Promise<unknown>;
listAssertionsForFeature?(id: string): Promise<unknown[]>; updateFeatureStatus(id: string, status: string, options: unknown): Promise<unknown>;
updateFeature?(id: string, updates: Partial<MissionFeature>, options?: unknown): Promise<MissionFeature>;
linkFeatureToTask?(featureId: string, taskId: string): Promise<MissionFeature>;
};
/*
FNXC:MissionAutoReconcile 2026-08-11-02:39:
The dashboard mission-store Proxy has only a get trap over an empty target, so `in` and hasOwn
report optional methods as absent. A property-read typeof probe works for that Proxy and concrete
stores, preventing terminal archived delivery repair from being silently disabled.
*/
export function hasTerminalReconcileCapability(missionStore: unknown): missionStore is TerminalCapability {
return typeof (missionStore as Record<string, unknown> | null | undefined)?.reconcileFeatureDoneWithTerminalTask === "function";
}
export type MissionReconcileExtensionHook = (context: { feature: MissionFeature; task?: Task }) => Promise<void> | void;
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" }>;
}
export interface ReconcileMissionStateDeps {
taskStore: TaskStore; missionStore: object;
repairFeatureValidationState?: (featureId: string, input: Record<string, unknown>) => Promise<unknown>;
extensionHook?: MissionReconcileExtensionHook;
}
function actorFor(source: MissionReconcileSource, supplied?: MissionTransitionActor): MissionTransitionActor {
if (supplied) return supplied;
if (source === "api" || source === "tool") throw new Error(`Mission reconcile source '${source}' requires an explicit actor`);
return { type: "system", id: "mission-reconcile", source: `mission-reconcile:${source}` };
}
function titleKey(sliceId: string, title: string): string { return `${sliceId}\0${title.trim().replace(/\s+/g, " ").toLowerCase()}`; }
function hasRepairCapability(store: unknown): store is RepairCapability {
return typeof (store as Record<string, unknown> | null | undefined)?.repairFeatureValidationState === "function";
}
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);
return ir ? resolveLifecycleColumns(ir)?.archived === task.column : false;
}
function liveGroundTruth(feature: MissionFeature, task: Task, laneRole: "wip" | "planner"): MissionFeatureRepairGroundTruth {
return { featureId: feature.id, taskId: task.id, taskLiveness: "live", taskColumn: task.column, taskUpdatedAt: task.updatedAt, laneRole, resolvedAt: new Date().toISOString() };
}
/*
FNXC:MissionAutoReconcile 2026-08-11-02:39:
This is the single deterministic reconciliation authority. Future git graph, GitHub PR, FR-41
receipt, and FR-51/FN-8845 drift inputs attach through extensionHook rather than adding writers.
The terminal store primitive deliberately owns its terminal-task-reconcile actor and has no actor
parameter; preserve that attribution exception instead of widening its transaction contract.
*/
export async function reconcileMissionState(
deps: ReconcileMissionStateDeps,
options: { missionId?: string; source: MissionReconcileSource; actor?: MissionTransitionActor; dryRun?: boolean },
): Promise<MissionReconcilePassResult> {
const result: MissionReconcilePassResult = { missionsScanned: 0, featuresScanned: 0, statusUpdates: 0, badgeRepairs: 0, badgeRepairsSkipped: 0, terminalRepairs: 0, terminalSkipped: 0, conflicts: 0, failures: 0, ...(options.dryRun ? { planned: [] } : {}) };
const actor = actorFor(options.source, options.actor);
const missionApi = deps.missionStore as MissionApi;
const missions = options.missionId ? [missionApi.getMission ? await missionApi.getMission(options.missionId) : await missionApi.getMissionWithHierarchy(options.missionId)].filter(Boolean) : await missionApi.listMissions();
const requested = missions[0] as { status?: string } | undefined;
if (options.missionId && !requested) throw new Error(`Mission ${options.missionId} not found`);
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 }) : [];
const selectedIds = new Set(selected.map((mission) => mission.id));
const byTitle = new Map<string, Task | null>();
for (const task of liveTasks) {
if (!task.sliceId || !task.title || !task.missionId || !selectedIds.has(task.missionId)) continue;
const key = titleKey(task.sliceId, task.title);
byTitle.set(key, byTitle.has(key) ? null : task);
}
for (const mission of selected) {
const hierarchy = await missionApi.getMissionWithHierarchy(mission.id) as { milestones: Array<{ slices: Array<{ id: string; features: MissionFeature[] }> }> } | undefined;
if (!hierarchy) continue;
result.missionsScanned++;
for (const slice of hierarchy.milestones.flatMap((milestone) => milestone.slices)) {
const featureTitleCounts = new Map<string, number>();
for (const feature of slice.features) {
const key = titleKey(slice.id, feature.title);
featureTitleCounts.set(key, (featureTitleCounts.get(key) ?? 0) + 1);
}
for (const originalFeature of slice.features) {
result.featuresScanned++;
try {
let feature = originalFeature;
const explicitTaskId = feature.taskId;
/* FNXC:MissionAutoReconcile 2026-08-11-02:39: A generated remediation detached by supersedence is terminal, never a title-link candidate. */
if (!explicitTaskId && feature.status === "done" && (feature.generatedFromFeatureId || feature.generatedFromRunId)) continue;
const task = explicitTaskId ? await deps.taskStore.getTask(explicitTaskId) ?? undefined : byTitle.get(titleKey(slice.id, feature.title)) ?? undefined;
if (!explicitTaskId && task && missionApi.linkFeatureToTask && featureTitleCounts.get(titleKey(slice.id, feature.title)) === 1) {
/*
FNXC:MissionAutoReconcile 2026-08-11-03:27:
Automatic title repair is safe only when exactly one live task and one feature share the
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" });
else feature = await missionApi.linkFeatureToTask(feature.id, task.id);
}
const terminalCandidate = Boolean(explicitTaskId && task && await isArchivedTask(deps.taskStore, task));
if (!terminalCandidate && task) {
const assertions = missionApi.listAssertionsForFeature ? await missionApi.listAssertionsForFeature(feature.id) : [];
const plannerColumns = await resolvePlannerLanesForTask(deps.taskStore, task.id) ?? [];
const decision = await reconcileMissionFeatureState(deps.taskStore, task, feature, { hasLinkedAssertions: assertions.length > 0, plannerColumns });
const needsRepair = feature.status === "blocked" || feature.loopState === "blocked" || feature.loopState === "needs_fix";
if (decision.kind === "update" && feature.status !== decision.status) {
if (options.dryRun) result.planned!.push({ featureId: feature.id, action: "status" });
else { await missionApi.updateFeatureStatus(feature.id, decision.status, { actor }); result.statusUpdates++; }
}
/*
FNXC:MissionAutoReconcile 2026-08-11-03:10:
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.
*/
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 });
}
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");
if (needsRepair && (complete || wip) && !task.error && task.status !== "failed") {
const repair = deps.repairFeatureValidationState ?? (hasRepairCapability(deps.missionStore) ? deps.missionStore.repairFeatureValidationState.bind(deps.missionStore) : undefined);
if (!repair) result.badgeRepairsSkipped++;
else if (options.dryRun) result.planned!.push({ featureId: feature.id, action: "badge-clear" });
else {
try {
await repair(feature.id, { action: "clear", actor, ...(feature.status === "blocked" && wip ? { resolvedStatus: "in-progress", groundTruth: liveGroundTruth(feature, task, "wip") } : {}) });
result.badgeRepairs++;
} catch { result.conflicts++; }
}
}
await deps.extensionHook?.({ feature, task });
continue;
}
// Only explicit links may establish terminal evidence; title matching never fabricates completion.
if (explicitTaskId && !(feature.status === "done" && feature.taskId === explicitTaskId)) {
const terminal = hasTerminalReconcileCapability(deps.missionStore) ? deps.missionStore.reconcileFeatureDoneWithTerminalTask.bind(deps.missionStore) : undefined;
if (!terminal) result.terminalSkipped++;
else if (options.dryRun) result.planned!.push({ featureId: feature.id, action: "terminal-done" });
else try { await terminal(feature.id, explicitTaskId); result.terminalRepairs++; }
catch (error) {
if (error instanceof TerminalTaskReconciliationError) {
if (error.code === "FEATURE_TASK_CONFLICT" || error.code === "TASK_FEATURE_CONFLICT") result.conflicts++; else result.terminalSkipped++;
} else result.failures++;
}
}
} catch { result.failures++; }
}
}
}
if (!options.dryRun && result.featuresScanned > 0 && (result.statusUpdates + result.badgeRepairs + result.terminalRepairs + result.failures > 0)) try {
const auditor = createRunAuditor(deps.taskStore, { runId: generateSyntheticRunId("mission-reconcile", options.missionId ?? "project"), agentId: "mission-reconcile", phase: "mission-reconcile" });
await auditor.database({ type: "mission:reconcile-pass", target: options.missionId ?? "project", metadata: { ...(options.missionId ? { missionId: options.missionId } : {}), source: options.source, missionsScanned: result.missionsScanned, featuresScanned: result.featuresScanned, statusUpdates: result.statusUpdates, badgeRepairs: result.badgeRepairs, badgeRepairsSkipped: result.badgeRepairsSkipped, terminalRepairs: result.terminalRepairs, terminalSkipped: result.terminalSkipped, conflicts: result.conflicts, failures: result.failures } });
} catch { /* audit is observability only */ }
return result;
}

View File

@@ -1797,7 +1797,7 @@ export class InProcessRuntime
}
return this.missionExecutionLoop.reapStaleValidatorRuns(VALIDATOR_RUN_STALE_MAX_AGE_MS);
},
reconcileAllMissionFeatures: async () => this.scheduler.reconcileAllMissionFeatures(),
reconcileAllMissionFeatures: async () => this.scheduler.reconcileAllMissionFeatures("self-healing"),
chatStore: this.chatStore,
messageStore: this.messageStore,
restartDurableAgentHeartbeat: async (agentId: string, context: { reason: string; attempt: number }) => {
@@ -1906,7 +1906,7 @@ export class InProcessRuntime
// 13. Reconcile feature status for all active missions (not just autopilot)
if (activeMissionStore) {
void this.scheduler.reconcileAllMissionFeatures();
void this.scheduler.reconcileAllMissionFeatures("startup");
}
// 14. Start MissionAutopilot background polling

View File

@@ -30,8 +30,8 @@ import { planTaskWorktreePath, resolveTaskWorkingBranch } from "./worktree/workt
import { schedulerLog } from "./logger.js";
import { createRepeatSuppressedLog } from "./util/repeat-suppressed-log.js";
import { type PrMonitor, type PrComment } from "./merge/pr-monitor.js";
import { persistMissionFeatureReconciliation, reconcileMissionFeatureState } from "./missions/mission-feature-sync.js";
import { resolveDedicatedPlannerColumnsForTask, resolvePlannerLanesForTask } from "./planner-lane-resolution.js";
import { reconcileMissionState, type MissionReconcileSource } from "./missions/mission-state-reconcile.js";
import { resolveDedicatedPlannerColumnsForTask } from "./planner-lane-resolution.js";
import { evaluateSpecStaleness, getPromptPath } from "./execution/spec-staleness.js";
import { resolveEffectiveNode, type EffectiveNode } from "./project/effective-node.js";
import { applyUnavailableNodePolicy, decideOwningNodeHandoff } from "./project/node-routing-policy.js";
@@ -3141,7 +3141,16 @@ export class Scheduler {
return;
}
const feature = await this.resolveMissionFeatureForTask(missionStore, task);
/*
FNXC:MissionAutoReconcile 2026-08-11-03:27:
Task moves must enter the reconciliation authority before title-based ownership repair. The
authority rejects ambiguous titles within a slice, so duplicate feature titles cannot make a
move attach its task to whichever unlinked feature happened to be listed first.
*/
if (task.missionId) {
await reconcileMissionState({ taskStore: this.store, missionStore }, { missionId: task.missionId, source: "task-move" });
}
const feature = await resolveMissionFeatureForTask(missionStore, task);
if (!feature) {
/*
FNXC:EngineDiagnostics 2026-08-03-05:54:
@@ -3159,42 +3168,22 @@ export class Scheduler {
return;
}
const hasLinkedAssertions = (await missionStore.listAssertionsForFeature(feature.id)).length > 0;
const reconciliation = await reconcileMissionFeatureState(
this.store,
{ ...task, column: toColumn },
feature,
{
hasLinkedAssertions,
plannerColumns: await resolvePlannerLanesForTask(this.store, task.id),
},
);
const sliceIdBeforeUpdate = feature.sliceId;
const featureSlice = await missionStore.getSlice(feature.sliceId);
const milestone = featureSlice ? await missionStore.getMilestone(featureSlice.milestoneId) : undefined;
const missionId = task.missionId ?? milestone?.missionId;
if (!missionId) {
schedulerLog.warn(`Task ${taskId} feature ${feature.id} has no resolvable mission; skipping reconciliation`);
return;
}
/*
FNXC:SpecLockMissionAlignment 2026-08-10-16:17:
Scheduler move reconciliation is a production consumer of deterministic drift. Persist its
orthogonal projection even when delivery status is unchanged, or the evaluated alignment is
discarded before Mission Manager can render the roadmap's actual state.
FNXC:MissionAutoReconcile 2026-08-11-02:39:
Live moves use the same authority as maintenance rather than retaining a second direct
writer. This keeps task-move attribution and validation-badge repair identical to every
other deterministic ground-truth projection.
*/
if (await persistMissionFeatureReconciliation(missionStore, feature, reconciliation)) {
if (reconciliation.kind === "update") {
schedulerLog.log(
`Feature ${feature.id} marked ${reconciliation.status} (${reconciliation.reason})`,
);
}
}
if (reconciliation.kind === "blocked") {
schedulerLog.warn(`Task ${taskId} mission update blocked — ${reconciliation.reason}`);
return;
}
if (reconciliation.kind === "failure") {
schedulerLog.warn(`Task ${taskId} mission update reported failure — ${reconciliation.reason}`);
return;
if (missionId !== task.missionId) {
await reconcileMissionState({ taskStore: this.store, missionStore }, { missionId, source: "task-move" });
}
/*
@@ -3225,38 +3214,6 @@ export class Scheduler {
}
}
private async resolveMissionFeatureForTask(missionStore: MissionStore | AsyncMissionStore, task: Task): Promise<MissionFeature | undefined> {
const linkedFeature = await resolveMissionFeatureForTask(missionStore, task);
if (linkedFeature) {
return linkedFeature;
}
if (!task.sliceId || !task.title) {
return undefined;
}
const normalizedTaskTitle = this.normalizeMissionFeatureTitle(task.title);
const matchingFeature = (await missionStore
.listFeatures(task.sliceId))
.find((feature) =>
!feature.taskId
&& this.normalizeMissionFeatureTitle(feature.title) === normalizedTaskTitle
);
if (!matchingFeature) {
return undefined;
}
schedulerLog.warn(
`Repairing one-way mission link: task ${task.id} matched unlinked feature ${matchingFeature.id}`,
);
return missionStore.linkFeatureToTask(matchingFeature.id, task.id);
}
private normalizeMissionFeatureTitle(title: string): string {
return title.trim().replace(/\s+/g, " ").toLowerCase();
}
/**
* Handle mission task completion.
* When a task moves to "done", advance mission execution after the linked
@@ -3401,202 +3358,70 @@ export class Scheduler {
*
* @returns The total number of fixes applied across all missions
*/
async reconcileAllMissionFeatures(): Promise<number> {
if (!this.options.missionStore) {
return 0;
}
async reconcileAllMissionFeatures(source: MissionReconcileSource = "self-healing"): Promise<number> {
if (!this.options.missionStore) return 0;
/*
FNXC:MissionAutoReconcile 2026-08-11-02:39:
Correction scans every non-archived mission and slice, but never activates or triages work.
Those autonomy actions remain in their dedicated active-mission paths; this shared service only
projects deterministic task ground truth into existing roadmap state.
*/
const missionStore = this.options.missionStore;
let totalFixed = 0;
/*
FNXC:MissionAutoReconcile 2026-08-11-03:10:
Reconcile unique title matches before the active-only triage tier. Otherwise an unlinked
feature with an already-existing delivery task is triaged into a duplicate before correction
can restore its guarded task link.
*/
const result = await reconcileMissionState({ taskStore: this.store, missionStore }, { source });
const activationFixes = await this.reconcileActiveMissionAutomation(missionStore);
return result.statusUpdates + result.badgeRepairs + result.terminalRepairs + activationFixes;
}
/*
FNXC:MissionAutoReconcile 2026-08-11-02:39:
Correction intentionally scans non-archived history, but this activation tier remains limited
to active missions and active slices. It retains generated-fix recovery and triage without
creating work while correcting an idle, blocked, or complete roadmap.
*/
private async reconcileActiveMissionAutomation(missionStore: MissionStore | AsyncMissionStore): Promise<number> {
let fixed = 0;
try {
const missions = await missionStore.listMissions();
const activeMissions = missions.filter((m) => m.status === "active");
const activeMissionIds = new Set(activeMissions.map((mission) => mission.id));
const taskBySliceAndTitle = new Map<string, Task | null>();
const missionTasks = await this.store.listTasks({ slim: true, includeArchived: false });
for (const task of missionTasks) {
if (!task.missionId || !task.sliceId || !task.title || !activeMissionIds.has(task.missionId)) {
continue;
}
const key = this.getMissionFeatureTitleKey(task.sliceId, task.title);
taskBySliceAndTitle.set(
key,
taskBySliceAndTitle.has(key) ? null : task,
);
}
for (const mission of activeMissions) {
const missions = (await missionStore.listMissions()).filter((mission) => mission.status === "active");
for (const mission of missions) {
const hierarchy = await missionStore.getMissionWithHierarchy(mission.id);
if (!hierarchy) continue;
const activeSlices = hierarchy.milestones
.flatMap((milestone) => milestone.slices)
.filter((slice) => slice.status === "active");
for (const slice of activeSlices) {
const missionAutoTriageEnabled = mission.autopilotEnabled === true || mission.autoAdvance === true;
const supersededFixes = await missionStore.reconcileSupersededGeneratedFixFeatures(slice.id);
if (supersededFixes.supersededCount > 0) {
totalFixed += supersededFixes.supersededCount;
schedulerLog.warn(
`Superseded ${supersededFixes.supersededCount} stale generated fix feature(s) during mission reconciliation for slice ${slice.id}`,
);
for (const slice of hierarchy.milestones.flatMap((milestone) => milestone.slices).filter((slice) => slice.status === "active")) {
const superseded = await missionStore.reconcileSupersededGeneratedFixFeatures(slice.id);
fixed += superseded.supersededCount;
if (superseded.supersededCount > 0) {
const refreshed = await missionStore.getSlice(slice.id);
if (refreshed?.status === "complete") { await this.onSliceComplete(refreshed); continue; }
}
const features = supersededFixes.supersededCount > 0
? await missionStore.listFeatures(slice.id)
: slice.features;
const supersededFeatureIds = new Set(supersededFixes.featureIds);
if (supersededFixes.supersededCount > 0) {
const refreshedSlice = await missionStore.getSlice(slice.id);
if (refreshedSlice?.status === "complete") {
/*
FNXC:Missions 2026-07-11-12:35:
Startup reconciliation can terminalize every stale generated-fix feature in an active slice before the scheduler loop runs no-task recovery.
Treat the recomputed complete slice as complete immediately so stale fixes do not keep an active slice from advancing after restart.
*/
await this.onSliceComplete(refreshedSlice);
continue;
}
}
const listFeatures = (missionStore as unknown as { listFeatures?: (sliceId: string) => Promise<MissionFeature[]> }).listFeatures;
const features = listFeatures ? await listFeatures(slice.id) : slice.features;
const supersededFeatureIds = new Set(superseded.featureIds ?? []);
const autoTriage = mission.autopilotEnabled === true || mission.autoAdvance === true;
for (const feature of features) {
let featureForReconciliation = feature;
let task: Task | undefined;
if (supersededFeatureIds.has(feature.id)) {
/*
FNXC:Missions 2026-07-11-12:35:
The title-to-task map is built before generated-fix supersedence detaches stale task ownership.
Skip freshly superseded features so pre-reconciliation task links cannot be restored in the same startup sweep.
*/
if (supersededFeatureIds.has(feature.id) || feature.taskId || !autoTriage || feature.status === "blocked") continue;
if (feature.status !== "defined" && this.isGeneratedFixFeature(feature)) {
await missionStore.updateFeature(feature.id, { status: "blocked", loopState: "blocked", taskId: undefined });
fixed++;
continue;
}
if (feature.status === "done" && this.isGeneratedFixFeature(feature) && !feature.taskId) {
/*
FNXC:Missions 2026-07-11-12:35:
Done generated-fix features with no task ownership are terminal after supersedence clears their board links.
Keep any done feature that still has task ownership in startup self-healing so linked task drift can still be repaired.
*/
continue;
}
if (feature.taskId) {
task = await this.store.getTask(feature.taskId);
} else {
const matchedTask = taskBySliceAndTitle.get(
this.getMissionFeatureTitleKey(feature.sliceId, feature.title),
);
if (matchedTask) {
schedulerLog.warn(
`Repairing one-way mission link during reconciliation: task ${matchedTask.id} matched unlinked feature ${feature.id}`,
);
featureForReconciliation = await missionStore.linkFeatureToTask(feature.id, matchedTask.id);
task = matchedTask;
totalFixed++;
await this.emitStrandedFeatureTriageAudit(mission.id, slice.id, feature.id, matchedTask.id);
} else if (
missionAutoTriageEnabled
&& feature.status !== "blocked"
) {
if (feature.status !== "defined" && this.isGeneratedFixFeature(feature)) {
try {
schedulerLog.warn(
`Blocking stranded generated fix feature ${feature.id}: no linked task and no title-matched task available`,
);
await missionStore.updateFeature(feature.id, {
status: "blocked",
loopState: "blocked",
taskId: undefined,
});
totalFixed++;
} catch (error) {
schedulerLog.warn(
`Failed to block stranded fix feature ${feature.id} during reconciliation: ${error instanceof Error ? error.message : String(error)}`,
);
}
} else if (feature.status === "defined" || feature.status === "triaged" || feature.status === "in-progress") {
try {
const featureToTriage = feature.status === "defined"
? feature
: await missionStore.updateFeature(feature.id, {
status: "defined",
loopState: "idle",
taskId: undefined,
});
if (featureToTriage.status !== feature.status) {
totalFixed++;
}
featureForReconciliation = await missionStore.triageFeature(featureToTriage.id);
task = featureForReconciliation.taskId
? await this.store.getTask(featureForReconciliation.taskId)
: undefined;
totalFixed++;
if (featureForReconciliation.taskId) {
await this.emitStrandedFeatureTriageAudit(
mission.id,
slice.id,
featureForReconciliation.id,
featureForReconciliation.taskId,
);
}
} catch (error) {
schedulerLog.warn(
`Failed to triage stranded feature ${feature.id} during reconciliation: ${error instanceof Error ? error.message : String(error)}`,
);
}
} else {
schedulerLog.warn(
`Skipping stranded feature ${feature.id} with terminal status ${feature.status}: no linked task and no title-matched task available`,
);
}
}
}
if (!task) continue;
const hasLinkedAssertions = (await missionStore.listAssertionsForFeature(featureForReconciliation.id)).length > 0;
const reconciliation = await reconcileMissionFeatureState(this.store, task, featureForReconciliation, {
hasLinkedAssertions,
});
/*
FNXC:SpecLockMissionAlignment 2026-08-10-16:17:
Periodic reconciliation must retain the same drift projection as event-driven moves;
otherwise a quiet task's alignment disappears until an unrelated status transition.
*/
if (await persistMissionFeatureReconciliation(missionStore, featureForReconciliation, reconciliation)) {
totalFixed++;
}
if (reconciliation.kind === "failure") {
if (this.options.onTaskFailed) {
await this.options.onTaskFailed(task.id);
totalFixed++;
} else {
schedulerLog.warn(`Skipping failed feature reconciliation for ${feature.id} — ${reconciliation.reason}`);
}
continue;
}
if (reconciliation.kind === "blocked") {
schedulerLog.warn(`Skipping feature ${feature.id} reconciliation — ${reconciliation.reason}`);
continue;
}
if (feature.status !== "defined" && feature.status !== "triaged" && feature.status !== "in-progress") continue;
const ready = feature.status === "defined" ? feature : await missionStore.updateFeature(feature.id, { status: "defined", loopState: "idle", taskId: undefined });
if (ready !== feature) fixed++;
const triaged = await missionStore.triageFeature(ready.id);
fixed++;
if (triaged.taskId) await this.emitStrandedFeatureTriageAudit(mission.id, slice.id, triaged.id, triaged.taskId);
}
}
}
if (totalFixed > 0) {
schedulerLog.log(`Mission feature reconciliation: fixed ${totalFixed} inconsistencies`);
}
} catch (err) {
schedulerLog.error("Error during mission feature reconciliation:", err);
} catch (error) {
schedulerLog.error("Error during active mission recovery reconciliation:", error);
}
return totalFixed;
return fixed;
}
private async emitStrandedFeatureTriageAudit(
@@ -3630,10 +3455,6 @@ export class Scheduler {
}
}
private getMissionFeatureTitleKey(sliceId: string, title: string): string {
return `${sliceId}\0${this.normalizeMissionFeatureTitle(title)}`;
}
private isGeneratedFixFeature(feature: Pick<MissionFeature, "generatedFromFeatureId" | "generatedFromRunId">): boolean {
return Boolean(feature.generatedFromFeatureId || feature.generatedFromRunId);
}

View File

@@ -545,6 +545,8 @@ export type DatabaseMutationType =
*/
| "task:autostash-orphan-live-detected"
| "mission:stranded-feature-triaged"
/* FNXC:MissionAutoReconcile 2026-08-11-02:39: Periodic reconcile records only IDs, source enums, and bounded counters. */
| "mission:reconcile-pass"
| "task:auto-recover-branch-misbound"
| "task:auto-recover-misrouted-foreign-commit"
| "task:auto-recover-foreign-only-contamination"