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>
132 lines
5.4 KiB
TypeScript
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;
|
|
});
|
|
});
|