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:
7
.changeset/fn-7514-overseer-human-control-guard.md
Normal file
7
.changeset/fn-7514-overseer-human-control-guard.md
Normal 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.
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
88
packages/engine/src/overseer-human-control-policy.ts
Normal file
88
packages/engine/src/overseer-human-control-policy.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user