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:
7
.changeset/fn-7511-planner-overseer-monitoring.md
Normal file
7
.changeset/fn-7511-planner-overseer-monitoring.md
Normal 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+.
|
||||||
@@ -1352,6 +1352,32 @@ Limits are controlled by project settings (`maxSpawnedAgentsPerParent`, `maxSpaw
|
|||||||
### Custom instructions
|
### Custom instructions
|
||||||
`packages/engine/src/agent-instructions.ts` resolves per-agent instruction text/path with path-traversal and extension validation.
|
`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
|
## 11) Multi-Project Architecture
|
||||||
|
|||||||
@@ -461,6 +461,8 @@ A gate node also has a `gateMode`:
|
|||||||
Defaults:
|
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).
|
- 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
|
## Built-In Quality Gates
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
294
packages/engine/src/__tests__/planner-overseer.test.ts
Normal file
294
packages/engine/src/__tests__/planner-overseer.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -635,6 +635,18 @@ export {
|
|||||||
type LLMSynthesisProviderOptions,
|
type LLMSynthesisProviderOptions,
|
||||||
} from "./research/providers/index.js";
|
} from "./research/providers/index.js";
|
||||||
export { PrMonitor, type PrComment, type TrackedPr, type OnNewCommentsCallback } from "./pr-monitor.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 {
|
export {
|
||||||
SECRET_MUTATION_TYPES,
|
SECRET_MUTATION_TYPES,
|
||||||
SECRET_AUDIT_PLAINTEXT_FORBIDDEN_KEYS,
|
SECRET_AUDIT_PLAINTEXT_FORBIDDEN_KEYS,
|
||||||
|
|||||||
338
packages/engine/src/planner-overseer.ts
Normal file
338
packages/engine/src/planner-overseer.ts
Normal 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()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,13 +13,14 @@ import type {
|
|||||||
ResearchSynthesisRequest,
|
ResearchSynthesisRequest,
|
||||||
ResearchSynthesisResult,
|
ResearchSynthesisResult,
|
||||||
} from "@fusion/core";
|
} 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 { execFile } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { InProcessRuntime } from "./runtimes/in-process-runtime.js";
|
import { InProcessRuntime } from "./runtimes/in-process-runtime.js";
|
||||||
import type { WorktreePool } from "./worktree-pool.js";
|
import type { WorktreePool } from "./worktree-pool.js";
|
||||||
import type { ProjectRuntimeConfig } from "./project-runtime.js";
|
import type { ProjectRuntimeConfig } from "./project-runtime.js";
|
||||||
import { PrMonitor } from "./pr-monitor.js";
|
import { PrMonitor } from "./pr-monitor.js";
|
||||||
|
import { PlannerOverseerMonitor } from "./planner-overseer.js";
|
||||||
import type { PrNodeGithubOps } from "./pr-nodes.js";
|
import type { PrNodeGithubOps } from "./pr-nodes.js";
|
||||||
import { PrReconciler, type PrReconcileGithubOps } from "./pr-reconcile.js";
|
import { PrReconciler, type PrReconcileGithubOps } from "./pr-reconcile.js";
|
||||||
import { PrCommentHandler } from "./pr-comment-handler.js";
|
import { PrCommentHandler } from "./pr-comment-handler.js";
|
||||||
@@ -322,6 +323,18 @@ export class ProjectEngine {
|
|||||||
private runtime: InProcessRuntime;
|
private runtime: InProcessRuntime;
|
||||||
private started = false;
|
private started = false;
|
||||||
private prMonitor?: PrMonitor;
|
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 prReconciler?: PrReconciler;
|
||||||
private prCommentHandler?: PrCommentHandler;
|
private prCommentHandler?: PrCommentHandler;
|
||||||
private notifier?: NtfyNotifier;
|
private notifier?: NtfyNotifier;
|
||||||
@@ -577,6 +590,11 @@ export class ProjectEngine {
|
|||||||
runtimeLog.warn(`Remote tunnel restore evaluation failed (continuing startup): ${message}`);
|
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
|
// 2. Initialize PrMonitor + PrCommentHandler
|
||||||
this.prMonitor = new PrMonitor();
|
this.prMonitor = new PrMonitor();
|
||||||
this.prCommentHandler = new PrCommentHandler(store);
|
this.prCommentHandler = new PrCommentHandler(store);
|
||||||
@@ -809,6 +827,7 @@ export class ProjectEngine {
|
|||||||
clearInterval(this.mergeActiveReconcileTimer);
|
clearInterval(this.mergeActiveReconcileTimer);
|
||||||
this.mergeActiveReconcileTimer = null;
|
this.mergeActiveReconcileTimer = null;
|
||||||
}
|
}
|
||||||
|
this.stopPlannerOverseerPoll();
|
||||||
|
|
||||||
// Abort active/pending merge work before tearing down sessions.
|
// Abort active/pending merge work before tearing down sessions.
|
||||||
this.mergeAbortController?.abort();
|
this.mergeAbortController?.abort();
|
||||||
@@ -976,6 +995,11 @@ export class ProjectEngine {
|
|||||||
return this.prMonitor;
|
return this.prMonitor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the records-only PlannerOverseerMonitor (if initialized). See FN-7511. */
|
||||||
|
getPlannerOverseer(): PlannerOverseerMonitor | undefined {
|
||||||
|
return this.plannerOverseer;
|
||||||
|
}
|
||||||
|
|
||||||
/** Get the CronRunner (if initialized). */
|
/** Get the CronRunner (if initialized). */
|
||||||
getCronRunner(): CronRunner | undefined {
|
getCronRunner(): CronRunner | undefined {
|
||||||
return this.cronRunner;
|
return this.cronRunner;
|
||||||
@@ -1780,6 +1804,73 @@ export class ProjectEngine {
|
|||||||
return cleared;
|
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 {
|
private scheduleMergeActiveReconciliation(intervalMs: number): void {
|
||||||
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
|
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user