FN-8971: add mission blocked-status clearing tool

Add an operator-only CLI tool for repairing stale mission blocked badges.

- Register and document fn_mission_clear_blocked with PostgreSQL and status-error handling.
- Withhold the repair action from agent principals and deny it in readonly workflow steps.
- Classify the tool consistently across engine permission gates and add coverage.
- Add a minor CLI changeset for the new operator capability.

Files changed:
 .changeset/fn-8971-mission-clear-blocked-tool.md   |  7 +++
 docs/missions.md                                   |  7 ++-
 packages/cli/skill/fusion/SKILL.md                 |  2 +-
 .../cli/skill/fusion/references/extension-tools.md |  9 ++++
 .../skill/fusion/references/fusion-capabilities.md |  1 +
 .../__tests__/extension-permission-gates.test.ts   | 26 ++++++++++
 packages/cli/src/__tests__/extension.test.ts       | 56 +++++++++++++++++++++-
 packages/cli/src/extension.ts                      | 54 +++++++++++++++++++++
 .../src/__tests__/agent-mission-tools.test.ts      |  5 +-
 .../src/__tests__/gating-classifications.test.ts   |  8 ++++
 .../workflow-step-readonly-allowlist.test.ts       |  5 +-
 .../engine/src/execution/gating-classifications.ts |  7 +++
 12 files changed, 182 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-8971

Fusion-Task-Lineage: 650c13b3-c6c4-4dc5-a0b5-9c71d3b94fc5

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-11 01:45:08 -07:00
parent 7ddcf7e2a6
commit 8f6190910d
12 changed files with 182 additions and 5 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add an operator-only tool to clear a stale mission blocked badge from the CLI.
category: feature
dev: Registers fn_mission_clear_blocked in the pi extension (withheld from agent principals), classifies it as task_agent_mutation in gating-classifications.ts, and denies it in readonly workflow steps.

View File

@@ -216,6 +216,7 @@ The canonical per-parameter tool reference lives in `packages/cli/skill/fusion/r
| `fn_mission_delete` | Delete a mission and its hierarchy. |
| `fn_mission_update` | Update mission title/description using partial patches. |
| `fn_mission_set_status` | Set mission lifecycle status with an attributed audit event. |
| `fn_mission_clear_blocked` | Clear a stale mission-level `blocked` badge without resuming automation (operator-only). |
| `fn_milestone_add` | Add a milestone to a mission. |
| `fn_milestone_update` | Update milestone fields using partial patches. |
| `fn_slice_add` | Add a slice to a milestone. |
@@ -277,6 +278,10 @@ For a status-changing clear, the engine resolves a target status and loop target
The store rechecks the fence under its feature-row lock: a live task must still be live and unchanged, while an absent task must remain absent. It retries a stale resolution once. The no-`taskStore` fixture fallback records `groundTruthTaskVerified: false` for a non-null task ID. Callers provide both resolved targets: the store ignores `resolvedLoopState` on a status-only clear and ignores `resolvedStatus` on a loop-only clear, avoiding stale pre-lock branching. The mutation and `feature_validation_repaired` audit event commit in one transaction; clearing resets the implementation retry count, and unlinked features cannot be resumed as `triaged` or `in-progress`. The normal execution loop still cannot escape `blocked` by itself.
### Clear a stale mission blocked badge
`fn_mission_clear_blocked` repairs only a stale mission-level `blocked` badge. It accepts an audit-logged optional `reason` and reports residual canonical blockers, but does not clear them or resume automation. Use **Resume mission** when automation should be re-armed. The tool is withheld from agent sessions and is available only to a human operator through the CLI/pi extension.
## Mission delete policy (hard delete with linked-task guard)
Mission hierarchy records (`missions`, `milestones`, `slices`, `mission_features`) use hard deletes with FK cascades and do not have `deletedAt` soft-delete columns.
@@ -769,7 +774,7 @@ See also: [Multi-Project](./multi-project.md) and [Task Management](./task-manag
Mission hierarchy operations are available with the same project-scoped `MissionStore` contract in the pi extension, engine-managed executor/triage/heartbeat agents, and provider-backed dashboard chat. The surface is `fn_mission_list`, `fn_mission_show`, `fn_mission_create`, `fn_mission_update`, `fn_mission_set_status`, `fn_mission_delete`, `fn_milestone_add`, `fn_milestone_update`, `fn_milestone_delete`, `fn_slice_add`, `fn_slice_activate`, `fn_slice_delete`, `fn_feature_add`, `fn_feature_update`, `fn_feature_set_status`, `fn_feature_repair_validation`, `fn_feature_delete`, and `fn_feature_link_task`.
`fn_mission_list` and `fn_mission_show` are positively classified read-only. All other hierarchy operations, including `fn_feature_repair_validation` and `fn_mission_reconcile`, mutate persisted project data and remain subject to the engine action gate and permanent-agent permission policy; they are never treated as unknown or exempt tools.
`fn_mission_list` and `fn_mission_show` are positively classified read-only. All other hierarchy operations, including `fn_feature_repair_validation` and `fn_mission_reconcile`, mutate persisted project data and remain subject to the engine action gate and permanent-agent permission policy; they are never treated as unknown or exempt tools. `fn_mission_clear_blocked` is intentionally absent from agent tool lists: it is classified as `task_agent_mutation` in both gate paths and denied in readonly workflow steps, while the CLI/pi-extension hard-withholds it from agent principals.
## Automatic mission reconciliation

View File

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

View File

@@ -430,6 +430,15 @@ Set a mission lifecycle status.
| `status` | union | ✓ | |
| `reason` | string | — | |
### fn_mission_clear_blocked
Clear a stale mission-level blocked badge without resuming automation.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Mission ID (e.g., M-001) |
| `reason` | string | — | Why the badge is stale (audit-logged) |
### fn_mission_update
Update an existing mission's title or description. Partial patches leave untouched fields intact.

View File

@@ -67,6 +67,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
| `fn_mission_backfill_assertions` | Backfill mission assertions by deriving and linking one store-managed assertion for each feature without linked assertions. Supports dry-run mode. |
| `fn_mission_delete` | Delete a mission and all its milestones, slices, and features. Cannot be undone. |
| `fn_mission_set_status` | Set a mission lifecycle status. |
| `fn_mission_clear_blocked` | Clear a stale mission-level blocked badge without resuming automation. |
| `fn_mission_update` | Update an existing mission's title or description. Partial patches leave untouched fields intact. |
| `fn_milestone_add` | Add a milestone to a mission. Milestones represent phases of work. |
| `fn_slice_add` | Add a slice to a milestone. Slices are work units that can be activated for implementation. |

View File

@@ -255,6 +255,7 @@ pgDescribe("extension tool permission gates", () => {
const calls: Array<[string, Record<string, unknown>]> = [
["fn_task_bypass_review", { id: "FN-1", reason: "nope" }],
["fn_mission_delete", { id: "M-1" }],
["fn_mission_clear_blocked", { id: "M-1" }],
["fn_milestone_delete", { milestoneId: "MS-1" }],
["fn_slice_delete", { sliceId: "SL-1" }],
["fn_feature_delete", { featureId: "F-1" }],
@@ -272,6 +273,31 @@ pgDescribe("extension tool permission gates", () => {
}
});
it("fn_mission_clear_blocked: denies agent and ambiguous principals before the store, while operators proceed", async () => {
const cwd = h.rootDir();
const missionStore = h.store().getMissionStore();
const mission = await missionStore.createMission({ title: "withheld clear target" });
await missionStore.updateMission(mission.id, { status: "blocked" }, { actor: { type: "operator", id: "test", source: "test" } });
const clearMissionBlockedStatus = vi.spyOn(missionStore, "clearMissionBlockedStatus");
const tool = requireTool(freshApi(), "fn_mission_clear_blocked");
const explicitAgent = await tool.execute("agent", { id: mission.id }, undefined, undefined, { cwd, agentId: "agent-rogue" });
expect(explicitAgent).toMatchObject({ isError: true, details: { deniedFor: "agent-principal" } });
expect(clearMissionBlockedStatus).not.toHaveBeenCalled();
const disposeOne = registerFusionSessionIdentity(cwd, { agentId: "agent-one" });
const disposeTwo = registerFusionSessionIdentity(cwd, { agentId: "agent-two" });
const ambiguous = await tool.execute("ambiguous", { id: mission.id }, undefined, undefined, { cwd });
expect(ambiguous).toMatchObject({ isError: true, details: { deniedFor: "agent-principal" } });
expect(clearMissionBlockedStatus).not.toHaveBeenCalled();
disposeOne();
disposeTwo();
const operator = await tool.execute("operator", { id: mission.id }, undefined, undefined, { cwd });
expect(operator.isError).toBeUndefined();
expect(clearMissionBlockedStatus).toHaveBeenCalledTimes(1);
});
// ── Policy-gated list ────────────────────────────────────────────
it("default (unrestricted) preset: agent fn_task_pause proceeds with NO approval row", async () => {

View File

@@ -22,7 +22,7 @@ vi.mock("../commands/task.js", () => ({
}));
import { __setCachedStoreForTesting, closeCachedStores, resolveTaskListFormatter } from "../extension.js";
import { TaskStore, AgentStore, MANUAL_RETRY_RESET_COUNTER_KEYS, MAX_TASK_LIST_TEXT_CHARS, formatTaskListText, COLUMN_LABELS, drizzleSql } from "@fusion/core";
import { TaskStore, AgentStore, MANUAL_RETRY_RESET_COUNTER_KEYS, MAX_TASK_LIST_TEXT_CHARS, MissionBlockedClearConflictError, formatTaskListText, COLUMN_LABELS, drizzleSql } from "@fusion/core";
import type { WorkflowIr } from "@fusion/core";
import { isGhAvailable, isGhAuthenticated, runGhJsonAsync } from "@fusion/core/gh-cli";
import { runTaskPlan } from "../commands/task.js";
@@ -297,6 +297,7 @@ legacyDescribe("fn pi extension (legacy exhaustive suite)", () => {
"fn_mission_delete",
"fn_mission_update",
"fn_mission_set_status",
"fn_mission_clear_blocked",
"fn_milestone_add",
"fn_slice_add",
"fn_feature_add",
@@ -2963,6 +2964,59 @@ pgTest("fn pi extension (runnable structured-output regression slice)", () => {
expect(await missionStore.getFeature(feature.id)).toMatchObject({ status: "defined", loopState: "idle" });
});
describe("fn_mission_clear_blocked", () => {
it("calls the attributed repair primitive and reports residual blockers", async () => {
const clearMissionBlockedStatus = vi.fn().mockResolvedValue({
mission: { id: "M-1", status: "planning" },
blockers: [{ source: "lineage", reason: "pending delivery" }],
});
const missionStore = {
getMission: vi.fn().mockResolvedValue({ id: "M-1", status: "blocked" }),
clearMissionBlockedStatus,
};
__setCachedStoreForTesting(tmpDir, { getMissionStore: () => missionStore } as never);
const result = await api.tools.get("fn_mission_clear_blocked")!.execute(
"clear", { id: "M-1", reason: "stale badge" }, undefined, undefined, makeCtx(tmpDir),
);
expect(clearMissionBlockedStatus).toHaveBeenCalledWith("M-1", {
actor: { type: "operator", id: "cli-operator", displayName: "CLI operator", source: "pi-extension" },
reason: "stale badge",
});
expect(result.details).toMatchObject({ mission: { id: "M-1", status: "planning" }, blockers: [{ source: "lineage" }] });
expect(result.content[0]?.text).toContain("Cleared blocked status for M-1 → planning");
expect(result.content[0]?.text).toContain("1 blocker(s) remain; automation stays gated");
__setCachedStoreForTesting(tmpDir, h.store());
});
it("reports missing, non-blocked, and PostgreSQL-required mission stores without writing", async () => {
const tool = api.tools.get("fn_mission_clear_blocked")!;
const missingStore = { getMission: vi.fn().mockResolvedValue(null), clearMissionBlockedStatus: vi.fn() };
__setCachedStoreForTesting(tmpDir, { getMissionStore: () => missingStore } as never);
await expect(tool.execute("missing", { id: "M-missing" }, undefined, undefined, makeCtx(tmpDir))).resolves.toMatchObject({
isError: true, details: { code: "MISSION_NOT_FOUND" },
});
expect(missingStore.clearMissionBlockedStatus).not.toHaveBeenCalled();
const conflictStore = {
getMission: vi.fn().mockResolvedValue({ id: "M-1", status: "active" }),
clearMissionBlockedStatus: vi.fn().mockRejectedValue(new MissionBlockedClearConflictError("active")),
};
__setCachedStoreForTesting(tmpDir, { getMissionStore: () => conflictStore } as never);
await expect(tool.execute("conflict", { id: "M-1" }, undefined, undefined, makeCtx(tmpDir))).resolves.toMatchObject({
isError: true, details: { code: "MISSION_NOT_BLOCKED", status: "active" },
});
expect(conflictStore.clearMissionBlockedStatus).toHaveBeenCalledTimes(1);
__setCachedStoreForTesting(tmpDir, { getMissionStore: () => ({ getMission: vi.fn() }) } as never);
await expect(tool.execute("postgres", { id: "M-1" }, undefined, undefined, makeCtx(tmpDir))).resolves.toMatchObject({
isError: true, details: { code: "POSTGRES_REQUIRED" },
});
__setCachedStoreForTesting(tmpDir, h.store());
});
});
describe("fn_task_list", () => {
const HOST_SAFE_TASK_LIST_TEXT_CEILING = 3_000;

View File

@@ -875,6 +875,7 @@ const WITHHELD_FROM_AGENT_EXTENSION_TOOLS: ReadonlySet<string> = new Set([
"fn_task_bypass_review",
"fn_workflow_step_resume",
"fn_mission_delete",
"fn_mission_clear_blocked",
"fn_milestone_delete",
"fn_slice_delete",
"fn_feature_delete",
@@ -4569,6 +4570,59 @@ export default function kbExtension(pi: ExtensionAPI) {
},
});
/*
* FNXC:MissionBlockedRepair 2026-08-11-03:58:
* Clear repairs a stale blocked badge without re-arming automation. Because it overrides a
* durable pause, stop, or manual PATCH signal, it is operator-only and absent from
* createMissionTools so engine lanes and dashboard chat never receive it.
*/
// ── fn_mission_clear_blocked ─────────────────────────────────────
pi.registerTool({
name: "fn_mission_clear_blocked",
label: "fn: Clear Mission Blocked Status",
description: "Clear a stale mission-level blocked badge without resuming automation.",
promptSnippet: "Clear a stale mission blocked badge",
promptGuidelines: [
"Repairs the badge only; it does not resume mission automation",
"Use Resume mission as the only path that re-arms automation",
],
parameters: Type.Object({
id: Type.String({ description: "Mission ID (e.g., M-001)" }),
reason: Type.Optional(Type.String({ description: "Why the badge is stale (audit-logged)" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const withheldDenied = denyWithheldToolForAgentPrincipal("fn_mission_clear_blocked", ctx as ExtensionCallerContext);
if (withheldDenied) return withheldDenied;
const missionStore = (await getStore(ctx.cwd)).getMissionStore();
if (!("clearMissionBlockedStatus" in missionStore)) {
return { content: [{ type: "text", text: "Clearing mission blocked status requires the PostgreSQL mission store" }], isError: true, details: { code: "POSTGRES_REQUIRED" } };
}
const mission = await missionStore.getMission(params.id);
if (!mission) {
return { content: [{ type: "text", text: `Mission ${params.id} not found` }], isError: true, details: { code: "MISSION_NOT_FOUND" } };
}
try {
const { mission: clearedMission, blockers } = await missionStore.clearMissionBlockedStatus(params.id, {
actor: missionTransitionActor(ctx),
reason: params.reason,
});
const residualWarning = blockers.length > 0
? `\n${blockers.length} blocker(s) remain; automation stays gated until they are resolved or the mission is resumed.`
: "";
return {
content: [{ type: "text", text: `Cleared blocked status for ${params.id} → ${clearedMission.status}${residualWarning}` }],
details: { mission: clearedMission, blockers },
};
} catch (error) {
if (error instanceof fusionCore.MissionBlockedClearConflictError) {
return { content: [{ type: "text", text: `Mission ${params.id} is not blocked (status: ${error.status})` }], isError: true, details: { code: "MISSION_NOT_BLOCKED", status: error.status } };
}
const message = error instanceof Error ? error.message : String(error);
return { content: [{ type: "text", text: message }], isError: true, details: { error: message } };
}
},
});
// ── fn_mission_update ───────────────────────────────────────────
pi.registerTool({

View File

@@ -11,7 +11,10 @@ which owns live project-scoped task validation and bidirectional task linkage.
describe("createMissionTools", () => {
it("exposes the complete hierarchy surface with read and mutation names", () => {
const store = { getMissionStore: vi.fn() } as never;
expect(createMissionTools(store).map((tool) => tool.name)).toEqual([
const toolNames = createMissionTools(store).map((tool) => tool.name);
// FNXC:MissionBlockedRepair 2026-08-11-03:58: Operator-only repair stays out of executor, triage, heartbeat, and chat.
expect(toolNames).not.toContain("fn_mission_clear_blocked");
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",

View File

@@ -220,6 +220,14 @@ describe("gating-classifications parity", () => {
});
});
it("classifies mission blocked-badge repair in both gate paths without readonly exemptions", () => {
const toolName = "fn_mission_clear_blocked";
expect(ACTION_GATE_TASK_AGENT_MANAGEMENT_TOOLS.has(toolName)).toBe(true);
expect(PERMANENT_AGENT_TASK_MUTATION_TOOLS.has(toolName)).toBe(true);
expect(READONLY_FN_TOOLS.has(toolName)).toBe(false);
expect((COORDINATION_EXEMPT_TOOLS as readonly string[]).includes(toolName)).toBe(false);
});
it("classifies ideation reads and mutations in both policy paths", () => {
for (const toolName of ["fn_ideation_list", "fn_ideation_show"]) {
expect(READONLY_FN_TOOLS.has(toolName)).toBe(true);

View File

@@ -44,6 +44,7 @@ describe("workflow-step readonly allowlist policy", () => {
"fn_mission_delete",
"fn_mission_update",
"fn_mission_set_status",
"fn_mission_clear_blocked",
"fn_mission_backfill_assertions",
"fn_milestone_add",
"fn_slice_add",
@@ -66,10 +67,12 @@ describe("workflow-step readonly allowlist policy", () => {
{ name: "fn_task_list" } as any,
{ name: "edit" } as any,
{ name: "fn_task_update" } as any,
{ name: "fn_mission_clear_blocked" } as any,
]);
expect(filtered.allowed.map((tool) => tool.name)).toEqual(["read", "fn_task_list"]);
expect(filtered.denied).toEqual(["edit", "fn_task_update"]);
expect(filtered.denied).toEqual(["edit", "fn_task_update", "fn_mission_clear_blocked"]);
expect(isReadonlyAllowed("fn_mission_clear_blocked")).toBe(false);
});
it("allows explicitly approved tool objects without blanket-allowing mcp names", () => {

View File

@@ -95,6 +95,13 @@ const PERMANENT_TASK_AGENT_ONLY_TOOLS = [
"fn_mission_delete",
"fn_mission_update",
"fn_mission_set_status",
/*
* FNXC:ToolGovernance 2026-08-11-03:58:
* Mission blocked-badge repair overrides a durable stop signal, so its CLI/pi-extension
* surface is hard-withheld from agent principals. Classify it here so both gate paths
* never fall through as unrecognized and readonly workflow steps record an explicit denial.
*/
"fn_mission_clear_blocked",
"fn_mission_reconcile",
"fn_mission_backfill_assertions",
"fn_milestone_add",