feat(FN-3852): bypass heartbeat terminal tool in workflow gates

Hardens workflow gate execution to bypass the heartbeat terminal tool, preventing it from blocking terminal gates; adds regression tests covering approval request store and SSE relay behavior, with tightened typing on the test policy.

Fusion-Task-Id: FN-3852
This commit is contained in:
Fusion
2026-05-09 10:26:11 -07:00
committed by gsxdsm
parent 86baa2662e
commit c4e0c1d28c
3 changed files with 64 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Permanent-agent heartbeats can no longer be deadlocked by an approval policy interposing on `fn_heartbeat_done`. The terminal heartbeat-completion tool now bypasses both the action gate and the permanent-agent gate by reference, so even a misconfigured policy or classification-table regression cannot strand a heartbeat run. No user-visible behavior change for correctly classified deployments.

View File

@@ -529,6 +529,34 @@ describe("wrapToolsWithPermanentAgentGating", () => {
expect((result as any).details).toBeUndefined();
expect(tool.execute).not.toHaveBeenCalled();
});
it("bypasses wrapping for fn_heartbeat_done under locked-down policy", async () => {
const execute = vi.fn().mockResolvedValue({ ok: true, terminal: true });
const tool = { name: "fn_heartbeat_done", label: "Heartbeat Done", description: "", parameters: {}, execute };
const createApprovalRequest = vi.fn();
const findPendingApprovalRequest = vi.fn();
const { wrapToolsWithPermanentAgentGating } = await import("../pi.js");
const wrapped = wrapToolsWithPermanentAgentGating([tool as any], {
permissionPolicy: {
presetId: "locked-down",
rules: {
git_write: "block",
file_write_delete: "block",
command_execution: "block",
network_api: "block",
task_agent_mutation: "block",
},
},
createApprovalRequest,
findPendingApprovalRequest,
});
expect(wrapped[0]).toBe(tool);
await expect((wrapped[0] as any).execute("t1", {})).resolves.toEqual({ ok: true, terminal: true });
expect(execute).toHaveBeenCalledTimes(1);
expect(createApprovalRequest).not.toHaveBeenCalled();
expect(findPendingApprovalRequest).not.toHaveBeenCalled();
});
});
describe("wrapToolsWithActionGate", () => {
@@ -670,22 +698,34 @@ describe("wrapToolsWithActionGate", () => {
expect(tool.execute).not.toHaveBeenCalled();
});
it("passes through exempt internal tools under locked-down policy", async () => {
const execute = vi.fn().mockResolvedValue({ ok: true });
it.each<[
"locked-down" | "approval-required",
typeof lockedDownRules | typeof approvalRules
]>([
["locked-down", lockedDownRules],
["approval-required", approvalRules],
])("bypasses wrapping for fn_heartbeat_done under %s policy", async (presetId, rules) => {
const execute = vi.fn().mockResolvedValue({ ok: true, terminal: true });
const tool = { name: "fn_heartbeat_done", label: "Heartbeat Done", description: "", parameters: {}, execute };
const createApprovalRequest = vi.fn();
const pauseForApproval = 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: "locked-down", rules: lockedDownRules },
createApprovalRequest: vi.fn(),
permissionPolicy: { presetId, rules },
createApprovalRequest,
findApprovalByDedupeKey: vi.fn(),
pauseForApproval,
});
await (wrapped[0] as any).execute("t1", {});
expect(wrapped[0]).toBe(tool);
await expect((wrapped[0] as any).execute("t1", {})).resolves.toEqual({ ok: true, terminal: true });
expect(execute).toHaveBeenCalledTimes(1);
expect(createApprovalRequest).not.toHaveBeenCalled();
expect(pauseForApproval).not.toHaveBeenCalled();
});
});

View File

@@ -1049,6 +1049,8 @@ function buildPermanentAgentApprovalDedupeKey(input: {
].join("|");
}
const HEARTBEAT_TERMINAL_TOOL_NAME = "fn_heartbeat_done";
export function wrapToolsWithBoundary(
tools: ToolDefinition[],
worktreePath: string | null,
@@ -1114,6 +1116,12 @@ export function wrapToolsWithPermanentAgentGating(
}
return tools.map((tool) => {
// FN-3852: heartbeat terminal completion must never be approval-gated,
// otherwise permanent-agent runs can deadlock in an open session.
if (tool.name === HEARTBEAT_TERMINAL_TOOL_NAME) {
return tool;
}
const originalExecute = tool.execute as any;
return {
...tool,
@@ -1178,6 +1186,12 @@ export function wrapToolsWithActionGate(
}
return tools.map((tool) => {
// FN-3852: heartbeat terminal completion must never be approval-gated,
// otherwise permanent-agent runs can deadlock in an open session.
if (tool.name === HEARTBEAT_TERMINAL_TOOL_NAME) {
return tool;
}
const originalExecute = tool.execute as any;
return {
...tool,