FN-7511: add planner overseer stage monitoring

Add records-only planner overseer monitoring across in-flight task lifecycle stages.

- Add a PlannerOverseerMonitor with normalized observations and deterministic watched-stage resolution.
- Wire ProjectEngine to poll in-progress and in-review tasks, gated by effective planner oversight level.
- Document the monitoring seam and add focused coverage plus a release changeset.

Files changed:
 .changeset/fn-7511-planner-overseer-monitoring.md  |   7 +
 docs/architecture.md                               |  26 ++
 docs/workflow-steps.md                             |   2 +
 .../engine/src/__tests__/planner-overseer.test.ts  | 294 ++++++++++++++++++
 packages/engine/src/index.ts                       |  12 +
 packages/engine/src/planner-overseer.ts            | 338 +++++++++++++++++++++
 packages/engine/src/project-engine.ts              |  93 +++++-
 7 files changed, 771 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-7511

Fusion-Task-Lineage: 81b616cf-47e9-4769-b02d-fc7ebd3fcb2f

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 12:55:25 -07:00
parent 0689250097
commit 12a6d1bc6a
7 changed files with 771 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Planner oversight now monitors tasks across executor, reviewer, merger, pull-request, and workflow-gate stages.
category: feature
dev: Adds records-only PlannerOverseerMonitor + resolveWatchedStage + OverseerStageObservation in @fusion/engine, gated by resolveEffectivePlannerOversightLevel (off = no observation) and wired into ProjectEngine via a bounded poll. Steering/recovery and UI land in FN-7512/FN-7515+.

View File

@@ -1352,6 +1352,32 @@ Limits are controlled by project settings (`maxSpawnedAgentsPerParent`, `maxSpaw
### Custom instructions
`packages/engine/src/agent-instructions.ts` resolves per-agent instruction text/path with path-traversal and extension validation.
### Planner overseer monitoring (records-only)
/*
FNXC:PlannerOversight 2026-07-04-00:00:
FN-7511 delivers the monitoring foundation for a planner-oversight layer that watches an in-flight task's
lifecycle without steering it. `packages/engine/src/planner-overseer.ts` declares the five watched stages
(`OVERSEER_WATCHED_STAGES`: executor, reviewer, merger, pull-request, workflow-gate), a normalized
`OverseerStageObservation` model, and a `resolveWatchedStage(task)` resolver with deterministic precedence
(workflow-gate > pull-request > merger > reviewer > executor) so a task in a compound state resolves to
exactly one stage. `PlannerOverseerMonitor#observeTask(task, level)` is the gating seam: when the task's
effective planner oversight level (`resolveEffectivePlannerOversightLevel`, FN-7508/FN-7509/FN-7510) is
`"off"`, nothing is recorded; otherwise exactly one observation is recorded into a bounded per-task ring
buffer (default cap 20) and the optional `onObservation` callback is invoked best-effort.
*/
`ProjectEngine` constructs a `PlannerOverseerMonitor` alongside `PrMonitor` and exposes it via
`getPlannerOverseer()`. A bounded `setInterval` poll (45s cadence, cleared on `stop()`) walks the
current `in-progress`/`in-review` tasks, resolves each task's effective planner oversight level, and
calls `observeTask` — skipping tasks that resolve to `"off"` or to no watched stage. Observations for
tasks that leave the in-flight set are dropped from the ring buffer on the next poll.
This layer is **records-only**: no lifecycle mutation, retry, merge, notification, or external-service
call happens here, and it emits no run-audit events or dashboard UI. Steering/recovery, confirmation
gates, human-control safeguards, and dashboard/UI/run-audit surfaces are deferred to FN-7512 through
FN-7520; this module is the seam those subtasks read observations from.
---
## 11) Multi-Project Architecture

View File

@@ -461,6 +461,8 @@ A gate node also has a `gateMode`:
Defaults:
- gates are `advisory` by default (advisory-by-default per FN-4368); opt in to `gate` by setting the node's `gateMode` in the [Workflow Editor](./workflow-editor.md).
A task paused awaiting a prompt/script gate's cli-approval or ask-input response (`pausedReason` prefixed `workflow-cli-approval:` or `workflow-input:`) is one of the five stages the records-only planner-overseer monitor watches (`workflow-gate`, `packages/engine/src/planner-overseer.ts`, FN-7511) — see **`docs/architecture.md` § "Planner overseer monitoring (records-only)"**.
## Built-In Quality Gates
<!--

View File

@@ -0,0 +1,294 @@
import { describe, expect, it, vi } from "vitest";
import {
OVERSEER_WATCHED_STAGES,
PlannerOverseerMonitor,
resolveWatchedStage,
type OverseerTaskRef,
} from "../planner-overseer.js";
function taskFixture(overrides: Partial<OverseerTaskRef> = {}): OverseerTaskRef {
return {
id: "FN-1000",
column: "in-progress",
prInfo: undefined,
reviewState: undefined,
paused: false,
pausedReason: undefined,
workflowTransitionNotification: undefined,
...overrides,
} as OverseerTaskRef;
}
describe("resolveWatchedStage", () => {
it("resolves an active in-progress task to executor", () => {
expect(resolveWatchedStage(taskFixture({ column: "in-progress" }))).toBe("executor");
});
it("resolves in-review with a pending reviewState to reviewer", () => {
expect(
resolveWatchedStage(
taskFixture({
column: "in-review",
reviewState: {
source: "reviewer-agent",
items: [],
addressing: [],
} as unknown as OverseerTaskRef["reviewState"],
}),
),
).toBe("reviewer");
});
it("resolves in-review with no reviewState/PR/gate marker to merger (awaiting integration)", () => {
expect(resolveWatchedStage(taskFixture({ column: "in-review" }))).toBe("merger");
});
it("resolves in-review with an explicit manual-merge-hold marker to merger", () => {
expect(
resolveWatchedStage(
taskFixture({
column: "in-review",
reviewState: {
source: "reviewer-agent",
items: [],
addressing: [],
} as unknown as OverseerTaskRef["reviewState"],
workflowTransitionNotification: {
kind: "manual-merge-hold",
column: "in-review",
transitionId: "t-1",
} as unknown as OverseerTaskRef["workflowTransitionNotification"],
}),
),
).toBe("merger");
});
it("resolves in-review with an active (open) PR to pull-request", () => {
expect(
resolveWatchedStage(
taskFixture({
column: "in-review",
prInfo: {
url: "https://github.com/o/r/pull/1",
number: 1,
status: "open",
title: "t",
headBranch: "h",
baseBranch: "b",
commentCount: 0,
} as unknown as OverseerTaskRef["prInfo"],
}),
),
).toBe("pull-request");
});
it("resolves a paused workflow-cli-approval gate to workflow-gate regardless of column", () => {
expect(
resolveWatchedStage(
taskFixture({
column: "in-progress",
paused: true,
pausedReason: "workflow-cli-approval:build: npm run build",
}),
),
).toBe("workflow-gate");
});
it("resolves a paused workflow-input gate to workflow-gate", () => {
expect(
resolveWatchedStage(
taskFixture({
column: "in-review",
paused: true,
pausedReason: "workflow-input:ask: What environment?",
}),
),
).toBe("workflow-gate");
});
it("returns null for non-monitorable columns (todo/done/archived/triage)", () => {
for (const column of ["todo", "done", "archived", "triage"] as const) {
expect(resolveWatchedStage(taskFixture({ column }))).toBeNull();
}
});
it("deterministic precedence: workflow-gate wins over an active PR and reviewState in a compound state", () => {
const compound = taskFixture({
column: "in-review",
paused: true,
pausedReason: "workflow-cli-approval:deploy: run deploy",
prInfo: {
url: "https://github.com/o/r/pull/2",
number: 2,
status: "open",
title: "t",
headBranch: "h",
baseBranch: "b",
commentCount: 0,
} as unknown as OverseerTaskRef["prInfo"],
reviewState: {
source: "reviewer-agent",
items: [],
addressing: [],
} as unknown as OverseerTaskRef["reviewState"],
});
expect(resolveWatchedStage(compound)).toBe("workflow-gate");
});
it("deterministic precedence: an active PR wins over reviewState when no gate is paused", () => {
const compound = taskFixture({
column: "in-review",
prInfo: {
url: "https://github.com/o/r/pull/3",
number: 3,
status: "open",
title: "t",
headBranch: "h",
baseBranch: "b",
commentCount: 0,
} as unknown as OverseerTaskRef["prInfo"],
reviewState: {
source: "reviewer-agent",
items: [],
addressing: [],
} as unknown as OverseerTaskRef["reviewState"],
});
expect(resolveWatchedStage(compound)).toBe("pull-request");
});
it("never throws on a malformed/partial task (missing column/optional fields)", () => {
expect(resolveWatchedStage({} as OverseerTaskRef)).toBeNull();
expect(resolveWatchedStage(null)).toBeNull();
expect(resolveWatchedStage(undefined)).toBeNull();
expect(resolveWatchedStage({ id: "FN-1" } as OverseerTaskRef)).toBeNull();
});
});
describe("PlannerOverseerMonitor.observeTask", () => {
const stageFixtures: Array<{ stage: (typeof OVERSEER_WATCHED_STAGES)[number]; task: OverseerTaskRef }> = [
{ stage: "executor", task: taskFixture({ column: "in-progress" }) },
{
stage: "reviewer",
task: taskFixture({
column: "in-review",
reviewState: { source: "reviewer-agent", items: [], addressing: [] } as unknown as OverseerTaskRef["reviewState"],
}),
},
{ stage: "merger", task: taskFixture({ column: "in-review" }) },
{
stage: "pull-request",
task: taskFixture({
column: "in-review",
prInfo: {
url: "https://github.com/o/r/pull/9",
number: 9,
status: "open",
title: "t",
headBranch: "h",
baseBranch: "b",
commentCount: 0,
} as unknown as OverseerTaskRef["prInfo"],
}),
},
{
stage: "workflow-gate",
task: taskFixture({ column: "in-progress", paused: true, pausedReason: "workflow-input:ask: env?" }),
},
];
it.each(stageFixtures)(
"records exactly one observation for the $stage stage when level is not off",
async ({ stage, task }) => {
for (const level of ["observe", "steer", "autonomous"] as const) {
const monitor = new PlannerOverseerMonitor();
const observation = await monitor.observeTask(task, level);
expect(observation).not.toBeNull();
expect(observation?.stage).toBe(stage);
expect(observation?.oversightLevel).toBe(level);
expect(observation?.taskId).toBe(task.id);
expect(observation?.sources.length).toBeGreaterThan(0);
expect(monitor.getObservations(task.id)).toHaveLength(1);
}
},
);
it.each(stageFixtures)("records nothing and returns null for the $stage stage when level is off", async ({ task }) => {
const monitor = new PlannerOverseerMonitor();
const observation = await monitor.observeTask(task, "off");
expect(observation).toBeNull();
expect(monitor.getObservations(task.id)).toHaveLength(0);
});
it("returns null and records nothing when no stage is monitorable", async () => {
const monitor = new PlannerOverseerMonitor();
const observation = await monitor.observeTask(taskFixture({ column: "todo" }), "autonomous");
expect(observation).toBeNull();
expect(monitor.getObservations("FN-1000")).toHaveLength(0);
});
it("invokes the onObservation callback when provided", async () => {
const onObservation = vi.fn().mockResolvedValue(undefined);
const monitor = new PlannerOverseerMonitor({ onObservation });
const task = taskFixture({ column: "in-progress" });
const observation = await monitor.observeTask(task, "observe");
expect(onObservation).toHaveBeenCalledTimes(1);
expect(onObservation).toHaveBeenCalledWith(observation);
});
it("still resolves when the onObservation callback throws (best-effort)", async () => {
const onObservation = vi.fn().mockRejectedValue(new Error("callback exploded"));
const monitor = new PlannerOverseerMonitor({ onObservation });
const task = taskFixture({ column: "in-progress" });
await expect(monitor.observeTask(task, "observe")).resolves.not.toBeNull();
expect(monitor.getObservations(task.id)).toHaveLength(1);
});
it("records into the store best-effort and swallows logEntry failures", async () => {
const store = { logEntry: vi.fn().mockRejectedValue(new Error("log failed")) };
const monitor = new PlannerOverseerMonitor({ store });
const task = taskFixture({ column: "in-progress" });
await expect(monitor.observeTask(task, "observe")).resolves.not.toBeNull();
expect(store.logEntry).toHaveBeenCalledTimes(1);
});
it("bounds the per-task ring buffer to the configured cap, keeping the most recent N", async () => {
const monitor = new PlannerOverseerMonitor({ maxObservationsPerTask: 3 });
const task = taskFixture({ column: "in-progress" });
const observations = [];
for (let i = 0; i < 5; i++) {
const obs = await monitor.observeTask(task, "observe");
observations.push(obs);
}
const retained = monitor.getObservations(task.id);
expect(retained).toHaveLength(3);
// The three retained entries should be the last three recorded (index 2,3,4).
expect(retained.map((o) => o.observedAt)).toEqual(
[observations[2], observations[3], observations[4]].map((o) => o!.observedAt),
);
});
it("defaults the ring buffer cap to 20 entries per task", async () => {
const monitor = new PlannerOverseerMonitor();
const task = taskFixture({ column: "in-progress" });
for (let i = 0; i < 25; i++) {
await monitor.observeTask(task, "observe");
}
expect(monitor.getObservations(task.id)).toHaveLength(20);
});
it("clear() removes retained observations for a task", async () => {
const monitor = new PlannerOverseerMonitor();
const task = taskFixture({ column: "in-progress" });
await monitor.observeTask(task, "observe");
expect(monitor.getObservations(task.id)).toHaveLength(1);
monitor.clear(task.id);
expect(monitor.getObservations(task.id)).toHaveLength(0);
expect(monitor.getObservedTaskIds()).not.toContain(task.id);
});
it("never throws on a malformed/partial task passed to observeTask (degrades to no-op)", async () => {
const monitor = new PlannerOverseerMonitor();
await expect(monitor.observeTask({} as OverseerTaskRef, "autonomous")).resolves.toBeNull();
await expect(monitor.observeTask(undefined as unknown as OverseerTaskRef, "autonomous")).resolves.toBeNull();
});
});

View File

@@ -635,6 +635,18 @@ export {
type LLMSynthesisProviderOptions,
} from "./research/providers/index.js";
export { PrMonitor, type PrComment, type TrackedPr, type OnNewCommentsCallback } from "./pr-monitor.js";
export {
PlannerOverseerMonitor,
OVERSEER_WATCHED_STAGES,
resolveWatchedStage,
type OverseerWatchedStage,
type OverseerObservationSignal,
type OverseerStageObservation,
type OverseerSourceLink,
type OverseerTaskRef,
type OverseerLogStore,
type PlannerOverseerMonitorOptions,
} from "./planner-overseer.js";
export {
SECRET_MUTATION_TYPES,
SECRET_AUDIT_PLAINTEXT_FORBIDDEN_KEYS,

View File

@@ -0,0 +1,338 @@
/**
* FNXC:PlannerOversight 2026-07-04-00:00:
* FN-7511 delivers the monitoring foundation for the planner overseer: an
* engine module that watches an in-flight task's progression across five
* lifecycle stages — executor, reviewer, merger, pull-request, and
* workflow-gate — and records normalized `OverseerStageObservation`s gated by
* the task's effective planner oversight level (`resolveEffectivePlannerOversightLevel`,
* FN-7508/FN-7509/FN-7510). When the effective level is `"off"`, nothing is
* recorded. This layer is records-only: it does not steer, retry, fix, gate,
* or notify — those land in FN-7512 (steering/recovery), FN-7513
* (confirmation gates), FN-7514 (human-control safeguards), and
* FN-7515–FN-7520 (dashboard UI / run-audit events / intervention timeline).
* The observation model + `PlannerOverseerMonitor` registry declared here is
* the seam every later planner-oversight subtask reads from.
*/
import type { PlannerOversightLevel, PrInfo, Task } from "@fusion/core";
/** Alias for the `Task.reviewState` shape without requiring a separate core export. */
type OverseerTaskReviewState = NonNullable<Task["reviewState"]>;
/**
* The five lifecycle stages the planner overseer watches. Precedence when a
* task is in a compound state (see {@link resolveWatchedStage}):
* workflow-gate > pull-request > merger > reviewer > executor.
*/
export const OVERSEER_WATCHED_STAGES = ["executor", "reviewer", "merger", "pull-request", "workflow-gate"] as const;
export type OverseerWatchedStage = (typeof OVERSEER_WATCHED_STAGES)[number];
/** Normalized signal describing how a watched stage is currently progressing. */
export type OverseerObservationSignal = "progressing" | "stuck" | "failed" | "blocked" | "awaiting-human" | "complete";
/** A link back to the concrete evidence an observation was derived from. */
export interface OverseerSourceLink {
kind: "agent-log" | "review-comment" | "failed-check" | "merge-error" | "pr-state";
ref: string;
url?: string;
}
/** One normalized, oversight-gated observation of a task's current watched stage. */
export interface OverseerStageObservation {
taskId: string;
stage: OverseerWatchedStage;
signal: OverseerObservationSignal;
oversightLevel: PlannerOversightLevel;
observedAt: number;
reason: string;
sources: OverseerSourceLink[];
}
/** The minimal task shape the stage resolver reads. Kept a `Pick` so callers
* (and tests) can pass partial/malformed fixtures without satisfying the
* full `Task` interface. */
export type OverseerTaskRef = Pick<
Task,
"id" | "column" | "prInfo" | "reviewState" | "paused" | "pausedReason" | "workflowTransitionNotification"
>;
/**
* FNXC:PlannerOversight 2026-07-04-00:00:
* Maps a task's delivered lifecycle state to exactly one watched stage, or
* `null` when the task is not currently monitorable (e.g. `todo`, `done`,
* `archived`, `triage`).
*
* Precedence (deterministic, so a compound state resolves to a single stable
* stage): **workflow-gate > pull-request > merger > reviewer > executor**.
* A task paused on a workflow prompt/script gate node is reported as
* workflow-gate even if it also carries an open PR or pending review, because
* the gate is the current blocking reason. Below that, an active PR takes
* precedence over a bare merge/review classification since PR lifecycle is
* the more specific state. Below that, an explicit merge-hold/merge-error
* marker takes precedence over a generic pending-review read of `in-review`.
*
* Never throws — missing/partial fields degrade to `null`.
*/
export function resolveWatchedStage(task: Partial<OverseerTaskRef> | null | undefined): OverseerWatchedStage | null {
try {
if (!task) return null;
// workflow-gate: paused awaiting an explicit workflow prompt/script gate
// input (cli-approval or ask-input gate), regardless of column.
if (task.paused === true && typeof task.pausedReason === "string") {
if (task.pausedReason.startsWith("workflow-cli-approval:") || task.pausedReason.startsWith("workflow-input:")) {
return "workflow-gate";
}
}
const column = task.column;
if (column !== "in-progress" && column !== "in-review") {
return null;
}
if (column === "in-progress") {
return "executor";
}
// column === "in-review" beyond this point.
// pull-request: an active (non-terminal) PR lifecycle takes precedence
// over a plain merge/review read of in-review.
const prInfo = task.prInfo;
if (prInfo && typeof prInfo === "object" && prInfo.status !== "merged" && prInfo.status !== "closed") {
return "pull-request";
}
// merger: an explicit merge-hold notification marker or a recorded merge
// error means the task is in the merge/integration phase.
const marker = task.workflowTransitionNotification;
if (marker && marker.kind === "manual-merge-hold") {
return "merger";
}
if (prInfo && typeof prInfo === "object" && typeof prInfo.lastMergeError === "string" && prInfo.lastMergeError.length > 0) {
return "merger";
}
// reviewer: review is in progress / pending items.
const reviewState = task.reviewState;
if (reviewState && typeof reviewState === "object") {
return "reviewer";
}
// Plain in-review with no review state and no merge marker yet — treat as
// the merge/integration phase (awaiting auto-merge).
return "merger";
} catch {
return null;
}
}
function deriveSignalAndSources(
taskId: string,
stage: OverseerWatchedStage,
task: Partial<OverseerTaskRef>,
): { signal: OverseerObservationSignal; reason: string; sources: OverseerSourceLink[] } {
switch (stage) {
case "executor": {
if (task.paused === true) {
return {
signal: "blocked",
reason: task.pausedReason ? `Executor stage paused: ${task.pausedReason}` : "Executor stage paused",
sources: [{ kind: "agent-log", ref: taskId }],
};
}
return {
signal: "progressing",
reason: "Task is actively executing in-progress work",
sources: [{ kind: "agent-log", ref: taskId }],
};
}
case "reviewer": {
const reviewState = task.reviewState as OverseerTaskReviewState | undefined;
const summary = reviewState?.summary;
const decision = summary && "reviewDecision" in summary ? summary.reviewDecision : undefined;
if (decision === "CHANGES_REQUESTED") {
return {
signal: "blocked",
reason: "Review requested changes",
sources: [{ kind: "review-comment", ref: reviewState?.items?.[0]?.id ?? taskId }],
};
}
return {
signal: "progressing",
reason: "Review in progress",
sources: [{ kind: "review-comment", ref: reviewState?.items?.[0]?.id ?? taskId }],
};
}
case "merger": {
const prInfo = task.prInfo;
if (prInfo?.lastMergeError) {
return {
signal: "failed",
reason: `Merge failed: ${prInfo.lastMergeError}`,
sources: [{ kind: "merge-error", ref: prInfo.lastMergeError }],
};
}
if (task.workflowTransitionNotification?.kind === "manual-merge-hold") {
return {
signal: "awaiting-human",
reason: "Held awaiting manual merge decision",
sources: [{ kind: "merge-error", ref: task.workflowTransitionNotification.transitionId ?? taskId }],
};
}
return {
signal: "progressing",
reason: "Task is in the merge/integration phase",
sources: [{ kind: "merge-error", ref: taskId }],
};
}
case "pull-request": {
const prInfo = task.prInfo as PrInfo | undefined;
if (prInfo?.checkRollup === "failure") {
return {
signal: "failed",
reason: "PR checks failing",
sources: [{ kind: "failed-check", ref: prInfo.url, url: prInfo.url }],
};
}
return {
signal: "progressing",
reason: "PR lifecycle in progress",
sources: [{ kind: "pr-state", ref: prInfo?.url ?? taskId, url: prInfo?.url }],
};
}
case "workflow-gate": {
return {
signal: "awaiting-human",
reason: task.pausedReason ? `Paused on workflow gate: ${task.pausedReason}` : "Paused on workflow gate",
sources: [{ kind: "agent-log", ref: task.pausedReason ?? taskId }],
};
}
default: {
return {
signal: "progressing",
reason: "",
sources: [],
};
}
}
}
/** Minimal store seam the monitor records best-effort observations through —
* mirrors `fallback-model-observer.ts`'s `FallbackLogStore` seam. */
export interface OverseerLogStore {
logEntry?(taskId: string, action: string): Promise<unknown>;
appendAgentLog?(
taskId: string,
text: string,
type: "text" | "thinking" | "tool" | "tool_result" | "tool_error",
detail?: string,
agent?: string,
): Promise<unknown>;
}
export interface PlannerOverseerMonitorOptions {
store?: OverseerLogStore;
onObservation?: (observation: OverseerStageObservation) => void | Promise<void>;
/** Max observations retained per task in the in-memory ring buffer. Default: 20. */
maxObservationsPerTask?: number;
}
const DEFAULT_MAX_OBSERVATIONS_PER_TASK = 20;
/**
* FNXC:PlannerOversight 2026-07-04-00:00:
* Records-only monitor: watches a task's current lifecycle stage and, when
* the effective oversight level is not `"off"`, records one normalized
* `OverseerStageObservation` per call into a bounded per-task ring buffer and
* invokes the optional `onObservation` callback best-effort. Never mutates
* task lifecycle, never retries/fixes/merges/notifies — steering and
* recovery are FN-7512+.
*/
export class PlannerOverseerMonitor {
private readonly store?: OverseerLogStore;
private readonly onObservation?: (observation: OverseerStageObservation) => void | Promise<void>;
private readonly maxObservationsPerTask: number;
private readonly observations = new Map<string, OverseerStageObservation[]>();
constructor(options: PlannerOverseerMonitorOptions = {}) {
this.store = options.store;
this.onObservation = options.onObservation;
this.maxObservationsPerTask = options.maxObservationsPerTask ?? DEFAULT_MAX_OBSERVATIONS_PER_TASK;
}
/**
* Observe a task's current watched stage and record a gated observation.
* Returns `null` when the level is `"off"` or when no stage is currently
* monitorable. Never throws.
*/
async observeTask(task: OverseerTaskRef, level: PlannerOversightLevel): Promise<OverseerStageObservation | null> {
try {
if (level === "off") {
return null;
}
const stage = resolveWatchedStage(task);
if (!stage) {
return null;
}
const { signal, reason, sources } = deriveSignalAndSources(task.id, stage, task);
const observation: OverseerStageObservation = {
taskId: task.id,
stage,
signal,
oversightLevel: level,
observedAt: Date.now(),
reason,
sources,
};
this.record(observation);
if (this.onObservation) {
try {
await this.onObservation(observation);
} catch {
// Best-effort — never let a consumer callback fail the monitor.
}
}
if (this.store?.logEntry) {
await this.store
.logEntry(task.id, `[planner-overseer] stage=${stage} signal=${signal}: ${reason}`)
.catch(() => undefined);
}
return observation;
} catch {
return null;
}
}
private record(observation: OverseerStageObservation): void {
const existing = this.observations.get(observation.taskId) ?? [];
existing.push(observation);
if (existing.length > this.maxObservationsPerTask) {
existing.splice(0, existing.length - this.maxObservationsPerTask);
}
this.observations.set(observation.taskId, existing);
}
/** Return the recorded observations for a task, oldest first. */
getObservations(taskId: string): OverseerStageObservation[] {
return [...(this.observations.get(taskId) ?? [])];
}
/** Clear recorded observations for a task (e.g. on task completion). */
clear(taskId: string): void {
this.observations.delete(taskId);
}
/** Task IDs that currently retain at least one recorded observation. Used
* by the engine poll to release ring buffers for tasks that have left the
* in-flight set. */
getObservedTaskIds(): string[] {
return [...this.observations.keys()];
}
}

View File

@@ -13,13 +13,14 @@ import type {
ResearchSynthesisRequest,
ResearchSynthesisResult,
} from "@fusion/core";
import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, isWorkspaceTask, normalizeMergerMode, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core";
import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, isWorkspaceTask, normalizeMergerMode, resolveEffectivePlannerOversightLevel, resolveEffectiveSettings, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } from "@fusion/core";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { InProcessRuntime } from "./runtimes/in-process-runtime.js";
import type { WorktreePool } from "./worktree-pool.js";
import type { ProjectRuntimeConfig } from "./project-runtime.js";
import { PrMonitor } from "./pr-monitor.js";
import { PlannerOverseerMonitor } from "./planner-overseer.js";
import type { PrNodeGithubOps } from "./pr-nodes.js";
import { PrReconciler, type PrReconcileGithubOps } from "./pr-reconcile.js";
import { PrCommentHandler } from "./pr-comment-handler.js";
@@ -322,6 +323,18 @@ export class ProjectEngine {
private runtime: InProcessRuntime;
private started = false;
private prMonitor?: PrMonitor;
/**
* FNXC:PlannerOversight 2026-07-04-00:00:
* FN-7511 records-only planner-overseer monitor. Watches in-flight tasks
* (in-progress/in-review) across the executor/reviewer/merger/pull-request/
* workflow-gate stages, gated by the task's effective planner oversight
* level (`resolveEffectivePlannerOversightLevel`). No lifecycle mutation —
* steering/recovery land in FN-7512+.
*/
private plannerOverseer?: PlannerOverseerMonitor;
private plannerOverseerPollTimer: ReturnType<typeof setInterval> | null = null;
/** Conservative poll cadence for the records-only planner-overseer monitor (45s). */
private static readonly PLANNER_OVERSEER_POLL_INTERVAL_MS = 45 * 1000;
private prReconciler?: PrReconciler;
private prCommentHandler?: PrCommentHandler;
private notifier?: NtfyNotifier;
@@ -577,6 +590,11 @@ export class ProjectEngine {
runtimeLog.warn(`Remote tunnel restore evaluation failed (continuing startup): ${message}`);
}
// FN-7511: Initialize the records-only planner-overseer monitor and start
// its bounded, gated poll over in-flight tasks.
this.plannerOverseer = new PlannerOverseerMonitor({ store });
this.startPlannerOverseerPoll(store);
// 2. Initialize PrMonitor + PrCommentHandler
this.prMonitor = new PrMonitor();
this.prCommentHandler = new PrCommentHandler(store);
@@ -809,6 +827,7 @@ export class ProjectEngine {
clearInterval(this.mergeActiveReconcileTimer);
this.mergeActiveReconcileTimer = null;
}
this.stopPlannerOverseerPoll();
// Abort active/pending merge work before tearing down sessions.
this.mergeAbortController?.abort();
@@ -976,6 +995,11 @@ export class ProjectEngine {
return this.prMonitor;
}
/** Get the records-only PlannerOverseerMonitor (if initialized). See FN-7511. */
getPlannerOverseer(): PlannerOverseerMonitor | undefined {
return this.plannerOverseer;
}
/** Get the CronRunner (if initialized). */
getCronRunner(): CronRunner | undefined {
return this.cronRunner;
@@ -1780,6 +1804,73 @@ export class ProjectEngine {
return cleared;
}
/**
* FNXC:PlannerOversight 2026-07-04-00:00:
* Bounded, gated poll over in-flight tasks (in-progress/in-review). For
* each task, resolves the effective planner oversight level and, unless it
* is "off" or the task resolves to no watched stage, records one
* `OverseerStageObservation` via `PlannerOverseerMonitor#observeTask`.
* Records-only: no lifecycle mutation, retry, or notification here.
* Cleared on `stop()`. Never an unbounded loop — a single bounded
* `setInterval` at a conservative cadence.
*/
private startPlannerOverseerPoll(store: TaskStore): void {
if (this.plannerOverseerPollTimer) {
return;
}
this.plannerOverseerPollTimer = setInterval(() => {
void this.pollPlannerOverseer(store);
}, ProjectEngine.PLANNER_OVERSEER_POLL_INTERVAL_MS);
}
private async pollPlannerOverseer(store: TaskStore): Promise<void> {
if (!this.plannerOverseer || this.shuttingDown) {
return;
}
const overseer = this.plannerOverseer;
try {
const [inProgress, inReview] = await Promise.all([
store.listTasks({ column: "in-progress" }).catch(() => [] as Task[]),
store.listTasks({ column: "in-review" }).catch(() => [] as Task[]),
]);
const inFlight = [...inProgress, ...inReview];
const inFlightIds = new Set(inFlight.map((t) => t.id));
for (const task of inFlight) {
try {
const workflowEffective = await resolveEffectiveSettings(store, { id: task.id }).catch(() => ({}) as Record<string, unknown>);
const level = resolveEffectivePlannerOversightLevel(
task.plannerOversightLevel,
workflowEffective.plannerOversightLevel as string | undefined,
);
if (level === "off") {
continue;
}
await overseer.observeTask(task, level);
} catch {
// Best-effort per-task — never let one task's failure block the poll.
}
}
// Drop retained observations for tasks that have left the in-flight set
// (moved to done/archived/failed/etc.) so the ring buffers don't leak.
for (const taskId of overseer.getObservedTaskIds()) {
if (!inFlightIds.has(taskId)) {
overseer.clear(taskId);
}
}
} catch {
// Best-effort poll — degrade silently, never throw out of the interval.
}
}
private stopPlannerOverseerPoll(): void {
if (this.plannerOverseerPollTimer) {
clearInterval(this.plannerOverseerPollTimer);
this.plannerOverseerPollTimer = null;
}
}
private scheduleMergeActiveReconciliation(intervalMs: number): void {
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
return;