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:
7
.changeset/fn-7600-oversight-nudge-detail-snapshot.md
Normal file
7
.changeset/fn-7600-oversight-nudge-detail-snapshot.md
Normal 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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user