FN-7551: wire overseer decision points to engine emitOverseer* façade

Wires PlannerOverseerMonitor/PlannerRecoveryController decision points (human-control withholds, confirmation requests/resolutions, and related overseer stages) to the FN-7520 emitOverseer* façade using the real TaskStore, so the planner-oversight intervention timeline now populates from real engine activity instead of staying empty.

- Add onConfirmationResolved handler to PlannerRecoveryController, invoked (best-effort, audit-only) from resolveConfirmation for both approved and denied outcomes.
- Wire project-engine.ts to call emitOverseerObservation/emitOverseerEscalation/emitOverseerConfirmation at the real engine decision points, deduped per (task, stage[, signal]).
- Add planner-overseer-intervention-wiring.test.ts covering the new wiring end-to-end.
- Update docs/architecture.md to reflect the wiring.
- Add changeset fn-7551-overseer-timeline-wiring.md (patch).

Files changed:
 .changeset/fn-7551-overseer-timeline-wiring.md     |   7 +
 docs/architecture.md                               |   2 +-
 .../planner-overseer-intervention-wiring.test.ts   | 319 +++++++++++++++++++++
 packages/engine/src/planner-recovery-controller.ts |  36 +++
 packages/engine/src/project-engine.ts              | 248 +++++++++++++++-
 5 files changed, 607 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-7551

Fusion-Task-Lineage: 8bcd103e-8797-4ef5-9b68-bd2daec8d26b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 19:52:39 -07:00
parent 19a436ab5e
commit 3d58260e1a
5 changed files with 607 additions and 5 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Planner-oversight intervention timeline now populates from real engine activity.
category: fix
dev: Wires PlannerOverseerMonitor/PlannerRecoveryController decision points to the FN-7520 emitOverseer* façade with the real TaskStore; observation/escalation emission deduped per (task, stage[, signal]).

View File

@@ -1097,7 +1097,7 @@ Mesh configuration and post-provision managed-node operations are registered sep
### Run Audit API ### Run Audit API
The run-audit system records every mutation performed by the engine across four domains: The run-audit system records every mutation performed by the engine across four domains:
- **Database** — task:create, task:update, task:move, etc. Node handoff/recovery emits structured events: `node:handoff:parked` (handoff denied/parked), `node:handoff:reassign-local` (local takeover approved), `node:handoff:reassign-any` (any-healthy takeover approved), and `node:lease:recovered` (abandoned lease cleared and task requeued). - **Database** — task:create, task:update, task:move, etc. Node handoff/recovery emits structured events: `node:handoff:parked` (handoff denied/parked), `node:handoff:reassign-local` (local takeover approved), `node:handoff:reassign-any` (any-healthy takeover approved), and `node:lease:recovered` (abandoned lease cleared and task requeued).
- **Database / `overseer:intervention`** (FN-7519, emission façade FN-7520) — the planner-overseer intervention timeline's single canonical mutation type. `target` is the task ID; metadata carries the six intervention field groups (`stage`, `reason`, `action`, `outcome`, optional `attemptCount`/`attemptLimit`, optional `sourceLinks`). Written only via `recordPlannerIntervention` and read via `getPlannerInterventionTimeline`/`parseInterventionEntry` (`packages/core/src/planner-intervention.ts`) so no parallel audit store or timeline-mapping exists; surfaced read-only in the task-detail Intervention Timeline (`GET /tasks/:id/overseer/interventions`). FN-7520 adds the canonical `emitOverseerObservation` / `emitOverseerSteering` / `emitOverseerRecoveryAttempt` / `emitOverseerRetry` / `emitOverseerConfirmation` / `emitOverseerEscalation` emission façade (`packages/core/src/planner-overseer-events.ts`) that fixes each decision-point category's `action`/default `outcome` and funnels through `recordPlannerIntervention` — the single seam FN-7511/FN-7512/FN-7513 call rather than emitting `overseer:intervention` events inline. - **Database / `overseer:intervention`** (FN-7519, emission façade FN-7520, runtime wiring FN-7551) — the planner-overseer intervention timeline's single canonical mutation type. `target` is the task ID; metadata carries the six intervention field groups (`stage`, `reason`, `action`, `outcome`, optional `attemptCount`/`attemptLimit`, optional `sourceLinks`). Written only via `recordPlannerIntervention` and read via `getPlannerInterventionTimeline`/`parseInterventionEntry` (`packages/core/src/planner-intervention.ts`) so no parallel audit store or timeline-mapping exists; surfaced read-only in the task-detail Intervention Timeline (`GET /tasks/:id/overseer/interventions`). FN-7520 adds the canonical `emitOverseerObservation` / `emitOverseerSteering` / `emitOverseerRecoveryAttempt` / `emitOverseerRetry` / `emitOverseerConfirmation` / `emitOverseerEscalation` emission façade (`packages/core/src/planner-overseer-events.ts`) that fixes each decision-point category's `action`/default `outcome` and funnels through `recordPlannerIntervention` — the single seam FN-7511/FN-7512/FN-7513 call rather than emitting `overseer:intervention` events inline. FN-7551 wires this façade to the LIVE runtime: `ProjectEngine`'s `PlannerOverseerMonitor#onObservation` callback (deduped per `(taskId, stage:signal)`), `buildPlannerRecoveryHandlers` (steering/retry/targeted-fix/confirmation-request, plus `PlannerRecoveryController`'s new optional `onConfirmationResolved` hook for the approve/deny resolution), and `pollPlannerOverseer`'s bounded-recovery-exhaustion escalation (deduped per `(taskId, stage)`) — so the intervention timeline now reflects real engine activity, not only synthetic unit-test entries.
- **Git** — worktree:create, worktree:remove, `worktree:remove-fallback` (metadata `{ fallback: "filesystem-non-empty", error }` when native git removal falls back to filesystem removal + admin prune), commit:create, merge:resolve, merge:audit-failure, `worktree:reanchored`, and worktrunk lifecycle events (`worktree:worktrunk-install|create|sync|prune|remove`, plus `worktree:worktrunk-fallback`, `worktree:worktrunk-failure`, and `worktree:worktrunk-fallback-native`). Worktrunk events share metadata `{ op, binaryPath?, worktreePath?, durationMs?, exitCode?, stderrPreview?, installSource?, prunedCount? }` with `installSource` (`"release-binary" | "cargo"`) limited to successful `worktree:worktrunk-install` events and `prunedCount` limited to successful prune events when known. `worktree:worktrunk-install` is emitted only for true install actions; cache hits, configured `worktrunk.binaryPath` overrides, and `$PATH` resolutions intentionally remain silent. Dirty post-merge audit outcomes emit `merge:audit-failure` with metadata `{ mode, strategy, action, reason, issueCount, duplicateSubjectCount, touchedFileOverlapCount, verificationPassed, auditTargetLabel }`. FN-5279 adds `merge:reuse-handoff-acquired`, `merge:reuse-handoff-refused`, `merge:reuse-handoff-released`, and `merge:reuse-handoff-deferred-to-worktrunk` for task-worktree auto-merge handoff visibility. FN-5351 adds `merge:integration-worktree-state` (pre-handoff checkout/dirty snapshot for resolved integration branch), `merge:cwd-integration-fallback-refused` (terminal refusal park event), and `merge:integration-ref-advance` (integration ref advance outcome telemetry). - **Git** — worktree:create, worktree:remove, `worktree:remove-fallback` (metadata `{ fallback: "filesystem-non-empty", error }` when native git removal falls back to filesystem removal + admin prune), commit:create, merge:resolve, merge:audit-failure, `worktree:reanchored`, and worktrunk lifecycle events (`worktree:worktrunk-install|create|sync|prune|remove`, plus `worktree:worktrunk-fallback`, `worktree:worktrunk-failure`, and `worktree:worktrunk-fallback-native`). Worktrunk events share metadata `{ op, binaryPath?, worktreePath?, durationMs?, exitCode?, stderrPreview?, installSource?, prunedCount? }` with `installSource` (`"release-binary" | "cargo"`) limited to successful `worktree:worktrunk-install` events and `prunedCount` limited to successful prune events when known. `worktree:worktrunk-install` is emitted only for true install actions; cache hits, configured `worktrunk.binaryPath` overrides, and `$PATH` resolutions intentionally remain silent. Dirty post-merge audit outcomes emit `merge:audit-failure` with metadata `{ mode, strategy, action, reason, issueCount, duplicateSubjectCount, touchedFileOverlapCount, verificationPassed, auditTargetLabel }`. FN-5279 adds `merge:reuse-handoff-acquired`, `merge:reuse-handoff-refused`, `merge:reuse-handoff-released`, and `merge:reuse-handoff-deferred-to-worktrunk` for task-worktree auto-merge handoff visibility. FN-5351 adds `merge:integration-worktree-state` (pre-handoff checkout/dirty snapshot for resolved integration branch), `merge:cwd-integration-fallback-refused` (terminal refusal park event), and `merge:integration-ref-advance` (integration ref advance outcome telemetry).
- **Git / `merge:file-scope-violation`** — emitted by the merger when `FileScopeViolationError` aborts a squash. `target` is the task ID; metadata includes `stagedFiles`, `declaredScope`, `resetLabel`, `stagedFileCount`, and `declaredScopeCount`. Consumed by `fileScopeInvariantFailuresPerDay` in `GET /api/health/reliability` (FN-4360). - **Git / `merge:file-scope-violation`** — emitted by the merger when `FileScopeViolationError` aborts a squash. `target` is the task ID; metadata includes `stagedFiles`, `declaredScope`, `resetLabel`, `stagedFileCount`, and `declaredScopeCount`. Consumed by `fileScopeInvariantFailuresPerDay` in `GET /api/health/reliability` (FN-4360).
- **Git / `merge:no-op-attribution-mismatch`** — emitted by the rebase landed-files attribution guard (FN-5304) when `<rebaseBaseSha>..HEAD` has zero attributable own commits but the source `fusion/<id>` tip still carries attributable own commits. `target` is the task ID; metadata includes `recordedSha`, `rebaseMergeBaseSha`, `sourceBranchRef`, `sourceBranchOwnCommitCount`, and `sourceBranchOwnCommitShas`. - **Git / `merge:no-op-attribution-mismatch`** — emitted by the rebase landed-files attribution guard (FN-5304) when `<rebaseBaseSha>..HEAD` has zero attributable own commits but the source `fusion/<id>` tip still carries attributable own commits. `target` is the task ID; metadata includes `recordedSha`, `rebaseMergeBaseSha`, `sourceBranchRef`, `sourceBranchOwnCommitCount`, and `sourceBranchOwnCommitShas`.

View File

@@ -0,0 +1,319 @@
/**
* FNXC:PlannerOversight 2026-07-04-19:45:
* FN-7551 engine-level end-to-end test: proves real overseer decision points
* — observation, retry, targeted-fix, steering (reviewer), confirmation
* request, confirmation resolution, and bounded-recovery escalation —
* populate the `overseer:intervention` run-audit timeline via the ACTUAL
* production wiring in `project-engine.ts` (`PlannerOverseerMonitor#onObservation`
* → the private `emitOverseerObservationDeduped`, the private
* `buildPlannerRecoveryHandlers`, the private `emitOverseerEscalationDeduped`),
* against a REAL in-memory `TaskStore` — never by calling
* `emitOverseer*`/`recordPlannerIntervention` directly.
*
* Constructing a full `ProjectEngine` (via `start()`) pulls in cron/
* notification/research/tunnel/automation subsystems that are impractical to
* boot in a unit test. Instead this file extracts the engine's REAL
* prototype methods via `Object.create(ProjectEngine.prototype)`, seeding
* only the two dedup-map instance fields those methods read/write (normally
* initialized by class-field initializers the constructor never runs here)
* — this exercises the exact same code that runs inside
* `pollPlannerOverseer`/`start()` in production, not a reimplementation.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TaskStore, getPlannerInterventionTimeline, type Task } from "@fusion/core";
import { ProjectEngine } from "../project-engine.js";
import { PlannerOverseerMonitor, type OverseerStageObservation } from "../planner-overseer.js";
import { PlannerRecoveryController, type PlannerRecoveryHandlers, type PlannerRecoverySnapshotProvider } from "../planner-recovery-controller.js";
interface EngineOverseerInternals {
plannerObservationEmitDedup: Map<string, string>;
plannerEscalationEmitDedup: Set<string>;
buildPlannerRecoveryHandlers(store: TaskStore): PlannerRecoveryHandlers;
emitOverseerObservationDeduped(store: TaskStore, observation: OverseerStageObservation): void;
emitOverseerEscalationDeduped(
store: TaskStore,
taskId: string,
decision: { watchedStage: string | null; reason: string; attemptCount: number; attemptLimit: number; sourceLinks: unknown[] },
): void;
}
/** Extracts the real `ProjectEngine` prototype methods FN-7551 wired without running its heavy constructor/`start()`. */
function makeEngineInternals(): EngineOverseerInternals {
const engineLike = Object.create(ProjectEngine.prototype) as unknown as EngineOverseerInternals;
engineLike.plannerObservationEmitDedup = new Map();
engineLike.plannerEscalationEmitDedup = new Set();
return engineLike;
}
/** Builds the real production wiring (monitor + recovery-controller handlers) against a concrete `store`, mirroring `ProjectEngine.start()`. */
function wireRealEngineOverseer(store: TaskStore) {
const internals = makeEngineInternals();
const monitor = new PlannerOverseerMonitor({
store,
onObservation: (observation) => internals.emitOverseerObservationDeduped(store, observation),
});
const handlers = internals.buildPlannerRecoveryHandlers(store);
const controllerFromMonitor = new PlannerRecoveryController({ snapshotProvider: monitor, handlers });
return {
monitor,
handlers,
controllerFromMonitor,
/** A controller wired with the SAME real handlers but a synthetic snapshot, for exercising decision branches the monitor's own signal-derivation cannot produce (e.g. a failed executor signal). */
controllerWithSnapshot: (observation: OverseerStageObservation) => {
const provider: PlannerRecoverySnapshotProvider = { getSnapshot: () => observation };
return new PlannerRecoveryController({ snapshotProvider: provider, handlers });
},
emitEscalation: (
taskId: string,
decision: { watchedStage: string | null; reason: string; attemptCount: number; attemptLimit: number; sourceLinks: unknown[] },
) => internals.emitOverseerEscalationDeduped(store, taskId, decision),
};
}
function observation(overrides: Partial<OverseerStageObservation> = {}): OverseerStageObservation {
return {
taskId: "T",
stage: "executor",
signal: "progressing",
oversightLevel: "autonomous",
observedAt: Date.now(),
reason: "test",
sources: [],
...overrides,
};
}
describe("FN-7551 — overseer decision points populate the intervention timeline via the live wiring", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "fn-7551-engine-root-"));
globalDir = mkdtempSync(join(tmpdir(), "fn-7551-engine-global-"));
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
});
afterEach(() => {
store.stopWatching();
store.close();
rmSync(rootDir, { recursive: true, force: true });
rmSync(globalDir, { recursive: true, force: true });
});
async function seedTask(column: "in-progress" | "in-review" = "in-progress"): Promise<Task> {
const task = await store.createTask({ title: "T", description: "d" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
if (column === "in-review") {
await store.moveTask(task.id, "in-review", { preserveProgress: true } as never);
}
return (await store.getTask(task.id))!;
}
it("observation: emits exactly one entry per (stage, signal), dedupes an unchanged repeat, and appends on a changed signal", async () => {
const task = await seedTask("in-progress");
const { monitor } = wireRealEngineOverseer(store);
await monitor.observeTask(task, "autonomous");
await monitor.observeTask(task, "autonomous");
await monitor.observeTask(task, "autonomous");
let timeline = getPlannerInterventionTimeline(store, task.id);
expect(timeline).toHaveLength(1);
expect(timeline[0].action).toBe("observe");
expect(timeline[0].stage).toBe("executor");
// Change the signal (progressing -> "blocked" via paused) and observe again — must append.
const pausedTask = { ...task, paused: true, pausedReason: "manual test pause" } as Task;
await monitor.observeTask(pausedTask, "autonomous");
timeline = getPlannerInterventionTimeline(store, task.id);
expect(timeline).toHaveLength(2);
expect(timeline[0].action).toBe("observe"); // newest-first
});
it("level 'off' never records an observation entry", async () => {
const task = await seedTask("in-progress");
const { monitor } = wireRealEngineOverseer(store);
const result = await monitor.observeTask(task, "off");
expect(result).toBeNull();
expect(getPlannerInterventionTimeline(store, task.id)).toHaveLength(0);
});
it("failed executor with no error source dispatches retry_step and emits a retry entry with attemptCount/attemptLimit", async () => {
const task = await seedTask("in-progress");
const { controllerWithSnapshot } = wireRealEngineOverseer(store);
const controller = controllerWithSnapshot(observation({ taskId: task.id, stage: "executor", signal: "failed", sources: [] }));
const decision = await controller.tick(task);
expect(decision?.action).toBe("retry_step");
const timeline = getPlannerInterventionTimeline(store, task.id);
const retryEntry = timeline.find((e) => e.action === "retry");
expect(retryEntry).toBeTruthy();
expect(retryEntry?.stage).toBe("executor");
expect(retryEntry?.attemptCount).toBe(1);
expect(retryEntry?.attemptLimit).toBe(3);
});
it("failed executor WITH an error source (failed-check) dispatches request_targeted_fix and emits a request-fix entry", async () => {
const task = await seedTask("in-progress");
const { controllerWithSnapshot } = wireRealEngineOverseer(store);
const controller = controllerWithSnapshot(
observation({
taskId: task.id,
stage: "executor",
signal: "failed",
sources: [{ kind: "failed-check", ref: "lint" }],
}),
);
const decision = await controller.tick(task);
expect(decision?.action).toBe("request_targeted_fix");
const timeline = getPlannerInterventionTimeline(store, task.id);
const fixEntry = timeline.find((e) => e.action === "request-fix");
expect(fixEntry).toBeTruthy();
expect(fixEntry?.attemptCount).toBe(1);
expect(fixEntry?.attemptLimit).toBe(3);
expect(fixEntry?.sourceLinks?.[0]?.target).toBe("lint");
});
it("reviewer stage dispatches inject_guidance and emits an inject-guidance steering entry", async () => {
const task = await seedTask("in-review");
const { controllerWithSnapshot } = wireRealEngineOverseer(store);
const controller = controllerWithSnapshot(observation({ taskId: task.id, stage: "reviewer", signal: "progressing" }));
const decision = await controller.tick(task);
expect(decision?.action).toBe("inject_guidance");
const timeline = getPlannerInterventionTimeline(store, task.id);
const steeringEntry = timeline.find((e) => e.action === "inject-guidance");
expect(steeringEntry).toBeTruthy();
expect(steeringEntry?.stage).toBe("reviewer");
});
it("merger/pull-request confirmation-required decision emits a request-confirmation entry; approving it emits a resolution entry with 'succeeded'", async () => {
const task = await seedTask("in-review");
const { monitor, controllerFromMonitor: controller } = wireRealEngineOverseer(store);
await monitor.observeTask(task, "autonomous"); // merger stage (plain in-review, no PR/reviewState)
const decision = await controller.tick(task);
expect(decision?.requiresConfirmation).toBe(true);
let timeline = getPlannerInterventionTimeline(store, task.id);
const requestEntry = timeline.find((e) => e.action === "request-confirmation");
expect(requestEntry).toBeTruthy();
expect(requestEntry?.outcome).toBe("awaiting-confirmation");
const pending = controller.getPendingConfirmations(task.id);
expect(pending).toHaveLength(1);
await controller.resolveConfirmation(task.id, pending[0].requestId, "approved", "test-user");
timeline = getPlannerInterventionTimeline(store, task.id);
const confirmationEntries = timeline.filter((e) => e.action === "request-confirmation");
expect(confirmationEntries.length).toBeGreaterThanOrEqual(2);
expect(confirmationEntries.some((e) => e.outcome === "succeeded")).toBe(true);
});
it("denying a confirmation resolution emits a 'skipped' outcome entry", async () => {
const task = await seedTask("in-review");
const { monitor, controllerFromMonitor: controller } = wireRealEngineOverseer(store);
await monitor.observeTask(task, "autonomous");
await controller.tick(task);
const pending = controller.getPendingConfirmations(task.id);
expect(pending).toHaveLength(1);
await controller.resolveConfirmation(task.id, pending[0].requestId, "denied");
const timeline = getPlannerInterventionTimeline(store, task.id);
const confirmationEntries = timeline.filter((e) => e.action === "request-confirmation");
expect(confirmationEntries.some((e) => e.outcome === "skipped")).toBe(true);
});
it("bounded-recovery exhaustion emits exactly one escalate entry across repeated polls of the same exhausted stage", async () => {
const task = await seedTask("in-progress");
const { emitEscalation } = wireRealEngineOverseer(store);
const exhaustedDecision = {
watchedStage: "executor",
reason: 'Bounded recovery attempt budget (3) exhausted for stage "executor"',
attemptCount: 3,
attemptLimit: 3,
sourceLinks: [],
};
emitEscalation(task.id, exhaustedDecision);
emitEscalation(task.id, exhaustedDecision);
emitEscalation(task.id, exhaustedDecision);
const timeline = getPlannerInterventionTimeline(store, task.id);
const escalations = timeline.filter((e) => e.action === "escalate");
expect(escalations).toHaveLength(1);
expect(escalations[0].outcome).toBe("failed");
});
it("exhaustion actually reached through real tick()s (three denials) emits escalate exactly once thereafter", async () => {
const task = await seedTask("in-review");
const { monitor, controllerFromMonitor: controller, emitEscalation } = wireRealEngineOverseer(store);
await monitor.observeTask(task, "autonomous");
for (let i = 0; i < 3; i += 1) {
const decision = await controller.tick(task);
expect(decision?.requiresConfirmation).toBe(true);
const pending = controller.getPendingConfirmations(task.id);
await controller.resolveConfirmation(task.id, pending[0].requestId, "denied");
}
const finalDecision = await controller.tick(task);
expect(finalDecision?.action).toBe("none");
expect(finalDecision?.exhausted).toBe(true);
// The poll wires escalation emission itself (project-engine.ts), so drive
// it explicitly here with the real decision object, twice, to prove the dedup.
emitEscalation(task.id, finalDecision!);
emitEscalation(task.id, finalDecision!);
const timeline = getPlannerInterventionTimeline(store, task.id);
expect(timeline.filter((e) => e.action === "escalate")).toHaveLength(1);
});
it("oversight level 'off' and a human-control-withheld (userPaused) task never emit any steering/retry/fix/confirmation/escalation entry", async () => {
const task = await seedTask("in-review");
const { monitor, controllerFromMonitor: controller } = wireRealEngineOverseer(store);
// Level "off": monitor records nothing (mirrors the poll's `continue` before ever calling tick()).
const result = await monitor.observeTask(task, "off");
expect(result).toBeNull();
expect(getPlannerInterventionTimeline(store, task.id)).toHaveLength(0);
// Human-control withheld (userPaused) — tick() must short-circuit before
// any confirmation classification/dispatch, so no intervention entry is
// ever recorded (the withhold itself is a separate no-action event this
// task does not touch).
const pausedTask = { ...task, userPaused: true } as Task;
const decision = await controller.tick(pausedTask);
expect(decision).toBeNull();
expect(getPlannerInterventionTimeline(store, task.id)).toHaveLength(0);
});
it("a store/façade failure during observation emission never throws out of observeTask (best-effort contract)", async () => {
const task = await seedTask("in-progress");
const throwingStore = {
...store,
recordRunAuditEvent: () => {
throw new Error("boom");
},
} as unknown as TaskStore;
const { monitor } = wireRealEngineOverseer(throwingStore);
await expect(monitor.observeTask(task, "autonomous")).resolves.not.toThrow();
});
});

View File

@@ -30,6 +30,14 @@
* once per (taskId, watchedStage, reason) — repeated `tick()`s for the same * 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 * 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. * leaves the withheld state, so the audit trail isn't spammed every poll.
*
* FNXC:PlannerOversight 2026-07-04-19:45:
* FN-7551 adds the optional `onConfirmationResolved` handler, invoked from
* `resolveConfirmation` for both "approved" and "denied" outcomes so the
* engine wiring (`project-engine.ts`) can emit a matching
* `emitOverseerConfirmation({..., outcome: "succeeded"|"skipped"})`
* resolution entry through the FN-7520 façade. Purely additive/audit-only —
* it does not change the approve/deny execution semantics above.
*/ */
import type { PlannerConfirmationRequest, PlannerRecoveryDecision, PlannerRecoveryObservation, Settings, Task } from "@fusion/core"; import type { PlannerConfirmationRequest, PlannerRecoveryDecision, PlannerRecoveryObservation, Settings, Task } from "@fusion/core";
@@ -105,6 +113,24 @@ export interface PlannerRecoveryHandlers {
decision: OverseerHumanControlDecision & { reason: OverseerHumanControlWithholdReason }, decision: OverseerHumanControlDecision & { reason: OverseerHumanControlWithholdReason },
ctx: PlannerRecoveryContext, ctx: PlannerRecoveryContext,
) => Promise<void>; ) => Promise<void>;
/**
* FNXC:PlannerOversight 2026-07-04-19:45:
* FN-7551: audit-only notification invoked from `resolveConfirmation` for
* BOTH `"approved"` and `"denied"` resolutions, mirroring the optional/
* never-throw contract of `recordHumanControlWithheld`. This handler must
* NOT perform (or influence) the approve/deny execution path itself —
* that remains `executeMergePrAction`/`executeDestructiveExternalAction`,
* invoked separately from `executeApproved`. Callers wire this to
* `emitOverseerConfirmation({..., outcome: resolution === "approved" ?
* "succeeded" : "skipped"})` so the intervention timeline shows both the
* request and its resolution. Optional; a missing handler is a pure no-op.
*/
onConfirmationResolved?: (
taskId: string,
request: PlannerConfirmationRequest,
resolution: "approved" | "denied",
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. */
@@ -461,6 +487,16 @@ export class PlannerRecoveryController {
} }
} }
// FN-7551: audit-only resolution notification, additive to the
// approve/deny execution above — never influences it. Best-effort.
if (this.handlers.onConfirmationResolved) {
try {
await this.handlers.onConfirmationResolved(taskId, resolved, resolution, ctx);
} catch (err) {
this.logger.warn(`onConfirmationResolved handler failed for ${taskId}: ${(err as Error)?.message ?? String(err)}`);
}
}
return resolved; return resolved;
} catch (err) { } catch (err) {
this.logger.warn(`resolveConfirmation failed for ${taskId}: ${(err as Error)?.message ?? String(err)}`); this.logger.warn(`resolveConfirmation failed for ${taskId}: ${(err as Error)?.message ?? String(err)}`);

View File

@@ -13,8 +13,27 @@ import type {
ResearchSynthesisRequest, ResearchSynthesisRequest,
ResearchSynthesisResult, ResearchSynthesisResult,
PlannerOverseerRuntimeSnapshot, PlannerOverseerRuntimeSnapshot,
PlannerInterventionSourceLink,
PlannerOversightStage,
} from "@fusion/core";
import {
allowsAutoMergeProcessing,
compareTasksByPriorityThenAgeAndId,
emitOverseerConfirmation,
emitOverseerEscalation,
emitOverseerObservation,
emitOverseerRecoveryAttempt,
emitOverseerRetry,
emitOverseerSteering,
getTaskHardMergeBlocker,
isSharedBranchGroupMemberIntegration,
isWorkspaceTask,
normalizeMergerMode,
resolveEffectivePlannerOversightLevel,
resolveEffectiveSettings,
resolveMaxAutoMergeRetries,
sortTasksByPriorityThenAgeAndId,
} from "@fusion/core"; } from "@fusion/core";
import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, isWorkspaceTask, normalizeMergerMode, resolveEffectivePlannerOversightLevel, resolveEffectiveSettings, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core";
import { assemblePlannerOverseerRuntimeSnapshot } from "./planner-overseer-runtime-snapshot.js"; import { assemblePlannerOverseerRuntimeSnapshot } from "./planner-overseer-runtime-snapshot.js";
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
@@ -351,6 +370,27 @@ export class ProjectEngine {
* safeguards beyond the userPaused skip are FN-7514's responsibility. * safeguards beyond the userPaused skip are FN-7514's responsibility.
*/ */
private plannerRecoveryController?: PlannerRecoveryController; private plannerRecoveryController?: PlannerRecoveryController;
/**
* FNXC:PlannerOversight 2026-07-04-19:45:
* FN-7551 requirement: real overseer decision points (observation,
* steering/retry/targeted-fix, confirmation request+resolution, and
* bounded-recovery escalation) must emit exactly one `overseer:intervention`
* run-audit entry through the FN-7520 `emitOverseer*` façade with the real
* `TaskStore`, so the dashboard intervention timeline reflects live engine
* activity instead of only synthetic unit-test entries. Emission must be
* deduped so the 45s poll does not flood the timeline: this map tracks the
* last emitted `"stage:signal"` per taskId for observations, mirroring
* FN-7514's `lastWithheldReason` dedup pattern. Cleared alongside the
* monitor/controller ring buffers whenever a task leaves the in-flight set.
*/
private readonly plannerObservationEmitDedup = new Map<string, string>();
/**
* FNXC:PlannerOversight 2026-07-04-19:45:
* FN-7551: tracks `(taskId, stage)` pairs that have already had a bounded-
* recovery escalation emitted so a stage that stays exhausted across many
* subsequent polls emits exactly one `escalate` entry, not one per poll.
*/
private readonly plannerEscalationEmitDedup = new Set<string>();
private prReconciler?: PrReconciler; private prReconciler?: PrReconciler;
private prCommentHandler?: PrCommentHandler; private prCommentHandler?: PrCommentHandler;
private notifier?: NtfyNotifier; private notifier?: NtfyNotifier;
@@ -608,7 +648,16 @@ export class ProjectEngine {
// FN-7511: Initialize the records-only planner-overseer monitor and start // FN-7511: Initialize the records-only planner-overseer monitor and start
// its bounded, gated poll over in-flight tasks. // its bounded, gated poll over in-flight tasks.
this.plannerOverseer = new PlannerOverseerMonitor({ store }); this.plannerOverseer = new PlannerOverseerMonitor({
store,
// FN-7551: emit one deduped `overseer:intervention` observation entry
// through the FN-7520 façade for each real observation the monitor
// records, using the real TaskStore. Best-effort — never throws (the
// monitor already swallows callback errors around `onObservation`).
onObservation: (observation) => {
this.emitOverseerObservationDeduped(store, observation);
},
});
// FN-7512: bounded autonomous-recovery dispatcher, wired to the existing // FN-7512: bounded autonomous-recovery dispatcher, wired to the existing
// steering-comment API + store retry/re-enqueue path only — no new // steering-comment API + store retry/re-enqueue path only — no new
// session/tool/merge channel. Ticked from the same poll as the FN-7511 // session/tool/merge channel. Ticked from the same poll as the FN-7511
@@ -1123,6 +1172,12 @@ export class ProjectEngine {
const updatedTask = await store.updateTask(taskId, { plannerOversightLevel: "off" }); const updatedTask = await store.updateTask(taskId, { plannerOversightLevel: "off" });
this.plannerOverseer?.clear(taskId); this.plannerOverseer?.clear(taskId);
this.plannerRecoveryController?.clear(taskId); this.plannerRecoveryController?.clear(taskId);
// FN-7551: release the observation/escalation emission-dedup state too,
// so if oversight is later re-enabled for this task, the first new
// observation/escalation emits rather than staying suppressed by stale
// dedup keys from before the stop.
this.plannerObservationEmitDedup.delete(taskId);
this.clearPlannerEscalationDedup(taskId);
return { applied: true, reason: "stopped", task: updatedTask }; return { applied: true, reason: "stopped", task: updatedTask };
} catch (err) { } catch (err) {
@@ -1145,6 +1200,120 @@ export class ProjectEngine {
return this.getPlannerOverseerRuntimeSnapshot(taskId); return this.getPlannerOverseerRuntimeSnapshot(taskId);
} }
/**
* FNXC:PlannerOversight 2026-07-04-19:45:
* FN-7551 mapping helper: converts the `{kind, ref, url?}` source-link shape
* shared by `OverseerSourceLink` (FN-7511 observations) and
* `PlannerRecoverySourceLink` (FN-7512/FN-7513 decisions) into the FN-7520
* façade's `PlannerInterventionSourceLink` shape (`{kind, label, target, url}`),
* using `ref` as both `label` and `target` when no richer label exists.
* Never throws; an empty/undefined input yields `undefined` so callers can
* omit `sourceLinks` entirely rather than pass an empty array.
*/
private toInterventionSourceLinks(
links: ReadonlyArray<{ kind: string; ref: string; url?: string }> | undefined,
): PlannerInterventionSourceLink[] | undefined {
if (!links || links.length === 0) return undefined;
return links.map((link) => ({
kind: link.kind as PlannerInterventionSourceLink["kind"],
label: link.ref || link.kind,
target: link.ref,
url: link.url,
}));
}
/**
* FNXC:PlannerOversight 2026-07-04-19:45:
* FN-7551: best-effort wrapper shared by every non-observation emission
* call-site (steering/retry/targeted-fix/confirmation/escalation) —
* swallows and logs any façade/store failure so an audit-emission error
* never breaks the dispatching handler or the poll (mirrors the
* try/catch-degrade-to-no-op contract every FN-7512/FN-7513/FN-7514
* handler already follows).
*/
private emitOverseerInterventionSafe(fn: () => void): void {
try {
fn();
} catch (err) {
runtimeLog.warn(`Failed to emit overseer intervention: ${err instanceof Error ? err.message : String(err)}`);
}
}
/**
* FNXC:PlannerOversight 2026-07-04-19:45:
* FN-7551: emits one `overseer:intervention` `observe` entry through
* `emitOverseerObservation` for a real `OverseerStageObservation`, deduped
* per `(taskId, stage:signal)` so a 45s poll of an unchanged watched stage
* does not append a new observation entry every cycle — only a changed
* `(stage, signal)` pair emits. Best-effort: any store/façade failure is
* swallowed so it never breaks `PlannerOverseerMonitor#observeTask`/the poll.
*/
private emitOverseerObservationDeduped(store: TaskStore, observation: import("./planner-overseer.js").OverseerStageObservation): void {
try {
const dedupKey = `${observation.stage}:${observation.signal}`;
const last = this.plannerObservationEmitDedup.get(observation.taskId);
if (last === dedupKey) {
return;
}
this.plannerObservationEmitDedup.set(observation.taskId, dedupKey);
emitOverseerObservation({
store,
taskId: observation.taskId,
stage: observation.stage,
reason: observation.reason,
sourceLinks: this.toInterventionSourceLinks(observation.sources),
});
} catch (err) {
runtimeLog.warn(
`Failed to emit overseer observation intervention for ${observation.taskId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/**
* FNXC:PlannerOversight 2026-07-04-19:45:
* FN-7551: emits one `overseer:intervention` `escalate` entry through
* `emitOverseerEscalation` when a `(taskId, stage)` pair's bounded-recovery
* budget is exhausted, deduped so it is emitted exactly once while the
* stage stays exhausted across subsequent polls (a stage that later
* un-exhausts — e.g. cleared via `clear(taskId)` on terminal transition —
* clears the dedup entry and may escalate again in a future exhaustion).
* Best-effort; never throws out of the poll.
*/
private emitOverseerEscalationDeduped(
store: TaskStore,
taskId: string,
decision: { watchedStage: PlannerOversightStage | null; reason: string; attemptCount: number; attemptLimit: number; sourceLinks: ReadonlyArray<{ kind: string; ref: string; url?: string }> },
): void {
if (!decision.watchedStage) return;
const dedupKey = `${taskId}::${decision.watchedStage}`;
if (this.plannerEscalationEmitDedup.has(dedupKey)) {
return;
}
this.plannerEscalationEmitDedup.add(dedupKey);
this.emitOverseerInterventionSafe(() =>
emitOverseerEscalation({
store,
taskId,
stage: decision.watchedStage as PlannerOversightStage,
reason: decision.reason,
attemptCount: decision.attemptCount,
attemptLimit: decision.attemptLimit,
sourceLinks: this.toInterventionSourceLinks(decision.sourceLinks),
}),
);
}
/** FN-7551: clears any escalation-dedup entries for `taskId` across every watched stage. */
private clearPlannerEscalationDedup(taskId: string): void {
const prefix = `${taskId}::`;
for (const key of [...this.plannerEscalationEmitDedup]) {
if (key.startsWith(prefix)) {
this.plannerEscalationEmitDedup.delete(key);
}
}
}
/** /**
* FNXC:PlannerOversight 2026-07-04-12:00: * FNXC:PlannerOversight 2026-07-04-12:00:
* Concrete FN-7512 handler wiring — ONLY reuses existing mechanisms: * Concrete FN-7512 handler wiring — ONLY reuses existing mechanisms:
@@ -1161,9 +1330,34 @@ export class ProjectEngine {
injectGuidance: async (task, decision) => { injectGuidance: async (task, decision) => {
const text = `[planner-oversight] ${decision.reason}`; const text = `[planner-oversight] ${decision.reason}`;
await store.addSteeringComment(task.id, text, "agent"); await store.addSteeringComment(task.id, text, "agent");
// FN-7551: emit the steering intervention entry AFTER the steering
// comment succeeds, through the real store, so the timeline reflects
// the same guidance the agent actually saw.
this.emitOverseerInterventionSafe(() =>
emitOverseerSteering({
store,
taskId: task.id,
stage: (decision.watchedStage ?? "executor") as PlannerOversightStage,
reason: decision.reason,
sourceLinks: this.toInterventionSourceLinks(decision.sourceLinks),
}),
);
}, },
retryStep: async (task) => { retryStep: async (task, decision) => {
await store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine" } as Parameters<TaskStore["moveTask"]>[2]); await store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine" } as Parameters<TaskStore["moveTask"]>[2]);
// FN-7551: the attempt just dispatched — record it as attemptCount + 1
// (decision.attemptCount is the count BEFORE this dispatch).
this.emitOverseerInterventionSafe(() =>
emitOverseerRetry({
store,
taskId: task.id,
stage: (decision.watchedStage ?? "executor") as PlannerOversightStage,
reason: decision.reason,
attemptCount: decision.attemptCount + 1,
attemptLimit: decision.attemptLimit,
sourceLinks: this.toInterventionSourceLinks(decision.sourceLinks),
}),
);
}, },
requestTargetedFix: async (task, decision) => { requestTargetedFix: async (task, decision) => {
const sourceRef = decision.sourceLinks[0]?.ref; const sourceRef = decision.sourceLinks[0]?.ref;
@@ -1171,6 +1365,17 @@ export class ProjectEngine {
? `[planner-oversight] targeted-fix requested: ${decision.reason} (source: ${sourceRef})` ? `[planner-oversight] targeted-fix requested: ${decision.reason} (source: ${sourceRef})`
: `[planner-oversight] targeted-fix requested: ${decision.reason}`; : `[planner-oversight] targeted-fix requested: ${decision.reason}`;
await store.addSteeringComment(task.id, text, "agent"); await store.addSteeringComment(task.id, text, "agent");
this.emitOverseerInterventionSafe(() =>
emitOverseerRecoveryAttempt({
store,
taskId: task.id,
stage: (decision.watchedStage ?? "executor") as PlannerOversightStage,
reason: decision.reason,
attemptCount: decision.attemptCount + 1,
attemptLimit: decision.attemptLimit,
sourceLinks: this.toInterventionSourceLinks(decision.sourceLinks),
}),
);
}, },
// FNXC:PlannerOversight 2026-07-04-13:00: // FNXC:PlannerOversight 2026-07-04-13:00:
// FN-7513 requirement: merge/PR actions beyond guidance/retry, and any // FN-7513 requirement: merge/PR actions beyond guidance/retry, and any
@@ -1184,6 +1389,33 @@ export class ProjectEngine {
requestConfirmation: async (task, request) => { requestConfirmation: async (task, request) => {
const text = `[planner-oversight] confirmation required (${request.sideEffectClass}): ${request.reason}`; const text = `[planner-oversight] confirmation required (${request.sideEffectClass}): ${request.reason}`;
await store.addSteeringComment(task.id, text, "agent"); await store.addSteeringComment(task.id, text, "agent");
this.emitOverseerInterventionSafe(() =>
emitOverseerConfirmation({
store,
taskId: task.id,
stage: request.watchedStage as PlannerOversightStage,
reason: request.reason,
sourceLinks: this.toInterventionSourceLinks(request.sourceLinks),
}),
);
},
// FNXC:PlannerOversight 2026-07-04-19:45:
// FN-7551: audit-only confirmation-RESOLUTION emission. Invoked from
// `PlannerRecoveryController.resolveConfirmation` for both "approved"
// and "denied" outcomes, mirroring the request-path emission above so
// the timeline shows both the request and its resolution. Never touches
// the approve/deny execution path itself.
onConfirmationResolved: async (taskId, request, resolution) => {
this.emitOverseerInterventionSafe(() =>
emitOverseerConfirmation({
store,
taskId,
stage: request.watchedStage as PlannerOversightStage,
reason: request.reason,
outcome: resolution === "approved" ? "succeeded" : "skipped",
sourceLinks: this.toInterventionSourceLinks(request.sourceLinks),
}),
);
}, },
// FNXC:PlannerOversight 2026-07-04-14:30: // FNXC:PlannerOversight 2026-07-04-14:30:
// FN-7513 code-review fix: a `"merge_pr"`-classified confirmation covers // FN-7513 code-review fix: a `"merge_pr"`-classified confirmation covers
@@ -2111,7 +2343,13 @@ export class ProjectEngine {
// human-review) BEFORE any action/confirmation classification — // human-review) BEFORE any action/confirmation classification —
// never throws. // never throws.
if (level === "autonomous" && this.plannerRecoveryController) { if (level === "autonomous" && this.plannerRecoveryController) {
await this.plannerRecoveryController.tick(task, { settings: engineSettings }); const decision = await this.plannerRecoveryController.tick(task, { settings: engineSettings });
// FN-7551: bounded-recovery exhaustion is an escalation-worthy
// event — emit exactly one `escalate` entry per (taskId, stage)
// while the stage remains exhausted across subsequent polls.
if (decision?.exhausted && decision.watchedStage) {
this.emitOverseerEscalationDeduped(store, task.id, decision);
}
} }
} 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.
@@ -2124,6 +2362,8 @@ export class ProjectEngine {
if (!inFlightIds.has(taskId)) { if (!inFlightIds.has(taskId)) {
overseer.clear(taskId); overseer.clear(taskId);
this.plannerRecoveryController?.clear(taskId); this.plannerRecoveryController?.clear(taskId);
this.plannerObservationEmitDedup.delete(taskId);
this.clearPlannerEscalationDedup(taskId);
} }
} }
} catch { } catch {