Files
fusion/packages/engine/src/__tests__/executor-approval-gate-suspend.test.ts
gsxdsm bab42b40dd FN-7736: prevent recovery/oversight from resuming approval-blocked tasks
Introduces a canonical awaiting-approval pause reason and predicate so recovery and oversight paths treat approval-blocked tasks as terminal-until-approved instead of eligible for early rebound.

- Add isTaskBlockedOnApproval predicate and canonical "awaiting-approval" pause reason in @fusion/core (store.ts, task-merge.ts, index.ts/index.gate.ts)
- Exclude approval-blocked tasks from paused-scope-decay rebound in self-healing.ts
- Keep the planner overseer withholding oversight for approval-blocked tasks (overseer-human-control-policy.ts)
- Executor and agent-heartbeat now recognize the approval-blocked state and avoid resuming it
- Add regression tests across store-persistence, task-merge, overseer-human-control-policy, paused-scope-decay, and self-healing-paused-abort-recovery
- Update docs/architecture.md with the new approval-hold invariant
- Add changeset fn-7736-approval-hold.md (patch)

Files changed:
 .changeset/fn-7736-approval-hold.md                |  7 +++
 docs/architecture.md                               | 64 ++++++++++++++++++++--
 .../core/src/__tests__/store-persistence.test.ts   | 18 ++++++
 packages/core/src/__tests__/task-merge.test.ts     | 34 ++++++++++++
 packages/core/src/index.gate.ts                    |  2 +
 packages/core/src/index.ts                         |  2 +
 packages/core/src/store.ts                         | 18 +++++-
 packages/core/src/task-merge.ts                    | 34 ++++++++++++
 .../executor-approval-gate-suspend.test.ts         |  5 +-
 .../src/__tests__/heartbeat-executor.test.ts       |  5 +-
 .../overseer-human-control-policy.test.ts          | 44 +++++++++++++++
 .../paused-scope-decay.test.ts                     | 44 +++++++++++++++
 .../self-healing-paused-abort-recovery.test.ts     | 21 +++++++
 packages/engine/src/agent-heartbeat.ts             |  8 ++-
 packages/engine/src/executor.ts                    | 13 ++++-
 .../engine/src/overseer-human-control-policy.ts    | 45 +++++++++++----
 packages/engine/src/self-healing.ts                | 12 +++-
 17 files changed, 351 insertions(+), 25 deletions(-)

Fusion-Task-Id: FN-7736

Fusion-Task-Lineage: 67e05b7f-f621-4f9b-bc01-721ff05d715b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-09 19:58:06 -07:00

132 lines
5.4 KiB
TypeScript

import "./executor-test-helpers.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TaskExecutor } from "../executor.js";
import { resetExecutorMocks } from "./executor-test-helpers.js";
/*
FNXC:AgentGating 2026-07-05-00:30:
FN-7608 regression coverage: TaskExecutor.buildActionGateContext's
`pauseForApproval` closure must both pause the task in the store AND
synchronously trigger a session-suspending abort of the in-flight session for
that task (via the existing awaitAbortInFlightTaskWork hard-cancel surface).
Before this fix, pauseTask() marked the row paused but the running LLM turn
kept going -- the executor prompt forbade ending a turn without a tool call,
so the agent hunted for ungated workarounds while the task only *looked*
paused in the store. Assert both effects fire, and that a rejected/failed
abort call is swallowed (never breaks pauseForApproval's own control flow).
*/
function createEventedStore() {
const listeners = new Map<string, Set<(...args: any[]) => void>>();
return {
on: vi.fn((event: string, listener: (...args: any[]) => void) => {
const set = listeners.get(event) ?? new Set();
set.add(listener);
listeners.set(event, set);
}),
off: vi.fn((event: string, listener: (...args: any[]) => void) => {
listeners.get(event)?.delete(listener);
}),
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false }),
listTasks: vi.fn().mockResolvedValue([]),
pauseTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
} as any;
}
describe("TaskExecutor.buildActionGateContext pauseForApproval", () => {
beforeEach(() => {
resetExecutorMocks();
vi.clearAllMocks();
});
it("pauses the task and synchronously kicks off an in-flight session abort", async () => {
const store = createEventedStore();
const executor = new TaskExecutor(store, "/tmp/test");
const abortSpy = vi.spyOn(executor, "awaitAbortInFlightTaskWork").mockResolvedValue(undefined);
const gateContext = (executor as any).buildActionGateContext("FN-1", null, undefined);
expect(gateContext).toBeTruthy();
const decision = {
disposition: "require-approval",
category: "command_execution",
toolName: "bash",
operation: "shell command",
summary: "bash: shell command",
resourceType: "command",
approvalDedupeKey: "executor-FN-1|FN-1|bash|command_execution|command||shell command",
metadata: {},
};
await gateContext.pauseForApproval({ approvalRequestId: "apr-1", decision });
// FN-7736: pauseForApproval must durably stamp the canonical
// AWAITING_APPROVAL_PAUSE_REASON so recovery/oversight code can recognize
// this hold via isTaskBlockedOnApproval (not just paused:true).
expect(store.pauseTask).toHaveBeenCalledWith("FN-1", true, undefined, expect.objectContaining({ pausedByAgentId: expect.any(String), pausedReason: "awaiting-approval" }));
expect(store.logEntry).toHaveBeenCalled();
// Session suspension must be triggered synchronously (called, not merely
// scheduled for some later tick) as part of this same pauseForApproval
// invocation.
expect(abortSpy).toHaveBeenCalledTimes(1);
expect(abortSpy).toHaveBeenCalledWith("FN-1", expect.stringContaining("awaiting-approval"));
});
it("does not let a rejected session-abort break pauseForApproval's control flow", async () => {
const store = createEventedStore();
const executor = new TaskExecutor(store, "/tmp/test");
vi.spyOn(executor, "awaitAbortInFlightTaskWork").mockRejectedValue(new Error("boom"));
const gateContext = (executor as any).buildActionGateContext("FN-2", null, undefined);
const decision = {
disposition: "require-approval",
category: "command_execution",
toolName: "bash",
operation: "shell command",
summary: "bash: shell command",
resourceType: "command",
approvalDedupeKey: "k",
metadata: {},
};
await expect(gateContext.pauseForApproval({ approvalRequestId: "apr-2", decision })).resolves.toBeUndefined();
expect(store.pauseTask).toHaveBeenCalledTimes(1);
// Let the fire-and-forget rejected promise's .catch() handler settle
// before the test ends, so no unhandled rejection leaks.
await new Promise((resolve) => setImmediate(resolve));
});
it("fires the abort call without blocking on it (fire-and-forget, not awaited inline)", async () => {
const store = createEventedStore();
const executor = new TaskExecutor(store, "/tmp/test");
let resolveAbort: () => void = () => {};
const abortPromise = new Promise<void>((resolve) => {
resolveAbort = resolve;
});
vi.spyOn(executor, "awaitAbortInFlightTaskWork").mockReturnValue(abortPromise);
const gateContext = (executor as any).buildActionGateContext("FN-3", null, undefined);
const decision = {
disposition: "require-approval",
category: "command_execution",
toolName: "bash",
operation: "shell command",
summary: "bash: shell command",
resourceType: "command",
approvalDedupeKey: "k3",
metadata: {},
};
// If pauseForApproval awaited the abort call inline, this would hang
// forever since abortPromise never resolves during the test body.
await expect(gateContext.pauseForApproval({ approvalRequestId: "apr-3", decision })).resolves.toBeUndefined();
resolveAbort();
await abortPromise;
});
});