FN-8247: stop session advisor with oversight

Stop now persists session-advisor disablement and reflects inherited advisor state in task details.

- Persist an explicit advisor-off override and clear its live runtime when stopping oversight.
- Resolve workflow-level advisor defaults for task-detail icons and toggle behavior.
- Cover stop cleanup and desktop/mobile oversight state transitions with regression tests.
- Document the combined stop contract and add a patch changeset.

Files changed:
 .changeset/fn-8247-session-advisor-stop-and-icon.md       |   7 ++
 docs/dashboard-guide.md                            |   6 +-
 packages/dashboard/app/components/TaskDetailModal.tsx   |  86 +++++++++++------
 packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-controls.test.tsx    | 104 +++++++++++++++++++++
 packages/dashboard/app/components/__tests__/TaskDetailModal.oversight-mobile.test.tsx      |  62 ++++++++++++
 packages/engine/src/__tests__/project-engine-stop-overseer-session-advisor.test.ts |  62 ++++++++++++
 packages/engine/src/project-engine.ts              |  13 ++-
 7 files changed, 310 insertions(+), 30 deletions(-)

Fusion-Task-Id: FN-8247

Fusion-Task-Lineage: dcb67af7-9a36-4b25-bafa-84722fe158a0

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-17 16:20:10 -07:00
parent 669cb7cc56
commit 62f121e0c9
7 changed files with 310 additions and 30 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Stop now disables the session advisor, and its on/off state correctly updates the task-detail oversight icon.
category: fix
dev: stopOverseerTask persists sessionAdvisorEnabled:false and clears the advisor runtime; TaskDetailModal effective-state derivation now honors the resolver's workflow-legacy tier (plannerOverseerAdvisorEnabled).

View File

@@ -244,8 +244,10 @@ FNXC:PlannerOversight 2026-07-05-00:00: FN-7604 collapses the desktop inline clu
FNXC:QuickAddActionRow 2026-07-16-16:00: FN-8194 aligns task-detail metadata actions with Quick Add: attach, GitHub tracking, Oversight (Eye), Priority, then Fast.
FNXC:PlannerOversight 2026-07-17-13:18: FN-8233 makes the task-detail Oversight trigger communicate the combined effective overseer state: it uses Eye when the oversight level is active or the Session advisor is enabled, and EyeOff only when both are off. -->
- The task detail modal's inline meta-controls cluster follows Quick Add order: compact **Attach file** (`data-testid="detail-inline-attach"`), eligible-task **GitHub tracking** toggle (`data-testid="detail-inline-github-toggle"`), **Oversight**, Priority, and Execution mode. Attach opens the existing task attachment picker; GitHub toggles the existing tracking setting and is omitted for GitLab-tracked or non-editable tasks. The Oversight control exposes planner-overseer actions behind a compact **"Oversight" overflow-menu button** (`data-testid="detail-oversight-menu-trigger"`, `Eye` when the oversight level is active or the Session advisor is enabled, otherwise `EyeOff`, `aria-haspopup="menu"`) on every viewport, desktop and mobile alike. Clicking the trigger opens a `role="menu"` popover (mirroring the existing move-action dropdown pattern) containing: a **quick oversight-level select** (`data-testid="detail-oversight-level-select"`) that writes the per-task `plannerOversightLevel` override (Off/Observe/Steer/Autonomous recovery) or clears it back to the inherited workflow/project default via an "Inherit" option; a **manual nudge** button (`data-testid="detail-overseer-nudge"`) that asks the overseer to inject one guidance-only steering comment into the currently watched stage right now (never a merge/PR/destructive action), disabled when the overseer is off/inactive or the task is user-paused/done/archived/`autoMerge:false` in-review; a **stop oversight** button (`data-testid="detail-overseer-stop"`) that disables active oversight for the task (confirmation-gated), hidden once oversight is already off; and an **explain current action** button (`data-testid="detail-overseer-explain"`) that toggles a small read-only panel (`data-testid="detail-overseer-explain-panel"`) showing the overseer's watched stage, reason, last action, and attempt count/limit, with a non-empty-shell inactive state when the overseer is not currently watching. All three action controls call the `POST /tasks/:id/overseer/nudge`, `POST /tasks/:id/overseer/stop`, and `GET /tasks/:id/overseer/explain` routes. The trigger itself is withheld entirely when none of the menu's controls would render (no empty-shell overflow button for the unresolved-default case).
FNXC:PlannerOversight 2026-07-17-13:18: FN-8233 makes the task-detail Oversight trigger communicate the combined effective overseer state: it uses Eye when the oversight level is active or the Session advisor is enabled, and EyeOff only when both are off.
FNXC:PlannerOversight 2026-07-18-12:00: FN-8247 requires Stop to disable both lifecycle oversight and the independently-enabled session advisor, including its live runtime, so project/workflow defaults cannot leave advisor comments running after an operator stops it. -->
- The task detail modal's inline meta-controls cluster follows Quick Add order: compact **Attach file** (`data-testid="detail-inline-attach"`), eligible-task **GitHub tracking** toggle (`data-testid="detail-inline-github-toggle"`), **Oversight**, Priority, and Execution mode. Attach opens the existing task attachment picker; GitHub toggles the existing tracking setting and is omitted for GitLab-tracked or non-editable tasks. The Oversight control exposes planner-overseer actions behind a compact **"Oversight" overflow-menu button** (`data-testid="detail-oversight-menu-trigger"`, `Eye` when the oversight level is active or the Session advisor is enabled, otherwise `EyeOff`, `aria-haspopup="menu"`) on every viewport, desktop and mobile alike. Clicking the trigger opens a `role="menu"` popover (mirroring the existing move-action dropdown pattern) containing: a **quick oversight-level select** (`data-testid="detail-oversight-level-select"`) that writes the per-task `plannerOversightLevel` override (Off/Observe/Steer/Autonomous recovery) or clears it back to the inherited workflow/project default via an "Inherit" option; a **manual nudge** button (`data-testid="detail-overseer-nudge"`) that asks the overseer to inject one guidance-only steering comment into the currently watched stage right now (never a merge/PR/destructive action), disabled when the overseer is off/inactive or the task is user-paused/done/archived/`autoMerge:false` in-review; a **Stop** button (`data-testid="detail-overseer-stop"`) that disables both active lifecycle oversight and the session advisor for the task (confirmation-gated), including its live advisor runtime; it is hidden once oversight is already off; and an **explain current action** button (`data-testid="detail-overseer-explain"`) that toggles a small read-only panel (`data-testid="detail-overseer-explain-panel"`) showing the overseer's watched stage, reason, last action, and attempt count/limit, with a non-empty-shell inactive state when the overseer is not currently watching. All three action controls call the `POST /tasks/:id/overseer/nudge`, `POST /tasks/:id/overseer/stop`, and `GET /tasks/:id/overseer/explain` routes. The trigger itself is withheld entirely when none of the menu's controls would render (no empty-shell overflow button for the unresolved-default case).
<!-- FNXC:PlannerOversight 2026-07-04-20:30: FN-7546 clarifies the cluster above — operators reported the buttons were unlabeled and looked inert, with only a hover title explaining why. Adds a visible group label and an always-visible disabled-reason line, and makes Explain always openable since it never mutates anything. -->
- The controls inside the Oversight menu carry a visible, non-interactive **`"Overseer controls"` group label** (`data-testid="detail-oversight-controls-label"`) so Nudge/Stop/Explain read as an identifiable cluster rather than unlabeled entries; the label is gated by the same `(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff` condition as the buttons, so it never renders when oversight is Off/unresolved (opening the menu in that state shows only the level select). When **Nudge** is disabled, an always-visible helper line (`data-testid="detail-overseer-nudge-disabled-reason"`) states the reason in-DOM (mirroring the existing hover `title`) instead of relying on a mouse-hover tooltip alone. **Explain** is read-only and non-mutating, so it is never disabled purely because the overseer is inactive — clicking it always opens/closes the panel, which shows the overseer's live state when watching or an informative "not currently watching this task" message otherwise. Nudge's mutating enablement rule (`canNudgeOverseer`, including the human-control suppression cases) and Stop's confirmation dialog are unchanged.
<!-- FNXC:PlannerOversight 2026-07-04-19:00: FN-7545 originally introduced this overflow menu for mobile only, collapsing the level-select/nudge/stop/explain controls behind the trigger below the 768px breakpoint while desktop kept an inline cluster.

View File

@@ -20,6 +20,7 @@ import {
getErrorMessage,
} from "@fusion/core";
import { resolveEffectivePlannerOversightLevel } from "../../../core/src/workflow-settings-resolver";
import { resolveTaskSessionAdvisorEnabled } from "../../../core/src/session-advisor";
import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical";
import { getRevertOfId, findOpenUndoTaskForSource } from "../utils/taskRevert";
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
@@ -444,7 +445,12 @@ in-flight de-dup exactly (see packages/dashboard/app/components/TaskCard.tsx)
rather than re-deriving precedence locally — `resolveEffectivePlannerOversightLevel`
remains the single resolver both surfaces call.
*/
const modalWorkflowOversightEffectiveCache = new Map<string, PlannerOversightLevel | undefined>();
interface ModalWorkflowOversightSettings {
level: PlannerOversightLevel | undefined;
sessionAdvisorEnabled: boolean;
}
const modalWorkflowOversightEffectiveCache = new Map<string, ModalWorkflowOversightSettings>();
const modalWorkflowOversightInflight = new Map<string, Promise<void>>();
function getModalWorkflowOversightCacheKey(workflowId: string, projectId?: string): string {
@@ -455,20 +461,23 @@ function isPlannerOversightLevelValue(value: unknown): value is PlannerOversight
return typeof value === "string" && (PLANNER_OVERSIGHT_LEVELS as readonly string[]).includes(value);
}
async function loadModalWorkflowOversightEffectiveLevel(workflowId: string, projectId: string | undefined): Promise<PlannerOversightLevel | undefined> {
async function loadModalWorkflowOversightEffectiveLevel(workflowId: string, projectId: string | undefined): Promise<ModalWorkflowOversightSettings> {
const key = getModalWorkflowOversightCacheKey(workflowId, projectId);
if (modalWorkflowOversightEffectiveCache.has(key)) {
return modalWorkflowOversightEffectiveCache.get(key);
return modalWorkflowOversightEffectiveCache.get(key) ?? { level: undefined, sessionAdvisorEnabled: false };
}
let inflight = modalWorkflowOversightInflight.get(key);
if (!inflight) {
inflight = fetchWorkflowSettingValues(workflowId, projectId)
.then((payload) => {
const raw = payload.effective?.plannerOversightLevel;
modalWorkflowOversightEffectiveCache.set(key, isPlannerOversightLevelValue(raw) ? raw : undefined);
modalWorkflowOversightEffectiveCache.set(key, {
level: isPlannerOversightLevelValue(raw) ? raw : undefined,
sessionAdvisorEnabled: payload.effective?.plannerOverseerAdvisorEnabled === true,
});
})
.catch(() => {
modalWorkflowOversightEffectiveCache.set(key, undefined);
modalWorkflowOversightEffectiveCache.set(key, { level: undefined, sessionAdvisorEnabled: false });
})
.finally(() => {
modalWorkflowOversightInflight.delete(key);
@@ -476,7 +485,7 @@ async function loadModalWorkflowOversightEffectiveLevel(workflowId: string, proj
modalWorkflowOversightInflight.set(key, inflight);
}
await inflight;
return modalWorkflowOversightEffectiveCache.get(key);
return modalWorkflowOversightEffectiveCache.get(key) ?? { level: undefined, sessionAdvisorEnabled: false };
}
const OVERSIGHT_LEVEL_LABEL: Record<PlannerOversightLevel, string> = {
@@ -957,22 +966,26 @@ export function TaskDetailContent({
fetch above); a known per-task override renders synchronously regardless.
*/
const workflowIdForOversight = taskWorkflowBadge?.id;
const [workflowOversightState, setWorkflowOversightState] = useState<{ level: PlannerOversightLevel | undefined; resolved: boolean }>({ level: undefined, resolved: false });
const [workflowOversightState, setWorkflowOversightState] = useState<ModalWorkflowOversightSettings & { resolved: boolean }>({
level: undefined,
sessionAdvisorEnabled: false,
resolved: false,
});
useEffect(() => {
if (!workflowIdForOversight) {
setWorkflowOversightState({ level: undefined, resolved: true });
setWorkflowOversightState({ level: undefined, sessionAdvisorEnabled: false, resolved: true });
return;
}
const workflowId = workflowIdForOversight;
const key = getModalWorkflowOversightCacheKey(workflowId, projectId);
if (modalWorkflowOversightEffectiveCache.has(key)) {
setWorkflowOversightState({ level: modalWorkflowOversightEffectiveCache.get(key), resolved: true });
setWorkflowOversightState({ ...modalWorkflowOversightEffectiveCache.get(key)!, resolved: true });
return;
}
setWorkflowOversightState({ level: undefined, resolved: false });
setWorkflowOversightState({ level: undefined, sessionAdvisorEnabled: false, resolved: false });
let cancelled = false;
void loadModalWorkflowOversightEffectiveLevel(workflowId, projectId).then((level) => {
if (!cancelled) setWorkflowOversightState({ level, resolved: true });
void loadModalWorkflowOversightEffectiveLevel(workflowId, projectId).then((settings) => {
if (!cancelled) setWorkflowOversightState({ ...settings, resolved: true });
});
return () => {
cancelled = true;
@@ -2071,29 +2084,44 @@ export function TaskDetailContent({
/*
FNXC:PlannerOversight 2026-07-14-18:11:
Per-task session advisor (LLM overseer agent). Unset inherits project
sessionAdvisorEnabledByDefault; explicit boolean forces on/off. Toggle writes
an override when it differs from the project default and clears to null when
it matches (same inheritance model as Quick Add eye).
Per-task session advisor (LLM overseer agent). Unset inherits project and
workflow settings; explicit boolean forces on/off. Toggle writes an override
when it differs from the full inherited state and clears to null only when it
matches that same shared resolver result.
*/
const projectSessionAdvisorDefault = settings?.sessionAdvisorEnabledByDefault === true;
const hasSessionAdvisorOverride = typeof workingTask.sessionAdvisorEnabled === "boolean";
const effectiveSessionAdvisorEnabled = hasSessionAdvisorOverride
? workingTask.sessionAdvisorEnabled === true
: projectSessionAdvisorDefault;
/*
FNXC:PlannerOversight 2026-07-18-12:00:
FN-8247 requires the task-detail Eye/EyeOff affordances to use the shared
session-advisor precedence contract. The workflow legacy setting travels in
the existing workflow-settings fetch, so the UI cannot silently omit it or
retain a divergent local resolver.
*/
const effectiveSessionAdvisorEnabled = resolveTaskSessionAdvisorEnabled(
workingTask,
settings,
workflowOversightState.sessionAdvisorEnabled,
).enabled;
const inheritedSessionAdvisorEnabled = resolveTaskSessionAdvisorEnabled(
{ sessionAdvisorEnabled: undefined },
settings,
workflowOversightState.sessionAdvisorEnabled,
).enabled;
const handleSessionAdvisorToggle = useCallback(async () => {
setIsSavingSessionAdvisor(true);
try {
const nextEnabled = !effectiveSessionAdvisorEnabled;
const nextValue: boolean | null =
nextEnabled === projectSessionAdvisorDefault ? null : nextEnabled;
nextEnabled === inheritedSessionAdvisorEnabled ? null : nextEnabled;
const updatedTask = await updateTask(task.id, { sessionAdvisorEnabled: nextValue }, projectId);
onTaskUpdated?.(updatedTask);
addToast(
nextValue === null
? t("taskDetail.sessionAdvisor.reset", "Session advisor follows project default ({{default}})", {
default: projectSessionAdvisorDefault
? t("taskDetail.sessionAdvisor.reset", "Session advisor follows inherited defaults ({{default}})", {
default: inheritedSessionAdvisorEnabled
? t("tasks.sessionAdvisorDefaultOn", "on")
: t("tasks.sessionAdvisorDefaultOff", "off"),
})
@@ -2117,7 +2145,7 @@ export function TaskDetailContent({
}
}, [
effectiveSessionAdvisorEnabled,
projectSessionAdvisorDefault,
inheritedSessionAdvisorEnabled,
task.id,
projectId,
onTaskUpdated,
@@ -2190,11 +2218,15 @@ export function TaskDetailContent({
FN-7517 stop-oversight control. Disables active oversight for this task
(per-task override -> "off") — a lightweight `confirm(...)` guards it since
it's a disabling action, matching the PROMPT's guidance for this control.
FNXC:PlannerOversight 2026-07-18-12:00:
FN-8247 extends Stop to disable the independently-enabled session advisor,
so confirmation and success copy must tell operators it stops both systems.
*/
const handleStopOverseer = useCallback(async () => {
const shouldStop = await confirm({
title: t("taskDetail.oversight.stopTitle", "Stop planner oversight?"),
message: t("taskDetail.oversight.stopMessage", "This disables active planner oversight for this task (sets oversight level to Off)."),
message: t("taskDetail.oversight.stopMessage", "This disables planner oversight and the session advisor for this task."),
});
if (!shouldStop) return;
@@ -2204,7 +2236,7 @@ export function TaskDetailContent({
if (result.task) {
onTaskUpdated?.(result.task);
}
addToast(t("taskDetail.oversight.stopped", "Planner oversight stopped for this task"), "success");
addToast(t("taskDetail.oversight.stopped", "Planner oversight and session advisor stopped for this task"), "success");
} catch (err) {
addToast(t("taskDetail.updateFailed", "Failed to update {{id}}: {{error}}", { id: task.id, error: getErrorMessage(err) }), "error");
} finally {
@@ -4347,7 +4379,7 @@ export function TaskDetailContent({
)
: t(
"taskDetail.sessionAdvisor.inheritTitle",
"Session advisor {{state}} (follows project default)",
"Session advisor {{state}} (follows inherited defaults)",
{
state: effectiveSessionAdvisorEnabled
? t("tasks.sessionAdvisorDefaultOn", "on")
@@ -4366,7 +4398,7 @@ export function TaskDetailContent({
})}
{hasSessionAdvisorOverride
? ""
: t("taskDetail.sessionAdvisor.inheritSuffix", " (project)")}
: t("taskDetail.sessionAdvisor.inheritSuffix", " (inherited)")}
</span>
</button>
{!oversightIsOff && (

View File

@@ -81,6 +81,110 @@ describe("TaskDetailModal oversight controls", () => {
expect(trigger.querySelector('[data-testid="more-vertical-icon"]')).not.toBeInTheDocument();
});
it("uses the workflow legacy advisor tier for the shared detail trigger and toggle", async () => {
const api = await import("../../api");
vi.mocked(api.fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
sessionAdvisorEnabledByDefault: false,
} as any);
vi.mocked(api.fetchBoardWorkflows).mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "WF-advisor",
workflows: [{ id: "WF-advisor", name: "Advisor workflow" } as any],
taskWorkflowIds: { "FN-8247-workflow": "WF-advisor" },
});
vi.mocked(api.fetchWorkflowSettingValues).mockResolvedValueOnce({
stored: {},
effective: { plannerOversightLevel: "off", plannerOverseerAdvisorEnabled: true },
defaults: {},
});
render(
<TaskDetailModal
task={makeTask({ id: "FN-8247-workflow", column: "in-progress", plannerOversightLevel: "off", sessionAdvisorEnabled: undefined })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const trigger = await screen.findByTestId("detail-oversight-menu-trigger");
await waitFor(() => {
expect(trigger.querySelector('[data-testid="eye-icon"]')).toBeInTheDocument();
});
await openOversightMenu();
const toggle = await screen.findByTestId("detail-session-advisor-toggle");
expect(toggle).toHaveAttribute("aria-pressed", "true");
expect(toggle.querySelector('[data-testid="eye-icon"]')).toBeInTheDocument();
});
it("gives an explicit false override precedence and repaints when it is toggled on", async () => {
const api = await import("../../api");
let currentTask = makeTask({
id: "FN-8247-explicit-off",
column: "in-progress",
plannerOversightLevel: "off",
sessionAdvisorEnabled: false,
});
vi.mocked(api.fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
sessionAdvisorEnabledByDefault: false,
} as any);
vi.mocked(api.fetchBoardWorkflows).mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "WF-advisor-explicit-off",
workflows: [{ id: "WF-advisor-explicit-off", name: "Advisor workflow" } as any],
taskWorkflowIds: { [currentTask.id]: "WF-advisor-explicit-off" },
});
vi.mocked(api.fetchWorkflowSettingValues).mockResolvedValueOnce({
stored: {},
effective: { plannerOversightLevel: "off", plannerOverseerAdvisorEnabled: true },
defaults: {},
});
vi.mocked(api.updateTask).mockImplementation(async (_id, patch) => {
currentTask = makeTask({ ...currentTask, ...patch });
return currentTask as any;
});
let rerenderModal: (nextTask: typeof currentTask) => void;
const renderModal = (nextTask: typeof currentTask) => (
<TaskDetailModal
task={nextTask}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onTaskUpdated={(updatedTask) => rerenderModal(updatedTask as typeof currentTask)}
addToast={noop}
/>
);
const rendered = render(renderModal(currentTask));
rerenderModal = (nextTask) => rendered.rerender(renderModal(nextTask));
const trigger = await screen.findByTestId("detail-oversight-menu-trigger");
await waitFor(() => {
expect(trigger.querySelector('[data-testid="eye-off-icon"]')).toBeInTheDocument();
});
await openOversightMenu();
const toggle = await screen.findByTestId("detail-session-advisor-toggle");
expect(toggle).toHaveAttribute("aria-pressed", "false");
fireEvent.click(toggle);
await waitFor(() => {
expect(api.updateTask).toHaveBeenCalledWith(currentTask.id, { sessionAdvisorEnabled: null }, undefined);
expect(trigger.querySelector('[data-testid="eye-icon"]')).toBeInTheDocument();
expect(screen.getByTestId("detail-session-advisor-toggle")).toHaveAttribute("aria-pressed", "true");
});
});
it("updates the trigger icon for oversight-level and session-advisor predicate changes", async () => {
const api = await import("../../api");
const mockUpdate = vi.mocked(api.updateTask);

View File

@@ -72,6 +72,68 @@ describe("TaskDetailModal oversight controls — mobile overflow menu", () => {
setViewportWidth(DESKTOP_WIDTH);
});
it("repaints the shared trigger and menu toggle after disabling a workflow-enabled advisor", async () => {
const api = await import("../../api");
let currentTask = makeTask({
id: "FN-8247-mobile",
column: "in-progress",
plannerOversightLevel: "off",
sessionAdvisorEnabled: undefined,
});
vi.mocked(api.fetchSettings).mockResolvedValueOnce({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
sessionAdvisorEnabledByDefault: false,
} as any);
vi.mocked(api.fetchBoardWorkflows).mockResolvedValueOnce({
flagEnabled: true,
defaultWorkflowId: "WF-advisor-mobile",
workflows: [{ id: "WF-advisor-mobile", name: "Advisor workflow" } as any],
taskWorkflowIds: { [currentTask.id]: "WF-advisor-mobile" },
});
vi.mocked(api.fetchWorkflowSettingValues).mockResolvedValueOnce({
stored: {},
effective: { plannerOversightLevel: "off", plannerOverseerAdvisorEnabled: true },
defaults: {},
});
vi.mocked(api.updateTask).mockImplementation(async (_id, patch) => {
currentTask = makeTask({ ...currentTask, ...patch });
return currentTask as any;
});
let rerenderModal: (nextTask: typeof currentTask) => void;
const renderModal = (nextTask: typeof currentTask) => (
<TaskDetailModal
task={nextTask}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onTaskUpdated={(updatedTask) => rerenderModal(updatedTask as typeof currentTask)}
addToast={noop}
/>
);
const rendered = render(renderModal(currentTask));
rerenderModal = (nextTask) => rendered.rerender(renderModal(nextTask));
const trigger = await screen.findByTestId("detail-oversight-menu-trigger");
await waitFor(() => {
expect(trigger.querySelector('[data-testid="eye-icon"]')).toBeInTheDocument();
});
fireEvent.click(trigger);
const toggle = await screen.findByTestId("detail-session-advisor-toggle");
expect(toggle).toHaveAttribute("aria-pressed", "true");
fireEvent.click(toggle);
await waitFor(() => {
expect(api.updateTask).toHaveBeenCalledWith(currentTask.id, { sessionAdvisorEnabled: false }, undefined);
expect(trigger.querySelector('[data-testid="eye-off-icon"]')).toBeInTheDocument();
expect(screen.getByTestId("detail-session-advisor-toggle")).toHaveAttribute("aria-pressed", "false");
});
});
it("renders a single overflow trigger (no inline action buttons) when oversight actions are available", async () => {
render(
<TaskDetailModal

View File

@@ -0,0 +1,62 @@
/*
* FNXC:PlannerOversight 2026-07-18-12:00:
* FN-8247 keeps the Stop contract at the ProjectEngine boundary: lifecycle
* oversight and the session advisor have independent enablement axes, so Stop
* must persist an explicit advisor-off override and clear live advisor state.
*/
import { describe, expect, it, vi } from "vitest";
import { resolveTaskSessionAdvisorEnabled, type Task } from "@fusion/core";
import { ProjectEngine } from "../project-engine.js";
function makeTask(sessionAdvisorEnabled?: boolean): Task {
return {
id: "FN-8247",
title: "Stop session advisor",
description: "",
column: "in-progress",
status: "in-progress",
priority: "normal",
createdAt: "2026-07-18T00:00:00.000Z",
updatedAt: "2026-07-18T00:00:00.000Z",
sessionAdvisorEnabled,
} as Task;
}
describe("ProjectEngine.stopOverseerTask session advisor cleanup", () => {
it.each([
["inherit", undefined, {}, false],
["project-default-on", undefined, { sessionAdvisorEnabledByDefault: true }, false],
["workflow-advisor-on", undefined, {}, true],
["explicit-on", true, {}, false],
])("forces %s advisor state off and tears down its runtime", async (_source, sessionAdvisorEnabled, settings, workflowAdvisorEnabled) => {
let task = makeTask(sessionAdvisorEnabled);
const clear = vi.fn();
const cursor = new Map([[task.id, 4]]);
const updateTask = vi.fn(async (_id: string, patch: Partial<Task>) => {
task = { ...task, ...patch } as Task;
return task;
});
const engine = Object.create(ProjectEngine.prototype) as ProjectEngine;
Object.assign(engine as object, {
runtime: { getTaskStore: () => ({ getTask: async () => task, updateTask }) },
sessionAdvisor: { clear },
sessionAdvisorLogCursor: cursor,
plannerObservationEmitDedup: new Map(),
plannerEscalationEmitDedup: new Set(),
});
const result = await engine.stopOverseerTask(task.id);
expect(updateTask).toHaveBeenCalledWith(task.id, {
plannerOversightLevel: "off",
sessionAdvisorEnabled: false,
});
expect(result.task?.sessionAdvisorEnabled).toBe(false);
expect(clear).toHaveBeenCalledWith(task.id);
expect(cursor.has(task.id)).toBe(false);
expect(resolveTaskSessionAdvisorEnabled(task, settings, workflowAdvisorEnabled)).toMatchObject({
enabled: false,
source: "task",
});
});
});

View File

@@ -1554,6 +1554,12 @@ export class ProjectEngine {
* recovery-controller ring buffers for this task (mirrors the poll's
* leave-in-flight cleanup). This is a user action; it never mutates task
* lifecycle/column and never performs a merge/PR/destructive side effect.
*
* FNXC:PlannerOversight 2026-07-18-12:00:
* FN-8247 requires Stop to disable BOTH lifecycle oversight and the
* independently-gated session advisor. Persisting explicit false wins over
* project/workflow defaults, then immediate runtime teardown prevents a
* live advisor from spending or injecting after the operator stops it.
*/
async stopOverseerTask(taskId: string): Promise<{ applied: boolean; reason: string; task?: Task }> {
try {
@@ -1568,9 +1574,14 @@ export class ProjectEngine {
this.plannerRecoveryController?.recordManualAction(taskId, observation.stage, "manual_stop");
}
const updatedTask = await store.updateTask(taskId, { plannerOversightLevel: "off" });
const updatedTask = await store.updateTask(taskId, {
plannerOversightLevel: "off",
sessionAdvisorEnabled: false,
});
this.plannerOverseer?.clear(taskId);
this.plannerRecoveryController?.clear(taskId);
this.sessionAdvisor?.clear(taskId);
this.sessionAdvisorLogCursor.delete(taskId);
// FN-7551: release the observation/escalation emission-dedup state too,
// so if oversight is later re-enabled for this task, the first new
// observation/escalation emits rather than staying suppressed by stale