FN-7514: withhold overseer actions under human control

Add a human-control guard so planner overseer recovery stays inert for paused or human-review tasks.

- Add a pure overseer human-control policy that treats explicit user pauses and autoMerge:false / human-review tasks as full withhold states.
- Thread settings through planner recovery ticks, skip action classification and pending confirmations when withheld, and emit deduped no-action run-audit events.
- Wire ProjectEngine audit recording and document the new guard, run-audit event, exports, and release note.

Files changed:
 .changeset/fn-7514-overseer-human-control-guard.md |   7 +
 AGENTS.md                                          |   1 +
 docs/architecture.md                               |  39 +++++
 .../overseer-human-control-policy.test.ts          |  86 +++++++++++
 ...anner-recovery-controller-human-control.test.ts | 170 +++++++++++++++++++++
 packages/engine/src/index.ts                       |   7 +
 .../engine/src/overseer-human-control-policy.ts    |  88 +++++++++++
 packages/engine/src/planner-recovery-controller.ts | 107 ++++++++++++-
 packages/engine/src/project-engine.ts              |  44 +++++-
 packages/engine/src/run-audit.ts                   |  15 +-
 10 files changed, 558 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-7514

Fusion-Task-Lineage: d4d3bd04-3f8e-4a05-9636-f177e050390b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 14:11:25 -07:00
parent b545083249
commit 79ab367547
10 changed files with 558 additions and 6 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Planner overseer now stays fully hands-off for paused tasks and auto-merge-off / human-review tasks.
category: feature
dev: Adds the pure `evaluateOverseerHumanControl` policy (packages/engine/src/overseer-human-control-policy.ts), consulted at the top of `PlannerRecoveryController.tick()` before any action classification, confirmation gating, steering, retry, or dispatch — so a user-paused or `autoMerge:false`/human-review task never even records a pending confirmation. Reuses `allowsAutoMergeProcessing` from `@fusion/core` verbatim (never re-derives the auto-merge/human-review predicate). Distinguishes explicit user pause (`task.userPaused===true`, or `task.paused===true` with no `pausedReason`) from engine/self-healing parks (which always stamp a `pausedReason`). Emits a bounded `overseer:oversight-withheld-human-control` run-audit no-action event (metadata: `{ taskId, reason, stage, oversightLevel }`), deduped per (taskId, reason) so it does not spam every poll.

View File

@@ -232,6 +232,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
- Workspace (Phase D U1): self-healing emits `task:reconcile-workspace-partial-land` when it re-enqueues a partial/zero-landed workspace task's per-repo land (or parks it `failed` when a sub-repo's `fusion/<id>` branch is gone with no `landedSha`), and `task:reconcile-workspace-partial-land-no-action` when `autoMerge:false`, user-pause, or a live sub-repo worktree (workspace-aware liveness) blocks that backward move. - Workspace (Phase D U1): self-healing emits `task:reconcile-workspace-partial-land` when it re-enqueues a partial/zero-landed workspace task's per-repo land (or parks it `failed` when a sub-repo's `fusion/<id>` branch is gone with no `landedSha`), and `task:reconcile-workspace-partial-land-no-action` when `autoMerge:false`, user-pause, or a live sub-repo worktree (workspace-aware liveness) blocks that backward move.
- Workspace (Phase D U1): self-healing emits `task:reclaim-phantom-workspace-land-lease` when it clears a leaked `workspace-repo-land` lease whose owning task is terminal/dead and older than the FN-6736 staleness floor (a live merging owner is left untouched). - Workspace (Phase D U1): self-healing emits `task:reclaim-phantom-workspace-land-lease` when it clears a leaked `workspace-repo-land` lease whose owning task is terminal/dead and older than the FN-6736 staleness floor (a live merging owner is left untouched).
- Workspace (Phase D U1): self-healing emits `task:reconcile-orphaned-workspace-worktree` when it removes a done/dead workspace task's recorded per-repo worktree from its stored `worktreePath` (guarded by `isPathActive`; no temp-root walk). - Workspace (Phase D U1): self-healing emits `task:reconcile-orphaned-workspace-worktree` when it removes a done/dead workspace task's recorded per-repo worktree from its stored `worktreePath` (guarded by `isPathActive`; no temp-root walk).
- FN-7514: the planner overseer's per-task oversight loop (`PlannerRecoveryController.tick`) emits `overseer:oversight-withheld-human-control` when the pure `evaluateOverseerHumanControl` guard withholds ALL oversight action (no steering, retry, targeted-fix, or pending confirmation) for a task that is user-paused (`task.userPaused===true`, or `task.paused===true` with no `pausedReason`) or ineligible for auto-merge processing per `allowsAutoMergeProcessing` (`autoMerge:false`/PR-based human-review terminal contract). The guard runs BEFORE FN-7513's confirmation classification, so a withheld task never records a pending confirmation. Metadata: `{ taskId, reason: "user-paused" | "auto-merge-off-human-review", stage, oversightLevel }`; deduped per (taskId, withheld reason) so it is not re-emitted every poll while the reason is unchanged.
## Reference docs (deeper detail) ## Reference docs (deeper detail)

View File

@@ -1521,6 +1521,45 @@ FN-7517), comprehensive human-control safeguards beyond `userPaused` (FN-7514),
timeline (FN-7519), and run-audit/activity events (FN-7520) all consume the data this gate exposes but timeline (FN-7519), and run-audit/activity events (FN-7520) all consume the data this gate exposes but
are implemented elsewhere. are implemented elsewhere.
### Planner overseer human-control guard (FN-7514)
FN-7514 supplies the comprehensive human-control safeguard the FN-7512/FN-7513 layers deferred: the
overseer must be fully inert — no steering, retry, targeted-fix, or FN-7513 confirmation-required action
(merge/PR progression, destructive/external-service side effect) may fire, and no pending confirmation
may even be recorded — whenever a task is (a) user-paused, or (b) ineligible for auto-merge processing
per the FN-5147 `autoMerge:false` / PR-based human-review terminal contract.
`packages/engine/src/overseer-human-control-policy.ts` exports the pure predicate
`evaluateOverseerHumanControl(task, settings)` (no I/O, mirrors the `recovery-policy.ts` style), returning
`{ withhold: boolean; reason?: "user-paused" | "auto-merge-off-human-review" }`. It reuses
`allowsAutoMergeProcessing` from `@fusion/core` VERBATIM for the auto-merge-off half — never re-derives
the predicate inline. For the pause half, it distinguishes:
- Explicit user pause: `task.userPaused === true`, OR `task.paused === true` with NO `task.pausedReason`
(the `fn_task_pause` tool / `TaskStore.pauseTask` never stamps a `pausedReason`).
- Engine/self-healing park (NOT user pause): `task.paused === true` WITH a `pausedReason` (every
self-healing park path — branch-conflict-unrecoverable, token_budget_exceeded,
in-review-stall-deadlock, worktrunk_operation_failed, etc. — always stamps one).
`PlannerRecoveryController.tick()` (`planner-recovery-controller.ts`) consults this guard FIRST — before
the snapshot lookup, before `decidePlannerRecovery`, before FN-7513's confirmation classification. When
withheld, `tick()` returns `null` immediately (same contract as the prior bare `userPaused` check) and
never reaches the point where a pending `PlannerConfirmationRequest` could be created. A snapshot lookup
AFTER the withhold decision (read-only, for audit metadata only — stage/oversightLevel — never feeding
back into the decision) feeds an optional `recordHumanControlWithheld` handler, which `ProjectEngine`
wires to a bounded `RunAuditor.database({ type: "overseer:oversight-withheld-human-control", ... })`
no-action event (metadata: `{ taskId, reason, stage, oversightLevel }`). The controller dedupes this
emission per `(taskId, withheld reason)` — a task stuck in the same withheld state across many poll
cycles emits the event once, not on every tick; a reason change (or `clear(taskId)` on terminal
transition) re-arms it.
`ProjectEngine.pollPlannerOverseer` fetches global `Settings` once per poll cycle (not per task) and
threads it through `ctx.settings` to `tick()`, so the guard's `allowsAutoMergeProcessing` check sees the
same settings self-healing already gates lifecycle mutation on.
**Downstream ownership (not this layer):** the dashboard UI/badges surfacing withheld state (FN-7515+),
a persisted intervention timeline (FN-7519), and richer run-audit/activity presentation (FN-7520).
--- ---
## 11) Multi-Project Architecture ## 11) Multi-Project Architecture

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import {
evaluateOverseerHumanControl,
type OverseerHumanControlSettings,
type OverseerHumanControlTask,
} from "../overseer-human-control-policy.js";
function task(overrides: Partial<OverseerHumanControlTask> = {}): OverseerHumanControlTask {
return {
userPaused: undefined,
paused: undefined,
pausedReason: undefined,
autoMerge: undefined,
prInfo: undefined,
prInfos: undefined,
...overrides,
};
}
function settings(overrides: Partial<OverseerHumanControlSettings> = {}): OverseerHumanControlSettings {
return { autoMerge: true, ...overrides };
}
describe("evaluateOverseerHumanControl", () => {
it("withholds with reason user-paused when task.userPaused is true", () => {
const decision = evaluateOverseerHumanControl(task({ userPaused: true }), settings());
expect(decision).toEqual({ withhold: true, reason: "user-paused" });
});
it("withholds with reason user-paused for a user-source task.paused (no pausedReason)", () => {
const decision = evaluateOverseerHumanControl(task({ paused: true, pausedReason: undefined }), settings());
expect(decision).toEqual({ withhold: true, reason: "user-paused" });
});
it("does NOT treat an engine/self-healing park (paused with a pausedReason) as user pause", () => {
const decision = evaluateOverseerHumanControl(
task({ paused: true, pausedReason: "branch-conflict-unrecoverable" }),
settings(),
);
expect(decision.reason).not.toBe("user-paused");
expect(decision).toEqual({ withhold: false });
});
it("withholds with reason auto-merge-off-human-review when settings.autoMerge is false and no per-task override", () => {
const decision = evaluateOverseerHumanControl(task(), settings({ autoMerge: false }));
expect(decision).toEqual({ withhold: true, reason: "auto-merge-off-human-review" });
});
it("withholds with reason auto-merge-off-human-review when settings.autoMerge false and task.autoMerge is also false", () => {
const decision = evaluateOverseerHumanControl(task({ autoMerge: false }), settings({ autoMerge: false }));
expect(decision).toEqual({ withhold: true, reason: "auto-merge-off-human-review" });
});
it("is NOT withheld when task.autoMerge:true overrides a global autoMerge:false", () => {
const decision = evaluateOverseerHumanControl(task({ autoMerge: true }), settings({ autoMerge: false }));
expect(decision).toEqual({ withhold: false });
});
it("is NOT withheld for a fully live task (no pause, auto-merge eligible)", () => {
const decision = evaluateOverseerHumanControl(task(), settings());
expect(decision).toEqual({ withhold: false });
});
it("handles undefined userPaused/paused/autoMerge states as not-withheld (defaults)", () => {
const decision = evaluateOverseerHumanControl(
task({ userPaused: undefined, paused: undefined, autoMerge: undefined }),
settings({ autoMerge: true }),
);
expect(decision).toEqual({ withhold: false });
});
it("falls back to auto-merge-enabled defaults when settings is null/undefined", () => {
expect(evaluateOverseerHumanControl(task(), undefined)).toEqual({ withhold: false });
expect(evaluateOverseerHumanControl(task(), null)).toEqual({ withhold: false });
});
it("fails closed (withhold, no reason) when task is null/undefined", () => {
expect(evaluateOverseerHumanControl(null, settings())).toEqual({ withhold: true });
expect(evaluateOverseerHumanControl(undefined, settings())).toEqual({ withhold: true });
});
it("prioritizes user-paused over auto-merge-off when both conditions are true", () => {
const decision = evaluateOverseerHumanControl(task({ userPaused: true }), settings({ autoMerge: false }));
expect(decision).toEqual({ withhold: true, reason: "user-paused" });
});
});

View File

@@ -0,0 +1,170 @@
/**
* FNXC:PlannerOverseer 2026-07-04-15:00:
* FN-7514 Symptom Verification: for a user-paused task AND an auto-merge-off
* / human-review task with a pending recoverable condition, NO action
* handler may fire (bounded-recovery, retry, and pending-confirmation all
* withheld), and a `recordHumanControlWithheld` no-action notification must
* be delivered with the correct reason. Also asserts a non-paused,
* auto-merge-eligible task still receives normal oversight (guard does not
* over-block), and that the invariant holds across every watched stage the
* seam exposes (not only in-review/merger).
*/
import { describe, expect, it, vi } from "vitest";
import type { Task } from "@fusion/core";
import { PlannerRecoveryController, type PlannerRecoveryHandlers } from "../planner-recovery-controller.js";
import type { OverseerStageObservation, OverseerWatchedStage } from "../planner-overseer.js";
function task(overrides: Partial<Task> = {}): Task {
return {
id: "FN-1",
title: "t",
description: "",
column: "in-review",
...overrides,
} as Task;
}
function observation(overrides: Partial<OverseerStageObservation> = {}): OverseerStageObservation {
return {
taskId: "FN-1",
stage: "merger" as OverseerWatchedStage,
signal: "failed",
oversightLevel: "autonomous",
observedAt: Date.now(),
reason: "test",
sources: [],
...overrides,
};
}
function allHandlers() {
return {
injectGuidance: vi.fn().mockResolvedValue(undefined),
retryStep: vi.fn().mockResolvedValue(undefined),
requestTargetedFix: vi.fn().mockResolvedValue(undefined),
requestConfirmation: vi.fn().mockResolvedValue(undefined),
executeMergePrAction: vi.fn().mockResolvedValue(undefined),
executeDestructiveExternalAction: vi.fn().mockResolvedValue(undefined),
recordHumanControlWithheld: vi.fn().mockResolvedValue(undefined),
} satisfies PlannerRecoveryHandlers;
}
function makeController(obs: OverseerStageObservation | null, handlers: PlannerRecoveryHandlers) {
return new PlannerRecoveryController({
snapshotProvider: { getSnapshot: () => obs },
handlers,
});
}
const WATCHED_STAGES: OverseerWatchedStage[] = ["executor", "reviewer", "merger", "pull-request", "workflow-gate"];
describe("PlannerRecoveryController — human-control guard (FN-7514)", () => {
it("is fully inert for a user-paused task with a pending recoverable/confirmation-required condition", async () => {
const handlers = allHandlers();
const controller = makeController(observation({ stage: "merger" }), handlers);
const decision = await controller.tick(task({ userPaused: true }));
expect(decision).toBeNull();
expect(handlers.injectGuidance).not.toHaveBeenCalled();
expect(handlers.retryStep).not.toHaveBeenCalled();
expect(handlers.requestTargetedFix).not.toHaveBeenCalled();
expect(handlers.requestConfirmation).not.toHaveBeenCalled();
expect(handlers.executeMergePrAction).not.toHaveBeenCalled();
expect(handlers.executeDestructiveExternalAction).not.toHaveBeenCalled();
expect(controller.getPendingConfirmations("FN-1")).toEqual([]);
expect(handlers.recordHumanControlWithheld).toHaveBeenCalledTimes(1);
const [withheldTask, withheldDecision] = handlers.recordHumanControlWithheld.mock.calls[0]!;
expect(withheldTask.id).toBe("FN-1");
expect(withheldDecision.reason).toBe("user-paused");
});
it("is fully inert for an auto-merge-off / human-review task with a pending recoverable condition", async () => {
const handlers = allHandlers();
const controller = makeController(observation({ stage: "merger" }), handlers);
const decision = await controller.tick(task({ autoMerge: undefined }), { settings: { autoMerge: false } });
expect(decision).toBeNull();
expect(handlers.injectGuidance).not.toHaveBeenCalled();
expect(handlers.retryStep).not.toHaveBeenCalled();
expect(handlers.requestTargetedFix).not.toHaveBeenCalled();
expect(handlers.requestConfirmation).not.toHaveBeenCalled();
expect(handlers.executeMergePrAction).not.toHaveBeenCalled();
expect(handlers.executeDestructiveExternalAction).not.toHaveBeenCalled();
expect(controller.getPendingConfirmations("FN-1")).toEqual([]);
expect(handlers.recordHumanControlWithheld).toHaveBeenCalledTimes(1);
const [, withheldDecision] = handlers.recordHumanControlWithheld.mock.calls[0]!;
expect(withheldDecision.reason).toBe("auto-merge-off-human-review");
});
it("holds the guard across every watched stage the seam exposes, not only in-review/merger", async () => {
for (const stage of WATCHED_STAGES) {
const handlers = allHandlers();
const controller = makeController(observation({ stage }), handlers);
const decision = await controller.tick(task({ userPaused: true }));
expect(decision, `stage=${stage}`).toBeNull();
expect(handlers.injectGuidance, `stage=${stage}`).not.toHaveBeenCalled();
expect(handlers.retryStep, `stage=${stage}`).not.toHaveBeenCalled();
expect(handlers.requestTargetedFix, `stage=${stage}`).not.toHaveBeenCalled();
expect(handlers.requestConfirmation, `stage=${stage}`).not.toHaveBeenCalled();
}
});
it("does NOT re-emit recordHumanControlWithheld on repeated ticks for the same still-withheld reason", async () => {
const handlers = allHandlers();
const controller = makeController(observation({ stage: "merger" }), handlers);
await controller.tick(task({ userPaused: true }));
await controller.tick(task({ userPaused: true }));
await controller.tick(task({ userPaused: true }));
expect(handlers.recordHumanControlWithheld).toHaveBeenCalledTimes(1);
});
it("re-emits recordHumanControlWithheld when the withheld reason changes", async () => {
const handlers = allHandlers();
const controller = makeController(observation({ stage: "merger" }), handlers);
await controller.tick(task({ userPaused: true }));
await controller.tick(task({}), { settings: { autoMerge: false } });
expect(handlers.recordHumanControlWithheld).toHaveBeenCalledTimes(2);
expect(handlers.recordHumanControlWithheld.mock.calls[0]![1].reason).toBe("user-paused");
expect(handlers.recordHumanControlWithheld.mock.calls[1]![1].reason).toBe("auto-merge-off-human-review");
});
it("still delivers normal oversight for a non-paused, auto-merge-eligible task (guard does not over-block)", async () => {
const handlers = allHandlers();
const controller = makeController(observation({ stage: "merger" }), handlers);
const decision = await controller.tick(task(), { settings: { autoMerge: true } });
// Not withheld: the normal decidePlannerRecovery path runs.
expect(handlers.recordHumanControlWithheld).not.toHaveBeenCalled();
expect(decision).not.toBeNull();
});
it("clear(taskId) resets the withheld-reason dedup so a later re-entry re-emits", async () => {
const handlers = allHandlers();
const controller = makeController(observation({ stage: "merger" }), handlers);
await controller.tick(task({ userPaused: true }));
controller.clear("FN-1");
await controller.tick(task({ userPaused: true }));
expect(handlers.recordHumanControlWithheld).toHaveBeenCalledTimes(2);
});
it("never throws when recordHumanControlWithheld handler rejects", async () => {
const handlers = allHandlers();
handlers.recordHumanControlWithheld = vi.fn().mockRejectedValue(new Error("boom"));
const controller = makeController(observation({ stage: "merger" }), handlers);
await expect(controller.tick(task({ userPaused: true }))).resolves.toBeNull();
});
});

View File

@@ -655,6 +655,13 @@ export {
type PlannerRecoveryObservationSource, type PlannerRecoveryObservationSource,
type PlannerRecoveryControllerOptions, type PlannerRecoveryControllerOptions,
} from "./planner-recovery-controller.js"; } from "./planner-recovery-controller.js";
export {
evaluateOverseerHumanControl,
type OverseerHumanControlDecision,
type OverseerHumanControlWithholdReason,
type OverseerHumanControlTask,
type OverseerHumanControlSettings,
} from "./overseer-human-control-policy.js";
export { export {
decidePlannerRecovery, decidePlannerRecovery,
PLANNER_RECOVERY_MAX_ATTEMPTS, PLANNER_RECOVERY_MAX_ATTEMPTS,

View File

@@ -0,0 +1,88 @@
/**
* FNXC:PlannerOverseer 2026-07-04-15:00:
* FN-7514 human-control safeguard for the planner overseer chain
* (FN-7508→FN-7513). Requirement: the planner overseer must be fully inert
* — no steering, retry, targeted-fix, or FN-7513 confirmation-required
* action (merge/PR progression, destructive git, external-service side
* effect) may fire, and no pending confirmation may even be recorded —
* whenever a task is (a) user-paused, or (b) not eligible for auto-merge
* processing per the FN-5147 `autoMerge:false` / PR-based human-review
* terminal contract. This module supplies a single PURE predicate
* (`evaluateOverseerHumanControl`, no I/O) that the FN-7512/FN-7513 dispatch
* seam (`PlannerRecoveryController.tick`) must consult BEFORE any action
* classification, confirmation gating, steering, retry, or dispatch —
* mirroring the pure-decision style of `recovery-policy.ts` /
* `overseer-confirmation-policy`-equivalent `planner-confirmation.ts`.
*
* Pause-source distinction (do NOT conflate with engine/self-healing
* rebounds, which must remain eligible for oversight):
* - `task.userPaused === true` is the unambiguous explicit-user-pause
* signal (set by a user-source `moveTask(in-progress → todo)` hard
* cancel per the Move-Task contract in `store.ts`).
* - `task.paused === true` with NO `task.pausedReason` is also treated as
* user-source pause: the `fn_task_pause` tool (`TaskStore.pauseTask`)
* sets `paused=true` without ever writing `pausedReason`, whereas every
* engine/self-healing park path (branch-conflict-unrecoverable,
* token_budget_exceeded, in-review-stall-deadlock,
* worktrunk_operation_failed, etc.) always stamps a specific
* `pausedReason` string when it parks a task. A `paused===true` task that
* DOES carry a `pausedReason` is therefore an engine-originated park, not
* a user pause, and must NOT withhold oversight on that basis alone (the
* separate `allowsAutoMergeProcessing` / autoMerge-off check still
* applies independently).
*
* Auto-merge-off / human-review half: delegates verbatim to
* `allowsAutoMergeProcessing` from `@fusion/core` (the canonical FN-5147
* predicate `self-healing.ts` already gates lifecycle mutation on) — never
* re-derive the autoMerge/human-review contract inline here.
*/
import type { Settings, Task } from "@fusion/core";
import { allowsAutoMergeProcessing } from "@fusion/core";
export type OverseerHumanControlWithholdReason = "user-paused" | "auto-merge-off-human-review";
export interface OverseerHumanControlDecision {
/** `true` when the overseer must take NO action of any kind for this task. */
withhold: boolean;
/** Present only when `withhold` is `true`. */
reason?: OverseerHumanControlWithholdReason;
}
/** The minimal task shape the predicate needs — narrowed for testability and to keep the module engine-local/pure. */
export type OverseerHumanControlTask = Pick<Task, "userPaused" | "paused" | "pausedReason" | "autoMerge" | "prInfo" | "prInfos">;
/** The minimal settings shape the predicate needs (forwarded to `allowsAutoMergeProcessing`). */
export type OverseerHumanControlSettings = Pick<Settings, "autoMerge">;
/**
* Pure predicate — no I/O, no throws on well-formed input. Returns whether
* the planner overseer must withhold ALL oversight action for `task`, and
* why. Precedence: user-pause is checked first (it is the stronger signal —
* a user explicitly stopped the world for this task), then the FN-5147
* auto-merge-off / human-review terminal contract.
*/
export function evaluateOverseerHumanControl(
task: OverseerHumanControlTask | null | undefined,
settings: OverseerHumanControlSettings | null | undefined,
): OverseerHumanControlDecision {
if (!task) {
// No task to reason about — nothing to withhold from, but also nothing
// safe to act on. Fail closed with no specific reason (neither withhold
// reason cleanly applies to a missing task).
return { withhold: true };
}
const isUserPaused =
task.userPaused === true || (task.paused === true && !task.pausedReason);
if (isUserPaused) {
return { withhold: true, reason: "user-paused" };
}
const settingsForGate: OverseerHumanControlSettings = settings ?? { autoMerge: true };
if (!allowsAutoMergeProcessing(task, settingsForGate)) {
return { withhold: true, reason: "auto-merge-off-human-review" };
}
return { withhold: false };
}

View File

@@ -16,16 +16,45 @@
* external-service action — those are excluded by construction (only * external-service action — those are excluded by construction (only
* `injectGuidance` / `retryStep` / `requestTargetedFix` exist) and are owned * `injectGuidance` / `retryStep` / `requestTargetedFix` exist) and are owned
* by FN-7513's confirmation-gated layer. * by FN-7513's confirmation-gated layer.
*
* FNXC:PlannerOverseer 2026-07-04-15:00:
* FN-7514 upgrades the userPaused-only guard above to the full human-control
* predicate `evaluateOverseerHumanControl` (auto-merge-off/human-review PLUS
* user-pause, including the non-`userPaused` `task.paused` user-source park
* shape). The predicate is consulted at the TOP of `tick()`, before
* `decidePlannerRecovery`/confirmation classification even runs — a
* user-paused or auto-merge-off/human-review task never even records a
* pending `PlannerConfirmationRequest`. When withheld, a bounded
* `overseer:oversight-withheld-human-control` no-action audit event is
* recorded (via the optional `recordHumanControlWithheld` handler) at most
* once per (taskId, watchedStage, reason) — repeated `tick()`s for the same
* still-withheld reason do not re-emit until the reason changes or the task
* leaves the withheld state, so the audit trail isn't spammed every poll.
*/ */
import type { PlannerConfirmationRequest, PlannerRecoveryDecision, PlannerRecoveryObservation, Task } from "@fusion/core"; import type { PlannerConfirmationRequest, PlannerRecoveryDecision, PlannerRecoveryObservation, Settings, Task } from "@fusion/core";
import { decidePlannerRecovery, PLANNER_RECOVERY_MAX_ATTEMPTS } from "@fusion/core"; import { decidePlannerRecovery, PLANNER_RECOVERY_MAX_ATTEMPTS } from "@fusion/core";
import { createLogger, type Logger } from "./logger.js"; import { createLogger, type Logger } from "./logger.js";
import type { OverseerStageObservation } from "./planner-overseer.js"; import type { OverseerStageObservation } from "./planner-overseer.js";
import {
evaluateOverseerHumanControl,
type OverseerHumanControlDecision,
type OverseerHumanControlWithholdReason,
} from "./overseer-human-control-policy.js";
/** Minimal shared context threaded through to handlers (e.g. a run-id or clock). */ /** Minimal shared context threaded through to handlers (e.g. a run-id or clock). */
export interface PlannerRecoveryContext { export interface PlannerRecoveryContext {
now?: () => number; now?: () => number;
/**
* FN-7514: current engine `Settings` (or a narrowed pick of just
* `autoMerge`), used ONLY by the human-control guard's
* `allowsAutoMergeProcessing` check. When omitted, the guard falls back to
* `{ autoMerge: true }` (auto-merge globally enabled) so existing callers
* that don't yet thread settings are unaffected — the per-task
* `autoMerge`/`prInfo`/`prInfos` fields on `task` itself still fully
* participate in that check regardless.
*/
settings?: Pick<Settings, "autoMerge">;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -63,6 +92,19 @@ export interface PlannerRecoveryHandlers {
* ONLY from `resolveConfirmation(..., "approved")` — never from `tick`. * ONLY from `resolveConfirmation(..., "approved")` — never from `tick`.
*/ */
executeDestructiveExternalAction?: (taskId: string, request: PlannerConfirmationRequest, ctx: PlannerRecoveryContext) => Promise<void>; executeDestructiveExternalAction?: (taskId: string, request: PlannerConfirmationRequest, ctx: PlannerRecoveryContext) => Promise<void>;
/**
* FN-7514: notified (at most once per distinct withheld reason per
* `(taskId, watchedStage)`, see `tick()`) when the human-control guard
* withholds ALL oversight for a task. Callers wire this to a bounded
* `RunAuditor.database({ type: "overseer:oversight-withheld-human-control", ... })`
* no-action event. Optional; a missing handler is a pure no-op (the guard
* still withholds action either way — this handler is audit-only).
*/
recordHumanControlWithheld?: (
task: Task,
decision: OverseerHumanControlDecision & { reason: OverseerHumanControlWithholdReason },
ctx: PlannerRecoveryContext,
) => Promise<void>;
} }
/** Minimal seam for fetching the current watched-stage observation for a task. */ /** Minimal seam for fetching the current watched-stage observation for a task. */
@@ -125,6 +167,13 @@ export class PlannerRecoveryController {
*/ */
private readonly pendingConfirmations = new Map<string, PlannerConfirmationRequest>(); private readonly pendingConfirmations = new Map<string, PlannerConfirmationRequest>();
private confirmationSeq = 0; private confirmationSeq = 0;
/**
* FN-7514: last withheld reason recorded per taskId, so the
* `overseer:oversight-withheld-human-control` audit event is emitted only
* on the first `tick()` a task enters (or changes) a withheld state — not
* on every subsequent poll while it remains withheld for the same reason.
*/
private readonly lastWithheldReason = new Map<string, OverseerHumanControlWithholdReason>();
constructor(options: PlannerRecoveryControllerOptions) { constructor(options: PlannerRecoveryControllerOptions) {
this.snapshotProvider = normalizeProvider(options.snapshotProvider); this.snapshotProvider = normalizeProvider(options.snapshotProvider);
@@ -150,10 +199,31 @@ export class PlannerRecoveryController {
*/ */
async tick(task: Task, ctx: PlannerRecoveryContext = {}): Promise<PlannerRecoveryDecision | null> { async tick(task: Task, ctx: PlannerRecoveryContext = {}): Promise<PlannerRecoveryDecision | null> {
try { try {
if (!task || task.userPaused === true) { if (!task) {
return null; return null;
} }
// FN-7514: the human-control guard runs BEFORE anything else — before
// the snapshot lookup, before decidePlannerRecovery, before confirmation
// classification. A withheld task never even reaches the point where a
// pending confirmation could be recorded.
const humanControl = evaluateOverseerHumanControl(task, ctx.settings);
if (humanControl.withhold) {
// Snapshot lookup here is READ-ONLY metadata enrichment for the audit
// event (stage/oversightLevel) — it never feeds back into the
// withhold decision, which was already made above purely from `task`
// + `ctx.settings`.
const snapshotForAudit = await this.getSnapshotSafe(task.id);
await this.recordWithheldIfChanged(
task,
humanControl as OverseerHumanControlDecision & { reason: OverseerHumanControlWithholdReason },
snapshotForAudit,
ctx,
);
return null;
}
this.lastWithheldReason.delete(task.id);
const snapshot = await this.getSnapshotSafe(task.id); const snapshot = await this.getSnapshotSafe(task.id);
if (!snapshot) { if (!snapshot) {
return null; return null;
@@ -215,6 +285,38 @@ export class PlannerRecoveryController {
} }
} }
/**
* FN-7514: records `overseer:oversight-withheld-human-control` (via the
* optional `recordHumanControlWithheld` handler) the first time `task`
* enters a withheld reason, or when the withheld reason changes — never
* on repeated `tick()`s for the same still-withheld reason. Never throws.
*/
private async recordWithheldIfChanged(
task: Task,
decision: OverseerHumanControlDecision & { reason: OverseerHumanControlWithholdReason },
snapshotForAudit: OverseerStageObservation | null,
ctx: PlannerRecoveryContext,
): Promise<void> {
const previous = this.lastWithheldReason.get(task.id);
if (previous === decision.reason) {
return;
}
this.lastWithheldReason.set(task.id, decision.reason);
if (!this.handlers.recordHumanControlWithheld) {
return;
}
try {
await this.handlers.recordHumanControlWithheld(task, decision, {
...ctx,
stage: snapshotForAudit?.stage,
oversightLevel: snapshotForAudit?.oversightLevel,
});
} catch (err) {
this.logger.warn(`recordHumanControlWithheld handler failed for ${task.id}: ${(err as Error)?.message ?? String(err)}`);
}
}
private async getSnapshotSafe(taskId: string): Promise<OverseerStageObservation | null> { private async getSnapshotSafe(taskId: string): Promise<OverseerStageObservation | null> {
try { try {
const result = await this.snapshotProvider.getSnapshot(taskId); const result = await this.snapshotProvider.getSnapshot(taskId);
@@ -386,6 +488,7 @@ export class PlannerRecoveryController {
this.pendingConfirmations.delete(key); this.pendingConfirmations.delete(key);
} }
} }
this.lastWithheldReason.delete(taskId);
} }
/** Test/inspection seam: current attempt count for a `(taskId, watchedStage)` pair. */ /** Test/inspection seam: current attempt count for a `(taskId, watchedStage)` pair. */

View File

@@ -1097,6 +1097,37 @@ export class ProjectEngine {
// `destructive_external` action (FN-7511 has no destructive-action // `destructive_external` action (FN-7511 has no destructive-action
// signal), so this is intentionally left unset; a future task can wire // signal), so this is intentionally left unset; a future task can wire
// a concrete handler using existing safe helpers when one is needed. // a concrete handler using existing safe helpers when one is needed.
// FNXC:PlannerOverseer 2026-07-04-15:00:
// FN-7514 requirement: when the human-control guard (user-paused, or
// autoMerge:false/human-review) withholds ALL oversight for a task,
// record a bounded `overseer:oversight-withheld-human-control` no-action
// run-audit event (metadata: taskId/reason/stage/oversightLevel) so the
// withholding is observable, mirroring the `*-no-action` self-healing
// convention. Audit-only — this handler performs no lifecycle mutation.
recordHumanControlWithheld: async (task, decision, ctx) => {
try {
const auditor = createRunAuditor(store, {
runId: generateSyntheticRunId("planner-overseer-human-control", task.id),
agentId: "planner-overseer",
taskId: task.id,
phase: "planner-overseer-poll",
});
await auditor.database({
type: "overseer:oversight-withheld-human-control",
target: task.id,
metadata: {
taskId: task.id,
reason: decision.reason,
stage: (ctx as { stage?: string }).stage,
oversightLevel: (ctx as { oversightLevel?: string }).oversightLevel,
},
});
} catch (err: unknown) {
runtimeLog.warn(
`Failed to record overseer:oversight-withheld-human-control for ${task.id}: ${err instanceof Error ? err.message : String(err)}`,
);
}
},
}; };
} }
@@ -1935,6 +1966,11 @@ export class ProjectEngine {
]); ]);
const inFlight = [...inProgress, ...inReview]; const inFlight = [...inProgress, ...inReview];
const inFlightIds = new Set(inFlight.map((t) => t.id)); const inFlightIds = new Set(inFlight.map((t) => t.id));
// FN-7514: fetch global engine Settings ONCE per poll cycle (not per
// task) so `PlannerRecoveryController.tick`'s human-control guard can
// consult `allowsAutoMergeProcessing(task, settings)` — the same
// FN-5147 predicate `self-healing.ts` gates lifecycle mutation on.
const engineSettings = await store.getSettings().catch(() => undefined);
for (const task of inFlight) { for (const task of inFlight) {
try { try {
@@ -1951,10 +1987,12 @@ export class ProjectEngine {
// FN-7512: one guarded, autonomous-only bounded recovery tick at the // FN-7512: one guarded, autonomous-only bounded recovery tick at the
// same passive seam FN-7511 uses for observation. Inert for every // same passive seam FN-7511 uses for observation. Inert for every
// other effective level ("off"/"observe"/"steer" already `continue`d // other effective level ("off"/"observe"/"steer" already `continue`d
// above); `PlannerRecoveryController.tick` itself skips userPaused // above). FN-7514: `PlannerRecoveryController.tick` now consults the
// tasks and never throws. // full human-control guard (user-paused OR autoMerge:false/
// human-review) BEFORE any action/confirmation classification —
// never throws.
if (level === "autonomous" && this.plannerRecoveryController) { if (level === "autonomous" && this.plannerRecoveryController) {
await this.plannerRecoveryController.tick(task); await this.plannerRecoveryController.tick(task, { settings: engineSettings });
} }
} catch { } catch {
// Best-effort per-task — never let one task's failure block the poll. // Best-effort per-task — never let one task's failure block the poll.

View File

@@ -728,7 +728,20 @@ export type DatabaseMutationType =
* Goal injection diagnostic event (FN-5658). * Goal injection diagnostic event (FN-5658).
* Metadata: { lane, outcome, goalCount, goalIds, truncated, reason?, errorClass?, runId?, agentId?, taskId? } * Metadata: { lane, outcome, goalCount, goalIds, truncated, reason?, errorClass?, runId?, agentId?, taskId? }
*/ */
| "prompt:goal-injection"; | "prompt:goal-injection"
/**
* FNXC:PlannerOverseer 2026-07-04-15:00:
* FN-7514 no-action event: the planner overseer's per-task oversight loop
* (`PlannerRecoveryController.tick`) withheld ALL action (no steering,
* retry, targeted-fix, or pending confirmation) because the task is either
* user-paused or ineligible for auto-merge processing per the FN-5147
* `autoMerge:false` / PR-based human-review terminal contract
* (`allowsAutoMergeProcessing`). Emitted at most once per
* (taskId, withheld reason) transition — not on every poll while the
* withheld state persists unchanged.
* Metadata: { taskId: string; reason: "user-paused" | "auto-merge-off-human-review"; stage?: string; oversightLevel?: string }
*/
| "overseer:oversight-withheld-human-control";
// ── Filesystem mutation types ───────────────────────────────────────────────── // ── Filesystem mutation types ─────────────────────────────────────────────────