FN-7531: expose planner overseer state to task cards

Expose transient planner overseer runtime snapshots to board task payloads and cards.

- Add core planner overseer state types and deterministic state derivation.
- Assemble read-only engine runtime snapshots from overseer observations and recovery registries.
- Enrich GET /api/tasks with best-effort planner overseer state and render non-idle TaskCard badges.
- Cover state derivation, API enrichment, runtime snapshot assembly, and card rendering with tests.

Files changed:
 .../fn-7531-planner-overseer-state-exposure.md     |   7 ++
 docs/architecture.md                               |  36 +++++++
 .../src/__tests__/planner-overseer-state.test.ts   |  85 +++++++++++++++
 packages/core/src/index.ts                         |   7 ++
 packages/core/src/planner-overseer-state.ts        |  78 ++++++++++++++
 packages/core/src/types.ts                         |  12 +++
 packages/dashboard/app/components/TaskCard.tsx     |  27 ++++-
 .../app/components/__tests__/TaskCard.test.tsx     |  29 ++++++
 .../__tests__/tasks-planner-overseer-state.test.ts | 114 +++++++++++++++++++++
 .../src/routes/register-task-workflow-routes.ts    |  23 ++++-
 .../planner-overseer-runtime-snapshot.test.ts      | 104 +++++++++++++++++++
 packages/engine/src/index.ts                       |   8 ++
 .../src/planner-overseer-runtime-snapshot.ts       |  67 ++++++++++++
 packages/engine/src/project-engine.ts              |  17 +++
 14 files changed, 612 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-7531

Fusion-Task-Lineage: b7659ed2-bf33-4312-a5ab-818ad37049b9

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 15:23:12 -07:00
parent 5ad8ec8cb6
commit 726cbf89cc
14 changed files with 612 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Task cards can now show the planner overseer's active state (idle/watching/steering/recovering/awaiting-confirmation).
category: feature
dev: Adds a serializable `PlannerOverseerRuntimeSnapshot` + pure `derivePlannerOverseerState` (core), a read-only `ProjectEngine.getPlannerOverseerRuntimeSnapshot(taskId)` accessor assembling it from the FN-7511 monitor + FN-7512/7513 recovery controller, and a best-effort additive `plannerOverseerState` enrichment on `GET /api/tasks` (mirrors the `branchProgress` pattern; never persisted, never fails the board load). Consumed by FN-7516's TaskCard.

View File

@@ -1560,6 +1560,42 @@ same settings self-healing already gates lifecycle mutation on.
**Downstream ownership (not this layer):** the dashboard UI/badges surfacing withheld state (FN-7515+),
a persisted intervention timeline (FN-7519), and richer run-audit/activity presentation (FN-7520).
### Planner overseer runtime-state exposure (FN-7531)
/*
FNXC:PlannerOversight 2026-07-04-00:00:
FN-7531 closes the data-exposure gap FN-7516 needed: the planner overseer's runtime state (FN-7511's
`PlannerOverseerMonitor` observations, FN-7512/FN-7513's `PlannerRecoveryController` attempt/pending-
confirmation registries) was engine-side and in-memory only. This task adds a lightweight, serializable
snapshot and surfaces it on the `GET /api/tasks` payload so task cards can render an indicator without
a second round-trip.
*/
`packages/core/src/planner-overseer-state.ts` declares the externally-meaningful five-value enum
`PLANNER_OVERSEER_STATES` (`idle | watching | steering | recovering | awaiting-confirmation`), the
serializable `PlannerOverseerRuntimeSnapshot` interface (`state`, `oversightLevel`, `watchedStage?`,
`signal?`, `attemptCount?`, `attemptLimit?`, `pendingConfirmation?`, `observedAt?` — `watchedStage`/
`signal` are kept as bare `string` so the engine's stage taxonomy is not pulled into `@fusion/core`),
and the pure, never-throw `derivePlannerOverseerState(input)`. Precedence: `oversightLevel === "off"`
or no active observation → `"idle"`; a pending confirmation → `"awaiting-confirmation"` (wins over an
in-flight recovery attempt); a recorded recovery attempt → `"recovering"`; `"steer"` level → `"steering"`;
otherwise (`observe`/`autonomous` watching, no attempts/pending) → `"watching"`.
`ProjectEngine.getPlannerOverseerRuntimeSnapshot(taskId)` (delegating to the pure
`assemblePlannerOverseerRuntimeSnapshot` helper in `packages/engine/src/planner-overseer-runtime-snapshot.ts`
for testability) reads the latest observation from `PlannerOverseerMonitor.getObservations(taskId)` plus
`PlannerRecoveryController.getPendingConfirmations(taskId)`/`getAttemptCount(taskId, stage)`, and returns
`null` (never throws) when there is no active observation for the task. `GET /tasks`
(`register-task-workflow-routes.ts`) additively enriches each returned task with `plannerOverseerState`
when the engine snapshot is non-null — best-effort, mirroring the existing `branchProgress` enrichment
block right beside it: any engine error is swallowed and the un-enriched list is returned, and tasks with
no active observation omit the field entirely (byte-identical payload). `Task.plannerOverseerState?` is a
transient field — engine-populated at serialization time, never persisted to the store or task.json.
FN-7516's `TaskCard` renders the badge/affordance; this task only provides the field, the engine
accessor, and (since FN-7516 had not yet landed consumption) a minimal guarded read plus a
memo-comparator entry so the card repaints on state change.
---
## 11) Multi-Project Architecture

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { derivePlannerOverseerState } from "../planner-overseer-state.js";
describe("derivePlannerOverseerState", () => {
it("returns idle when oversightLevel is off, regardless of other inputs", () => {
expect(
derivePlannerOverseerState({
oversightLevel: "off",
hasObservation: true,
attemptCount: 5,
pendingConfirmationCount: 2,
}),
).toBe("idle");
});
it("returns idle when there is no active observation", () => {
expect(
derivePlannerOverseerState({
oversightLevel: "autonomous",
hasObservation: false,
attemptCount: 3,
pendingConfirmationCount: 1,
}),
).toBe("idle");
});
it("returns awaiting-confirmation when a pending confirmation exists, winning over attempts", () => {
expect(
derivePlannerOverseerState({
oversightLevel: "autonomous",
hasObservation: true,
attemptCount: 2,
pendingConfirmationCount: 1,
}),
).toBe("awaiting-confirmation");
});
it("returns recovering when an attempt has been recorded and there is no pending confirmation", () => {
expect(
derivePlannerOverseerState({
oversightLevel: "autonomous",
hasObservation: true,
attemptCount: 1,
pendingConfirmationCount: 0,
}),
).toBe("recovering");
});
it("returns steering for an active steer-level observation with no attempts/pending", () => {
expect(
derivePlannerOverseerState({
oversightLevel: "steer",
hasObservation: true,
attemptCount: 0,
pendingConfirmationCount: 0,
}),
).toBe("steering");
});
it("returns watching for observe/autonomous active observations with no attempts/pending", () => {
expect(
derivePlannerOverseerState({
oversightLevel: "observe",
hasObservation: true,
}),
).toBe("watching");
expect(
derivePlannerOverseerState({
oversightLevel: "autonomous",
hasObservation: true,
}),
).toBe("watching");
});
it("never throws on undefined optional inputs", () => {
expect(() =>
derivePlannerOverseerState({
oversightLevel: "autonomous",
hasObservation: true,
attemptCount: undefined,
pendingConfirmationCount: undefined,
}),
).not.toThrow();
});
});

View File

@@ -443,6 +443,13 @@ export {
type PlannerRecoveryDecision,
type DecidePlannerRecoveryInput,
} from "./planner-recovery.js";
export {
PLANNER_OVERSEER_STATES,
derivePlannerOverseerState,
type PlannerOverseerState,
type PlannerOverseerRuntimeSnapshot,
type DerivePlannerOverseerStateInput,
} from "./planner-overseer-state.js";
export {
classifyPlannerActionSideEffect,
requiresPlannerConfirmation,

View File

@@ -0,0 +1,78 @@
import type { PlannerOversightLevel } from "./types.js";
/**
* FNXC:PlannerOversight 2026-07-04-00:00:
* FN-7531 exposes the planner overseer's engine-side, in-memory runtime state
* (from FN-7511's `PlannerOverseerMonitor` observations and FN-7512/FN-7513's
* `PlannerRecoveryController` attempt/confirmation registries) to the
* dashboard. This module declares the externally-meaningful, serializable
* state enum and the pure derivation function that is the seam FN-7516's
* `TaskCard` badge consumes — it does NOT change the monitor, the recovery
* controller, the oversight-level resolution, the confirmation gates, or the
* human-control safeguards (those remain owned by FN-7511–FN-7514).
*
* `watchedStage`/`signal` are intentionally typed as bare `string` (not the
* engine's `OverseerWatchedStage`/`OverseerObservationSignal` unions) so the
* engine's stage taxonomy is not pulled into `@fusion/core`.
*/
export const PLANNER_OVERSEER_STATES = ["idle", "watching", "steering", "recovering", "awaiting-confirmation"] as const;
export type PlannerOverseerState = (typeof PLANNER_OVERSEER_STATES)[number];
/**
* A transient, serializable snapshot of the planner overseer's current
* runtime state for one task. Engine-populated at `GET /api/tasks`
* serialization time (mirroring the additive `branchProgress` board-payload
* convention) — never persisted to the store or task.json.
*/
export interface PlannerOverseerRuntimeSnapshot {
state: PlannerOverseerState;
oversightLevel: PlannerOversightLevel;
watchedStage?: string;
signal?: string;
attemptCount?: number;
attemptLimit?: number;
pendingConfirmation?: boolean;
observedAt?: number;
}
/** Pure input the state derivation reads — no engine types, no side effects. */
export interface DerivePlannerOverseerStateInput {
oversightLevel: PlannerOversightLevel;
hasObservation: boolean;
attemptCount?: number;
pendingConfirmationCount?: number;
}
/**
* FNXC:PlannerOversight 2026-07-04-00:00:
* Pure, deterministic, never-throw mapping from the overseer's current
* inputs to exactly one of the five `PlannerOverseerState` values. Checked
* in this precedence order:
* 1. `oversightLevel === "off"` OR no active observation → `"idle"`.
* 2. A pending confirmation exists → `"awaiting-confirmation"` (wins over
* an in-flight recovery attempt — a human decision is blocking).
* 3. A recovery attempt has been recorded → `"recovering"`.
* 4. `oversightLevel === "steer"` → `"steering"`.
* 5. Otherwise (`observe`/`autonomous` watching with no attempts/pending)
* → `"watching"`.
*/
export function derivePlannerOverseerState(input: DerivePlannerOverseerStateInput): PlannerOverseerState {
const oversightLevel = input?.oversightLevel;
const hasObservation = Boolean(input?.hasObservation);
const attemptCount = input?.attemptCount ?? 0;
const pendingConfirmationCount = input?.pendingConfirmationCount ?? 0;
if (oversightLevel === "off" || !hasObservation) {
return "idle";
}
if (pendingConfirmationCount > 0) {
return "awaiting-confirmation";
}
if (attemptCount > 0) {
return "recovering";
}
if (oversightLevel === "steer") {
return "steering";
}
return "watching";
}

View File

@@ -1,4 +1,5 @@
import type { InReviewStallSignal } from "./in-review-stall.js";
import type { PlannerOverseerRuntimeSnapshot } from "./planner-overseer-state.js";
import type { ModelPricing } from "./model-pricing.js";
import type { InReviewStalledSignal } from "./in-review-stalled.js";
import type { StalePausedReviewSignal } from "./stale-paused-review.js";
@@ -2473,6 +2474,17 @@ export interface Task {
* "inherit workflow default" — see `resolveEffectivePlannerOversightLevel` in
* workflow-settings-resolver.ts for precedence. */
plannerOversightLevel?: PlannerOversightLevel;
/**
* FNXC:PlannerOversight 2026-07-04-00:00:
* FN-7531 transient, engine-populated snapshot of the planner overseer's
* current runtime state (idle/watching/steering/recovering/awaiting-
* confirmation), assembled from the FN-7511 `PlannerOverseerMonitor` +
* FN-7512/FN-7513 `PlannerRecoveryController` registries. Attached
* best-effort to the `GET /api/tasks` payload (mirroring the additive
* `branchProgress` board-payload convention) — NEVER written to the
* store or task.json. Consumed by FN-7516's `TaskCard` badge.
*/
plannerOverseerState?: PlannerOverseerRuntimeSnapshot;
/** Explicitly assigned agent ID for task-agent linking. Distinct from Agent.taskId active execution state. */
assignedAgentId?: string;
/** Per-task node override. When set, this task routes to the specified node instead of the project's default node. Undefined means use the project default. Use empty string to explicitly clear. */

View File

@@ -715,7 +715,13 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
areTaskBadgeInfosEqual(previousTask.issueInfo, nextTask.issueInfo) &&
// FNXC:GitHubTracking 2026-07-01-00:00: Context-menu tracking actions depend on githubTracking.enabled, so memoized cards must repaint when a PATCH enables tracking and remove the now-ineligible menu item.
JSON.stringify(previousTask.githubTracking ?? null) === JSON.stringify(nextTask.githubTracking ?? null) &&
JSON.stringify(previousTask.gitlabTracking ?? null) === JSON.stringify(nextTask.gitlabTracking ?? null)
JSON.stringify(previousTask.gitlabTracking ?? null) === JSON.stringify(nextTask.gitlabTracking ?? null) &&
// FNXC:PlannerOversight 2026-07-04-00:00: FN-7531 exposes the transient, engine-populated
// `plannerOverseerState` snapshot on the board payload; repaint the card whenever the
// overseer state changes (idle/watching/steering/recovering/awaiting-confirmation) so a
// consumer's badge stays live. FN-7516 owns the visual affordance/design; this task only
// provides a minimal, type-safe, guarded read.
JSON.stringify(previousTask.plannerOverseerState ?? null) === JSON.stringify(nextTask.plannerOverseerState ?? null)
);
}
@@ -2506,6 +2512,25 @@ function TaskCardComponent({
})}
</span>
)}
{/*
FNXC:PlannerOversight 2026-07-04-00:00:
FN-7531 provides `task.plannerOverseerState` (transient, engine-populated on the
board payload) plus a repaint-correct memo comparator; FN-7516 owns the styled
badge/design and surface-by-surface rendering. This is a minimal, type-safe,
guarded read only — nothing renders for an absent field or the "idle" state.
*/}
{task.plannerOverseerState && task.plannerOverseerState.state !== "idle" && (
<span
className="card-status-badge card-planner-overseer-state"
title={t("tasks.plannerOverseerStateTitle", "Planner overseer: {{state}}", {
state: task.plannerOverseerState.state,
})}
data-testid="planner-overseer-state-badge"
data-planner-overseer-state={task.plannerOverseerState.state}
>
{task.plannerOverseerState.state}
</span>
)}
{showStalledReview && stalledReview && (
<span
className="card-status-badge card-status-badge--in-review stalled-review"

View File

@@ -260,6 +260,35 @@ describe("TaskCard", () => {
expect(__test_areTaskCardPropsEqual(base as any, withGitLab as any)).toBe(false);
});
it("repaints the memoized card when plannerOverseerState changes, and renders nothing when absent", () => {
const idleTask = makeTask({ plannerOverseerState: undefined });
const watchingTask = makeTask({
plannerOverseerState: {
state: "watching",
oversightLevel: "autonomous",
watchedStage: "executor",
signal: "progressing",
attemptCount: 0,
attemptLimit: 3,
pendingConfirmation: false,
observedAt: 1700000000000,
},
});
expect(
__test_areTaskCardPropsEqual({ task: idleTask } as any, { task: watchingTask } as any),
).toBe(false);
expect(
__test_areTaskCardPropsEqual({ task: watchingTask } as any, { task: watchingTask } as any),
).toBe(true);
const { rerender } = render(<TaskCard task={idleTask} onOpenDetail={noop} addToast={noop} />);
expect(screen.queryByTestId("planner-overseer-state-badge")).not.toBeInTheDocument();
rerender(<TaskCard task={watchingTask} onOpenDetail={noop} addToast={noop} />);
expect(screen.getByTestId("planner-overseer-state-badge")).toBeInTheDocument();
});
it("shows an Answer-questions button when awaiting user input and opens the workflow tab", async () => {
const onOpenDetailWithTab = vi.fn();
render(

View File

@@ -0,0 +1,114 @@
// @vitest-environment node
//
// FN-7531: HTTP-level coverage for the additive `plannerOverseerState`
// enrichment on `GET /tasks`. Mirrors the `branchProgress` enrichment
// contract: attach when the engine snapshot accessor returns a non-null
// snapshot, omit entirely (byte-identical payload) otherwise, and never
// fail the board load even when the accessor throws.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TaskStore } from "@fusion/core";
import type { ProjectEngine } from "@fusion/engine";
import { createApiRoutes } from "../../routes.js";
import { request as REQUEST } from "../../test-request.js";
describe("GET /tasks — plannerOverseerState enrichment", () => {
let store: TaskStore;
let rootDir: string;
let globalDir: string;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "planner-overseer-state-root-"));
globalDir = mkdtempSync(join(tmpdir(), "planner-overseer-state-global-"));
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
});
afterEach(() => {
store.close();
rmSync(rootDir, { recursive: true, force: true });
rmSync(globalDir, { recursive: true, force: true });
});
function buildApp(engine: Partial<ProjectEngine> | undefined): express.Express {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, engine ? { engine: engine as unknown as ProjectEngine } : undefined));
return app;
}
it("attaches plannerOverseerState when the engine snapshot accessor returns a snapshot", async () => {
const task = await store.createTask({ description: "watched task" });
const snapshot = {
state: "watching" as const,
oversightLevel: "autonomous" as const,
watchedStage: "executor",
signal: "progressing",
attemptCount: 0,
attemptLimit: 3,
pendingConfirmation: false,
observedAt: 1700000000000,
};
const engineStub: Partial<ProjectEngine> = {
getTaskStore: () => store,
getPlannerOverseerRuntimeSnapshot: (taskId: string) => (taskId === task.id ? snapshot : null),
};
const app = buildApp(engineStub);
const res = await REQUEST(app, "GET", "/api/tasks");
expect(res.status).toBe(200);
const found = (res.body as Array<Record<string, unknown>>).find((t) => t.id === task.id);
expect(found?.plannerOverseerState).toEqual(snapshot);
});
it("omits plannerOverseerState entirely (no key) when the accessor returns null", async () => {
const task = await store.createTask({ description: "idle task" });
const engineStub: Partial<ProjectEngine> = {
getTaskStore: () => store,
getPlannerOverseerRuntimeSnapshot: () => null,
};
const app = buildApp(engineStub);
const res = await REQUEST(app, "GET", "/api/tasks");
expect(res.status).toBe(200);
const found = (res.body as Array<Record<string, unknown>>).find((t) => t.id === task.id);
expect(found).toBeDefined();
expect(found && "plannerOverseerState" in found).toBe(false);
});
it("returns 200 with the un-enriched list when the accessor throws (board load never fails)", async () => {
const task = await store.createTask({ description: "throwing task" });
const engineStub: Partial<ProjectEngine> = {
getTaskStore: () => store,
getPlannerOverseerRuntimeSnapshot: () => {
throw new Error("boom");
},
};
const app = buildApp(engineStub);
const res = await REQUEST(app, "GET", "/api/tasks");
expect(res.status).toBe(200);
const found = (res.body as Array<Record<string, unknown>>).find((t) => t.id === task.id);
expect(found).toBeDefined();
expect(found && "plannerOverseerState" in found).toBe(false);
});
it("returns 200 with the un-enriched list when no engine is present at all", async () => {
const task = await store.createTask({ description: "no engine task" });
const app = buildApp(undefined);
const res = await REQUEST(app, "GET", "/api/tasks");
expect(res.status).toBe(200);
const found = (res.body as Array<Record<string, unknown>>).find((t) => t.id === task.id);
expect(found).toBeDefined();
expect(found && "plannerOverseerState" in found).toBe(false);
});
});

View File

@@ -859,7 +859,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
router.get("/tasks", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { store: scopedStore, engine } = await getProjectContext(req);
const limit = typeof req.query.limit === "string" ? Number.parseInt(req.query.limit, 10) : undefined;
const offset = typeof req.query.offset === "string" ? Number.parseInt(req.query.offset, 10) : undefined;
const q = typeof req.query.q === "string" ? req.query.q.trim() : undefined;
@@ -909,6 +909,27 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
// Branch-progress enrichment is best-effort and must never fail the
// board load — fall through with the un-enriched task list.
}
// FNXC:PlannerOversight 2026-07-04-00:00:
// FN-7531 additively attaches the transient `plannerOverseerState`
// snapshot for each task with an active planner-overseer observation,
// mirroring the `branchProgress` enrichment block directly above: it
// is best-effort, never fails the board load on any engine error, and
// omits the field entirely (rather than attaching `null`) for tasks
// with no active observation — the payload stays byte-identical for
// those tasks. Consumed by FN-7516's TaskCard badge.
try {
if (engine && tasks.length > 0) {
tasks = tasks.map((task) => {
const plannerOverseerState = engine.getPlannerOverseerRuntimeSnapshot(task.id);
return plannerOverseerState ? { ...task, plannerOverseerState } : task;
});
}
} catch {
// Planner-overseer-state enrichment is best-effort and must never
// fail the board load — fall through with the un-enriched task list.
}
res.json(tasks);
} catch (err: unknown) {
if (err instanceof ApiError) {

View File

@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import { assemblePlannerOverseerRuntimeSnapshot } from "../planner-overseer-runtime-snapshot.js";
import type { OverseerStageObservation, OverseerWatchedStage } from "../planner-overseer.js";
function observation(overrides: Partial<OverseerStageObservation> = {}): OverseerStageObservation {
return {
taskId: "FN-1",
stage: "executor" as OverseerWatchedStage,
signal: "progressing",
oversightLevel: "autonomous",
observedAt: 1700000000000,
reason: "test",
sources: [],
...overrides,
};
}
function fakeMonitor(observations: OverseerStageObservation[]) {
return {
getObservations: (taskId: string) => (taskId === "FN-1" ? observations : []),
};
}
function fakeController(opts: { pending?: { status?: string }[]; attempts?: Record<string, number> } = {}) {
return {
getPendingConfirmations: () => opts.pending ?? [],
getAttemptCount: (_taskId: string, stage: string) => opts.attempts?.[stage] ?? 0,
};
}
describe("assemblePlannerOverseerRuntimeSnapshot", () => {
it("returns null when there is no observation for the task", () => {
const monitor = fakeMonitor([]);
const controller = fakeController();
expect(assemblePlannerOverseerRuntimeSnapshot("FN-1", monitor, controller)).toBeNull();
});
it("returns null when the monitor is undefined", () => {
expect(assemblePlannerOverseerRuntimeSnapshot("FN-1", undefined, fakeController())).toBeNull();
});
it("returns a watching snapshot for an active observation with no attempts/pending", () => {
const obs = observation({ oversightLevel: "autonomous", stage: "reviewer" as OverseerWatchedStage, signal: "stuck" });
const snapshot = assemblePlannerOverseerRuntimeSnapshot("FN-1", fakeMonitor([obs]), fakeController());
expect(snapshot).toMatchObject({
state: "watching",
oversightLevel: "autonomous",
watchedStage: "reviewer",
signal: "stuck",
attemptCount: 0,
pendingConfirmation: false,
observedAt: 1700000000000,
});
expect(snapshot?.attemptLimit).toBeGreaterThan(0);
});
it("returns a steering snapshot for an active steer-level observation", () => {
const obs = observation({ oversightLevel: "steer" });
const snapshot = assemblePlannerOverseerRuntimeSnapshot("FN-1", fakeMonitor([obs]), fakeController());
expect(snapshot?.state).toBe("steering");
});
it("returns a recovering snapshot with attemptCount/attemptLimit when attempts are recorded", () => {
const obs = observation({ stage: "executor" as OverseerWatchedStage });
const controller = fakeController({ attempts: { executor: 2 } });
const snapshot = assemblePlannerOverseerRuntimeSnapshot("FN-1", fakeMonitor([obs]), controller);
expect(snapshot).toMatchObject({ state: "recovering", attemptCount: 2 });
expect(snapshot?.attemptLimit).toBeGreaterThan(0);
});
it("returns an awaiting-confirmation snapshot with pendingConfirmation:true when a confirmation is pending", () => {
const obs = observation();
const controller = fakeController({ pending: [{ status: "pending" }] });
const snapshot = assemblePlannerOverseerRuntimeSnapshot("FN-1", fakeMonitor([obs]), controller);
expect(snapshot).toMatchObject({ state: "awaiting-confirmation", pendingConfirmation: true });
});
it("uses the latest observation when the ring buffer has multiple entries", () => {
const older = observation({ signal: "progressing", observedAt: 1 });
const latest = observation({ signal: "failed", observedAt: 2 });
const snapshot = assemblePlannerOverseerRuntimeSnapshot("FN-1", fakeMonitor([older, latest]), fakeController());
expect(snapshot?.signal).toBe("failed");
expect(snapshot?.observedAt).toBe(2);
});
it("never throws — a throwing monitor/controller degrades to null", () => {
const throwingMonitor = {
getObservations: () => {
throw new Error("boom");
},
};
expect(assemblePlannerOverseerRuntimeSnapshot("FN-1", throwingMonitor, fakeController())).toBeNull();
const throwingController = {
getPendingConfirmations: () => {
throw new Error("boom");
},
getAttemptCount: () => 0,
};
expect(
assemblePlannerOverseerRuntimeSnapshot("FN-1", fakeMonitor([observation()]), throwingController),
).toBeNull();
});
});

View File

@@ -647,6 +647,14 @@ export {
type OverseerLogStore,
type PlannerOverseerMonitorOptions,
} from "./planner-overseer.js";
// FN-7531: re-export the core planner-overseer state types for engine consumers
// (e.g. `ProjectEngine.getPlannerOverseerRuntimeSnapshot`).
export {
PLANNER_OVERSEER_STATES,
derivePlannerOverseerState,
type PlannerOverseerState,
type PlannerOverseerRuntimeSnapshot,
} from "@fusion/core";
export {
PlannerRecoveryController,
type PlannerRecoveryContext,

View File

@@ -0,0 +1,67 @@
import { derivePlannerOverseerState, PLANNER_RECOVERY_MAX_ATTEMPTS, type PlannerOverseerRuntimeSnapshot } from "@fusion/core";
import type { OverseerStageObservation } from "./planner-overseer.js";
/**
* FNXC:PlannerOversight 2026-07-04-00:00:
* FN-7531 narrow read-only shapes for the two engine subsystems this
* assembly helper reads. Kept as minimal structural interfaces (rather than
* importing the concrete `PlannerOverseerMonitor`/`PlannerRecoveryController`
* classes) so unit tests can pass focused in-memory fakes without
* constructing the full engine.
*/
export interface PlannerOverseerObservationSource {
getObservations(taskId: string): OverseerStageObservation[];
}
export interface PlannerRecoveryRegistrySource {
getPendingConfirmations(taskId: string): { status?: string }[];
getAttemptCount(taskId: string, stage: string): number;
}
/**
* FNXC:PlannerOversight 2026-07-04-00:00:
* Pure(-ish) assembly of the transient `PlannerOverseerRuntimeSnapshot` for
* one task from the FN-7511 monitor's latest observation plus the
* FN-7512/FN-7513 controller's attempt/pending-confirmation registries.
* Read-only: never mutates either source. Returns `null` when there is no
* active observation for the task (nothing to show on the card). Never
* throws — this is `ProjectEngine.getPlannerOverseerRuntimeSnapshot`'s
* delegate, called from the hot `GET /api/tasks` path, so any subsystem
* error degrades to `null` rather than risking the board load.
*/
export function assemblePlannerOverseerRuntimeSnapshot(
taskId: string,
monitor: PlannerOverseerObservationSource | undefined,
recoveryController: PlannerRecoveryRegistrySource | undefined,
): PlannerOverseerRuntimeSnapshot | null {
try {
const observations = monitor?.getObservations(taskId);
const observation = observations && observations.length > 0 ? observations[observations.length - 1] : undefined;
if (!observation) {
return null;
}
const pendingConfirmationCount = recoveryController?.getPendingConfirmations(taskId).length ?? 0;
const attemptCount = recoveryController?.getAttemptCount(taskId, observation.stage) ?? 0;
const state = derivePlannerOverseerState({
oversightLevel: observation.oversightLevel,
hasObservation: true,
attemptCount,
pendingConfirmationCount,
});
return {
state,
oversightLevel: observation.oversightLevel,
watchedStage: observation.stage,
signal: observation.signal,
attemptCount,
attemptLimit: PLANNER_RECOVERY_MAX_ATTEMPTS,
pendingConfirmation: pendingConfirmationCount > 0,
observedAt: observation.observedAt,
};
} catch {
return null;
}
}

View File

@@ -12,8 +12,10 @@ import type {
ResearchModelSettings,
ResearchSynthesisRequest,
ResearchSynthesisResult,
PlannerOverseerRuntimeSnapshot,
} 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 { execFile } from "node:child_process";
import { promisify } from "node:util";
import { InProcessRuntime } from "./runtimes/in-process-runtime.js";
@@ -1026,6 +1028,21 @@ export class ProjectEngine {
return this.plannerRecoveryController;
}
/**
* FNXC:PlannerOversight 2026-07-04-00:00:
* FN-7531 read-only accessor assembling the transient, serializable
* `PlannerOverseerRuntimeSnapshot` for one task from the FN-7511
* `PlannerOverseerMonitor`'s latest observation plus the FN-7512/FN-7513
* `PlannerRecoveryController`'s attempt/pending-confirmation registries.
* Never mutates either subsystem, never throws (any failure degrades to
* `null` so a hot request path like `GET /api/tasks` is never put at
* risk), and returns `null` when there is no active observation for the
* task (nothing to show on the card).
*/
getPlannerOverseerRuntimeSnapshot(taskId: string): PlannerOverseerRuntimeSnapshot | null {
return assemblePlannerOverseerRuntimeSnapshot(taskId, this.plannerOverseer, this.plannerRecoveryController);
}
/**
* FNXC:PlannerOversight 2026-07-04-12:00:
* Concrete FN-7512 handler wiring — ONLY reuses existing mechanisms: