diff --git a/.changeset/rufu-110-feature-unlink-repoint.md b/.changeset/rufu-110-feature-unlink-repoint.md new file mode 100644 index 0000000000..cff8a0d4c7 --- /dev/null +++ b/.changeset/rufu-110-feature-unlink-repoint.md @@ -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`. \ No newline at end of file diff --git a/docs/missions.md b/docs/missions.md index b02c1d48af..455ec94f53 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -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 **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 diff --git a/packages/cli/skill/fusion/SKILL.md b/packages/cli/skill/fusion/SKILL.md index 4699d3c8a8..88933f553a 100644 --- a/packages/cli/skill/fusion/SKILL.md +++ b/packages/cli/skill/fusion/SKILL.md @@ -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` diff --git a/packages/cli/skill/fusion/references/extension-tools.md b/packages/cli/skill/fusion/references/extension-tools.md index ded37f06ee..8774123d6f 100644 --- a/packages/cli/skill/fusion/references/extension-tools.md +++ b/packages/cli/skill/fusion/references/extension-tools.md @@ -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. diff --git a/packages/cli/skill/fusion/references/fusion-capabilities.md b/packages/cli/skill/fusion/references/fusion-capabilities.md index 79b9414aea..b373891001 100644 --- a/packages/cli/skill/fusion/references/fusion-capabilities.md +++ b/packages/cli/skill/fusion/references/fusion-capabilities.md @@ -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. | diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index 3743c2d677..f3afc85bb9 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -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({ diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 53bb4d8743..2037da7b38 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -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({ diff --git a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts index 707799c9aa..fc69b85fff 100644 --- a/packages/core/src/__tests__/postgres/mission-store.pg.test.ts +++ b/packages/core/src/__tests__/postgres/mission-store.pg.test.ts @@ -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" }); diff --git a/packages/core/src/async-stores/async-mission-store-queries.ts b/packages/core/src/async-stores/async-mission-store-queries.ts index caa63ccdcf..6d0beb944d 100644 --- a/packages/core/src/async-stores/async-mission-store-queries.ts +++ b/packages/core/src/async-stores/async-mission-store-queries.ts @@ -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 { await handle diff --git a/packages/core/src/async-stores/async-mission-store.ts b/packages/core/src/async-stores/async-mission-store.ts index 0544456c8e..f3822fd375 100644 --- a/packages/core/src/async-stores/async-mission-store.ts +++ b/packages/core/src/async-stores/async-mission-store.ts @@ -163,6 +163,7 @@ import { listFeaturesForAssertion, listLiveLinkedTaskIds, getLiveTaskById, + lockLiveTaskForClaim, setTaskMissionLinkage, clearTaskMissionLinkage, listFailedTaskIds, @@ -1377,6 +1378,15 @@ export class AsyncMissionStore extends EventEmitter { ); } + /* + 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 { 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 { 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 { 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 { } async unlinkFeatureFromTask(featureId: string): Promise { - 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 { + /* + 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 ════════════════ diff --git a/packages/core/src/missions/mission-store.ts b/packages/core/src/missions/mission-store.ts index 93a88332c7..3f5686f54e 100644 --- a/packages/core/src/missions/mission-store.ts +++ b/packages/core/src/missions/mission-store.ts @@ -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 { 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 = 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); diff --git a/packages/core/src/missions/mission-types.ts b/packages/core/src/missions/mission-types.ts index 7d3bacd64f..ea21362a2b 100644 --- a/packages/core/src/missions/mission-types.ts +++ b/packages/core/src/missions/mission-types.ts @@ -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 */ diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index 42a7cf1eb1..2628bc7d24 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -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); }) ); diff --git a/packages/dashboard/src/sse.ts b/packages/dashboard/src/sse.ts index 0e603033b3..dd6523455d 100644 --- a/packages/dashboard/src/sse.ts +++ b/packages/dashboard/src/sse.ts @@ -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); diff --git a/packages/engine/src/__tests__/agent-mission-tools.test.ts b/packages/engine/src/__tests__/agent-mission-tools.test.ts index 6c7b2ecd46..26b7e5fb85 100644 --- a/packages/engine/src/__tests__/agent-mission-tools.test.ts +++ b/packages/engine/src/__tests__/agent-mission-tools.test.ts @@ -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 = { diff --git a/packages/engine/src/__tests__/heartbeat-executor.test.ts b/packages/engine/src/__tests__/heartbeat-executor.test.ts index c36f6b1628..77e3844566 100644 --- a/packages/engine/src/__tests__/heartbeat-executor.test.ts +++ b/packages/engine/src/__tests__/heartbeat-executor.test.ts @@ -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", diff --git a/packages/engine/src/__tests__/mission-state-reconcile.test.ts b/packages/engine/src/__tests__/mission-state-reconcile.test.ts index 41b7e8c5f6..4694c9d783 100644 --- a/packages/engine/src/__tests__/mission-state-reconcile.test.ts +++ b/packages/engine/src/__tests__/mission-state-reconcile.test.ts @@ -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); + }); }); diff --git a/packages/engine/src/__tests__/workflow-step-readonly-allowlist.test.ts b/packages/engine/src/__tests__/workflow-step-readonly-allowlist.test.ts index dfd13dcee8..fea6cf58ef 100644 --- a/packages/engine/src/__tests__/workflow-step-readonly-allowlist.test.ts +++ b/packages/engine/src/__tests__/workflow-step-readonly-allowlist.test.ts @@ -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", diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index d8de8d763d..dc4f2cf449 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -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); diff --git a/packages/engine/src/execution/gating-classifications.ts b/packages/engine/src/execution/gating-classifications.ts index c57b2eb475..2c8bb75a6c 100644 --- a/packages/engine/src/execution/gating-classifications.ts +++ b/packages/engine/src/execution/gating-classifications.ts @@ -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", diff --git a/packages/engine/src/missions/mission-feature-sync.ts b/packages/engine/src/missions/mission-feature-sync.ts index cf53c5a7ae..65c2b4e799 100644 --- a/packages/engine/src/missions/mission-feature-sync.ts +++ b/packages/engine/src/missions/mission-feature-sync.ts @@ -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, 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>>; + selectionCache?: WorkflowSelectionCache; + }, ): Promise { 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): diff --git a/packages/engine/src/missions/mission-state-reconcile.ts b/packages/engine/src/missions/mission-state-reconcile.ts index db5f4cdb47..0a991a2c81 100644 --- a/packages/engine/src/missions/mission-state-reconcile.ts +++ b/packages/engine/src/missions/mission-state-reconcile.ts @@ -110,6 +110,14 @@ export async function reconcileMissionState( } const byTitle = new Map(); const featuresWithLiveLineageDescendants = new Set(); + /* + 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(); 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(); 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(); + 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" }); diff --git a/packages/engine/src/planner-lane-resolution.ts b/packages/engine/src/planner-lane-resolution.ts index 831c2d742a..77f52d3a96 100644 --- a/packages/engine/src/planner-lane-resolution.ts +++ b/packages/engine/src/planner-lane-resolution.ts @@ -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, + /* + 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 { - 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;