FN-7600: fix Nudge control stuck on periodic-observation copy when overseer is active

Attach the transient plannerOverseerState snapshot to the single-task detail route so the Nudge control reflects live overseer observation instead of always showing the periodic-observation message.

- GET /api/tasks/:id now best-effort attaches plannerOverseerState (mirrors the list route), never throwing on enrichment failure.
- TaskDetailModal reads overseerSnapshot from workingTask (merged full-detail object) instead of the raw task prop, so detail refetches via fetchTaskDetail (dependency chips, Documents view, logs, post-open refetch) no longer drop the snapshot.
- Added regression tests for the detail-route enrichment and the modal's Nudge-availability behavior.
- Added a patch changeset documenting the fix.

Files changed:
 .changeset/fn-7600-oversight-nudge-detail-snapshot.md             |   7 ++
 packages/dashboard/app/components/TaskDetailModal.tsx             |  14 ++-
 .../TaskDetailModal.oversight-controls.test.tsx                   | 131 +++++++++++++++++++++
 .../__tests__/tasks-planner-overseer-state.test.ts                |  95 +++++++++++++++
 packages/dashboard/src/routes/register-task-workflow-routes.ts    |  25 +++-
 5 files changed, 269 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-7600

Fusion-Task-Lineage: 500614d0-091a-461c-8e7b-329a7b791502

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-05 17:03:56 -07:00
parent e0f3d3d14c
commit 5b193d2d08
5 changed files with 269 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix the task-detail Nudge control staying disabled when the overseer is actively watching.
category: fix
dev: GET /api/tasks/:id now attaches the transient plannerOverseerState snapshot (mirrors the list route); TaskDetailModal reads the snapshot from workingTask so detail refetches no longer drop it.

View File

@@ -3183,7 +3183,19 @@ export function TaskDetailContent({
copy-only, selected via the already-computed `overseerHumanControlSuppressed`
/ `overseerActive` booleans below.
*/
const overseerSnapshot = task.plannerOverseerState ?? null;
/*
FNXC:PlannerOversight 2026-07-05-00:00:
FN-7600: this used to read `task.plannerOverseerState` — the transient
snapshot enrichment from `GET /api/tasks` (list) — but the modal is
frequently opened via `fetchTaskDetail` (dependency chips, Documents view,
logs, or the post-open detail refetch) where the parent `task` prop never
carries the snapshot, so `overseerActive`/`canNudgeOverseer` were almost
always false and Nudge showed the periodic-observation copy even while the
overseer was actively watching. `GET /api/tasks/:id` now attaches the same
snapshot (mirrors the list route), so read it from `workingTask` — the
full-detail-backed merged object — instead of the raw prop.
*/
const overseerSnapshot = workingTask.plannerOverseerState ?? null;
const overseerActive = Boolean(overseerSnapshot);
const isDoneOrArchivedColumn = task.column === "done" || task.column === "archived";
const isOverseerHumanReviewTerminal = task.column === "in-review" && !effectiveAutoMerge;

View File

@@ -407,6 +407,137 @@ describe("TaskDetailModal oversight controls", () => {
});
});
/*
FNXC:PlannerOversight 2026-07-05-00:00:
FN-7600 regression coverage: the modal previously read `overseerSnapshot` from
the raw `task` prop, which loses the snapshot whenever the modal is opened via
`fetchTaskDetail` (dependency chips, Documents view, logs) because those call
sites pass a slim `Task` (no `prompt` key) that never carries
`plannerOverseerState` — only the full-detail fetch response does. These
tests reproduce that exact path: a slim task prop with NO snapshot, plus a
mocked `fetchTaskDetail` resolving a full TaskDetail WITH an active snapshot,
and assert Nudge enables (helper absent) once the fetched detail lands — at
both the desktop inline site and the mobile overflow-menu site.
*/
describe("TaskDetailModal oversight controls — snapshot delivered via fetched full detail (FN-7600)", () => {
const originalInnerWidth = window.innerWidth;
beforeEach(async () => {
vi.clearAllMocks();
mockConfirm.mockResolvedValue(true);
const api = await import("../../api");
vi.mocked(api.fetchBoardWorkflows).mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} });
vi.mocked(api.fetchWorkflowSettingValues).mockResolvedValue({ stored: {}, effective: {}, defaults: {} });
vi.mocked(api.nudgeOverseer).mockResolvedValue({ applied: false, reason: "oversight-off" });
vi.mocked(api.stopOverseer).mockResolvedValue({ applied: true, reason: "stopped" });
vi.mocked(api.explainOverseer).mockResolvedValue({ snapshot: null });
});
afterEach(() => {
Object.defineProperty(window, "innerWidth", { value: originalInnerWidth, configurable: true });
});
function makeSlimTaskWithoutSnapshot(overrides: Record<string, unknown> = {}) {
// Omit `prompt`/`log`/`steps` so the modal treats this as a slim `Task`
// (not a `TaskDetail`) and triggers the `fetchTaskDetail` fetch-on-open
// path instead of using the prop directly as `fullDetail`.
const { prompt: _prompt, log: _log, steps: _steps, plannerOverseerState: _snap, ...task } = makeTask({
id: "FN-220",
column: "in-progress",
plannerOversightLevel: "autonomous",
...overrides,
});
return task;
}
it("desktop: enables Nudge and hides the disabled-reason helper once the fetched full detail carries an active snapshot", async () => {
const api = await import("../../api");
vi.mocked(api.fetchTaskDetail).mockResolvedValueOnce(makeTask({
id: "FN-220",
column: "in-progress",
plannerOversightLevel: "autonomous",
plannerOverseerState: activeSnapshot,
}));
render(
<TaskDetailModal
task={makeSlimTaskWithoutSnapshot() as any}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const nudgeBtn = await screen.findByTestId("detail-overseer-nudge");
await waitFor(() => {
expect(nudgeBtn).not.toBeDisabled();
});
expect(screen.queryByTestId("detail-overseer-nudge-disabled-reason")).not.toBeInTheDocument();
});
it("desktop: still shows the periodic-observation copy while the fetched full detail carries no snapshot", async () => {
const api = await import("../../api");
vi.mocked(api.fetchTaskDetail).mockResolvedValueOnce(makeTask({
id: "FN-221",
column: "in-progress",
plannerOversightLevel: "autonomous",
}));
render(
<TaskDetailModal
task={makeSlimTaskWithoutSnapshot({ id: "FN-221" }) as any}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const nudgeBtn = await screen.findByTestId("detail-overseer-nudge");
expect(nudgeBtn).toBeDisabled();
const reason = await screen.findByTestId("detail-overseer-nudge-disabled-reason");
expect(reason).toHaveTextContent("Nudge becomes available once the overseer is observing this task's current stage");
});
it("mobile: enables Nudge and hides the disabled-reason helper behind the overflow menu once the fetched full detail carries an active snapshot", async () => {
Object.defineProperty(window, "innerWidth", { value: 375, configurable: true });
const api = await import("../../api");
vi.mocked(api.fetchTaskDetail).mockResolvedValueOnce(makeTask({
id: "FN-222",
column: "in-progress",
plannerOversightLevel: "autonomous",
plannerOverseerState: activeSnapshot,
}));
render(
<TaskDetailModal
task={makeSlimTaskWithoutSnapshot({ id: "FN-222" }) as any}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const trigger = await screen.findByTestId("detail-oversight-menu-trigger");
fireEvent.click(trigger);
const nudgeBtn = await screen.findByTestId("detail-overseer-nudge");
await waitFor(() => {
expect(nudgeBtn).not.toBeDisabled();
});
expect(screen.queryByTestId("detail-overseer-nudge-disabled-reason")).not.toBeInTheDocument();
});
});
/*
* FNXC:PlannerOversight 2026-07-04-20:30 (FN-7558):
* FN-7521's original mobile suite asserted the oversight quick-controls

View File

@@ -112,3 +112,98 @@ describe("GET /tasks — plannerOverseerState enrichment", () => {
expect(found && "plannerOverseerState" in found).toBe(false);
});
});
// FN-7600: GET /tasks/:id (detail route) must attach the same transient
// `plannerOverseerState` snapshot as the list route above — the Task Detail
// modal's Overseer/Nudge controls read the snapshot from the full-detail
// payload, not the list payload, so the detail route previously never
// carried it and Nudge always showed the periodic-observation disabled copy.
describe("GET /tasks/:id — plannerOverseerState enrichment", () => {
let store: TaskStore;
let rootDir: string;
let globalDir: string;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "planner-overseer-state-detail-root-"));
globalDir = mkdtempSync(join(tmpdir(), "planner-overseer-state-detail-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/${task.id}`);
expect(res.status).toBe(200);
expect((res.body as Record<string, unknown>).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/${task.id}`);
expect(res.status).toBe(200);
expect("plannerOverseerState" in (res.body as Record<string, unknown>)).toBe(false);
});
it("returns 200 with the un-enriched task when the accessor throws (detail 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/${task.id}`);
expect(res.status).toBe(200);
expect("plannerOverseerState" in (res.body as Record<string, unknown>)).toBe(false);
});
it("returns 200 with the un-enriched task 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/${task.id}`);
expect(res.status).toBe(200);
expect("plannerOverseerState" in (res.body as Record<string, unknown>)).toBe(false);
});
});

View File

@@ -2933,11 +2933,32 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
// Get single task with prompt content
router.get("/tasks/:id", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { store: scopedStore, engine } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id, {
activityLogLimit: TASK_DETAIL_ACTIVITY_LOG_LIMIT,
});
res.json(trimTaskDetailActivityLog(task));
let enrichedTask = task;
// FNXC:PlannerOversight 2026-07-05-00:00:
// FN-7600: the Task Detail modal's Overseer/Nudge controls read
// `plannerOverseerState` from the merged full-detail object, but this
// detail route previously never attached it (only the list route did,
// per FN-7531 above) — so opening the modal via fetchTaskDetail
// (dependency chips, Documents view, logs, or the post-open detail
// refetch) always lost the snapshot and the Nudge button showed the
// periodic-observation disabled copy even when the overseer was
// actively watching. Mirror the list-route contract exactly: best-
// effort, never throws, and omits the key (not `null`) when the
// accessor returns no active observation.
try {
const plannerOverseerState = engine?.getPlannerOverseerRuntimeSnapshot(task.id);
if (plannerOverseerState) {
enrichedTask = { ...task, plannerOverseerState };
}
} catch {
// Planner-overseer-state enrichment is best-effort and must never
// fail the task-detail load — fall through with the un-enriched task.
}
res.json(trimTaskDetailActivityLog(enrichedTask));
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;