From 726cbf89cc11afcdd7e24d48835e9160bf141b86 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 4 Jul 2026 15:23:12 -0700 Subject: [PATCH] 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) --- ...fn-7531-planner-overseer-state-exposure.md | 7 ++ docs/architecture.md | 36 ++++++ .../__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 ++ .../dashboard/app/components/TaskCard.tsx | 27 ++++- .../components/__tests__/TaskCard.test.tsx | 29 +++++ .../tasks-planner-overseer-state.test.ts | 114 ++++++++++++++++++ .../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(-) create mode 100644 .changeset/fn-7531-planner-overseer-state-exposure.md create mode 100644 packages/core/src/__tests__/planner-overseer-state.test.ts create mode 100644 packages/core/src/planner-overseer-state.ts create mode 100644 packages/dashboard/src/routes/__tests__/tasks-planner-overseer-state.test.ts create mode 100644 packages/engine/src/__tests__/planner-overseer-runtime-snapshot.test.ts create mode 100644 packages/engine/src/planner-overseer-runtime-snapshot.ts diff --git a/.changeset/fn-7531-planner-overseer-state-exposure.md b/.changeset/fn-7531-planner-overseer-state-exposure.md new file mode 100644 index 0000000000..e47ffac7bc --- /dev/null +++ b/.changeset/fn-7531-planner-overseer-state-exposure.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index bde6f88015..bb0ae8134e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/packages/core/src/__tests__/planner-overseer-state.test.ts b/packages/core/src/__tests__/planner-overseer-state.test.ts new file mode 100644 index 0000000000..89b36ef1af --- /dev/null +++ b/packages/core/src/__tests__/planner-overseer-state.test.ts @@ -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(); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c97a106d62..5d5ce5e3ae 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/planner-overseer-state.ts b/packages/core/src/planner-overseer-state.ts new file mode 100644 index 0000000000..3bea80f36e --- /dev/null +++ b/packages/core/src/planner-overseer-state.ts @@ -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"; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e00612cb5e..6152b03b28 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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. */ diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 7d301a0cea..73f158170f 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -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({ })} )} + {/* + 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" && ( + + {task.plannerOverseerState.state} + + )} {showStalledReview && stalledReview && ( { 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(); + expect(screen.queryByTestId("planner-overseer-state-badge")).not.toBeInTheDocument(); + + rerender(); + 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( diff --git a/packages/dashboard/src/routes/__tests__/tasks-planner-overseer-state.test.ts b/packages/dashboard/src/routes/__tests__/tasks-planner-overseer-state.test.ts new file mode 100644 index 0000000000..f401b54d33 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/tasks-planner-overseer-state.test.ts @@ -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 | 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 = { + 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>).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 = { + 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>).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 = { + 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>).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>).find((t) => t.id === task.id); + expect(found).toBeDefined(); + expect(found && "plannerOverseerState" in found).toBe(false); + }); +}); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index bf29c8cb3b..4ee2a3847a 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -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) { diff --git a/packages/engine/src/__tests__/planner-overseer-runtime-snapshot.test.ts b/packages/engine/src/__tests__/planner-overseer-runtime-snapshot.test.ts new file mode 100644 index 0000000000..6d6e82a3f1 --- /dev/null +++ b/packages/engine/src/__tests__/planner-overseer-runtime-snapshot.test.ts @@ -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 { + 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 } = {}) { + 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(); + }); +}); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 61faa20af7..b202d6b4aa 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -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, diff --git a/packages/engine/src/planner-overseer-runtime-snapshot.ts b/packages/engine/src/planner-overseer-runtime-snapshot.ts new file mode 100644 index 0000000000..43b9fa103a --- /dev/null +++ b/packages/engine/src/planner-overseer-runtime-snapshot.ts @@ -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; + } +} diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index c82c924ebc..aaa2138307 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -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: