feat(missions): done-credit via reverse lineage, feature re-point/unlink tools, and live unlink SSE (#3491)

## What this PR does

Closes three lifecycle gaps in the Mission → Milestone → Slice →
**Feature** model, where a feature links to exactly one delivery task
(single-valued `feature.taskId`, one-feature-one-task):

1. **Reverse-lineage done-credit** — a terminal, non-failed task
carrying a feature's reverse `missionLineage`
(featureId/sliceId/missionId) now credits the feature as satisfying its
acceptance criteria, independent of where the forward `feature.taskId`
points (e.g. a newer follow-up task). Credit is only applied when
**none** of the feature's lineage candidates is live, so a live
follow-up keeps the feature active. Fixes features that otherwise sit
"in-progress" forever after their delivering task completes.
2. **Re-point / unlink surface** — new `fn_feature_repoint_task` /
`fn_feature_unlink_task` agent tools (engine + CLI) backed by an atomic
`repointFeatureToTask` store primitive, so a mis-linked feature can be
re-attached to the correct delivery task or unlinked — without a manual
DB edit. Single-valued `feature.taskId` and one-feature-one-task
invariants are preserved; unlinking an already-unlinked feature errors
clearly. Classified as mutation tools, same class as the existing
`fn_feature_link_task`.
3. **Live SSE update** — the `feature:unlinked` store event is now
emitted over SSE (`event: feature:unlinked`) so the dashboard refreshes
immediately after a re-point/unlink instead of waiting for the next
poll.

## Why

The forward-only `feature.taskId` link made three real failure modes
unrecoverable from the product surface: stale done-features after task
replacement, permanently mis-linked features, and a stale dashboard
after a repair. This closes the feature lifecycle end-to-end.

## Invariants

- `feature.taskId` stays single-valued; one feature is linked to at most
one task at any time (atomic store primitive).
- Done-credit requires: terminal column, non-failed, and no live lineage
candidate.
- No new run-audit prose; store events carry ids/counts/outcomes only.

## Tests

- `mission-state-reconcile.test.ts` — reverse-lineage credit matrix
(live vs. terminal vs. failed candidates)
- `mission-store.pg.test.ts` — `repointFeatureToTask` atomicity +
invariants (PG; gate-safe auto-skip without Postgres)
- `agent-mission-tools.test.ts` — tool delegation (unlink / re-point
exactly once)
- `heartbeat-executor.test.ts`, `extension.test.ts` — tool exposure and
heartbeat interaction
- Changeset: `@runfusion/fusion: minor`

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

- **New Features**
- Added tools to reassign a feature to another live task or remove its
task link.
- Unlinking resets the feature to a defined state and provides clear
errors when applicable.
  - Added live updates for feature unlink events.
- Improved reconciliation to recognize completed reverse-lineage work
while respecting active follow-up tasks and failures.
- Improved relationship consistency when concurrent task-link operations
occur.

- **Documentation**
- Documented feature linking, unlinking, reassignment, lifecycle events,
and reconciliation behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
ischindl
2026-08-20 06:01:56 +02:00
committed by GitHub
parent e099511b10
commit 3d355465c7
23 changed files with 1300 additions and 25 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Mission features: done-credit via reverse lineage, re-point/unlink tools, and live unlink SSE updates.
category: feature
dev: New `fn_feature_repoint_task` / `fn_feature_unlink_task` agent tools (engine + CLI) backed by an atomic `repointFeatureToTask` store primitive preserving single-valued `feature.taskId` and one-feature-one-task invariants; unlink of an unlinked feature errors clearly. Classified as mutation tools like `fn_feature_link_task`.

View File

@@ -31,6 +31,8 @@ Interactive/user-supervised, task-scoped heartbeat, executor, triage, and workfl
A valid active lineage may name a hand-authored `defined` feature only for its first task. Fusion atomically claims the feature, links that exact task, and promotes the feature to `triaged`; an already-linked feature rejects rather than overwriting its canonical task. This bootstrap exception does not make `defined` executable: later scheduler and symbol-lock admission still uses the stricter contract below.
Both feature→task link transitions emit lifecycle events observable via mission-store subscribers and the dashboard SSE. Linking (`linkFeatureToTask` / claims) emits `feature:updated`, a `feature_status_changed` `mission:event` sourced `mission-link`, and `feature:linked`. Unlinking (`unlinkFeatureFromTask`) mirrors that family: it emits `feature:updated`, a `feature_status_changed` `mission:event` sourced `mission-unlink`, and `feature:unlinked` (the payload carries the detached `taskId`). Unlinking a feature that is not linked to any task is an error — it changes nothing and emits nothing. Consumers that react to `feature:linked` for feature→task automation see unlinks too via `feature:unlinked`, and the `mission-unlink` source distinguishes an unlink from a generic feature status edit.
## Canonical lineage approval for autonomous symbol locks
Before autonomous scheduler work may acquire a symbol lock, it resolves the task's Mission → Milestone → Slice → Feature lineage and evaluates the single `@fusion/core` contract: `evaluateMissionLineageApproval`. Resolution and lock acquisition remain scheduler responsibilities; downstream schedulers must not redefine the approval rule.
@@ -444,6 +446,15 @@ Use this endpoint when a feature's delivery task has already shipped and is now
<!-- FNXC:MissionReconciliation 2026-07-20-08:34: Operators need a supported atomic terminal-evidence repair because unarchive/move workarounds enter ordinary task lifecycle observers and can wake a parked mission or generate duplicate work. -->
**Safe duplicate cleanup:** preserve the first `409`; verify through supported APIs that the current linked task is generated duplicate work with no unique delivery or lineage value; call `POST /api/missions/features/:featureId/unlink-task`; archive only the proven duplicate through the supported task archive API; then call `reconcile-done` with the canonical terminal task. Never overwrite a mismatched link, unarchive/move canonical delivery evidence, or use direct storage edits. If the duplicate is ambiguous, leave it untouched and escalate for evidence.
## Unlink / Re-point a feature's task link
A mission feature's forward `taskId` link is single-valued and pinned: `fn_feature_link_task` / `linkFeatureToTask` refuse to re-point an already-linked feature (`Feature … is already linked to task …`). To correct a feature pinned to the wrong task (for example a shared vision document instead of the deterministic delivery task), use the re-point or unlink surface rather than the status-lossy unlink-then-link two-step:
- **Re-point** moves the single-valued `taskId` directly with no status loss: `fn_feature_repoint_task` (engine tool) and the corresponding `fn_feature_repoint_task` CLI/pi-extension tool call the `repointFeatureToTask` store primitive. It atomically clears the old task's reverse `missionId`/`sliceId` linkage, sets the new task's, keeps an already-linked feature's status/loop/attempts, and preserves single-valuedness via the same conflicting-feature guard as linking. Same-task re-point is an idempotent no-op.
- **Unlink** detaches the feature entirely: `fn_feature_unlink_task` (engine and CLI tools) and the write surface `POST /api/missions/features/:featureId/unlink-task` call the `unlinkFeatureFromTask` store primitive, which clears `taskId`, clears the old task's reverse linkage, and demotes the feature to `defined` — all in one transaction. Unlink of a feature not currently linked to any task is an error: it changes nothing and emits nothing (the CLI/agent surfaces report the error, and the dashboard route maps it to a 4xx).
Both tools are classified as permanent-task-agent mutation surfaces (action gating and readonly workflow-step denial behave like `fn_feature_link_task`). Re-point is preferred over unlink-then-link because it preserves loop/status progress; unlink remains the correct path before the documented safe duplicate-cleanup + `reconcile-done` flow above.
**How this differs from `PATCH /api/missions/features/:featureId`:**
- `PATCH` keeps the execution-status guard and rejects `done`/`triaged`/`in-progress`/`blocked` when no linked task exists.
@@ -788,6 +799,8 @@ Selection changes discard reconcile responses silently, including responses arri
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.
Beyond the single-valued forward `feature.taskId` link, the reconcile also credits a feature as satisfying its acceptance criteria when a **terminal, non-failed** task carries the feature's reverse `mission_lineage` (`sourceMetadata.missionLineage` naming the feature's `missionId`/`sliceId`/`featureId`). This reverse-lineage credit is an additional satisfying input, not a replacement: it leaves the forward link untouched, never fires when any live lineage follow-up keeps the feature active (live withholding takes precedence), and ignores failed/errored lineage tasks. It lets a roadmap feature close `done` even when its forward link is pinned to a shared, non-satisfying task (RUFU-109).
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.
## Ideation handoff

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_clear_blocked`, `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`
- **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_clear_blocked`, `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_repoint_task`, `fn_feature_unlink_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_read_evaluations`, `fn_agent_evaluation_followup`, `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

@@ -526,6 +526,23 @@ Link a feature to a fn task for implementation. Updates the feature status to 't
| `featureId` | string | ✓ | Feature ID to link (e.g., F-001) |
| `taskId` | string | ✓ | Task ID to link to (e.g., FN-001) |
### fn_feature_repoint_task
Atomically re-point an already-linked feature's single-valued taskId to a different task. Corrects a feature pinned to the wrong task (for example a shared vision doc) without the status-lossy unlink then link two-step. The target task must be live; same-task re-point is an idempotent no-op.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `featureId` | string | ✓ | Feature ID to re-point (e.g., F-001) |
| `taskId` | string | ✓ | Task ID to re-point to (e.g., FN-001) |
### fn_feature_unlink_task
Detach a feature from its linked task entirely, clearing its single-valued taskId and demoting its status to 'defined'. Use before the documented safe duplicate-cleanup and reconcile-done flow. Returns a clear error if the feature is not currently linked to any task.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `featureId` | string | ✓ | Feature ID to unlink (e.g., F-001) |
### fn_feature_set_status
Set a feature lifecycle status.

View File

@@ -77,6 +77,8 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
| `fn_milestone_delete` | Delete a milestone and all descendant slices/features. Rejects deletion when child features link to live tasks unless force=true. |
| `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_repoint_task` | Atomically re-point an already-linked feature's single-valued taskId to a different task. Corrects a feature pinned to the wrong task (for example a shared vision doc) without the status-lossy unlink then link two-step. The target task must be live; same-task re-point is an idempotent no-op. |
| `fn_feature_unlink_task` | Detach a feature from its linked task entirely, clearing its single-valued taskId and demoting its status to 'defined'. Use before the documented safe duplicate-cleanup and reconcile-done flow. Returns a clear error if the feature is not currently linked to any task. |
| `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. |

View File

@@ -306,6 +306,8 @@ legacyDescribe("fn pi extension (legacy exhaustive suite)", () => {
"fn_milestone_delete",
"fn_slice_activate",
"fn_feature_link_task",
"fn_feature_repoint_task",
"fn_feature_unlink_task",
"fn_feature_update",
"fn_feature_repair_validation",
"fn_feature_set_status",
@@ -2964,6 +2966,99 @@ pgTest("fn pi extension (runnable structured-output regression slice)", () => {
expect(await missionStore.getFeature(feature.id)).toMatchObject({ status: "defined", loopState: "idle" });
});
describe("fn_feature_repoint_task / fn_feature_unlink_task", () => {
it("re-points a linked feature to a second delivery task atomically", async () => {
const context = makeCtx(tmpDir);
const mission = await api.tools.get("fn_mission_create")!.execute("m", { title: "Repoint Mission" }, undefined, undefined, context);
const milestone = await api.tools.get("fn_milestone_add")!.execute("ms", { missionId: mission.details.missionId, title: "Milestone" }, undefined, undefined, context);
const slice = await api.tools.get("fn_slice_add")!.execute("sl", { milestoneId: milestone.details.milestoneId, title: "Slice" }, undefined, undefined, context);
const feature = await api.tools.get("fn_feature_add")!.execute("f", { sliceId: slice.details.sliceId, title: "Feature" }, undefined, undefined, context);
const taskA = await api.tools.get("fn_task_create")!.execute("ta", { description: "wrong task" }, undefined, undefined, context);
const taskB = await api.tools.get("fn_task_create")!.execute("tb", { description: "right delivery task" }, undefined, undefined, context);
// Original symptom reproduction: first pin to the wrong task (as fn_feature_link_task
// would naively do for the wrong target).
const link = await api.tools.get("fn_feature_link_task")!.execute("link", { featureId: feature.details.featureId, taskId: taskA.details.taskId }, undefined, undefined, context);
expect(link.isError).not.toBe(true);
const store = h.store();
const missionStore = store.getMissionStore();
expect((await missionStore.getFeature(feature.details.featureId))?.taskId).toBe(taskA.details.taskId);
const taskARow = (await store.getTask(taskA.details.taskId)) as any;
expect(taskARow.sliceId).toBe(slice.details.sliceId);
/*
FNXC:MissionFeatureRepointContract 2026-08-19-23:26 (RUFU-134 / PR #3491):
CodeRabbit flagged that this test asserted only ONE reverse field (`sliceId`).
`setTaskMissionLinkage` writes BOTH `missionId` AND `sliceId` onto the task row, so
the proof that the repoint moved the reverse link must assert BOTH fields on the new
task (set) and on the old task (cleared); asserting only `sliceId` would miss a
repoint that silently dropped `missionId`.
*/
expect(taskARow.missionId).toBe(mission.details.missionId);
const repoint = await api.tools.get("fn_feature_repoint_task")!.execute("repoint", { featureId: feature.details.featureId, taskId: taskB.details.taskId }, undefined, undefined, context);
expect(repoint.isError).not.toBe(true);
expect(repoint.details.taskId).toBe(taskB.details.taskId);
const afterFeature = (await missionStore.getFeature(feature.details.featureId))!;
expect(afterFeature.taskId).toBe(taskB.details.taskId);
expect(afterFeature.status).toBe("triaged");
// Old reverse linkage cleared, new set. BOTH reverse fields (missionId + sliceId).
const oldTaskRow = (await store.getTask(taskA.details.taskId)) as any;
expect(oldTaskRow.sliceId).toBeUndefined();
expect(oldTaskRow.missionId).toBeUndefined();
const newTaskRow = (await store.getTask(taskB.details.taskId)) as any;
expect(newTaskRow.sliceId).toBe(slice.details.sliceId);
expect(newTaskRow.missionId).toBe(mission.details.missionId);
});
it("unlinks a linked feature (clearing taskId and demoting to defined) and errors on an already-unlinked feature", async () => {
const context = makeCtx(tmpDir);
const mission = await api.tools.get("fn_mission_create")!.execute("m", { title: "Unlink Mission" }, undefined, undefined, context);
const milestone = await api.tools.get("fn_milestone_add")!.execute("ms", { missionId: mission.details.missionId, title: "Milestone" }, undefined, undefined, context);
const slice = await api.tools.get("fn_slice_add")!.execute("sl", { milestoneId: milestone.details.milestoneId, title: "Slice" }, undefined, undefined, context);
const feature = await api.tools.get("fn_feature_add")!.execute("f", { sliceId: slice.details.sliceId, title: "Feature" }, undefined, undefined, context);
const task = await api.tools.get("fn_task_create")!.execute("t", { description: "linked delivery" }, undefined, undefined, context);
const store = h.store();
const missionStore = store.getMissionStore();
await api.tools.get("fn_feature_link_task")!.execute("link", { featureId: feature.details.featureId, taskId: task.details.taskId }, undefined, undefined, context);
expect((await missionStore.getFeature(feature.details.featureId))?.status).toBe("triaged");
const unlink = await api.tools.get("fn_feature_unlink_task")!.execute("unlink", { featureId: feature.details.featureId }, undefined, undefined, context);
expect(unlink.isError).not.toBe(true);
const after = (await missionStore.getFeature(feature.details.featureId))!;
expect(after.taskId).toBeUndefined();
expect(after.status).toBe("defined");
const taskRow = (await store.getTask(task.details.taskId)) as any;
expect(taskRow.sliceId).toBeUndefined();
const second = await api.tools.get("fn_feature_unlink_task")!.execute("unlink2", { featureId: feature.details.featureId }, undefined, undefined, context);
expect(second.isError).toBe(true);
expect(second.content[0].text).toContain("not linked");
});
it("re-points to a missing task with a clear error and handles an unknown feature", async () => {
const context = makeCtx(tmpDir);
const mission = await api.tools.get("fn_mission_create")!.execute("m", { title: "Repoint Err Mission" }, undefined, undefined, context);
const milestone = await api.tools.get("fn_milestone_add")!.execute("ms", { missionId: mission.details.missionId, title: "Milestone" }, undefined, undefined, context);
const slice = await api.tools.get("fn_slice_add")!.execute("sl", { milestoneId: milestone.details.milestoneId, title: "Slice" }, undefined, undefined, context);
const feature = await api.tools.get("fn_feature_add")!.execute("f", { sliceId: slice.details.sliceId, title: "Feature" }, undefined, undefined, context);
const toMissing = await api.tools.get("fn_feature_repoint_task")!.execute(
"repoint-missing-task", { featureId: feature.details.featureId, taskId: "FN-999" }, undefined, undefined, context,
);
expect(toMissing.isError).toBe(true);
expect(toMissing.content[0].text).toMatch(/not found|not on the active board/i);
const unknownFeature = await api.tools.get("fn_feature_repoint_task")!.execute(
"repoint-missing-feature", { featureId: "F-NOPE", taskId: "FN-1" }, undefined, undefined, context,
);
expect(unknownFeature.isError).toBe(true);
expect(unknownFeature.content[0].text).toContain("not found");
});
});
describe("fn_mission_clear_blocked", () => {
it("calls the attributed repair primitive and reports residual blockers", async () => {
const clearMissionBlockedStatus = vi.fn().mockResolvedValue({

View File

@@ -5230,6 +5230,115 @@ export default function kbExtension(pi: ExtensionAPI) {
},
});
// ── fn_feature_repoint_task ────────────────────────────────────
pi.registerTool({
name: "fn_feature_repoint_task",
label: "fn: Re-point Feature to Task",
description:
"Atomically re-point an already-linked feature's single-valued taskId to a different task. " +
"Corrects a feature pinned to the wrong task (for example a shared vision doc) without the status-lossy " +
"unlink then link two-step. The target task must be live; same-task re-point is an idempotent no-op.",
promptSnippet: "Re-point a feature to a different task",
promptGuidelines: [
"Use when a feature is linked to the wrong task and should point at a different delivery task",
"The target task must be active; re-point fails with a clear error otherwise",
"A task already linked to another feature rejects the re-point with a conflict error",
"Same-task re-point is a safe idempotent no-op",
],
parameters: Type.Object({
featureId: Type.String({ description: "Feature ID to re-point (e.g., F-001)" }),
taskId: Type.String({ description: "Task ID to re-point to (e.g., FN-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const feature = await missionStore.getFeature(params.featureId);
if (!feature) {
return {
content: [{ type: "text", text: `Feature ${params.featureId} not found` }],
isError: true,
details: { error: "Feature not found" },
};
}
try {
const updated = await missionStore.repointFeatureToTask(params.featureId, params.taskId);
return {
content: [
{
type: "text",
text: `Re-pointed ${updated.id}: "${updated.title}" → ${params.taskId}\nStatus: ${updated.status}`,
},
],
details: { featureId: updated.id, taskId: params.taskId, title: updated.title, status: updated.status },
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: message }],
isError: true,
details: { error: message },
};
}
},
});
// ── fn_feature_unlink_task ─────────────────────────────────────
pi.registerTool({
name: "fn_feature_unlink_task",
label: "fn: Unlink Feature from Task",
description:
"Detach a feature from its linked task entirely, clearing its single-valued taskId and demoting its status " +
"to 'defined'. Use before the documented safe duplicate-cleanup and reconcile-done flow. Returns a clear error " +
"if the feature is not currently linked to any task.",
promptSnippet: "Unlink a feature from its task",
promptGuidelines: [
"Use to fully detach a feature from its current task before re-linking or cleaning up a duplicate",
"Fails with a clear error if the feature is not linked to any task",
"Clears the old task's reverse mission/slice linkage and demotes the feature to 'defined'",
"Prefer fn_feature_repoint_task to move a link directly without the status loss",
],
parameters: Type.Object({
featureId: Type.String({ description: "Feature ID to unlink (e.g., F-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const feature = await missionStore.getFeature(params.featureId);
if (!feature) {
return {
content: [{ type: "text", text: `Feature ${params.featureId} not found` }],
isError: true,
details: { error: "Feature not found" },
};
}
try {
const updated = await missionStore.unlinkFeatureFromTask(params.featureId);
return {
content: [
{
type: "text",
text: `Unlinked ${updated.id}: "${updated.title}" from its task\nStatus: ${updated.status}`,
},
],
details: { featureId: updated.id, title: updated.title, status: updated.status },
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: message }],
isError: true,
details: { error: message },
};
}
},
});
// ── fn_feature_set_status ───────────────────────────────────────
/* FNXC:MissionStatusWrites 2026-08-10-12:47: Dedicated tools keep the linked-task execution-status guard unambiguous instead of widening generic updates. */
pi.registerTool({

View File

@@ -22,7 +22,7 @@ import { eq, sql } from "drizzle-orm";
import { readFile } from "node:fs/promises";
import type { DbTransaction } from "../../postgres/data-layer.js";
import type { TaskCreateInput } from "../../types/task/task-core.js";
import type { MissionEvent } from "../../missions/mission-types.js";
import type { MissionEvent, FeatureUnlinkedPayload } from "../../missions/mission-types.js";
import {
pgDescribe,
@@ -312,11 +312,14 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
const feature = await m.addFeature(slice.id, { title: "F" });
const task = await h.store().createTask({ description: "delivery task" });
const observedEvents: MissionEvent[] = [];
const unlinkedEvents: FeatureUnlinkedPayload[] = [];
m.on("mission:event", (event) => observedEvents.push(event));
m.on("feature:unlinked", (payload) => unlinkedEvents.push(payload));
const linked = await m.linkFeatureToTask(feature.id, task.id);
expect(linked.taskId).toBe(task.id);
expect(linked.status).toBe("triaged");
expect(unlinkedEvents).toHaveLength(0);
expect(observedEvents).toEqual(expect.arrayContaining([expect.objectContaining({ eventType: "feature_status_changed", metadata: expect.objectContaining({ source: "mission-link" }) })]));
expect((await m.getMissionEvents(mission.id, { limit: 10 })).events).toEqual(expect.arrayContaining([
expect.objectContaining({
@@ -325,17 +328,188 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
}),
]));
/*
FNXC:MissionFeatureUnlinkEvent 2026-08-17-12:20:
RUFU-116 symptom verification: an unlink must surface as a feature:unlinked EventEmitter
event (with the detached taskId) plus a feature_status_changed mission:event sourced
mission-unlink, so subscribers observing feature:linked transitions can see unlinks too.
*/
const unlinked = await m.unlinkFeatureFromTask(feature.id);
expect(unlinked.taskId).toBeUndefined();
expect(unlinked.status).toBe("defined");
expect(unlinkedEvents).toHaveLength(1);
expect(unlinkedEvents[0]).toEqual(expect.objectContaining({
feature: expect.objectContaining({ id: feature.id, taskId: undefined, status: "defined" }),
taskId: task.id,
}));
expect((await m.getMissionEvents(mission.id, { limit: 10 })).events).toEqual(expect.arrayContaining([
expect.objectContaining({
eventType: "feature_status_changed",
metadata: expect.objectContaining({ featureId: feature.id, from: "triaged", to: "defined", source: "mission-store" }),
metadata: expect.objectContaining({ featureId: feature.id, from: "triaged", to: "defined", source: "mission-unlink" }),
}),
]));
});
/*
FNXC:MissionFeatureClaimRace 2026-08-19-21:24 (RUFU-134 / PR #3491 Greptile P1):
Two transactions claiming the same unclaimed task used to both pass the conflicting-feature
check (each read the task as unclaimed) and both commit, corrupting the single-valued
feature→task invariant. Claim paths now hold the task row lock (lockLiveTaskForClaim) BEFORE
the conflict check. This test simulates the first claimant's transaction on a separate
connection: it locks the task row with SELECT ... FOR UPDATE and writes the first claimant's
linkage while holding the lock. The store's concurrent link (second claimant) must (a) remain
blocked on the row lock while the first transaction is open (~250ms pending probe) and
(b) after the first transaction commits, reject with the conflicting-feature error instead of
overwriting the first claimant's linkage.
*/
it("serializes concurrent claims on the same task (Greptile P1 race)", async () => {
const m = missions();
const mission = await m.createMission({ title: "Claim race" });
const milestone = await m.addMilestone(mission.id, { title: "MS" });
const slice = await m.addSlice(milestone.id, { title: "SL" });
const featureOne = await m.addFeature(slice.id, { title: "F1" });
const featureTwo = await m.addFeature(slice.id, { title: "F2" });
const task = await h.store().createTask({ description: "contested task" });
const db = h.adminDb();
// Second claimant's link — fired while the first claimant holds the row lock.
let settled = false;
const contestedLink = m.linkFeatureToTask(featureTwo.id, task.id).then(
(value) => { settled = true; return value; },
(error) => { settled = true; throw error; },
);
// First claimant's transaction: lock the task row, write its linkage, hold it open.
await db.transaction(async (tx) => {
await tx.select({ id: schema.project.tasks.id })
.from(schema.project.tasks)
.where(eq(schema.project.tasks.id, task.id))
.for("update");
await tx.update(schema.project.missionFeatures)
.set({ taskId: task.id, status: "triaged", updatedAt: new Date().toISOString() })
.where(eq(schema.project.missionFeatures.id, featureOne.id));
await tx.update(schema.project.tasks)
.set({ missionId: mission.id, sliceId: slice.id, updatedAt: new Date().toISOString() })
.where(eq(schema.project.tasks.id, task.id));
// While the first claimant is uncommitted, the second claimant must still be blocked
// on the task row lock, not settled (success or failure).
await new Promise((resolve) => setTimeout(resolve, 250));
expect(settled).toBe(false);
});
// First claimant committed: the second claimant now observes the committed link and
// must reject with the conflicting-feature error (the pre-fix code committed here,
// silently overwriting the first claimant's linkage).
await expect(contestedLink).rejects.toThrow(`Task ${task.id} is already linked to feature ${featureOne.id}`);
const persistedTwo = await m.getFeature(featureTwo.id);
expect(persistedTwo?.taskId).toBeUndefined();
const taskRow = (await h.store().getTask(task.id)) as { missionId?: string | null; sliceId?: string | null };
expect(taskRow.missionId).toBe(mission.id);
expect(taskRow.sliceId).toBe(slice.id);
});
it("repointFeatureToTask atomically re-points the single-valued taskId", async () => {
const m = missions();
const mission = await m.createMission({ title: "Repoint" });
const milestone = await m.addMilestone(mission.id, { title: "MS" });
const slice = await m.addSlice(milestone.id, { title: "SL" });
const feature = await m.addFeature(slice.id, { title: "F" });
const wrongTask = await h.store().createTask({ description: "wrong task" });
const rightTask = await h.store().createTask({ description: "right delivery task" });
const store = h.store();
// Link feature to the wrong task (reproduces the original symptom: a feature
// pinned to the wrong task via fn_feature_link_task).
await m.linkFeatureToTask(feature.id, wrongTask.id);
expect((await m.getFeature(feature.id))?.taskId).toBe(wrongTask.id);
const wrongTaskRow = (await store.getTask(wrongTask.id)) as any;
expect(wrongTaskRow.missionId).toBe(mission.id);
expect(wrongTaskRow.sliceId).toBe(slice.id);
// Re-point to the right delivery task.
const repointed = await m.repointFeatureToTask(feature.id, rightTask.id);
expect(repointed.taskId).toBe(rightTask.id);
const persisted = await m.getFeature(feature.id);
expect(persisted?.taskId).toBe(rightTask.id);
expect(persisted?.status).toBe("triaged");
// The old task's reverse linkage is cleared; the new task's is set.
const oldTaskRow = (await store.getTask(wrongTask.id)) as any;
expect(oldTaskRow.missionId).toBeUndefined();
expect(oldTaskRow.sliceId).toBeUndefined();
const rightTaskRow = (await store.getTask(rightTask.id)) as any;
expect(rightTaskRow.missionId).toBe(mission.id);
expect(rightTaskRow.sliceId).toBe(slice.id);
// Same-task re-point is an idempotent no-op preserving status/loop/attempts.
await m.updateFeatureStatus(feature.id, "in-progress");
const beforeSame = await m.getFeature(feature.id);
const same = await m.repointFeatureToTask(feature.id, rightTask.id);
const afterSame = await m.getFeature(feature.id);
expect(same.taskId).toBe(rightTask.id);
expect(afterSame?.taskId).toBe(rightTask.id);
expect(beforeSame?.status).toBe("in-progress");
expect(afterSame?.status).toBe("in-progress");
// Conflict: re-point to a task already owned by another feature is rejected.
const otherFeature = await m.addFeature(slice.id, { title: "Other" });
await m.linkFeatureToTask(otherFeature.id, wrongTask.id);
await expect(m.repointFeatureToTask(feature.id, wrongTask.id))
.rejects.toThrow(`Task ${wrongTask.id} is already linked to feature ${otherFeature.id}`);
// Unlink after re-point clears the (new) task.
const finalUnlink = await m.unlinkFeatureFromTask(feature.id);
expect(finalUnlink.taskId).toBeUndefined();
expect(finalUnlink.status).toBe("defined");
const rightTaskAfterUnlink = (await store.getTask(rightTask.id)) as any;
expect(rightTaskAfterUnlink.missionId).toBeUndefined();
expect(rightTaskAfterUnlink.sliceId).toBeUndefined();
});
/*
FNXC:MissionFeatureUnlinkContract 2026-08-19-21:24 (RUFU-134 / PR #3491):
Unlinking a feature that is not linked to any task is an error: it changes nothing and emits
nothing. This replaced the previous "idempotent no-op that still emits feature:unlinked"
behavior, which no public surface ever honored and which silently rewrote the row — including
the silent status demotion of a reverse-lineage-credited done feature (RUFU-109 credits the
status without setting taskId). The CLI/agent surfaces report the error, and the dashboard
route maps it to a 4xx.
*/
it("unlinkFeatureFromTask of a not-linked feature rejects, changes nothing, and emits nothing", async () => {
const m = missions();
const mission = await m.createMission({ title: "Unlink-contract" });
const milestone = await m.addMilestone(mission.id, { title: "MS" });
const slice = await m.addSlice(milestone.id, { title: "SL" });
const feature = await m.addFeature(slice.id, { title: "F" });
const task = await h.store().createTask({ description: "unlink-contract task" });
const observedEvents: MissionEvent[] = [];
const unlinkedEvents: FeatureUnlinkedPayload[] = [];
m.on("mission:event", (event) => observedEvents.push(event));
m.on("feature:unlinked", (payload) => unlinkedEvents.push(payload));
// (1) A feature that was never linked: the store rejects with the documented message.
await expect(m.unlinkFeatureFromTask(feature.id)).rejects.toThrow(`Feature ${feature.id} is not linked to any task`);
expect(unlinkedEvents).toHaveLength(0);
expect(observedEvents).toHaveLength(0);
expect((await m.getMissionEvents(mission.id, { limit: 10 })).events).toEqual([]);
// (2) A feature that was linked and then unlinked: the same error, no residual linkage.
await m.linkFeatureToTask(feature.id, task.id);
await m.unlinkFeatureFromTask(feature.id);
const rowBefore = await m.getFeature(feature.id);
expect(rowBefore?.taskId).toBeUndefined();
expect(rowBefore?.status).toBe("defined");
await expect(m.unlinkFeatureFromTask(feature.id)).rejects.toThrow(`Feature ${feature.id} is not linked to any task`);
expect(unlinkedEvents).toHaveLength(1); // only the real unlink above
const rowAfter = await m.getFeature(feature.id);
expect(rowAfter).toEqual(rowBefore); // the failed unlink rewrote nothing
const taskRow = (await h.store().getTask(task.id)) as { missionId?: string | null; sliceId?: string | null };
expect(taskRow.missionId).toBeUndefined();
expect(taskRow.sliceId).toBeUndefined();
});
it("audits defined-feature bootstrap claims inside their task transaction", async () => {
const m = missions();
const mission = await m.createMission({ title: "Claim audit" });

View File

@@ -2344,6 +2344,31 @@ export async function getLiveTaskById(handle: QueryHandle, taskId: string): Prom
return row ? { id: row.id, column: row.column as string } : undefined;
}
/**
* Lock a live (non-deleted) task row for the duration of the caller's transaction and return
* its id + column, or undefined when the task is not on the active board.
*
* FNXC:MissionFeatureClaimRace 2026-08-19-21:24 (RUFU-134 / PR #3491 Greptile P1):
* The claim paths (link, re-point, terminal reconcile, bootstrap-duplicate archive) previously
* ran their conflicting-feature check against an UNLOCKED target-task read, so two transactions
* could both observe the task as unclaimed and both commit, breaking the single-valued
* feature→task invariant. Every path that assigns task ownership takes this lock BEFORE its
* conflict check. All of those paths take the feature-row lock first, so the global order
* feature→task is cycle-free (the bootstrap-duplicate path takes no feature lock, only this
* one task lock, before its archive write). Soft-deleted rows are excluded: link/re-point
* refuse them outright, so no concurrent claimant can race on a tombstone — the terminal
* reconcile's archived-tombstone arm therefore needs no lock.
*/
export async function lockLiveTaskForClaim(handle: QueryHandle, taskId: string): Promise<{ id: string; column: string } | undefined> {
const rows = await handle
.select({ id: schema.project.tasks.id, column: schema.project.tasks.column })
.from(schema.project.tasks)
.where(and(missionProjectScope(schema.project.tasks.projectId), eq(schema.project.tasks.id, taskId), sql`${schema.project.tasks.deletedAt} is null`))
.for("update");
const row = rows[0];
return row ? { id: row.id, column: row.column as string } : undefined;
}
/** Set a live task's mission/slice linkage (bidirectional link). */
export async function setTaskMissionLinkage(handle: QueryHandle, taskId: string, missionId: string, sliceId: string): Promise<void> {
await handle

View File

@@ -163,6 +163,7 @@ import {
listFeaturesForAssertion,
listLiveLinkedTaskIds,
getLiveTaskById,
lockLiveTaskForClaim,
setTaskMissionLinkage,
clearTaskMissionLinkage,
listFailedTaskIds,
@@ -1377,6 +1378,15 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
);
}
/*
FNXC:MissionFeatureClaimRace 2026-08-19-21:24 (RUFU-134 / PR #3491 Greptile P1):
A live done target is claimable by concurrent link/re-point; hold its row lock before the
conflict check so two claimants cannot both observe it as unclaimed. The archived-tombstone
arm is soft-deleted and unclaimable by design, so it needs no lock.
*/
if (evidence.kind === "done") {
await lockLiveTaskForClaim(tx, taskId);
}
const taskFeature = await getConflictingFeatureByTaskId(tx, taskId, featureId);
if (taskFeature) {
throw new TerminalTaskReconciliationError(
@@ -1508,6 +1518,16 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
if (task.missionId !== input.missionId || task.sliceId !== input.sliceId) {
throw new Error(`Cannot bootstrap feature ${input.featureId}: task ${input.taskId} has unrelated mission lineage`);
}
/*
FNXC:MissionFeatureClaimRace 2026-08-19-21:24 (RUFU-134 / PR #3491 Greptile P1):
No lockLiveTaskForClaim on this check, deliberately: both arms that reach it are already
serialized on the target. The afterTaskInsert arm runs inside the creating task's own
transaction, which holds the insert row lock on the new task until commit, so a concurrent
claimant blocks on that insert. The re-claim arm (claimDefinedFeatureTask,
requireExistingFeatureLink) requires this feature to already own the task, and the
single-valued invariant means no other feature can — an external claimant sees this link as
its conflict and fails. The feature row was locked first, preserving the feature→task order.
*/
const conflict = await getConflictingFeatureByTaskId(tx, input.taskId, input.featureId);
if (conflict) throw new Error(`Task ${input.taskId} is already linked to feature ${conflict.id}`);
@@ -1582,6 +1602,13 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
it here would corrupt that Feature's canonical task. Keep both tasks and
let each feature retain its own transactional bootstrap claim.
*/
/*
FNXC:MissionFeatureClaimRace 2026-08-19-21:24 (RUFU-134 / PR #3491 Greptile P1):
Hold the duplicate's row lock before the ownership check so a concurrent link/re-point
claiming the duplicate cannot commit between the read and the archive write. This path
takes no feature lock, so its single task lock cannot join a feature→task cycle.
*/
await lockLiveTaskForClaim(tx, input.duplicateTaskId);
const duplicateFeature = await getConflictingFeatureByTaskId(tx, input.duplicateTaskId, input.featureId);
if (duplicateFeature) return;
/*
@@ -1626,7 +1653,13 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
if (feature.taskId && feature.taskId !== taskId) {
throw new Error(`Feature ${featureId} is already linked to task ${feature.taskId}`);
}
const liveTask = await getLiveTaskById(tx, taskId);
/*
FNXC:MissionFeatureClaimRace 2026-08-19-21:24 (RUFU-134 / PR #3491 Greptile P1):
The liveness read IS the claim lock: FOR UPDATE on the live task row serializes concurrent
claimants on the same target (see lockLiveTaskForClaim), and the feature row was locked
first by getFeatureForStatusWrite, keeping the feature→task order cycle-free.
*/
const liveTask = await lockLiveTaskForClaim(tx, taskId);
if (!liveTask) {
throw new Error(
`Cannot link feature ${featureId} to task ${taskId}: task is not on the active board (it may be archived, deleted, or never existed). Only active tasks can be linked to features.`,
@@ -1661,13 +1694,115 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
}
async unlinkFeatureFromTask(featureId: string): Promise<MissionFeature> {
const feature = await getFeature(this.db, featureId);
if (!feature) throw new Error(`Feature ${featureId} not found`);
const { taskId } = feature;
const updated = await this.updateFeature(featureId, { taskId: undefined, status: "defined" });
if (taskId) await clearTaskMissionLinkage(this.db, taskId);
await this.recomputeSliceStatus(updated.sliceId);
return updated;
/*
FNXC:MissionFeatureUnlinkEvent 2026-08-17-12:20:
Unlink emits the same lifecycle event family as link/re-point (feature:updated + a persisted
mission:event + feature:unlinked) using an explicit mission-unlink source so subscribers can
distinguish an unlink from a generic feature_status_changed. The status event and the row
mutation share one transaction, so there is no incidental default-sourced status event.
FNXC:MissionFeatureUnlinkContract 2026-08-19-21:24 (RUFU-134 / PR #3491 CodeRabbit):
Unlinking a feature that has NO task is a caller error, not an idempotent no-op: every
documented surface (CLI fn_feature_unlink_task, the dashboard unlink-task route, the engine
agent tool, docs/missions.md) promises an error, and the old silent path was worse than a
no-op — it still rewrote the row (status→"defined") and recorded a status event, which would
have demoted a reverse-lineage-credited done feature (RUFU-109 credits status without
setting taskId). The guard runs after the feature row lock and before any mutation, so a
concurrent link/unlink cannot produce a partial unlink, and a failed unlink emits nothing.
The task-side reverse lineage is cleared INSIDE the same transaction; the pre-change code
ran the clear after commit, so a crash between the two writes left the task pointing at an
unlinked feature.
*/
const outcome = await this.layer.transactionImmediate(async (tx) => {
const feature = await this.getFeatureForStatusWrite(tx, featureId);
if (!feature) throw new Error(`Feature ${featureId} not found`);
const taskId = feature.taskId;
if (!taskId) {
throw new Error(`Feature ${featureId} is not linked to any task`);
}
const updated: MissionFeature = { ...feature, taskId: undefined, status: "defined", updatedAt: new Date().toISOString() };
await updateFeature(tx, updated);
const event = await this.recordFeatureStatusChange(tx, feature, "defined", { type: "system", id: "mission-store", source: "mission-unlink" });
await clearTaskMissionLinkage(tx, taskId);
return { feature: updated, event, taskId };
});
this.emit("feature:updated", outcome.feature);
if (outcome.event) this.emit("mission:event", outcome.event);
this.emit("feature:unlinked", { feature: outcome.feature, taskId: outcome.taskId });
await this.recomputeSliceStatus(outcome.feature.sliceId);
return outcome.feature;
}
/**
* Atomically re-point a feature's single-valued taskId to a different live
* target task, preserving the forward-link model's invariants. Re-point is the
* supported way to correct a feature pinned to the wrong task without the
* status-lossy unlink→link two-step.
*/
async repointFeatureToTask(featureId: string, taskId: string): Promise<MissionFeature> {
/*
FNXC:FeatureRepoint 2026-08-17-09:59:
Re-point is the supported way to correct a mis-pinned single-valued feature taskId
without the status-lossy unlink→link two-step. Single-valuedness is enforced at the
application layer (no DB unique constraint on mission_features.task_id): the
conflicting-feature query guards the target, and the feature row lock via
getFeatureForStatusWrite serializes concurrent re-points so two writers cannot
both observe the same pre-image.
*/
const outcome = await this.layer.transactionImmediate(async (tx) => {
const feature = await this.getFeatureForStatusWrite(tx, featureId);
if (!feature) throw new Error(`Feature ${featureId} not found`);
const fromTaskId = feature.taskId;
if (fromTaskId === taskId) return { feature, event: undefined, fromTaskId } as const;
/*
FNXC:MissionFeatureClaimRace 2026-08-19-21:24 (RUFU-134 / PR #3491 Greptile P1):
Same claim lock as link: the re-point target is locked before the conflict check so a
concurrent link/re-point on the same target serializes instead of both committing.
*/
const liveTask = await lockLiveTaskForClaim(tx, taskId);
if (!liveTask) {
throw new Error(
`Cannot re-point feature ${featureId} to task ${taskId}: task is not on the active board (it may be archived, deleted, or never existed). Only active tasks can be linked to features.`,
);
}
const conflictingFeature = await getConflictingFeatureByTaskId(tx, taskId, featureId);
if (conflictingFeature) {
throw new Error(`Task ${taskId} is already linked to feature ${conflictingFeature.id}`);
}
const slice = await getSlice(tx, feature.sliceId);
const milestone = slice ? await getMilestone(tx, slice.milestoneId) : undefined;
if (!slice || !milestone) throw new Error(`Feature ${featureId} has incomplete mission hierarchy`);
const now = new Date().toISOString();
/*
FNXC:FeatureRepoint 2026-08-17-09:59:
Loop/status transition reuses the link method's rule: a feature promoted out of
unlinked (idle/absent loopState) starts an implementing loop; an already-linked
feature keeps its status/loop/attempts when re-pointed. This contrasts with
unlink→link, which demotes to defined before the link re-promotes and therefore
discards loop progress.
*/
const transitioningFromUnlinked = !fromTaskId;
const shouldTransitionLoop = transitioningFromUnlinked && (!feature.loopState || feature.loopState === "idle");
const updated: MissionFeature = {
...feature,
taskId,
status: transitioningFromUnlinked ? "triaged" : feature.status,
...(shouldTransitionLoop ? { loopState: "implementing", implementationAttemptCount: 1 } : {}),
updatedAt: now,
};
await updateFeature(tx, updated);
const event = transitioningFromUnlinked
? await this.recordFeatureStatusChange(tx, feature, "triaged", { type: "system", id: "mission-store", source: "mission-repoint" })
: undefined;
if (fromTaskId) await clearTaskMissionLinkage(tx, fromTaskId);
await setTaskMissionLinkage(tx, taskId, milestone.missionId, slice.id);
return { feature: updated, event, fromTaskId: fromTaskId as string | undefined };
});
this.emit("feature:updated", outcome.feature);
if (outcome.event) this.emit("mission:event", outcome.event);
if (outcome.fromTaskId !== taskId) this.emit("feature:linked", { feature: outcome.feature, taskId });
await this.recomputeSliceStatus(outcome.feature.sliceId);
return outcome.feature;
}
// ════════════════ VALIDATOR RUNS ════════════════

View File

@@ -58,6 +58,7 @@ import type {
ValidationDiagnostics,
MissionTransitionActor,
MissionUpdateOptions,
FeatureUnlinkedPayload,
} from "./mission-types.js";
import { reconcileDeterministicDuplicate, runDeterministicDuplicateGuard } from "../duplicates/duplicate-guard.js";
import { resolveEntryPointBranchAssignment } from "../branch/branch-assignment.js";
@@ -202,6 +203,16 @@ export interface MissionStoreEvents {
"feature:deleted": [string];
/** Emitted when a feature is linked to a task */
"feature:linked": [{ feature: MissionFeature; taskId: string }];
/*
FNXC:MissionFeatureUnlinkEvent 2026-08-17-12:20:
The unlink primitive must emit the same lifecycle event family as link/re-point so SSE
subscribers and automation observe feature→task unlinks. Previously an unlink was only
observable as an incidental default-sourced `feature_status_changed` mission:event, so
consumers reacting to feature:linked missed unlink transitions entirely. Mirrors
feature:linked; taskId is undefined when the feature had no live link (idempotent unlink).
*/
/** Emitted when a feature is unlinked from a task */
"feature:unlinked": [FeatureUnlinkedPayload];
/** Emitted when a mission lifecycle event is persisted */
"mission:event": [MissionEvent];
/** Emitted when a contract assertion is created */
@@ -2751,6 +2762,96 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
return featureUpdate;
});
// FNXC:MissionFeatureUnlinkEvent 2026-08-17-12:20:
// Sync parity for feature:unlinked, mirroring the feature:linked emit in linkFeatureToTask.
// The sync store does not persist mission events; the EventEmitter emit is the parity surface.
this.emit("feature:unlinked", { feature: updated, taskId });
// Recompute slice status
this.recomputeSliceStatus(updated.sliceId);
return updated;
}
/**
* Atomically re-point a feature's single-valued taskId to a different live
* target task, preserving the forward-link model's invariants. Re-point is the
* supported way to correct a feature pinned to the wrong task without the
* status-lossy unlink→link two-step.
*
* @param featureId - Feature ID
* @param taskId - Task ID to re-point to
* @returns The updated feature
* @throws Error if feature not found, task not live, or task owned by another feature
*/
repointFeatureToTask(featureId: string, taskId: string): MissionFeature {
/*
FNXC:FeatureRepoint 2026-08-17-09:59:
Re-point is the supported way to correct a mis-pinned single-valued feature taskId
without the status-lossy unlink→link two-step. Single-valuedness is enforced at the
application layer (no DB unique constraint on mission_features.task_id): the
conflicting-feature query guards the target, and same-task re-point is an idempotent
no-op preserving status/loop/attempts.
*/
const feature = this.getFeature(featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
const fromTaskId = feature.taskId;
if (fromTaskId === taskId) return feature;
const liveTask = this.db
.prepare(`SELECT id FROM tasks WHERE id = ? AND "deletedAt" IS NULL`)
.get(taskId) as { id: string } | undefined;
if (!liveTask) {
throw new Error(
`Cannot re-point feature ${featureId} to task ${taskId}: task is not on the active board (it may be archived, deleted, or never existed). Only active tasks can be linked to features.`,
);
}
const conflictingFeature = this.db
.prepare(`SELECT id FROM mission_features WHERE taskId = ? AND id != ? LIMIT 1`)
.get(taskId, featureId) as { id: string } | undefined;
if (conflictingFeature) {
throw new Error(`Task ${taskId} is already linked to feature ${conflictingFeature.id}`);
}
const linkage = this.resolveTaskLinkage(feature.sliceId);
/*
FNXC:FeatureRepoint 2026-08-17-09:59:
Loop/status transition reuses the link method's rule: a feature promoted out of
unlinked (idle/absent loopState) starts an implementing loop; an already-linked
feature keeps its status/loop/attempts when re-pointed.
*/
const transitioningFromUnlinked = !fromTaskId;
const shouldTransitionLoop = transitioningFromUnlinked && (!feature.loopState || feature.loopState === "idle");
const loopStateUpdates: Partial<MissionFeature> = shouldTransitionLoop
? { loopState: "implementing", implementationAttemptCount: 1 }
: {};
const updated = this.db.transaction(() => {
const featureUpdate = this.updateFeature(featureId, {
taskId,
...(transitioningFromUnlinked ? { status: "triaged" as const } : {}),
...loopStateUpdates,
});
// Move the reverse mission/slice linkage: clear the old task, set the new one.
if (fromTaskId) {
this.db.prepare(`
UPDATE tasks SET missionId = NULL, sliceId = NULL WHERE id = ? AND "deletedAt" IS NULL
`).run(fromTaskId);
}
this.db.prepare(`
UPDATE tasks SET missionId = ?, sliceId = ? WHERE id = ? AND "deletedAt" IS NULL
`).run(linkage.missionId, linkage.sliceId, taskId);
this.db.bumpLastModified();
return featureUpdate;
});
if (transitioningFromUnlinked) this.emit("feature:linked", { feature: updated, taskId });
// Recompute slice status
this.recomputeSliceStatus(updated.sliceId);

View File

@@ -1004,6 +1004,14 @@ export interface FeatureLinkedPayload {
taskId: string;
}
/** Payload for feature:unlinked event */
export interface FeatureUnlinkedPayload {
/** The feature that was unlinked */
feature: MissionFeature;
/** ID of the task that was detached; undefined if the feature had no live link */
taskId?: string;
}
/** Payload for fix-feature:created event */
export interface FixFeatureCreatedPayload {
/** The generated fix feature */

View File

@@ -3016,7 +3016,22 @@ export function createMissionRouter(
throw badRequest("Feature is not linked to a task");
}
const feature = await missionStore.unlinkFeatureFromTask(featureId);
let feature;
try {
feature = await missionStore.unlinkFeatureFromTask(featureId);
} catch (error) {
/*
FNXC:MissionFeatureUnlinkRoute 2026-08-19-21:24 (RUFU-134 / PR #3491 CodeRabbit):
The pre-check above handles the normal case; the store still throws its not-linked
error if a concurrent unlink wins between the check and the write. That is a
client-visible precondition (4xx), not a server fault — an uncaught generic Error
would surface as a 500 via catchHandler.
*/
if (error instanceof Error && /is not linked to any task/.test(error.message)) {
throw badRequest(error.message);
}
throw error;
}
res.json(feature);
})
);

View File

@@ -850,6 +850,9 @@ export function createSSE(
const onFeatureLinked = (data: unknown) => {
send(`event: feature:linked\ndata: ${JSON.stringify(data)}\n\n`);
};
const onFeatureUnlinked = (data: unknown) => {
send(`event: feature:unlinked\ndata: ${JSON.stringify(data)}\n\n`);
};
const onAssertionCreated = (data: unknown) => {
send(`event: assertion:created\ndata: ${JSON.stringify(data)}\n\n`);
};
@@ -1113,6 +1116,7 @@ export function createSSE(
missionStore.off("feature:updated", onFeatureUpdated);
missionStore.off("feature:deleted", onFeatureDeleted);
missionStore.off("feature:linked", onFeatureLinked);
missionStore.off("feature:unlinked", onFeatureUnlinked);
missionStore.off("assertion:created", onAssertionCreated);
missionStore.off("assertion:updated", onAssertionUpdated);
missionStore.off("assertion:deleted", onAssertionDeleted);
@@ -1235,6 +1239,7 @@ export function createSSE(
missionStore.on("feature:updated", onFeatureUpdated);
missionStore.on("feature:deleted", onFeatureDeleted);
missionStore.on("feature:linked", onFeatureLinked);
missionStore.on("feature:unlinked", onFeatureUnlinked);
missionStore.on("assertion:created", onAssertionCreated);
missionStore.on("assertion:updated", onAssertionUpdated);
missionStore.on("assertion:deleted", onAssertionDeleted);

View File

@@ -18,7 +18,7 @@ describe("createMissionTools", () => {
expect(toolNames).toEqual([
"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",
"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_feature_repoint_task", "fn_feature_unlink_task", "fn_research_promote_finding",
]);
});
@@ -111,6 +111,26 @@ describe("createMissionTools", () => {
expect(result.details).toMatchObject({ feature: { taskId: "FN-1", status: "triaged" } });
});
it("delegates feature unlink to MissionStore unlinkFeatureFromTask", async () => {
const unlinkFeatureFromTask = vi.fn().mockResolvedValue({ id: "F-1", taskId: undefined, status: "defined" });
const store = { getMissionStore: () => ({ unlinkFeatureFromTask }) } as never;
const tool = createMissionTools(store).find((candidate) => candidate.name === "fn_feature_unlink_task")!;
const result = await tool.execute("call", { featureId: "F-1" });
expect(unlinkFeatureFromTask).toHaveBeenCalledWith("F-1");
expect(unlinkFeatureFromTask).toHaveBeenCalledTimes(1);
expect(result.details).toMatchObject({ feature: { taskId: undefined, status: "defined" } });
});
it("delegates feature re-point to MissionStore repointFeatureToTask exactly once", async () => {
const repointFeatureToTask = vi.fn().mockResolvedValue({ id: "F-1", taskId: "FN-2", status: "triaged" });
const store = { getMissionStore: () => ({ repointFeatureToTask }) } as never;
const tool = createMissionTools(store).find((candidate) => candidate.name === "fn_feature_repoint_task")!;
const result = await tool.execute("call", { featureId: "F-1", taskId: "FN-2" });
expect(repointFeatureToTask).toHaveBeenCalledWith("F-1", "FN-2");
expect(repointFeatureToTask).toHaveBeenCalledTimes(1);
expect(result.details).toMatchObject({ feature: { taskId: "FN-2", status: "triaged" } });
});
it("promotes completed findings through the idempotent mission-store facade", async () => {
const addResearchFeature = vi.fn().mockResolvedValue({ reused: false, feature: { id: "F-1", status: "defined" } });
const store = {

View File

@@ -3389,7 +3389,8 @@ describe("executeHeartbeat", () => {
// fn_artifact_register/list/view, agent config/provisioning, mission hierarchy, ideation, goals/evaluations/identity,
// task read discovery (incl. logs_read), workflow discovery/authoring, task promotion, bounded research, clarification, web fetch, memory, and fn_heartbeat_done.
// FN-8948 added fn_mission_reconcile and the feature-validation repair surface added fn_feature_repair_validation. Count rose 66→68; keep exact so new tools fail loudly.
expect(callArgs.customTools).toHaveLength(68);
// RUFU-110 added fn_feature_repoint_task and fn_feature_unlink_task to the mission surface. Count rose 68→70; keep exact so new tools fail loudly.
expect(callArgs.customTools).toHaveLength(70);
expect(callArgs.customTools!.map((tool) => tool.name)).toEqual([
"fn_task_create",
"fn_task_log",
@@ -3425,6 +3426,8 @@ describe("executeHeartbeat", () => {
"fn_feature_set_status",
"fn_feature_delete",
"fn_feature_link_task",
"fn_feature_repoint_task",
"fn_feature_unlink_task",
"fn_research_promote_finding",
"fn_ideation_list",
"fn_ideation_show",

View File

@@ -295,4 +295,428 @@ describe("reconcileMissionState", () => {
expect(linkFeatureToTask).not.toHaveBeenCalled();
});
it("reverts a feature to in-progress when its only link is a live non-satisfying task (forward model preserved)", async () => {
const forward = {
id: "T-LIVE", title: "Vision", 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: forward.id, status: "triaged",
createdAt: "2026-08-11T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z",
};
const updateFeatureStatus = vi.fn();
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([]),
updateFeatureStatus,
linkFeatureToTask,
};
const taskStore = {
listTasks: vi.fn().mockResolvedValue([forward]),
getTask: vi.fn().mockResolvedValue(forward),
getLatestSpecDriftReport: vi.fn().mockResolvedValue(undefined),
};
await reconcileMissionState({ taskStore: taskStore as never, missionStore }, { source: "self-healing" });
expect(updateFeatureStatus).toHaveBeenCalledWith("F-1", "in-progress", expect.anything());
expect(updateFeatureStatus).not.toHaveBeenCalledWith("F-1", "done", expect.anything());
expect(linkFeatureToTask).not.toHaveBeenCalled();
expect(feature.taskId).toBe(forward.id);
});
it("reconciles a feature to done via a done reverse-lineage task, leaving the forward link untouched", async () => {
const forward = {
id: "RUFU-101", title: "Vision", column: "in-progress", status: "in-progress",
missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:00:00.000Z",
};
const delivery = {
id: "RUFU-108", title: "Delivery", column: "done", status: undefined,
missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:01:00.000Z",
sourceMetadata: { missionLineage: { missionId: "M-1", sliceId: "SL-1", featureId: "F-1" } },
};
const feature = {
id: "F-1", title: "Delivery", sliceId: "SL-1", taskId: forward.id, status: "in-progress",
createdAt: "2026-08-11T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z",
};
const updateFeatureStatus = vi.fn();
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([]),
updateFeatureStatus,
linkFeatureToTask,
};
const taskStore = {
listTasks: vi.fn().mockResolvedValue([forward, delivery]),
getTask: vi.fn((taskId: string) => Promise.resolve(taskId === forward.id ? forward : delivery)),
getLatestSpecDriftReport: vi.fn().mockResolvedValue(undefined),
getTaskWorkflowSelectionsAsync: vi.fn().mockResolvedValue(new Map([
[forward.id, { workflowId: "custom:delivery", stepIds: [] }],
[delivery.id, { workflowId: "custom:delivery", stepIds: [] }],
])),
getTaskWorkflowSelectionAsync: vi.fn().mockResolvedValue({ workflowId: "custom:delivery", stepIds: [] }),
getWorkflowDefinition: vi.fn().mockResolvedValue({
ir: {
version: "v2", id: "custom:delivery", nodes: [], edges: [],
columns: [
{ id: "in-progress", label: "In Progress", traits: [{ trait: "wip" }] },
{ id: "todo", label: "Todo", traits: [{ trait: "hold" }] },
{ id: "done", label: "Done", traits: [{ trait: "complete" }] },
],
},
}),
};
await reconcileMissionState({ taskStore: taskStore as never, missionStore }, { source: "self-healing" });
expect(updateFeatureStatus).toHaveBeenCalledWith(
"F-1",
"done",
{ actor: { type: "system", id: "mission-reconcile", source: "mission-reconcile:self-healing" } },
);
expect(feature.taskId).toBe(forward.id);
expect(linkFeatureToTask).not.toHaveBeenCalled();
});
it("keeps a feature active when a live lineage follow-up exists even with a done delivery", async () => {
const forward = {
id: "RUFU-101", title: "Vision", column: "done", status: undefined,
missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:00:00.000Z",
};
const followUp = {
id: "FN-2", title: "Follow-up", column: "todo", status: "queued",
missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:00:30.000Z",
sourceMetadata: { missionLineage: { missionId: "M-1", sliceId: "SL-1", featureId: "F-1" } },
};
const delivery = {
id: "RUFU-108", title: "Delivery", column: "done", status: undefined,
missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:01:00.000Z",
sourceMetadata: { missionLineage: { missionId: "M-1", sliceId: "SL-1", featureId: "F-1" } },
};
const feature = {
id: "F-1", title: "Delivery", sliceId: "SL-1", taskId: forward.id, status: "done",
createdAt: "2026-08-11T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z",
};
const updateFeatureStatus = 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([]),
updateFeatureStatus,
};
const taskStore = {
listTasks: vi.fn().mockResolvedValue([forward, followUp, delivery]),
getTask: vi.fn((taskId: string) => Promise.resolve(
taskId === forward.id ? forward : taskId === followUp.id ? followUp : delivery,
)),
getLatestSpecDriftReport: vi.fn().mockResolvedValue(undefined),
};
await reconcileMissionState({ taskStore: taskStore as never, missionStore }, { source: "self-healing" });
expect(updateFeatureStatus).not.toHaveBeenCalledWith("F-1", "done", expect.anything());
expect(updateFeatureStatus).toHaveBeenCalledWith("F-1", "in-progress", expect.anything());
});
it("holds an already-done feature idempotently without a second write when a done lineage delivery exists", async () => {
const forward = {
id: "RUFU-101", title: "Vision", column: "done", status: undefined,
missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:00:00.000Z",
};
const delivery = {
id: "RUFU-108", title: "Delivery", column: "done", status: undefined,
missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:01:00.000Z",
sourceMetadata: { missionLineage: { missionId: "M-1", sliceId: "SL-1", featureId: "F-1" } },
};
const feature = {
id: "F-1", title: "Delivery", sliceId: "SL-1", taskId: forward.id, status: "done",
createdAt: "2026-08-11T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z",
};
const updateFeatureStatus = 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([]),
updateFeatureStatus,
};
const taskStore = {
listTasks: vi.fn().mockResolvedValue([forward, delivery]),
getTask: vi.fn((taskId: string) => Promise.resolve(taskId === forward.id ? forward : delivery)),
getLatestSpecDriftReport: vi.fn().mockResolvedValue(undefined),
};
await reconcileMissionState({ taskStore: taskStore as never, missionStore }, { source: "self-healing" });
expect(updateFeatureStatus).not.toHaveBeenCalled();
});
it("does not credit a done-candidate lineage task that failed or errored", async () => {
const forward = {
id: "RUFU-101", title: "Vision", column: "in-progress", status: "in-progress",
missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:00:00.000Z",
};
const failed = {
id: "RUFU-108", title: "Failed delivery", column: "done", status: "failed", error: "boom",
missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:01:00.000Z",
sourceMetadata: { missionLineage: { missionId: "M-1", sliceId: "SL-1", featureId: "F-1" } },
};
/*
FNXC:MissionReconcileFailedLineage 2026-08-19-21:44 (RUFU-134 / PR #3491):
The feature starts in-progress (not done) so the reverse-credit branch is actually REACHABLE
for this fixture: if a failed/errored terminal lineage task were (wrongly) treated as a
satisfying done delivery, the branch would credit it to done and the assertion below would
catch it. With the fixture at status "done" the branch gate (feature.status !== "done")
short-circuited and the test passed for the wrong reason.
*/
const feature = {
id: "F-1", title: "Delivery", sliceId: "SL-1", taskId: forward.id, status: "in-progress",
createdAt: "2026-08-11T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z",
};
const updateFeatureStatus = 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([]),
updateFeatureStatus,
};
const taskStore = {
listTasks: vi.fn().mockResolvedValue([forward, failed]),
getTask: vi.fn((taskId: string) => Promise.resolve(taskId === forward.id ? forward : failed)),
getLatestSpecDriftReport: vi.fn().mockResolvedValue(undefined),
};
await reconcileMissionState({ taskStore: taskStore as never, missionStore }, { source: "self-healing" });
expect(updateFeatureStatus).not.toHaveBeenCalledWith("F-1", "done", expect.anything());
expect(feature.status).toBe("in-progress");
});
/*
FNXC:MissionReverseLineageSpecAlignment 2026-08-19-21:44 (RUFU-134 / PR #3491):
A reverse-lineage-credited feature must get the same spec-alignment projection the live path
always applies — computed against the SATISFYING delivery task, not the unrelated forward
link — and a dry-run pass must preview the write instead of applying it.
*/
it("projects spec alignment from the satisfying delivery task when reverse-crediting a feature", async () => {
const forward = {
id: "RUFU-101", title: "Vision", column: "in-progress", status: "in-progress",
missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:00:00.000Z",
};
const delivery = {
id: "RUFU-108", title: "Delivery", column: "done", status: undefined,
missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:01:00.000Z",
sourceMetadata: { missionLineage: { missionId: "M-1", sliceId: "SL-1", featureId: "F-1" } },
};
const feature = {
id: "F-1", title: "Delivery", sliceId: "SL-1", taskId: forward.id, status: "in-progress",
specAlignment: "on-plan",
createdAt: "2026-08-11T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z",
};
const updateFeatureStatus = vi.fn();
const updateFeature = vi.fn().mockResolvedValue(undefined);
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,
updateFeature,
};
const taskStore = {
listTasks: vi.fn().mockResolvedValue([forward, delivery]),
getTask: vi.fn((taskId: string) => Promise.resolve(taskId === forward.id ? forward : delivery)),
getLatestSpecDriftReport: vi.fn().mockResolvedValue({ alignment: "diverged-needs-review" }),
getTaskWorkflowSelectionsAsync: vi.fn().mockResolvedValue(new Map([
[forward.id, { workflowId: "custom:delivery", stepIds: [] }],
[delivery.id, { workflowId: "custom:delivery", stepIds: [] }],
])),
getTaskWorkflowSelectionAsync: vi.fn().mockResolvedValue({ workflowId: "custom:delivery", stepIds: [] }),
getWorkflowDefinition: vi.fn().mockResolvedValue({
ir: {
version: "v2", id: "custom:delivery", nodes: [], edges: [],
columns: [
{ id: "in-progress", label: "In Progress", traits: [{ trait: "wip" }] },
{ id: "todo", label: "Todo", traits: [{ trait: "hold" }] },
{ id: "done", label: "Done", traits: [{ trait: "complete" }] },
],
},
}),
};
await reconcileMissionState({ taskStore: taskStore as never, missionStore }, { source: "self-healing" });
expect(updateFeatureStatus).toHaveBeenCalledWith(
"F-1", "done",
{ actor: { type: "system", id: "mission-reconcile", source: "mission-reconcile:self-healing" } },
);
// The alignment is projected from the delivery task's drift report, not the forward link's.
expect(taskStore.getLatestSpecDriftReport).toHaveBeenCalledWith(delivery.id);
expect(updateFeature).toHaveBeenCalledWith(
"F-1", { specAlignment: "diverged-needs-review" },
{ actor: { type: "system", id: "mission-reconcile", source: "mission-reconcile:self-healing" } },
);
});
it("previews the reverse-lineage spec-alignment write in a dry run without mutating", async () => {
const forward = {
id: "RUFU-101", title: "Vision", column: "in-progress", status: "in-progress",
missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:00:00.000Z",
};
const delivery = {
id: "RUFU-108", title: "Delivery", column: "done", status: undefined,
missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:01:00.000Z",
sourceMetadata: { missionLineage: { missionId: "M-1", sliceId: "SL-1", featureId: "F-1" } },
};
const feature = {
id: "F-1", title: "Delivery", sliceId: "SL-1", taskId: forward.id, status: "in-progress",
specAlignment: "on-plan",
createdAt: "2026-08-11T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z",
};
const updateFeatureStatus = vi.fn();
const updateFeature = vi.fn().mockResolvedValue(undefined);
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,
updateFeature,
};
const taskStore = {
listTasks: vi.fn().mockResolvedValue([forward, delivery]),
getTask: vi.fn((taskId: string) => Promise.resolve(taskId === forward.id ? forward : delivery)),
getLatestSpecDriftReport: vi.fn().mockResolvedValue({ alignment: "diverged-needs-review" }),
getTaskWorkflowSelectionsAsync: vi.fn().mockResolvedValue(new Map([
[forward.id, { workflowId: "custom:delivery", stepIds: [] }],
[delivery.id, { workflowId: "custom:delivery", stepIds: [] }],
])),
getTaskWorkflowSelectionAsync: vi.fn().mockResolvedValue({ workflowId: "custom:delivery", stepIds: [] }),
getWorkflowDefinition: vi.fn().mockResolvedValue({
ir: {
version: "v2", id: "custom:delivery", nodes: [], edges: [],
columns: [
{ id: "in-progress", label: "In Progress", traits: [{ trait: "wip" }] },
{ id: "todo", label: "Todo", traits: [{ trait: "hold" }] },
{ id: "done", label: "Done", traits: [{ trait: "complete" }] },
],
},
}),
};
const result = await reconcileMissionState(
{ taskStore: taskStore as never, missionStore },
{ source: "self-healing", dryRun: true },
);
expect(result.planned).toContainEqual({ featureId: "F-1", action: "status" });
expect(result.planned).toContainEqual({ featureId: "F-1", action: "spec-alignment" });
expect(updateFeature).not.toHaveBeenCalled();
expect(updateFeatureStatus).not.toHaveBeenCalled();
});
it("previews a reverse-lineage done credit as a planned status update without mutating", async () => {
const forward = {
id: "RUFU-101", title: "Vision", column: "in-progress", status: "in-progress",
missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:00:00.000Z",
};
const delivery = {
id: "RUFU-108", title: "Delivery", column: "done", status: undefined,
missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:01:00.000Z",
sourceMetadata: { missionLineage: { missionId: "M-1", sliceId: "SL-1", featureId: "F-1" } },
};
const feature = {
id: "F-1", title: "Delivery", sliceId: "SL-1", taskId: forward.id, status: "in-progress",
createdAt: "2026-08-11T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z",
};
const updateFeatureStatus = 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([]),
updateFeatureStatus,
};
const taskStore = {
listTasks: vi.fn().mockResolvedValue([forward, delivery]),
getTask: vi.fn((taskId: string) => Promise.resolve(taskId === forward.id ? forward : delivery)),
getLatestSpecDriftReport: vi.fn().mockResolvedValue(undefined),
};
const result = await reconcileMissionState(
{ taskStore: taskStore as never, missionStore },
{ source: "self-healing", dryRun: true },
);
expect(result.planned).toContainEqual({ featureId: "F-1", action: "status" });
expect(updateFeatureStatus).not.toHaveBeenCalled();
});
it("holds a reverse-lineage feature at done across two reconcile passes (reversion loop broken)", async () => {
// Mirrors the live mission M-MSL4E01A-0001-Y9QC / F-MSL72J08-000L-ZGFL shape:
// the single-valued forward link is pinned to RUFU-101 (the shared vision doc, which does
// not satisfy the AC) while the done delivery RUFU-108 carries the correct reverse lineage.
const forward = {
id: "RUFU-101", title: "Vision", column: "in-progress", status: "in-progress",
missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:00:00.000Z",
};
const delivery = {
id: "RUFU-108", title: "Delivery", column: "done", status: undefined,
missionId: "M-1", sliceId: "SL-1", updatedAt: "2026-08-11T00:01:00.000Z",
sourceMetadata: { missionLineage: { missionId: "M-1", sliceId: "SL-1", featureId: "F-1" } },
};
const feature = {
id: "F-1", title: "Delivery", sliceId: "SL-1", taskId: forward.id, status: "in-progress",
createdAt: "2026-08-11T00:00:00.000Z", updatedAt: "2026-08-11T00:00:00.000Z",
};
const updateFeatureStatus = vi.fn().mockImplementation((_id: string, status: string) => {
// The real store persists the status; mirror that so pass 2 observes the feature already done.
feature.status = status;
return Promise.resolve(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,
};
const taskStore = {
listTasks: vi.fn().mockResolvedValue([forward, delivery]),
getTask: vi.fn((taskId: string) => Promise.resolve(taskId === forward.id ? forward : delivery)),
getLatestSpecDriftReport: vi.fn().mockResolvedValue(undefined),
};
const deps = { taskStore: taskStore as never, missionStore };
const pass1 = await reconcileMissionState(deps, { source: "self-healing" });
expect(updateFeatureStatus).toHaveBeenCalledWith("F-1", "done", expect.anything());
expect(feature.status).toBe("done");
expect(pass1.statusUpdates).toBeGreaterThan(0);
updateFeatureStatus.mockClear();
const pass2 = await reconcileMissionState(deps, { source: "self-healing" });
// Feature is already `done` and the done delivery persists, so the second pass performs NO
// status write and the feature holds `done` — the reversion loop is broken.
expect(updateFeatureStatus).not.toHaveBeenCalled();
expect(feature.status).toBe("done");
expect(pass2.statusUpdates).toBe(0);
});
});

View File

@@ -54,6 +54,8 @@ describe("workflow-step readonly allowlist policy", () => {
"fn_milestone_delete",
"fn_slice_activate",
"fn_feature_link_task",
"fn_feature_unlink_task",
"fn_feature_repoint_task",
"fn_feature_update",
"fn_feature_repair_validation",
"fn_feature_set_status",

View File

@@ -4396,6 +4396,8 @@ export const featureUpdateParams = Type.Object({ id: Type.String(), title: Type.
export const featureDeleteParams = Type.Object({ featureId: Type.String(), force: Type.Optional(Type.Boolean()) });
export const featureSetStatusParams = Type.Object({ id: Type.String(), status: Type.Union(fusionCore.FEATURE_STATUSES.map((status) => Type.Literal(status))), reason: Type.Optional(Type.String()) });
export const featureLinkTaskParams = Type.Object({ featureId: Type.String(), taskId: Type.String() });
export const featureRepointTaskParams = featureLinkTaskParams;
export const featureUnlinkTaskParams = Type.Object({ featureId: Type.String() });
export const featureRepairValidationParams = Type.Object({ id: Type.String(), action: Type.Union([Type.Literal("clear"), Type.Literal("re_run")]), reason: Type.Optional(Type.String()) });
export const researchFindingPromoteParams = Type.Object({
runId: Type.String(),
@@ -4592,6 +4594,8 @@ export function createMissionTools(store: TaskStore, context: MissionToolActorCo
}),
tool("fn_feature_delete", "Delete Feature", "Delete a feature, respecting linked-task guards.", featureDeleteParams, async (p) => { await store.getMissionStore().deleteFeature(p.featureId, p.force ===true); return missionToolResult(`Deleted ${p.featureId}`, { featureId: p.featureId }); }),
tool("fn_feature_link_task", "Link Feature to Task", "Link a feature to a live project-scoped task.", featureLinkTaskParams, async (p) => { const feature = await store.getMissionStore().linkFeatureToTask(p.featureId, p.taskId); return missionToolResult(`Linked ${feature.id} to ${p.taskId}`, { feature }); }),
tool("fn_feature_repoint_task", "Re-point Feature to Task", "Atomically re-point an already-linked feature's single-valued taskId to a different live project-scoped task, correcting a mis-pinned link without the status-lossy unlink then link two-step. Same-task re-point is an idempotent no-op.", featureRepointTaskParams, async (p) => { const feature = await store.getMissionStore().repointFeatureToTask(p.featureId, p.taskId); return missionToolResult(`Re-pointed ${feature.id} to ${p.taskId}`, { feature }); }),
tool("fn_feature_unlink_task", "Unlink Feature from Task", "Detach a feature from its linked task entirely, clearing its single-valued taskId and demoting its status to defined (for use before the documented safe duplicate-cleanup flow). Fails if the feature is not currently linked.", featureUnlinkTaskParams, async (p) => { const feature = await store.getMissionStore().unlinkFeatureFromTask(p.featureId); return missionToolResult(`Unlinked ${feature.id} from its task`, { feature }); }),
tool("fn_research_promote_finding", "Promote Research Finding", "Promote a completed research finding into a canonical mission feature.", researchFindingPromoteParams, async (p) => {
const missionStore = store.getMissionStore();
if (!("addResearchFeature" in missionStore)) return missionToolResult("Research promotion requires the PostgreSQL mission store", { code: "POSTGRES_REQUIRED" }, true);

View File

@@ -112,6 +112,8 @@ const PERMANENT_TASK_AGENT_ONLY_TOOLS = [
"fn_milestone_delete",
"fn_slice_activate",
"fn_feature_link_task",
"fn_feature_unlink_task",
"fn_feature_repoint_task",
"fn_feature_update",
"fn_feature_repair_validation",
"fn_feature_set_status",

View File

@@ -1,4 +1,4 @@
import type { DriftAlignment, DriftReport, MissionFeature, Task, TaskStore } from "@fusion/core";
import type { DriftAlignment, DriftReport, MissionFeature, Task, TaskStore, WorkflowSelectionCache } from "@fusion/core";
import { getTaskCompletionBlockerForStore } from "../execution/task-completion.js";
import { resolveLifecycleColumns, resolveTaskLifecycleColumns, resolveWorkflowIrForTask } from "@fusion/core";
@@ -198,6 +198,16 @@ export async function reconcileMissionFeatureState(
task: Task,
feature: Pick<MissionFeature, "id" | "status" | "lastValidatorStatus" | "specAlignment">,
context: MissionFeatureSyncContext = {},
/*
FNXC:MissionReverseLineageSpecAlignment 2026-08-19-21:44 (RUFU-134 / PR #3491):
Optional caller-owned IR cache: a reconcile pass that already resolved this task's workflow
(the terminal-column snapshot) projects alignment without a second per-task selection read.
Single-task callers omit it and keep the original behavior.
*/
caches?: {
irCache?: Map<string, Awaited<ReturnType<typeof resolveWorkflowIrForTask>>>;
selectionCache?: WorkflowSelectionCache;
},
): Promise<MissionFeatureSyncDecision> {
const alignment = await resolveMissionFeatureAlignment(taskStore, task.id);
@@ -247,7 +257,7 @@ export async function reconcileMissionFeatureState(
The predictor that found it is mechanical rather than clever: grep for two resolver calls inside one
function.
*/
const ir = await resolveWorkflowIrForTask(taskStore, task.id).catch(() => undefined);
const ir = await resolveWorkflowIrForTask(taskStore, task.id, caches?.irCache, caches?.selectionCache).catch(() => undefined);
const roles = ir ? resolveLifecycleColumns(ir) : undefined;
/*
FNXC:MissionFeatureSyncLanes 2026-07-30-05:40 (PR #2602 review — greptile P1):

View File

@@ -110,6 +110,14 @@ export async function reconcileMissionState(
}
const byTitle = new Map<string, Task | null>();
const featuresWithLiveLineageDescendants = new Set<string>();
/*
FNXC:MissionReverseLineageCredit 2026-08-17-09:08 (RUFU-109):
A terminal, non-failed task carrying a feature's reverse `missionLineage` (featureId/sliceId/
missionId) is an ADDITIONAL satisfying input that credits the feature as meeting its AC,
independent of the single-valued forward `feature.taskId` link. A feature key lands here only
when none of its lineage candidates is live, so a live follow-up keeps the feature active.
*/
const featuresWithDoneLineageDelivery = new Set<string>();
const lineageCandidates: Array<{ task: Task; key: string }> = [];
for (const task of liveTasks) {
if (!task.sliceId || !task.missionId || !selectedIds.has(task.missionId)) continue;
@@ -148,14 +156,47 @@ export async function reconcileMissionState(
}
}
const lineageBatchSize = 8;
/*
FNXC:MissionReverseLineageCredit 2026-08-17-09:08 (RUFU-109):
Each lineage candidate records whether it keeps its feature live AND whether it is a satisfied
done-lineage delivery. The liveness classification reuses the existing `resolveTerminalColumnsFor`
call unchanged; a terminal (done/archived/complete or soft-deleted-with-retained-evidence),
non-failed candidate additionally credits its feature. A feature key is added to
`featuresWithDoneLineageDelivery` only when none of its candidates is live.
*/
const lineageDeliverySnapshot = new Map<string, { hasLive: boolean; hasSatisfyingDoneDelivery: boolean; satisfyingTask?: Task }>();
for (let index = 0; index < lineageCandidates.length; index += lineageBatchSize) {
const batch = lineageCandidates.slice(index, index + lineageBatchSize);
const live = await Promise.all(batch.map(async ({ task, key }) => ({
key,
isLive: !task.deletedAt && !(await resolveTerminalColumnsFor(deps.taskStore, task.id, terminalIrCache, selectionCache)).includes(task.column),
})));
for (const candidate of live) {
const snapshot = await Promise.all(batch.map(async ({ task, key }) => {
const terminalColumns = await resolveTerminalColumnsFor(deps.taskStore, task.id, terminalIrCache, selectionCache);
/*
FNXC:MissionReconcileLiveTasks 2026-08-19-21:44 (RUFU-134 / PR #3491):
liveTasks comes from listTasks({ includeArchived: false }) with includeDeleted UNSET, so
soft-deleted rows never reach this loop (VAL-DATA-005: live readers leave tombstoned tasks
off the board). The deletedAt arms in isLive/isDoneDelivery were therefore dead and are
deleted; a tombstone's retained-evidence classification belongs to the store's terminal
evidence query, not this snapshot.
*/
const isLive = !terminalColumns.includes(task.column);
const isDoneDelivery = !task.error && task.status !== "failed" && terminalColumns.includes(task.column);
return { key, isLive, isDoneDelivery, task };
}));
for (const candidate of snapshot) {
if (candidate.isLive) featuresWithLiveLineageDescendants.add(candidate.key);
const prior = lineageDeliverySnapshot.get(candidate.key);
lineageDeliverySnapshot.set(candidate.key, {
hasLive: (prior?.hasLive ?? false) || candidate.isLive,
hasSatisfyingDoneDelivery: (prior?.hasSatisfyingDoneDelivery ?? false) || candidate.isDoneDelivery,
satisfyingTask: candidate.isDoneDelivery ? (prior?.satisfyingTask ?? candidate.task) : prior?.satisfyingTask,
});
}
}
/* FNXC:MissionReverseLineageSpecAlignment 2026-08-19-21:44 (RUFU-134 / PR #3491): first satisfying delivery per key, remembered so the reverse-credit branch can project spec alignment against the real delivery task. */
const lineageSatisfyingTasks = new Map<string, Task>();
for (const [key, delivery] of lineageDeliverySnapshot) {
if (!delivery.hasLive && delivery.hasSatisfyingDoneDelivery) {
featuresWithDoneLineageDelivery.add(key);
if (delivery.satisfyingTask) lineageSatisfyingTasks.set(key, delivery.satisfyingTask);
}
}
for (const { mission, hierarchy } of selectedHierarchies) {
@@ -173,6 +214,9 @@ export async function reconcileMissionState(
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 featureLineageKey = lineageKey(mission.id, slice.id, feature.id);
const hasDoneLineageDelivery = featuresWithDoneLineageDelivery.has(featureLineageKey);
const hasLiveLineage = featuresWithLiveLineageDescendants.has(featureLineageKey);
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) {
/*
@@ -184,15 +228,68 @@ export async function reconcileMissionState(
if (options.dryRun) result.planned!.push({ featureId: feature.id, action: "link" });
else feature = await missionApi.linkFeatureToTask(feature.id, task.id);
}
/*
FNXC:MissionReverseLineageCredit 2026-08-17-09:08 (RUFU-109):
A done reverse-lineage delivery is an ADDITIONAL satisfying input. It must not bypass the
live-lineage done-withholding (`hasLiveLineage` takes precedence and keeps the feature
active), and it must leave the single-valued forward-link `feature.taskId` untouched — never
call `updateFeature` or `linkFeatureToTask` in this branch. Reuses the forward-path badge-clear
so an already-passed validation is not left needing repair.
*/
if (hasDoneLineageDelivery && !hasLiveLineage && feature.status !== "done") {
const reverseNeedsRepair = feature.status === "blocked" || feature.loopState === "blocked" || feature.loopState === "needs_fix";
const assertions = missionApi.listAssertionsForFeature ? await missionApi.listAssertionsForFeature(feature.id) : [];
if (options.dryRun) result.planned!.push({ featureId: feature.id, action: "status" });
else { await missionApi.updateFeatureStatus(feature.id, "done", { actor }); result.statusUpdates++; }
if (reverseNeedsRepair) {
const complete = !assertions.length || feature.lastValidatorStatus === "passed";
if (complete && !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 }); result.badgeRepairs++; }
catch { result.conflicts++; }
}
}
}
/*
FNXC:MissionReverseLineageSpecAlignment 2026-08-19-21:44 (RUFU-134 / PR #3491):
The reverse-credit branch used to stop at the status credit and skip the spec-alignment
projection that the live path always applies, so a reverse-lineage-credited feature kept
a stale specAlignment until some other pass touched its forward link. Project the same
alignment against the SATISFYING delivery task (the title-matched `task` here may be
absent or unrelated — the feature has no forward link to it); only decision.alignment
is consumed, the status decision is ignored (the branch already credited done).
Fail-soft per the capability guard: no captured delivery task or no write capability
skips the projection, exactly like the live path.
*/
const satisfyingTask = lineageSatisfyingTasks.get(featureLineageKey);
if (satisfyingTask) {
/* FNXC:MissionReverseLineageSpecAlignment 2026-08-19-21:44 (RUFU-134 / PR #3491): the snapshot loop already resolved this delivery's selection and IR (resolveTerminalColumnsFor), so reusing both caches keeps the projection read-free instead of triggering a second per-task selection read. */
const plannerColumns = await resolvePlannerLanesForTask(deps.taskStore, satisfyingTask.id, terminalIrCache, selectionCache) ?? [];
const decision = await reconcileMissionFeatureState(deps.taskStore, satisfyingTask, feature, {
hasLinkedAssertions: assertions.length > 0,
hasLiveLineageDescendants: false,
plannerColumns,
}, { irCache: terminalIrCache, selectionCache });
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 });
}
}
await deps.extensionHook?.({ feature, task });
continue;
}
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 plannerColumns = await resolvePlannerLanesForTask(deps.taskStore, task.id, terminalIrCache, selectionCache) ?? [];
const decision = await reconcileMissionFeatureState(deps.taskStore, task, feature, {
hasLinkedAssertions: assertions.length > 0,
hasLiveLineageDescendants: featuresWithLiveLineageDescendants.has(lineageKey(mission.id, slice.id, feature.id)),
plannerColumns,
});
}, { irCache: terminalIrCache, selectionCache });
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" });

View File

@@ -27,7 +27,7 @@ is stranded rather than in a planner lane, and rescuing it belongs to the
undeclared-column sweep, not to these guards. `undefined` is reserved for the case
where even the default cannot be resolved.
*/
import { resolveLifecycleColumns, resolveTaskLifecycleColumns, resolveWorkflowIrForTask, type TaskStore, type WorkflowIr } from "@fusion/core";
import { resolveLifecycleColumns, resolveTaskLifecycleColumns, resolveWorkflowIrForTask, type TaskStore, type WorkflowIr, type WorkflowSelectionCache } from "@fusion/core";
/*
FNXC:WorkflowLifecycleColumns 2026-07-31-04:10 (PR #2616 review — greptile; a real
@@ -54,8 +54,15 @@ export async function resolvePlannerLanesForTask(
store: TaskStore,
taskId: string,
cache?: Map<string, WorkflowIr>,
/*
FNXC:MissionReverseLineageSpecAlignment 2026-08-19-21:44 (RUFU-134 / PR #3491):
The selection cache (task → workflow selection) is what prevents a second per-task
SELECTION read; the IR cache is keyed by workflow id. Sweep callers pre-populate the
selection cache with one batched plural read, mirroring resolveTerminalColumnsFor.
*/
selectionCache?: WorkflowSelectionCache,
): Promise<readonly string[] | undefined> {
const ir = await resolveWorkflowIrForTask(store, taskId, cache).catch(() => undefined);
const ir = await resolveWorkflowIrForTask(store, taskId, cache, selectionCache).catch(() => undefined);
if (!ir) return undefined;
const roles = resolveLifecycleColumns(ir);
if (!roles) return undefined;