FN-5714: reject linking features to non-active tasks

Gracefully handle feature-to-task linking when the referenced task is archived, deleted, or missing.

- enforce active-board task validation in mission store linkage path with clear error text
- return surfaced validation errors from fn_feature_link_task instead of throwing opaque failures
- add regression coverage in core and CLI extension tests for archived/non-active task references
- add changeset and sync Fusion skill reference docs with the new fn_feature_link_task behavior

Files changed:
 .changeset/fn-5714-link-archived-task.md           |  5 +++
 .../cli/skill/fusion/references/extension-tools.md |  2 +-
 .../skill/fusion/references/fusion-capabilities.md |  2 +-
 packages/cli/src/__tests__/extension.test.ts       | 50 ++++++++++++++++++++++
 packages/cli/src/extension.ts                      | 35 +++++++++------
 packages/core/src/__tests__/mission-store.test.ts  | 17 ++++++++
 packages/core/src/mission-store.ts                 |  9 ++++
 7 files changed, 106 insertions(+), 14 deletions(-)

Fusion-Task-Id: FN-5714

Fusion-Task-Lineage: d84c45cf-0a0d-47a3-81c5-f99d16e03ecf
This commit is contained in:
gsxdsm
2026-05-30 07:39:59 -07:00
parent 3b594879a8
commit 033f74c458
7 changed files with 106 additions and 14 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Improve `fn_feature_link_task` error handling when linking to tasks that are not on the active board. Instead of surfacing a raw SQLite foreign key failure, the tool now returns a clear validation error explaining that only active (non-archived, non-deleted) tasks can be linked to mission features.

View File

@@ -255,7 +255,7 @@ Activate a pending slice for implementation. Sets status to 'active' and enables
### fn_feature_link_task ### 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. 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.
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
|-----------|------|----------|-------------| |-----------|------|----------|-------------|

View File

@@ -54,7 +54,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
| `fn_slice_add` | Add a slice to a milestone. Slices are work units that can be activated for implementation. | | `fn_slice_add` | Add a slice to a milestone. Slices are work units that can be activated for implementation. |
| `fn_feature_add` | Add a feature to a slice. Features are deliverables that can be linked to tasks. | | `fn_feature_add` | Add a feature to a slice. Features are deliverables that can be linked to tasks. |
| `fn_slice_activate` | Activate a pending slice for implementation. Sets status to 'active' and enables task linking for its features. | | `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. | | `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_update` | Update an existing feature's title, description, or acceptance criteria. Partial patches leave untouched fields intact. | | `fn_feature_update` | Update an existing feature's title, description, or acceptance criteria. Partial patches leave untouched fields intact. |
| `fn_milestone_update` | Update an existing milestone's title, description, or acceptance criteria (the structured pass/fail bar, distinct from verification's free-form how-to-confirm notes). Partial patches leave untouched fields intact. | | `fn_milestone_update` | Update an existing milestone's title, description, or acceptance criteria (the structured pass/fail bar, distinct from verification's free-form how-to-confirm notes). Partial patches leave untouched fields intact. |
| `fn_agent_stop` | Stop a running agent — pauses its execution. Transitions the agent from running/active to paused state. | | `fn_agent_stop` | Stop a running agent — pauses its execution. Transitions the agent from running/active to paused state. |

View File

@@ -1447,6 +1447,56 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
expect(result.content[0].text).toContain("Task FN-999 not found"); expect(result.content[0].text).toContain("Task FN-999 not found");
}); });
it("returns clear error when task is archived/non-active", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const linkTool = api.tools.get("fn_feature_link_task")!;
const store = new TaskStore(tmpDir);
await store.init();
const archivedTask = await store.createTask({ description: "Archived task" });
await store.moveTask(archivedTask.id, "done");
await store.archiveTask(archivedTask.id);
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const feature = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await linkTool.execute(
"l0b",
{ featureId: feature.details.featureId, taskId: archivedTask.id },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("task is not on the active board");
expect(result.content[0].text).toContain(`Cannot link feature ${feature.details.featureId} to task ${archivedTask.id}`);
expect(result.details.error).toContain("Only active tasks can be linked to features");
});
it("links feature to task", async () => { it("links feature to task", async () => {
const missionTool = api.tools.get("fn_mission_create")!; const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!; const milestoneTool = api.tools.get("fn_milestone_add")!;

View File

@@ -2976,7 +2976,9 @@ export default function kbExtension(pi: ExtensionAPI) {
label: "fn: Link Feature to Task", label: "fn: Link Feature to Task",
description: description:
"Link a feature to a fn task for implementation. " + "Link a feature to a fn task for implementation. " +
"Updates the feature status to 'triaged' and associates it with the task.", "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.",
promptSnippet: "Link a feature to a task", promptSnippet: "Link a feature to a task",
promptGuidelines: [ promptGuidelines: [
"Use when a feature is ready for implementation and has a corresponding task", "Use when a feature is ready for implementation and has a corresponding task",
@@ -3013,18 +3015,27 @@ export default function kbExtension(pi: ExtensionAPI) {
}; };
} }
const updated = missionStore.linkFeatureToTask(params.featureId, params.taskId); try {
await store.updateTask(params.taskId, { sliceId: feature.sliceId }); const updated = missionStore.linkFeatureToTask(params.featureId, params.taskId);
await store.updateTask(params.taskId, { sliceId: feature.sliceId });
return { return {
content: [ content: [
{ {
type: "text", type: "text",
text: `Linked ${updated.id}: "${updated.title}" → ${params.taskId}\nStatus: ${updated.status}`, text: `Linked ${updated.id}: "${updated.title}" → ${params.taskId}\nStatus: ${updated.status}`,
}, },
], ],
details: { featureId: updated.id, taskId: params.taskId, title: updated.title, status: 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 },
};
}
}, },
}); });

View File

@@ -1244,6 +1244,23 @@ describe("MissionStore", () => {
expect(taskRow.sliceId).toBe(slice.id); expect(taskRow.sliceId).toBe(slice.id);
}); });
it("throws a clear error when linking to a task not on the active board", () => {
const mission = store.createMission({ title: "Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Linkable" });
expect(() => store.linkFeatureToTask(feature.id, "FN-ARCHIVED")).toThrow(
`Cannot link feature ${feature.id} to task FN-ARCHIVED: task is not on the active board (it may be archived, deleted, or never existed). Only active tasks can be linked to features.`,
);
const unchanged = store.getFeature(feature.id)!;
expect(unchanged.taskId).toBeUndefined();
expect(unchanged.status).toBe("defined");
expect(unchanged.loopState).toBe("idle");
expect(unchanged.implementationAttemptCount).toBe(0);
});
it("emits feature:linked event", () => { it("emits feature:linked event", () => {
createTaskInDb(db, "FN-001"); createTaskInDb(db, "FN-001");

View File

@@ -2085,6 +2085,15 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
throw new Error(`Feature ${featureId} not found`); throw new Error(`Feature ${featureId} not found`);
} }
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 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.`,
);
}
const linkage = this.resolveTaskLinkage(feature.sliceId); const linkage = this.resolveTaskLinkage(feature.sliceId);
// When first linking (loopState is idle or falsy), transition to implementing // When first linking (loopState is idle or falsy), transition to implementing