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:
7
.changeset/fn-8971-mission-clear-blocked-tool.md
Normal file
7
.changeset/fn-8971-mission-clear-blocked-tool.md
Normal 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.
|
||||||
@@ -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_delete` | Delete a mission and its hierarchy. |
|
||||||
| `fn_mission_update` | Update mission title/description using partial patches. |
|
| `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_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_add` | Add a milestone to a mission. |
|
||||||
| `fn_milestone_update` | Update milestone fields using partial patches. |
|
| `fn_milestone_update` | Update milestone fields using partial patches. |
|
||||||
| `fn_slice_add` | Add a slice to a milestone. |
|
| `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.
|
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 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.
|
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`.
|
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
|
## Automatic mission reconciliation
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
- **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`
|
- **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`
|
- **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`
|
- **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`
|
- **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`
|
- **Skills tools** — `fn_skills_search`, `fn_skills_install`
|
||||||
|
|||||||
@@ -430,6 +430,15 @@ Set a mission lifecycle status.
|
|||||||
| `status` | union | ✓ | |
|
| `status` | union | ✓ | |
|
||||||
| `reason` | string | — | |
|
| `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
|
### fn_mission_update
|
||||||
|
|
||||||
Update an existing mission's title or description. Partial patches leave untouched fields intact.
|
Update an existing mission's title or description. Partial patches leave untouched fields intact.
|
||||||
|
|||||||
@@ -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_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_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_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_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_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. |
|
| `fn_slice_add` | Add a slice to a milestone. Slices are work units that can be activated for implementation. |
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ pgDescribe("extension tool permission gates", () => {
|
|||||||
const calls: Array<[string, Record<string, unknown>]> = [
|
const calls: Array<[string, Record<string, unknown>]> = [
|
||||||
["fn_task_bypass_review", { id: "FN-1", reason: "nope" }],
|
["fn_task_bypass_review", { id: "FN-1", reason: "nope" }],
|
||||||
["fn_mission_delete", { id: "M-1" }],
|
["fn_mission_delete", { id: "M-1" }],
|
||||||
|
["fn_mission_clear_blocked", { id: "M-1" }],
|
||||||
["fn_milestone_delete", { milestoneId: "MS-1" }],
|
["fn_milestone_delete", { milestoneId: "MS-1" }],
|
||||||
["fn_slice_delete", { sliceId: "SL-1" }],
|
["fn_slice_delete", { sliceId: "SL-1" }],
|
||||||
["fn_feature_delete", { featureId: "F-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 ────────────────────────────────────────────
|
// ── Policy-gated list ────────────────────────────────────────────
|
||||||
|
|
||||||
it("default (unrestricted) preset: agent fn_task_pause proceeds with NO approval row", async () => {
|
it("default (unrestricted) preset: agent fn_task_pause proceeds with NO approval row", async () => {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ vi.mock("../commands/task.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { __setCachedStoreForTesting, closeCachedStores, resolveTaskListFormatter } from "../extension.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 type { WorkflowIr } from "@fusion/core";
|
||||||
import { isGhAvailable, isGhAuthenticated, runGhJsonAsync } from "@fusion/core/gh-cli";
|
import { isGhAvailable, isGhAuthenticated, runGhJsonAsync } from "@fusion/core/gh-cli";
|
||||||
import { runTaskPlan } from "../commands/task.js";
|
import { runTaskPlan } from "../commands/task.js";
|
||||||
@@ -297,6 +297,7 @@ legacyDescribe("fn pi extension (legacy exhaustive suite)", () => {
|
|||||||
"fn_mission_delete",
|
"fn_mission_delete",
|
||||||
"fn_mission_update",
|
"fn_mission_update",
|
||||||
"fn_mission_set_status",
|
"fn_mission_set_status",
|
||||||
|
"fn_mission_clear_blocked",
|
||||||
"fn_milestone_add",
|
"fn_milestone_add",
|
||||||
"fn_slice_add",
|
"fn_slice_add",
|
||||||
"fn_feature_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" });
|
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", () => {
|
describe("fn_task_list", () => {
|
||||||
const HOST_SAFE_TASK_LIST_TEXT_CEILING = 3_000;
|
const HOST_SAFE_TASK_LIST_TEXT_CEILING = 3_000;
|
||||||
|
|
||||||
|
|||||||
@@ -875,6 +875,7 @@ const WITHHELD_FROM_AGENT_EXTENSION_TOOLS: ReadonlySet<string> = new Set([
|
|||||||
"fn_task_bypass_review",
|
"fn_task_bypass_review",
|
||||||
"fn_workflow_step_resume",
|
"fn_workflow_step_resume",
|
||||||
"fn_mission_delete",
|
"fn_mission_delete",
|
||||||
|
"fn_mission_clear_blocked",
|
||||||
"fn_milestone_delete",
|
"fn_milestone_delete",
|
||||||
"fn_slice_delete",
|
"fn_slice_delete",
|
||||||
"fn_feature_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 ───────────────────────────────────────────
|
// ── fn_mission_update ───────────────────────────────────────────
|
||||||
|
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ which owns live project-scoped task validation and bidirectional task linkage.
|
|||||||
describe("createMissionTools", () => {
|
describe("createMissionTools", () => {
|
||||||
it("exposes the complete hierarchy surface with read and mutation names", () => {
|
it("exposes the complete hierarchy surface with read and mutation names", () => {
|
||||||
const store = { getMissionStore: vi.fn() } as never;
|
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_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_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_research_promote_finding",
|
||||||
|
|||||||
@@ -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", () => {
|
it("classifies ideation reads and mutations in both policy paths", () => {
|
||||||
for (const toolName of ["fn_ideation_list", "fn_ideation_show"]) {
|
for (const toolName of ["fn_ideation_list", "fn_ideation_show"]) {
|
||||||
expect(READONLY_FN_TOOLS.has(toolName)).toBe(true);
|
expect(READONLY_FN_TOOLS.has(toolName)).toBe(true);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ describe("workflow-step readonly allowlist policy", () => {
|
|||||||
"fn_mission_delete",
|
"fn_mission_delete",
|
||||||
"fn_mission_update",
|
"fn_mission_update",
|
||||||
"fn_mission_set_status",
|
"fn_mission_set_status",
|
||||||
|
"fn_mission_clear_blocked",
|
||||||
"fn_mission_backfill_assertions",
|
"fn_mission_backfill_assertions",
|
||||||
"fn_milestone_add",
|
"fn_milestone_add",
|
||||||
"fn_slice_add",
|
"fn_slice_add",
|
||||||
@@ -66,10 +67,12 @@ describe("workflow-step readonly allowlist policy", () => {
|
|||||||
{ name: "fn_task_list" } as any,
|
{ name: "fn_task_list" } as any,
|
||||||
{ name: "edit" } as any,
|
{ name: "edit" } as any,
|
||||||
{ name: "fn_task_update" } 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.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", () => {
|
it("allows explicitly approved tool objects without blanket-allowing mcp names", () => {
|
||||||
|
|||||||
@@ -95,6 +95,13 @@ const PERMANENT_TASK_AGENT_ONLY_TOOLS = [
|
|||||||
"fn_mission_delete",
|
"fn_mission_delete",
|
||||||
"fn_mission_update",
|
"fn_mission_update",
|
||||||
"fn_mission_set_status",
|
"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_reconcile",
|
||||||
"fn_mission_backfill_assertions",
|
"fn_mission_backfill_assertions",
|
||||||
"fn_milestone_add",
|
"fn_milestone_add",
|
||||||
|
|||||||
Reference in New Issue
Block a user