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:
7
.changeset/fn-7531-planner-overseer-state-exposure.md
Normal file
7
.changeset/fn-7531-planner-overseer-state-exposure.md
Normal 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.
|
||||||
@@ -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+),
|
**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).
|
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
|
## 11) Multi-Project Architecture
|
||||||
|
|||||||
85
packages/core/src/__tests__/planner-overseer-state.test.ts
Normal file
85
packages/core/src/__tests__/planner-overseer-state.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -443,6 +443,13 @@ export {
|
|||||||
type PlannerRecoveryDecision,
|
type PlannerRecoveryDecision,
|
||||||
type DecidePlannerRecoveryInput,
|
type DecidePlannerRecoveryInput,
|
||||||
} from "./planner-recovery.js";
|
} from "./planner-recovery.js";
|
||||||
|
export {
|
||||||
|
PLANNER_OVERSEER_STATES,
|
||||||
|
derivePlannerOverseerState,
|
||||||
|
type PlannerOverseerState,
|
||||||
|
type PlannerOverseerRuntimeSnapshot,
|
||||||
|
type DerivePlannerOverseerStateInput,
|
||||||
|
} from "./planner-overseer-state.js";
|
||||||
export {
|
export {
|
||||||
classifyPlannerActionSideEffect,
|
classifyPlannerActionSideEffect,
|
||||||
requiresPlannerConfirmation,
|
requiresPlannerConfirmation,
|
||||||
|
|||||||
78
packages/core/src/planner-overseer-state.ts
Normal file
78
packages/core/src/planner-overseer-state.ts
Normal 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";
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { InReviewStallSignal } from "./in-review-stall.js";
|
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 { ModelPricing } from "./model-pricing.js";
|
||||||
import type { InReviewStalledSignal } from "./in-review-stalled.js";
|
import type { InReviewStalledSignal } from "./in-review-stalled.js";
|
||||||
import type { StalePausedReviewSignal } from "./stale-paused-review.js";
|
import type { StalePausedReviewSignal } from "./stale-paused-review.js";
|
||||||
@@ -2473,6 +2474,17 @@ export interface Task {
|
|||||||
* "inherit workflow default" — see `resolveEffectivePlannerOversightLevel` in
|
* "inherit workflow default" — see `resolveEffectivePlannerOversightLevel` in
|
||||||
* workflow-settings-resolver.ts for precedence. */
|
* workflow-settings-resolver.ts for precedence. */
|
||||||
plannerOversightLevel?: PlannerOversightLevel;
|
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. */
|
/** Explicitly assigned agent ID for task-agent linking. Distinct from Agent.taskId active execution state. */
|
||||||
assignedAgentId?: string;
|
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. */
|
/** 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. */
|
||||||
|
|||||||
@@ -715,7 +715,13 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
|||||||
areTaskBadgeInfosEqual(previousTask.issueInfo, nextTask.issueInfo) &&
|
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.
|
// 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.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>
|
</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 && (
|
{showStalledReview && stalledReview && (
|
||||||
<span
|
<span
|
||||||
className="card-status-badge card-status-badge--in-review stalled-review"
|
className="card-status-badge card-status-badge--in-review stalled-review"
|
||||||
|
|||||||
@@ -260,6 +260,35 @@ describe("TaskCard", () => {
|
|||||||
expect(__test_areTaskCardPropsEqual(base as any, withGitLab as any)).toBe(false);
|
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 () => {
|
it("shows an Answer-questions button when awaiting user input and opens the workflow tab", async () => {
|
||||||
const onOpenDetailWithTab = vi.fn();
|
const onOpenDetailWithTab = vi.fn();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -859,7 +859,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
|
|
||||||
router.get("/tasks", async (req, res) => {
|
router.get("/tasks", async (req, res) => {
|
||||||
try {
|
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 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 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;
|
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
|
// Branch-progress enrichment is best-effort and must never fail the
|
||||||
// board load — fall through with the un-enriched task list.
|
// 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);
|
res.json(tasks);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -647,6 +647,14 @@ export {
|
|||||||
type OverseerLogStore,
|
type OverseerLogStore,
|
||||||
type PlannerOverseerMonitorOptions,
|
type PlannerOverseerMonitorOptions,
|
||||||
} from "./planner-overseer.js";
|
} 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 {
|
export {
|
||||||
PlannerRecoveryController,
|
PlannerRecoveryController,
|
||||||
type PlannerRecoveryContext,
|
type PlannerRecoveryContext,
|
||||||
|
|||||||
67
packages/engine/src/planner-overseer-runtime-snapshot.ts
Normal file
67
packages/engine/src/planner-overseer-runtime-snapshot.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,10 @@ import type {
|
|||||||
ResearchModelSettings,
|
ResearchModelSettings,
|
||||||
ResearchSynthesisRequest,
|
ResearchSynthesisRequest,
|
||||||
ResearchSynthesisResult,
|
ResearchSynthesisResult,
|
||||||
|
PlannerOverseerRuntimeSnapshot,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { allowsAutoMergeProcessing, compareTasksByPriorityThenAgeAndId, getTaskHardMergeBlocker, isSharedBranchGroupMemberIntegration, isWorkspaceTask, normalizeMergerMode, resolveEffectivePlannerOversightLevel, resolveEffectiveSettings, resolveMaxAutoMergeRetries, sortTasksByPriorityThenAgeAndId } 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 { 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";
|
||||||
@@ -1026,6 +1028,21 @@ export class ProjectEngine {
|
|||||||
return this.plannerRecoveryController;
|
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:
|
* FNXC:PlannerOversight 2026-07-04-12:00:
|
||||||
* Concrete FN-7512 handler wiring — ONLY reuses existing mechanisms:
|
* Concrete FN-7512 handler wiring — ONLY reuses existing mechanisms:
|
||||||
|
|||||||
Reference in New Issue
Block a user