From 79ab3675478a59b2a5e0de01b6c9c6eb7e3d9be5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 4 Jul 2026 14:11:25 -0700 Subject: [PATCH] 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) --- .../fn-7514-overseer-human-control-guard.md | 7 + AGENTS.md | 1 + docs/architecture.md | 39 ++++ .../overseer-human-control-policy.test.ts | 86 +++++++++ ...-recovery-controller-human-control.test.ts | 170 ++++++++++++++++++ packages/engine/src/index.ts | 7 + .../src/overseer-human-control-policy.ts | 88 +++++++++ .../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(-) create mode 100644 .changeset/fn-7514-overseer-human-control-guard.md create mode 100644 packages/engine/src/__tests__/overseer-human-control-policy.test.ts create mode 100644 packages/engine/src/__tests__/planner-recovery-controller-human-control.test.ts create mode 100644 packages/engine/src/overseer-human-control-policy.ts diff --git a/.changeset/fn-7514-overseer-human-control-guard.md b/.changeset/fn-7514-overseer-human-control-guard.md new file mode 100644 index 0000000000..469d0ffb18 --- /dev/null +++ b/.changeset/fn-7514-overseer-human-control-guard.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 1db71d73d1..b57742214d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/` 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: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) diff --git a/docs/architecture.md b/docs/architecture.md index d250ba8ec6..bde6f88015 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 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 diff --git a/packages/engine/src/__tests__/overseer-human-control-policy.test.ts b/packages/engine/src/__tests__/overseer-human-control-policy.test.ts new file mode 100644 index 0000000000..9171e6e1b6 --- /dev/null +++ b/packages/engine/src/__tests__/overseer-human-control-policy.test.ts @@ -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 { + return { + userPaused: undefined, + paused: undefined, + pausedReason: undefined, + autoMerge: undefined, + prInfo: undefined, + prInfos: undefined, + ...overrides, + }; +} + +function settings(overrides: Partial = {}): 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" }); + }); +}); diff --git a/packages/engine/src/__tests__/planner-recovery-controller-human-control.test.ts b/packages/engine/src/__tests__/planner-recovery-controller-human-control.test.ts new file mode 100644 index 0000000000..ee93266008 --- /dev/null +++ b/packages/engine/src/__tests__/planner-recovery-controller-human-control.test.ts @@ -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 { + return { + id: "FN-1", + title: "t", + description: "", + column: "in-review", + ...overrides, + } as Task; +} + +function observation(overrides: Partial = {}): 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(); + }); +}); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 6d7d429d70..61faa20af7 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -655,6 +655,13 @@ export { type PlannerRecoveryObservationSource, type PlannerRecoveryControllerOptions, } from "./planner-recovery-controller.js"; +export { + evaluateOverseerHumanControl, + type OverseerHumanControlDecision, + type OverseerHumanControlWithholdReason, + type OverseerHumanControlTask, + type OverseerHumanControlSettings, +} from "./overseer-human-control-policy.js"; export { decidePlannerRecovery, PLANNER_RECOVERY_MAX_ATTEMPTS, diff --git a/packages/engine/src/overseer-human-control-policy.ts b/packages/engine/src/overseer-human-control-policy.ts new file mode 100644 index 0000000000..77a6cc2097 --- /dev/null +++ b/packages/engine/src/overseer-human-control-policy.ts @@ -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; + +/** The minimal settings shape the predicate needs (forwarded to `allowsAutoMergeProcessing`). */ +export type OverseerHumanControlSettings = Pick; + +/** + * 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 }; +} diff --git a/packages/engine/src/planner-recovery-controller.ts b/packages/engine/src/planner-recovery-controller.ts index 08e8568bf9..403e197efd 100644 --- a/packages/engine/src/planner-recovery-controller.ts +++ b/packages/engine/src/planner-recovery-controller.ts @@ -16,16 +16,45 @@ * external-service action — those are excluded by construction (only * `injectGuidance` / `retryStep` / `requestTargetedFix` exist) and are owned * 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 { createLogger, type Logger } from "./logger.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). */ export interface PlannerRecoveryContext { 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; [key: string]: unknown; } @@ -63,6 +92,19 @@ export interface PlannerRecoveryHandlers { * ONLY from `resolveConfirmation(..., "approved")` — never from `tick`. */ executeDestructiveExternalAction?: (taskId: string, request: PlannerConfirmationRequest, ctx: PlannerRecoveryContext) => Promise; + /** + * 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; } /** Minimal seam for fetching the current watched-stage observation for a task. */ @@ -125,6 +167,13 @@ export class PlannerRecoveryController { */ private readonly pendingConfirmations = new Map(); 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(); constructor(options: PlannerRecoveryControllerOptions) { this.snapshotProvider = normalizeProvider(options.snapshotProvider); @@ -150,10 +199,31 @@ export class PlannerRecoveryController { */ async tick(task: Task, ctx: PlannerRecoveryContext = {}): Promise { try { - if (!task || task.userPaused === true) { + if (!task) { 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); if (!snapshot) { 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 { + 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 { try { const result = await this.snapshotProvider.getSnapshot(taskId); @@ -386,6 +488,7 @@ export class PlannerRecoveryController { this.pendingConfirmations.delete(key); } } + this.lastWithheldReason.delete(taskId); } /** Test/inspection seam: current attempt count for a `(taskId, watchedStage)` pair. */ diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 1c1bf7f3c2..c82c924ebc 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -1097,6 +1097,37 @@ export class ProjectEngine { // `destructive_external` action (FN-7511 has no destructive-action // signal), so this is intentionally left unset; a future task can wire // 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 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) { try { @@ -1951,10 +1987,12 @@ export class ProjectEngine { // FN-7512: one guarded, autonomous-only bounded recovery tick at the // same passive seam FN-7511 uses for observation. Inert for every // other effective level ("off"/"observe"/"steer" already `continue`d - // above); `PlannerRecoveryController.tick` itself skips userPaused - // tasks and never throws. + // above). FN-7514: `PlannerRecoveryController.tick` now consults the + // full human-control guard (user-paused OR autoMerge:false/ + // human-review) BEFORE any action/confirmation classification — + // never throws. if (level === "autonomous" && this.plannerRecoveryController) { - await this.plannerRecoveryController.tick(task); + await this.plannerRecoveryController.tick(task, { settings: engineSettings }); } } catch { // Best-effort per-task — never let one task's failure block the poll. diff --git a/packages/engine/src/run-audit.ts b/packages/engine/src/run-audit.ts index 57f90139c0..57183db02f 100644 --- a/packages/engine/src/run-audit.ts +++ b/packages/engine/src/run-audit.ts @@ -728,7 +728,20 @@ export type DatabaseMutationType = * Goal injection diagnostic event (FN-5658). * 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 ─────────────────────────────────────────────────