feat(FN-3548): add agent action routes and approval pause/resume lifecycle

Merges the FN-3548 approval pause/resume system: agents now stall at workflow gates pending approval, with a full lifecycle spanning gate context lookup, action-gate pause/retry, executor and heartbeat pause callbacks, and dedicated approval decision routes. Also lands FN-3744 agent action routes an

Fusion-Task-Id: FN-3548
This commit is contained in:
Fusion
2026-05-08 20:19:15 -07:00
committed by gsxdsm
parent c27fd0a1f9
commit 92ca3a238f
17 changed files with 833 additions and 69 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Pause permanent-agent execution when approval is required, add approve/deny API endpoints, and resume task/agent state correctly after decisions with deduped approval request handling.

View File

@@ -90,11 +90,14 @@ Unknown/unclassified tool fallback:
- Operators can reload the in-memory exempt-tool registry at runtime via `POST /api/action-gate/reload` (optional body `{ "tools": string[] }`) to apply exemption-list updates without restarting the engine process.
- Canonical tool classification/exemption sets live in `packages/engine/src/gating-classifications.ts` and are shared by both action-gate paths.
Interim enforcement behavior (persistence-integrated, pre-resume lifecycle):
Approval pause/resume lifecycle (FN-3548):
- Permanent-agent gating short-circuits `block` and `require-approval` actions before tool execution and returns structured non-success tool results.
- For `require-approval`, the engine now creates durable approval requests (via `ApprovalRequestStore`) with requester identity, task/run context, and tool/action metadata; the original mutation is not executed.
- Pause/resume execution, suspended-run continuation, and approve/deny continuation behavior remain deferred to FN-3548.
- For `require-approval`, the engine creates/reuses a durable approval request and pauses execution with canonical `pauseReason: "awaiting-approval"`.
- If task-backed, the owning task is paused (`Task.paused=true`, `pausedByAgentId=<requester>`); the requesting agent is paused (`state="paused"`, `pauseReason="awaiting-approval"`).
- Dedupe semantics by `approvalDedupeKey`: `pending` reuses the same request, `approved` allows exactly one execution and then marks request `completed`, `denied` stays blocked, `completed` requires a fresh request.
- HTTP decision endpoints resume best-effort: `POST /api/approval-requests/:id/approve` and `POST /api/approval-requests/:id/deny` unpause matching task/agent when they are paused for `awaiting-approval`.
- Approval API surface: `GET /api/approval-requests`, `GET /api/approval-requests/:id`, `GET /api/approval-requests/:id/audit`, `POST /api/approval-requests/:id/approve`, `POST /api/approval-requests/:id/deny`.
Default and legacy fallback behavior:

View File

@@ -218,6 +218,15 @@ Schema (migration 68 in `db.ts`) adds two tables:
Store API (`packages/core/src/approval-request-store.ts`):
Dashboard approval endpoints (`packages/dashboard/src/routes/register-approval-routes.ts`):
- `GET /api/approval-requests`
- `GET /api/approval-requests/:id`
- `GET /api/approval-requests/:id/audit`
- `POST /api/approval-requests/:id/approve`
- `POST /api/approval-requests/:id/deny`
Runtime flow: engine action gate creates/reuses request → pauses task/agent with `pauseReason="awaiting-approval"` → approver calls approve/deny endpoint → request transitions (`pending→approved|denied`) → route resumes matching paused task/agent best-effort → next tool retry consumes `approved` exactly once (then `completed`) or returns structured denial.
- `create(input: ApprovalRequestCreateInput)` — inserts a `pending` request and appends a `created` audit event
- `get(id)` — returns one request or `null`
- `list(input?: ApprovalRequestListInput)` — filters by `status`, `requesterActorId`, `taskId`, `runId`; ordered `createdAt DESC, id DESC`; paginated by `limit`/`offset`

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -193,6 +193,98 @@ describe("ApprovalRequestStore", () => {
expect(byTask.map((r) => r.id)).toEqual([first.id]);
});
it("findLatestByDedupeKey returns newest match across statuses", () => {
vi.useFakeTimers();
const dedupeKey = "agent-1|FN-100|write|file_write_delete|file|a.ts|write";
vi.setSystemTime(new Date("2026-05-08T00:00:00.000Z"));
const first = store.create({
requester: REQUESTER,
targetAction: {
category: "file_write_delete",
action: "write",
summary: "write a.ts",
resourceType: "file",
resourceId: "a.ts",
context: { approvalDedupeKey: dedupeKey },
},
taskId: "FN-100",
});
store.decide(first.id, "approved", { actor: APPROVER });
vi.setSystemTime(new Date("2026-05-08T00:00:01.000Z"));
const second = store.create({
requester: REQUESTER,
targetAction: {
category: "file_write_delete",
action: "write",
summary: "write a.ts again",
resourceType: "file",
resourceId: "a.ts",
context: { approvalDedupeKey: dedupeKey },
},
taskId: "FN-100",
});
const latest = store.findLatestByDedupeKey({ requesterActorId: REQUESTER.actorId, taskId: "FN-100", dedupeKey });
expect(latest?.id).toBe(second.id);
expect(latest?.status).toBe("pending");
vi.useRealTimers();
});
it("findLatestByDedupeKey scopes by requester and task", () => {
const dedupeKey = "shared-key";
const mine = store.create({
requester: REQUESTER,
targetAction: {
category: "command_execution",
action: "bash",
summary: "run command",
resourceType: "command",
resourceId: "pnpm test",
context: { approvalDedupeKey: dedupeKey },
},
taskId: "FN-200",
});
store.create({
requester: { ...REQUESTER, actorId: "agent-2" },
targetAction: {
category: "command_execution",
action: "bash",
summary: "other requester",
resourceType: "command",
resourceId: "pnpm lint",
context: { approvalDedupeKey: dedupeKey },
},
taskId: "FN-200",
});
store.create({
requester: REQUESTER,
targetAction: {
category: "command_execution",
action: "bash",
summary: "other task",
resourceType: "command",
resourceId: "pnpm build",
context: { approvalDedupeKey: dedupeKey },
},
taskId: "FN-201",
});
const scoped = store.findLatestByDedupeKey({ requesterActorId: REQUESTER.actorId, taskId: "FN-200", dedupeKey });
expect(scoped?.id).toBe(mine.id);
});
it("findLatestByDedupeKey returns null when no dedupe key matches", () => {
createSampleRequest();
const latest = store.findLatestByDedupeKey({ requesterActorId: REQUESTER.actorId, taskId: "FN-3546", dedupeKey: "missing" });
expect(latest).toBeNull();
});
it("persists requests and audit history across restart/migration", () => {
db.close();

View File

@@ -221,6 +221,31 @@ export class ApprovalRequestStore {
return rows.map((row) => this.rowToRequest(row));
}
findLatestByDedupeKey(input: { requesterActorId: string; taskId?: string; dedupeKey: string }): ApprovalRequest | null {
const where = ["requesterActorId = ?"];
const params: Array<string> = [input.requesterActorId];
if (input.taskId !== undefined) {
where.push("taskId = ?");
params.push(input.taskId);
}
const rows = this.db.prepare(`
SELECT * FROM approval_requests
WHERE ${where.join(" AND ")}
ORDER BY createdAt DESC, id DESC
`).all(...params) as ApprovalRequestRow[];
for (const row of rows) {
const context = fromJson<Record<string, unknown>>(row.targetContext);
if (context?.approvalDedupeKey === input.dedupeKey) {
return this.rowToRequest(row);
}
}
return null;
}
decide(requestId: string, status: "approved" | "denied", input: ApprovalRequestDecisionInput): ApprovalRequest {
const existing = this.get(requestId);
if (!existing) {

View File

@@ -111,8 +111,8 @@
}
.project-stat--error {
border-color: rgba(248, 81, 73, 0.3);
background: rgba(248, 81, 73, 0.08);
border-color: color-mix(in srgb, var(--color-error) 30%, var(--border));
background: color-mix(in srgb, var(--color-error) 8%, transparent);
}
.project-stat--error .project-stat__value {
@@ -180,8 +180,8 @@
}
.project-filter-tab.has-errors.active {
background: rgba(248, 81, 73, 0.12);
border-color: rgba(248, 81, 73, 0.3);
background: color-mix(in srgb, var(--color-error) 12%, transparent);
border-color: color-mix(in srgb, var(--color-error) 30%, var(--border));
}
.project-filter-count {
@@ -206,7 +206,7 @@
}
.project-filter-tab.has-errors .project-filter-count {
background: rgba(248, 81, 73, 0.2);
background: color-mix(in srgb, var(--color-error) 20%, transparent);
color: var(--color-error);
}

View File

@@ -0,0 +1,153 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import { get, request } from "../test-request.js";
const state = {
requests: new Map<string, any>(),
audits: new Map<string, any[]>(),
task: { id: "FN-1", paused: true, pausedByAgentId: "agent-1" },
agent: { id: "agent-1", state: "paused", pauseReason: "awaiting-approval" },
};
class MockApprovalRequestStore {
constructor(_: unknown) {}
list(input: any = {}) {
let rows = [...state.requests.values()];
if (input.status) rows = rows.filter((r) => r.status === input.status);
if (input.requesterActorId) rows = rows.filter((r) => r.requester.actorId === input.requesterActorId);
if (input.taskId) rows = rows.filter((r) => r.taskId === input.taskId);
return rows;
}
get(id: string) {
return state.requests.get(id) ?? null;
}
decide(id: string, status: "approved" | "denied") {
const req = state.requests.get(id);
if (!req) throw new Error("Approval request not found");
if (req.status !== "pending") throw new Error(`Invalid approval request transition: ${req.status} -> ${status}`);
req.status = status;
state.audits.set(id, [...(state.audits.get(id) ?? []), { event: status }]);
return req;
}
getAuditHistory(id: string) {
return state.audits.get(id) ?? [];
}
}
const updateAgent = vi.fn(async (_id: string, updates: any) => ({ ...state.agent, ...updates }));
class MockAgentStore {
constructor(_: unknown) {}
async init() {}
async getAgent(id: string) {
return id === state.agent.id ? state.agent : null;
}
async updateAgentState(id: string, nextState: string) {
if (id === state.agent.id) state.agent = { ...state.agent, state: nextState };
}
async updateAgent(id: string, updates: any) {
if (id === state.agent.id) state.agent = { ...state.agent, ...updates };
return updateAgent(id, updates);
}
}
vi.mock("@fusion/core", () => ({
ApprovalRequestStore: MockApprovalRequestStore,
AgentStore: MockAgentStore,
}));
describe("approval routes", async () => {
const { registerApprovalRoutes } = await import("../routes/register-approval-routes.js");
function createApp() {
const router = express.Router();
router.use(express.json());
registerApprovalRoutes({
router,
runtimeLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() } as any,
getProjectContext: async () => ({
store: {
getDatabase: () => ({}),
getFusionDir: () => "/tmp/fusion",
getTask: async () => state.task,
pauseTask: async (_id: string, paused: boolean) => {
state.task = { ...state.task, paused, pausedByAgentId: paused ? state.task.pausedByAgentId : undefined };
},
},
engine: undefined,
projectId: "p1",
}),
rethrowAsApiError: (e: unknown) => {
throw e;
},
} as any);
const app = express();
app.use("/api", router);
app.use((err: any, _req: any, res: any, _next: any) => {
const status = err?.statusCode ?? 500;
res.status(status).json({ error: err?.message ?? String(err) });
});
return app;
}
beforeEach(() => {
updateAgent.mockClear();
state.task = { id: "FN-1", paused: true, pausedByAgentId: "agent-1" };
state.agent = { id: "agent-1", state: "paused", pauseReason: "awaiting-approval" };
state.requests = new Map([
["apr-1", { id: "apr-1", status: "pending", requester: { actorId: "agent-1" }, taskId: "FN-1" }],
["apr-2", { id: "apr-2", status: "denied", requester: { actorId: "agent-1" }, taskId: "FN-1" }],
]);
state.audits = new Map([["apr-1", [{ event: "created" }]]]);
});
it("lists and filters requests", async () => {
const app = createApp();
const res = await get(app, "/api/approval-requests?status=pending");
expect(res.status).toBe(200);
expect((res.body as any[]).map((r) => r.id)).toEqual(["apr-1"]);
});
it("returns 404 for missing request", async () => {
const app = createApp();
const res = await get(app, "/api/approval-requests/missing");
expect(res.status).toBe(404);
});
it("returns audit history", async () => {
const app = createApp();
const res = await get(app, "/api/approval-requests/apr-1/audit");
expect(res.status).toBe(200);
expect(res.body).toEqual([{ event: "created" }]);
});
it("approves and unpauses task/agent", async () => {
const app = createApp();
const res = await request(app, "POST", "/api/approval-requests/apr-1/approve", JSON.stringify({}));
expect(res.status).toBe(200);
expect((res.body as any).status).toBe("approved");
expect(state.task.paused).toBe(false);
expect(updateAgent).toHaveBeenCalledWith("agent-1", { pauseReason: undefined });
});
it("denies and unpauses task/agent", async () => {
const app = createApp();
const res = await request(app, "POST", "/api/approval-requests/apr-1/deny", JSON.stringify({}));
expect(res.status).toBe(200);
expect((res.body as any).status).toBe("denied");
expect(state.task.paused).toBe(false);
});
it("no-ops when task already unpaused", async () => {
state.task.paused = false;
const app = createApp();
const res = await request(app, "POST", "/api/approval-requests/apr-1/deny", JSON.stringify({}));
expect(res.status).toBe(200);
});
it("returns 409 for invalid transition", async () => {
const app = createApp();
const res = await request(app, "POST", "/api/approval-requests/apr-2/approve", JSON.stringify({}));
expect(res.status).toBe(409);
});
});

View File

@@ -119,6 +119,7 @@ import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provide
import { registerFnBinaryRoutes } from "./routes/register-fn-binary-routes.js";
import { registerUpdateCheckRoutes } from "./routes/register-update-check-routes.js";
import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js";
import { registerApprovalRoutes } from "./routes/register-approval-routes.js";
import { runGitCommand } from "./routes/resolve-diff-base.js";
const TASK_DETAIL_ACTIVITY_LOG_LIMIT = 500;
@@ -982,6 +983,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
registerFilesTerminalWorkspaceRoutes(routeContext);
registerAgentsProjectsNodesRoutes(routeContext);
registerPluginsAutomationRoutes(routeContext);
registerApprovalRoutes(routeContext);
// HeartbeatMonitor for triggering agent execution runs
const heartbeatMonitor = options?.heartbeatMonitor;

View File

@@ -0,0 +1,157 @@
import { AgentStore, ApprovalRequestStore, type ApprovalRequestActorSnapshot, type ApprovalRequestStatus } from "@fusion/core";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
const DEFAULT_ACTOR: ApprovalRequestActorSnapshot = {
actorId: "user",
actorType: "user",
actorName: "User",
};
function parseOptionalString(value: unknown, field: string): string | undefined {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value !== "string") throw badRequest(`${field} must be a string`);
return value;
}
function parseOptionalInt(value: unknown, field: string): number | undefined {
if (value === undefined || value === null || value === "") return undefined;
const n = Number.parseInt(String(value), 10);
if (!Number.isFinite(n) || n < 0) throw badRequest(`${field} must be a non-negative integer`);
return n;
}
function parseStatus(value: unknown): ApprovalRequestStatus | undefined {
if (value === undefined || value === null || value === "") return undefined;
if (value === "pending" || value === "approved" || value === "denied" || value === "completed") return value;
throw badRequest("status must be one of: pending, approved, denied, completed");
}
async function resumeAfterDecision(params: {
scopedStore: import("@fusion/core").TaskStore;
request: import("@fusion/core").ApprovalRequest;
runtimeLogger: ApiRoutesContext["runtimeLogger"];
}): Promise<void> {
const { scopedStore, request, runtimeLogger } = params;
try {
if (request.taskId) {
const task = await scopedStore.getTask(request.taskId);
if (task?.paused && task.pausedByAgentId === request.requester.actorId) {
await scopedStore.pauseTask(request.taskId, false, undefined);
}
}
} catch (error) {
runtimeLogger.warn("Failed to unpause task after approval decision", {
requestId: request.id,
taskId: request.taskId,
error: error instanceof Error ? error.message : String(error),
});
}
try {
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agent = await agentStore.getAgent(request.requester.actorId);
if (agent?.state === "paused" && agent.pauseReason === "awaiting-approval") {
await agentStore.updateAgentState(agent.id, "idle");
await agentStore.updateAgent(agent.id, { pauseReason: undefined });
}
} catch (error) {
runtimeLogger.warn("Failed to unpause agent after approval decision", {
requestId: request.id,
agentId: request.requester.actorId,
error: error instanceof Error ? error.message : String(error),
});
}
}
export function registerApprovalRoutes(ctx: ApiRoutesContext): void {
const { router, getProjectContext, rethrowAsApiError, runtimeLogger } = ctx;
router.get("/approval-requests", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
const requests = approvalStore.list({
status: parseStatus(req.query.status),
requesterActorId: parseOptionalString(req.query.requesterActorId, "requesterActorId"),
taskId: parseOptionalString(req.query.taskId, "taskId"),
runId: parseOptionalString(req.query.runId, "runId"),
limit: parseOptionalInt(req.query.limit, "limit"),
offset: parseOptionalInt(req.query.offset, "offset"),
});
res.json(requests);
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
router.get("/approval-requests/:id", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
const requestId = String(req.params.id);
const request = approvalStore.get(requestId);
if (!request) throw notFound("Approval request not found");
res.json(request);
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
router.get("/approval-requests/:id/audit", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
const requestId = String(req.params.id);
const request = approvalStore.get(requestId);
if (!request) throw notFound("Approval request not found");
res.json(approvalStore.getAuditHistory(requestId));
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
const decideHandler = (status: "approved" | "denied") => async (req: import("express").Request, res: import("express").Response) => {
try {
const body = (req.body ?? {}) as { actor?: ApprovalRequestActorSnapshot; note?: string };
const { store: scopedStore } = await getProjectContext(req);
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
const requestId = String(req.params.id);
const existing = approvalStore.get(requestId);
if (!existing) throw notFound("Approval request not found");
const actor = body.actor ?? DEFAULT_ACTOR;
if (!actor || typeof actor.actorId !== "string" || typeof actor.actorType !== "string" || typeof actor.actorName !== "string") {
throw badRequest("actor must include actorId, actorType, and actorName");
}
if (body.note !== undefined && typeof body.note !== "string") {
throw badRequest("note must be a string");
}
let updated;
try {
updated = approvalStore.decide(requestId, status, { actor, note: body.note });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("Invalid approval request transition")) {
throw conflict(message);
}
throw error;
}
await resumeAfterDecision({ scopedStore, request: updated, runtimeLogger });
res.json(updated);
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
};
router.post("/approval-requests/:id/approve", decideHandler("approved"));
router.post("/approval-requests/:id/deny", decideHandler("denied"));
}

View File

@@ -5,6 +5,7 @@ import {
evaluateAgentActionGate,
getExemptToolNames,
reloadExemptTools,
resolveGateOutcome,
} from "../agent-action-gate.js";
import type { AgentPermissionPolicy } from "@fusion/core";
@@ -215,6 +216,65 @@ describe("agent-action-gate", () => {
expect(result.disposition).toBe("require-approval");
});
it("resolveGateOutcome waits when there is no latest request", () => {
const decision = evaluateAgentActionGate({
agentId: "a1",
toolName: "write",
args: { path: "a.ts" },
permissionPolicy: approvalPolicy,
});
expect(resolveGateOutcome(decision, null)).toEqual({ outcome: "wait-for-approval" });
});
it("resolveGateOutcome reuses pending request", () => {
const decision = evaluateAgentActionGate({
agentId: "a1",
toolName: "write",
args: { path: "a.ts" },
permissionPolicy: approvalPolicy,
});
expect(resolveGateOutcome(decision, { id: "apr-1", status: "pending" })).toEqual({
outcome: "wait-for-approval",
approvalRequestId: "apr-1",
});
});
it("resolveGateOutcome executes once on approved request", () => {
const decision = evaluateAgentActionGate({
agentId: "a1",
toolName: "write",
args: { path: "a.ts" },
permissionPolicy: approvalPolicy,
});
expect(resolveGateOutcome(decision, { id: "apr-1", status: "approved" })).toEqual({
outcome: "execute-once-then-complete",
approvalRequestId: "apr-1",
});
});
it("resolveGateOutcome blocks denied request", () => {
const decision = evaluateAgentActionGate({
agentId: "a1",
toolName: "write",
args: { path: "a.ts" },
permissionPolicy: approvalPolicy,
});
expect(resolveGateOutcome(decision, { id: "apr-1", status: "denied" })).toEqual({
outcome: "block",
approvalRequestId: "apr-1",
});
});
it("resolveGateOutcome requires new approval after completion", () => {
const decision = evaluateAgentActionGate({
agentId: "a1",
toolName: "write",
args: { path: "a.ts" },
permissionPolicy: approvalPolicy,
});
expect(resolveGateOutcome(decision, { id: "apr-1", status: "completed" })).toEqual({ outcome: "wait-for-approval" });
});
it("computes deterministic dedupe key", () => {
const key = computeApprovalDedupeKey({
agentId: "agent-1",

View File

@@ -151,6 +151,42 @@ describe("buildExecutionPrompt", () => {
});
});
describe("TaskExecutor action gate context", () => {
it("pauses task and agent for approval and marks completion", async () => {
const store = createMockStore();
store.pauseTask = vi.fn().mockResolvedValue(undefined);
store.logEntry = vi.fn().mockResolvedValue(undefined);
const agentStore = {
updateAgentState: vi.fn().mockResolvedValue(undefined),
updateAgent: vi.fn().mockResolvedValue(undefined),
} as any;
const executor = new TaskExecutor(store as any, "/tmp/project", { agentStore });
(executor as any).currentRunContext = { runId: "run-1" };
const context = (executor as any).buildActionGateContext("FN-1", { id: "agent-1", name: "Agent One", permissionPolicy: undefined });
await context.pauseForApproval({
approvalRequestId: "apr-1",
decision: {
disposition: "require-approval",
category: "command_execution",
toolName: "bash",
operation: "git commit",
summary: "bash: git commit",
resourceType: "git",
approvalDedupeKey: "dedupe-1",
metadata: {},
},
});
expect(store.pauseTask).toHaveBeenCalledWith("FN-1", true, { runId: "run-1" }, { pausedByAgentId: "agent-1" });
expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-1", "paused");
expect(agentStore.updateAgent).toHaveBeenCalledWith("agent-1", { pauseReason: "awaiting-approval" });
});
});
// ── Skill Selection Regression Tests (FN-1514) ──────────────────────────
describe("TaskExecutor skillSelection regression (FN-1511)", () => {

View File

@@ -267,6 +267,59 @@ describe("executeHeartbeat", () => {
expect(args.permanentAgentGating?.permissionPolicy?.presetId).toBe("unrestricted");
});
it("pauseForApproval pauses task and agent when taskId exists", async () => {
const store = createStoreWithAgentForExec({ taskId: "FN-001" });
const pauseTask = vi.fn().mockResolvedValue(undefined);
const logEntry = vi.fn().mockResolvedValue(undefined);
mockTaskStore = createMockTaskStore({ pauseTask, logEntry });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const ctx = (monitor as any).buildActionGateContext({ id: "agent-001", name: "Test Agent", permissionPolicy: undefined }, "FN-001", "run-1");
await ctx.pauseForApproval({
approvalRequestId: "apr-1",
decision: {
disposition: "require-approval",
category: "command_execution",
toolName: "bash",
operation: "git commit",
summary: "bash: git commit",
resourceType: "git",
approvalDedupeKey: "dedupe-1",
metadata: {},
},
});
expect(pauseTask).toHaveBeenCalledWith("FN-001", true, undefined, { pausedByAgentId: "agent-001" });
expect((store.updateAgentState as any)).toHaveBeenCalledWith("agent-001", "paused");
expect((store.updateAgent as any)).toHaveBeenCalledWith("agent-001", { pauseReason: "awaiting-approval" });
});
it("pauseForApproval still pauses agent when taskId is undefined", async () => {
const store = createStoreWithAgentForExec({ taskId: undefined });
const pauseTask = vi.fn().mockResolvedValue(undefined);
mockTaskStore = createMockTaskStore({ pauseTask });
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
const ctx = (monitor as any).buildActionGateContext({ id: "agent-001", name: "Test Agent", permissionPolicy: undefined }, undefined, "run-1");
await ctx.pauseForApproval({
approvalRequestId: "apr-1",
decision: {
disposition: "require-approval",
category: "command_execution",
toolName: "bash",
operation: "git commit",
summary: "bash: git commit",
resourceType: "git",
approvalDedupeKey: "dedupe-1",
metadata: {},
},
});
expect(pauseTask).not.toHaveBeenCalled();
expect((store.updateAgentState as any)).toHaveBeenCalledWith("agent-001", "paused");
expect((store.updateAgent as any)).toHaveBeenCalledWith("agent-001", { pauseReason: "awaiting-approval" });
});
it("omits permanent-agent gating context for ephemeral heartbeat agents", async () => {
const store = createStoreWithAgentForExec({
taskId: "FN-001",

View File

@@ -532,6 +532,22 @@ describe("wrapToolsWithPermanentAgentGating", () => {
});
describe("wrapToolsWithActionGate", () => {
const lockedDownRules = {
"git_write": "block",
"file_write_delete": "block",
"command_execution": "block",
"network_api": "block",
"task_agent_mutation": "block",
} as const;
const approvalRules = {
"git_write": "require-approval",
"file_write_delete": "require-approval",
"command_execution": "require-approval",
"network_api": "require-approval",
"task_agent_mutation": "require-approval",
} as const;
it("blocks disallowed actions and skips underlying tool", async () => {
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn() };
const { wrapToolsWithActionGate } = await import("../pi.js");
@@ -540,18 +556,9 @@ describe("wrapToolsWithActionGate", () => {
agentName: "Agent",
isEphemeral: false,
taskId: "FN-1",
permissionPolicy: {
presetId: "locked-down",
rules: {
"git_write": "block",
"file_write_delete": "block",
"command_execution": "block",
"network_api": "block",
"task_agent_mutation": "block",
},
},
permissionPolicy: { presetId: "locked-down", rules: lockedDownRules },
createApprovalRequest: vi.fn(),
findPendingApprovalByDedupeKey: vi.fn(),
findApprovalByDedupeKey: vi.fn(),
});
const result = await (wrapped[0] as any).execute("t1", { path: "a.ts" });
@@ -566,51 +573,100 @@ describe("wrapToolsWithActionGate", () => {
agentId: "agent-1",
agentName: "Agent",
isEphemeral: true,
permissionPolicy: {
presetId: "locked-down",
rules: {
"git_write": "block",
"file_write_delete": "block",
"command_execution": "block",
"network_api": "block",
"task_agent_mutation": "block",
},
},
permissionPolicy: { presetId: "locked-down", rules: lockedDownRules },
createApprovalRequest: vi.fn(),
findPendingApprovalByDedupeKey: vi.fn(),
findApprovalByDedupeKey: vi.fn(),
});
await (wrapped[0] as any).execute("t1", { path: "a.ts" });
expect(tool.execute).toHaveBeenCalled();
});
it("creates approval request once for require-approval", async () => {
it("creates request once and pauses once while pending", async () => {
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn() };
const createApprovalRequest = vi.fn().mockResolvedValue({ id: "apr-1" });
const findPendingApprovalByDedupeKey = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({ id: "apr-1" });
const pauseForApproval = vi.fn();
const findApprovalByDedupeKey = vi.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: "apr-1", status: "pending" });
const { wrapToolsWithActionGate } = await import("../pi.js");
const wrapped = wrapToolsWithActionGate([tool as any], {
agentId: "agent-1",
agentName: "Agent",
isEphemeral: false,
taskId: "FN-1",
permissionPolicy: {
presetId: "approval-required",
rules: {
"git_write": "require-approval",
"file_write_delete": "require-approval",
"command_execution": "require-approval",
"network_api": "require-approval",
"task_agent_mutation": "require-approval",
},
},
permissionPolicy: { presetId: "approval-required", rules: approvalRules },
createApprovalRequest,
findPendingApprovalByDedupeKey,
findApprovalByDedupeKey,
pauseForApproval,
});
const first = await (wrapped[0] as any).execute("t1", { path: "a.ts" });
const second = await (wrapped[0] as any).execute("t2", { path: "a.ts" });
expect((first as any).decision.metadata.approvalRequestId).toBe("apr-1");
expect((second as any).decision.metadata.approvalRequestId).toBe("apr-1");
expect(createApprovalRequest).toHaveBeenCalledTimes(1);
expect(pauseForApproval).toHaveBeenCalledTimes(1);
expect(tool.execute).not.toHaveBeenCalled();
});
it("executes once and marks completed for approved retry", async () => {
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn().mockResolvedValue({ ok: true }) };
const markApprovalCompleted = vi.fn();
const { wrapToolsWithActionGate } = await import("../pi.js");
const wrapped = wrapToolsWithActionGate([tool as any], {
agentId: "agent-1",
agentName: "Agent",
isEphemeral: false,
taskId: "FN-1",
permissionPolicy: { presetId: "approval-required", rules: approvalRules },
createApprovalRequest: vi.fn(),
findApprovalByDedupeKey: vi.fn().mockResolvedValue({ id: "apr-2", status: "approved" }),
markApprovalCompleted,
});
await (wrapped[0] as any).execute("t1", { path: "a.ts" });
await (wrapped[0] as any).execute("t2", { path: "a.ts" });
expect(createApprovalRequest).toHaveBeenCalledTimes(1);
expect(tool.execute).toHaveBeenCalledTimes(1);
expect(markApprovalCompleted).toHaveBeenCalledWith("apr-2");
});
it("does not mark completed when approved execution throws", async () => {
const error = new Error("write failed");
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn().mockRejectedValue(error) };
const markApprovalCompleted = vi.fn();
const { wrapToolsWithActionGate } = await import("../pi.js");
const wrapped = wrapToolsWithActionGate([tool as any], {
agentId: "agent-1",
agentName: "Agent",
isEphemeral: false,
taskId: "FN-1",
permissionPolicy: { presetId: "approval-required", rules: approvalRules },
createApprovalRequest: vi.fn(),
findApprovalByDedupeKey: vi.fn().mockResolvedValue({ id: "apr-2", status: "approved" }),
markApprovalCompleted,
});
await expect((wrapped[0] as any).execute("t1", { path: "a.ts" })).rejects.toThrow("write failed");
expect(markApprovalCompleted).not.toHaveBeenCalled();
});
it("returns rejection and never executes when latest decision is denied", async () => {
const tool = { name: "write", label: "Write", description: "", parameters: {}, execute: vi.fn() };
const { wrapToolsWithActionGate } = await import("../pi.js");
const wrapped = wrapToolsWithActionGate([tool as any], {
agentId: "agent-1",
agentName: "Agent",
isEphemeral: false,
taskId: "FN-1",
permissionPolicy: { presetId: "approval-required", rules: approvalRules },
createApprovalRequest: vi.fn(),
findApprovalByDedupeKey: vi.fn().mockResolvedValue({ id: "apr-3", status: "denied" }),
});
const result = await (wrapped[0] as any).execute("t1", { path: "a.ts" });
expect((result as any).isError).toBe(true);
expect((result as any).error).toContain("denied by approver");
expect(tool.execute).not.toHaveBeenCalled();
});
@@ -623,18 +679,9 @@ describe("wrapToolsWithActionGate", () => {
agentName: "Agent",
isEphemeral: false,
taskId: "FN-1",
permissionPolicy: {
presetId: "locked-down",
rules: {
"git_write": "block",
"file_write_delete": "block",
"command_execution": "block",
"network_api": "block",
"task_agent_mutation": "block",
},
},
permissionPolicy: { presetId: "locked-down", rules: lockedDownRules },
createApprovalRequest: vi.fn(),
findPendingApprovalByDedupeKey: vi.fn(),
findApprovalByDedupeKey: vi.fn(),
});
await (wrapped[0] as any).execute("t1", {});

View File

@@ -2,6 +2,7 @@ import type {
AgentPermissionPolicy,
AgentPermissionPolicyActionCategory,
AgentPermissionPolicyDisposition,
ApprovalRequestStatus,
} from "@fusion/core";
import {
ACTION_GATE_NETWORK_API_TOOLS,
@@ -34,7 +35,11 @@ export interface AgentActionGateContext {
runId?: string;
permissionPolicy: AgentPermissionPolicy;
createApprovalRequest: (decision: AgentActionGateDecision, args: Record<string, unknown>) => Promise<unknown>;
findPendingApprovalByDedupeKey: (dedupeKey: string) => Promise<unknown | null>;
findApprovalByDedupeKey?: (dedupeKey: string) => Promise<{ id: string; status: ApprovalRequestStatus } | null>;
/** @deprecated Use findApprovalByDedupeKey */
findPendingApprovalByDedupeKey?: (dedupeKey: string) => Promise<{ id: string } | null>;
pauseForApproval?: (info: { approvalRequestId: string; decision: AgentActionGateDecision }) => Promise<void>;
markApprovalCompleted?: (approvalRequestId: string) => Promise<void>;
}
// FN-3724: Internal Fusion runtime/coordinator tools never perform external mutations.
@@ -187,6 +192,31 @@ export function evaluateAgentActionGate(params: {
};
}
export function resolveGateOutcome(
decision: AgentActionGateDecision,
latestRequest: { id: string; status: ApprovalRequestStatus } | null,
): { outcome: "allow" | "block" | "execute-once-then-complete" | "wait-for-approval"; approvalRequestId?: string } {
if (decision.disposition === "allow") {
return { outcome: "allow" };
}
if (decision.disposition === "block") {
return { outcome: "block" };
}
if (!latestRequest) {
return { outcome: "wait-for-approval" };
}
if (latestRequest.status === "pending") {
return { outcome: "wait-for-approval", approvalRequestId: latestRequest.id };
}
if (latestRequest.status === "approved") {
return { outcome: "execute-once-then-complete", approvalRequestId: latestRequest.id };
}
if (latestRequest.status === "denied") {
return { outcome: "block", approvalRequestId: latestRequest.id };
}
return { outcome: "wait-for-approval" };
}
export function buildGateRejection(decision: AgentActionGateDecision, reason: string) {
return {
content: [{ type: "text", text: reason }],

View File

@@ -617,9 +617,30 @@ export class HeartbeatMonitor {
context: { ...decision.metadata, approvalDedupeKey: decision.approvalDedupeKey, toolName: decision.toolName, toolArgs: args },
},
}),
findApprovalByDedupeKey: async (dedupeKey) => {
const latest = this.getApprovalRequestStore().findLatestByDedupeKey({ requesterActorId: agent.id, taskId, dedupeKey });
return latest ? { id: latest.id, status: latest.status } : null;
},
findPendingApprovalByDedupeKey: async (dedupeKey) => {
const pending = this.getApprovalRequestStore().list({ status: "pending", requesterActorId: agent.id, taskId, limit: 100 });
return pending.find((request) => request.targetAction.context?.approvalDedupeKey === dedupeKey) ?? null;
const latest = this.getApprovalRequestStore().findLatestByDedupeKey({ requesterActorId: agent.id, taskId, dedupeKey });
return latest?.status === "pending" ? { id: latest.id } : null;
},
pauseForApproval: async ({ approvalRequestId, decision }) => {
if (taskId && this.taskStore) {
await this.taskStore.pauseTask(taskId, true, undefined, { pausedByAgentId: agent.id });
await this.taskStore.logEntry(
taskId,
`Approval required for ${decision.toolName}. Request ${approvalRequestId} created; task and agent paused awaiting decision.`,
);
}
await this.store.updateAgentState(agent.id, "paused");
await this.store.updateAgent(agent.id, { pauseReason: "awaiting-approval" });
},
markApprovalCompleted: async (approvalRequestId) => {
await this.getApprovalRequestStore().markCompleted(approvalRequestId, {
actor: { actorId: agent.id, actorType: "agent", actorName: agent.name },
note: "Tool executed after approval",
});
},
};
}

View File

@@ -755,9 +755,34 @@ export class TaskExecutor {
},
},
}),
findApprovalByDedupeKey: async (dedupeKey) => {
const latest = this.approvalRequestStore.findLatestByDedupeKey({ requesterActorId: agent.id, taskId, dedupeKey });
return latest ? { id: latest.id, status: latest.status } : null;
},
findPendingApprovalByDedupeKey: async (dedupeKey) => {
const pending = this.approvalRequestStore.list({ status: "pending", requesterActorId: agent.id, taskId, limit: 100 });
return pending.find((request) => request.targetAction.context?.approvalDedupeKey === dedupeKey) ?? null;
const latest = this.approvalRequestStore.findLatestByDedupeKey({ requesterActorId: agent.id, taskId, dedupeKey });
return latest?.status === "pending" ? { id: latest.id } : null;
},
pauseForApproval: async ({ approvalRequestId, decision }) => {
if (taskId) {
await this.store.pauseTask(taskId, true, this.currentRunContext, { pausedByAgentId: agent.id });
await this.store.logEntry(
taskId,
`Approval required for ${decision.toolName}. Request ${approvalRequestId} created; task and agent paused awaiting decision.`,
undefined,
this.currentRunContext,
);
}
if (this.options.agentStore) {
await this.options.agentStore.updateAgentState(agent.id, "paused");
await this.options.agentStore.updateAgent(agent.id, { pauseReason: "awaiting-approval" });
}
},
markApprovalCompleted: async (approvalRequestId) => {
await this.approvalRequestStore.markCompleted(approvalRequestId, {
actor: { actorId: agent.id, actorType: "agent", actorName: agent.name },
note: "Tool executed after approval",
});
},
};
}

View File

@@ -52,6 +52,7 @@ import { readCustomProviders } from "./custom-providers.js";
import {
buildGateRejection,
evaluateAgentActionGate,
resolveGateOutcome,
type AgentActionGateContext,
} from "./agent-action-gate.js";
import { resolvePermanentAgentToolDecision } from "./permanent-agent-gating.js";
@@ -1190,25 +1191,70 @@ export function wrapToolsWithActionGate(
permissionPolicy: gateContext.permissionPolicy,
});
if (decision.disposition === "allow") {
const latestApproval = gateContext.findApprovalByDedupeKey
? await gateContext.findApprovalByDedupeKey(decision.approvalDedupeKey)
: await gateContext.findPendingApprovalByDedupeKey?.(decision.approvalDedupeKey).then((request) =>
request ? { id: request.id, status: "pending" as const } : null
);
const gateOutcome = resolveGateOutcome(decision, latestApproval ?? null);
if (gateOutcome.outcome === "allow") {
return originalExecute(...args);
}
if (decision.disposition === "block") {
if (gateOutcome.outcome === "execute-once-then-complete") {
try {
const result = await originalExecute(...args);
if (gateOutcome.approvalRequestId) {
await gateContext.markApprovalCompleted?.(gateOutcome.approvalRequestId);
}
return result;
} catch (error) {
throw error;
}
}
if (gateOutcome.outcome === "block") {
if (latestApproval?.status === "denied") {
return buildGateRejection(
{
...decision,
metadata: {
...decision.metadata,
approvalRequestId: latestApproval.id,
dedupeKey: decision.approvalDedupeKey,
},
},
"Action was denied by approver. The agent must not retry this action.",
);
}
return buildGateRejection(
decision,
`Action blocked by permission policy (${decision.category}) for ${gateContext.agentName}`,
);
}
const existing = await gateContext.findPendingApprovalByDedupeKey(decision.approvalDedupeKey);
if (!existing) {
await gateContext.createApprovalRequest(decision, params);
let approvalRequestId = gateOutcome.approvalRequestId;
if (!approvalRequestId) {
const created = await gateContext.createApprovalRequest(decision, params) as { id?: string } | null;
approvalRequestId = created?.id;
if (approvalRequestId) {
await gateContext.pauseForApproval?.({ approvalRequestId, decision });
}
}
return buildGateRejection(
decision,
`Action requires approval (${decision.category}). Approval request queued.`,
{
...decision,
metadata: {
...decision.metadata,
...(approvalRequestId ? { approvalRequestId } : {}),
dedupeKey: decision.approvalDedupeKey,
},
},
`Action requires approval (request ${approvalRequestId ?? "pending"}). Agent and task have been paused; will resume once a decision is made.`,
);
},
};