diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 073f6298aa..7a18f30292 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -2190,6 +2190,7 @@ function AppInner() { initialTab={initialTab} projectId={currentProject?.id} tasks={tasks} + globalPaused={globalPaused} active={popupVisible} embedded onOpenDetail={popOutTaskDetailForCurrentView} @@ -2215,6 +2216,7 @@ function AppInner() { projectId={currentProject?.id} tasks={tasks} columnFlagsByTaskId={footerColumnFlagsByTaskId} + globalPaused={globalPaused} projects={projects} currentProject={currentProject} addToast={addToast} diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index 7854047b3d..0023789ee2 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -46,6 +46,7 @@ interface AppModalsProps { tasks: Task[]; /* Per-task lifecycle traits, forwarded to Task Detail's blocker fan-out. */ columnFlagsByTaskId?: ReadonlyMap; + globalPaused?: boolean; projects: ProjectInfo[]; currentProject: ProjectInfo | null; addToast: (message: string, type?: ToastType) => void; @@ -108,6 +109,7 @@ export function AppModals({ projectId, tasks, columnFlagsByTaskId, + globalPaused = false, projects, currentProject, addToast, @@ -313,6 +315,7 @@ export function AppModals({ projectId={projectId} tasks={tasks} columnFlagsByTaskId={columnFlagsByTaskId} + globalPaused={globalPaused} onClose={closeDetailWithNav} onOpenDetail={openDetailTaskWithNav} mobileHeaderMode={modalManager.detailTaskOrigin === "list-mobile" ? "back" : "close"} diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 253e03bc25..f73cc1acbc 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -24,7 +24,7 @@ import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/project import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection"; import { getRunningOptionalGateBadge, getRunningWorkflowStepLabel, getUnifiedTaskProgress } from "../utils/taskProgress"; import { isTaskAgentActive } from "../utils/taskActivity"; -import { getTaskStatusBadgeLabel, hasTaskStatusBadge , type TaskStatusBadgeContext} from "../utils/taskStatusBadgeLabel"; +import { getTaskStatusBadgeLabel, hasTaskStatusBadge, isTaskPlanningActive, type TaskStatusBadgeContext } from "../utils/taskStatusBadgeLabel"; import { isReviewBudgetExhaustedApproval } from "../utils/reviewBudgetApproval"; import { useConfirm } from "../hooks/useConfirm"; import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete"; @@ -2468,7 +2468,7 @@ export function ListView({ if (!previous || previous.id !== detail.id) { return previous; } - return mergeTaskSnapshot(previous, detail); + return mergeTaskSnapshot(previous, detail, { fullSnapshot: true }); }); }) .catch(() => { @@ -3126,6 +3126,7 @@ export function ListView({ && !visualStatus && Boolean(task.recentAgentActivityAt) && isAgentActive; + const isLivePlanning = isTaskPlanningActive(task, { globalPaused }); const hasStatus = (hasTaskStatusBadge(visualStatus) && visualStatus !== "queued") || isTransientPlannerActive; const isReviewBudgetExhausted = isReviewBudgetExhaustedApproval(task); @@ -3148,7 +3149,7 @@ export function ListView({ */ const statusBadgeLabel = isReviewBudgetExhausted ? t("tasks.reviewBudgetExhausted", "Review budget exhausted") - : isTransientPlannerActive + : isLivePlanning || isTransientPlannerActive ? t("tasks.statusPlanning", "Planning") : getTaskStatusLabel(visualStatus ?? "", t, showOptionalGateBadge ? undefined : getRunningWorkflowStepLabel(task), { idle: !isAgentActive, overlapBlockedBy: task.overlapBlockedBy ?? null }); const hasDependencies = Boolean(task.dependencies && task.dependencies.length > 0); @@ -3398,6 +3399,7 @@ export function ListView({ && !visualStatus && Boolean(task.recentAgentActivityAt) && isAgentActive; + const isLivePlanning = isTaskPlanningActive(task, { globalPaused }); const showStatusBadge = (hasTaskStatusBadge(visualStatus) && visualStatus !== "queued") || isTransientPlannerActive; /* @@ -3415,7 +3417,7 @@ export function ListView({ // gate badge — see the grouped-card render path above. const statusBadgeLabel = isReviewBudgetExhausted ? t("tasks.reviewBudgetExhausted", "Review budget exhausted") - : isTransientPlannerActive + : isLivePlanning || isTransientPlannerActive ? t("tasks.statusPlanning", "Planning") : getTaskStatusLabel(visualStatus ?? "", t, showOptionalGateBadge ? undefined : getRunningWorkflowStepLabel(task), { idle: !isAgentActive, overlapBlockedBy: task.overlapBlockedBy ?? null }); const isDragging = draggingTaskId === task.id; @@ -3629,6 +3631,7 @@ export function ListView({ task={selectedTaskSnapshot} projectId={projectId} tasks={tasks} + globalPaused={globalPaused} embedded onRequestClose={closeEmbeddedTaskDetail} onOpenDetail={handleEmbeddedOpenDetail} diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 43d38a7aee..46c0103514 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -51,7 +51,7 @@ import { getRunningOptionalGateBadge, getRunningWorkflowStepLabel, getUnifiedTas import { ACTIVE_STATUSES, isTaskAgentActive } from "../utils/taskActivity"; import { getPrBadgeModifierClass } from "../utils/prBadgeClass"; import { getTotalAgentActiveMs, getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming"; -import { getTaskStatusBadgeLabel, type TaskStatusBadgeContext, hasTaskStatusBadge } from "../utils/taskStatusBadgeLabel"; +import { getTaskStatusBadgeLabel, type TaskStatusBadgeContext, hasTaskStatusBadge, isTaskPlanningActive } from "../utils/taskStatusBadgeLabel"; import { isReviewBudgetExhaustedApproval, isTaskAwaitingPlanApproval } from "../utils/reviewBudgetApproval"; import { canStartPrFeedbackAddressing, getTaskPrimaryPrInfo } from "../utils/prFeedback"; import type { ToastType } from "../hooks/useToast"; @@ -3377,6 +3377,7 @@ function TaskCardComponent({ && !visualStatus && Boolean(task.recentAgentActivityAt) && isAgentActive; + const isLivePlanning = isTaskPlanningActive(task, { globalPaused }); /* FNXC:TaskStatusBadge 2026-08-01-07:20 (operator: queued belongs with Planning and Ready): Queued used to render as a clock-and-text footer tag, separating the waiting state from the @@ -3407,7 +3408,7 @@ function TaskCardComponent({ ? t("tasks.awaitingApproval", "Awaiting Approval") : isAwaitingInput ? t("tasks.needsInput", "Needs input") - : isTransientPlannerActive + : isLivePlanning || isTransientPlannerActive ? t("tasks.statusPlanning", "Planning") /* FNXC:TaskStatusBadge 2026-08-01-03:20 (operator: ONE queued badge family, no dupes): @@ -3621,7 +3622,7 @@ function TaskCardComponent({ ) : undefined } - aria-label={isTransientPlannerActive ? t("tasks.statusPlanning", "Planning") : undefined} + aria-label={isLivePlanning || isTransientPlannerActive ? t("tasks.statusPlanning", "Planning") : undefined} data-testid={isAwaitingApproval ? `card-awaiting-approval-${task.id}` : showQueuedToPlanBadge ? `card-queued-to-plan-${task.id}` : undefined} data-awaiting-approval-reason={isAwaitingApproval ? (task.awaitingApprovalReason ?? "manual") : undefined} > diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 5f1731a8fe..21e25ec44a 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -87,6 +87,7 @@ import { findInReviewStallLogEntry, IN_REVIEW_STALL_LOG_REGEX } from "../utils/f import { getTaskLogEntryAction, getTaskLogEntryOutcome } from "../utils/taskLogEntryDisplay"; import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; import { isReviewBudgetExhaustedApproval, isTaskAwaitingPlanApproval } from "../utils/reviewBudgetApproval"; +import { getTaskStatusBadgeLabel, hasTaskStatusBadge, isTaskPlanningActive } from "../utils/taskStatusBadgeLabel"; import { ACTIVE_STATUSES, resolveEffectiveExecutor, resolveEffectivePlanning, resolveEffectiveValidator, type ModelSelection } from "./effective-model-resolution"; import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel } from "./TaskContextMenu"; import type { TaskContextMenuColumnFlags, TaskContextMenuColumnMetadata } from "./TaskContextMenu"; @@ -406,6 +407,8 @@ export interface TaskDetailModalProps { addToast: (message: string, type?: ToastType) => void; prAuthAvailable?: boolean; autoMergeEnabled?: boolean; + /** Prevent transient planner activity from presenting as live during an engine-wide pause. */ + globalPaused?: boolean; onOpenWorkflowEditor?: () => void; /** Open the modal with this tab active instead of the default done-aware landing view. */ initialTab?: TabId; @@ -786,6 +789,7 @@ export function TaskDetailContent({ addToast, prAuthAvailable, autoMergeEnabled: autoMergeEnabledProp, + globalPaused = false, onOpenWorkflowEditor, /** * FNXC:TaskDetailActivityFirst 2026-06-30-23:59: @@ -898,7 +902,7 @@ export function TaskDetailContent({ requestTaskDetail(task.id, projectId) .then((detail) => { if (!cancelled && detailRequestGenerationRef.current === requestGeneration) { - setFullDetail((previous) => previous?.id === detail.id ? mergeTaskSnapshot(previous, detail) : detail); + setFullDetail((previous) => previous?.id === detail.id ? mergeTaskSnapshot(previous, detail, { fullSnapshot: true }) : detail); setDetailLoading(false); } }) @@ -937,6 +941,18 @@ export function TaskDetailContent({ : task.overlapBlockedBy === undefined ? fullDetail.overlapBlockedBy : task.overlapBlockedBy, } as TaskDetail) : ({ ...task, prompt: "" } as TaskDetail); + /* + FNXC:TaskStatusConsistency 2026-08-05-04:30: + Detail hosts consume the same reconciled snapshot as board and list cards. Show live planning as + Planning, while an idle `needs-replan` remains Queued to revise; this prevents an open modal from + presenting a different lifecycle than the card that launched it. + */ + const taskStatusBadgeLabel = isTaskPlanningActive(workingTask, { globalPaused }) + ? t("tasks.statusPlanning", "Planning") + : getTaskStatusBadgeLabel(workingTask.status, t, undefined, { + idle: true, + overlapBlockedBy: workingTask.overlapBlockedBy ?? null, + }); const originalTaskPrompt = workingTask.description ?? ""; const hasOriginalTaskPrompt = originalTaskPrompt.trim().length > 0; /* @@ -3636,7 +3652,7 @@ export function TaskDetailContent({ const handleWorkflowReconciled = useCallback(async () => { try { const detail = await fetchTaskDetail(task.id, projectId); - setFullDetail((previous) => previous?.id === detail.id ? mergeTaskSnapshot(previous, detail) : detail); + setFullDetail((previous) => previous?.id === detail.id ? mergeTaskSnapshot(previous, detail, { fullSnapshot: true }) : detail); onTaskUpdated?.(detail); } catch { // Best-effort refresh; the SSE stream will catch the board up regardless. @@ -3645,7 +3661,7 @@ export function TaskDetailContent({ const handleBranchGroupReset = useCallback(async () => { const detail = await fetchTaskDetail(task.id, projectId); - setFullDetail((previous) => previous?.id === detail.id ? mergeTaskSnapshot(previous, detail) : detail); + setFullDetail((previous) => previous?.id === detail.id ? mergeTaskSnapshot(previous, detail, { fullSnapshot: true }) : detail); onTaskUpdated?.(detail); }, [task.id, projectId, onTaskUpdated]); @@ -4545,6 +4561,11 @@ export function TaskDetailContent({ {workflowColumnDisplayName ?? columnLabel(workingTask.column)} + {hasTaskStatusBadge(workingTask.status) && ( + + {taskStatusBadgeLabel} + + )}
{!isEditing && canEdit && ( diff --git a/packages/dashboard/app/components/__tests__/AppModals.test.tsx b/packages/dashboard/app/components/__tests__/AppModals.test.tsx index 41a7aef381..81445c98bf 100644 --- a/packages/dashboard/app/components/__tests__/AppModals.test.tsx +++ b/packages/dashboard/app/components/__tests__/AppModals.test.tsx @@ -1,25 +1,40 @@ -import { useEffect as reactUseEffect } from "react"; +import { createElement, useEffect as reactUseEffect } from "react"; import type { ReactElement, ReactNode } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { fireEvent, render as rtlRender, screen, waitFor, type RenderOptions } from "@testing-library/react"; +import { act, fireEvent, render as rtlRender, screen, waitFor, type RenderOptions } from "@testing-library/react"; import { AppModals } from "../AppModals"; +import { TaskCard } from "../TaskCard"; +import { ListView } from "../ListView"; +import { useTasks } from "../../hooks/useTasks"; +import * as taskApi from "../../api"; import { NavigationHistoryProvider, useNavigationHistory } from "../../hooks/useNavigationHistory"; import type { ModalManager } from "../../hooks/useModalManager"; import type { Toast } from "../../hooks/useToast"; -// Mock the modals to avoid rendering all of them -const mockTaskDetailModalProps = vi.fn(); -vi.mock("../TaskDetailModal", () => ({ - TaskDetailModal: (props: any) => { - mockTaskDetailModalProps(props); - return ( - - ); - }, +const sseSubscriptions = vi.hoisted(() => [] as Array<{ + url: string; + events?: Record void>; +}>); +vi.mock("../../sse-bus", () => ({ + subscribeSse: vi.fn((url: string, options: { events?: Record void> }) => { + sseSubscriptions.push({ url, events: options.events }); + return vi.fn(); + }), })); +// Spy through the real detail host so lifecycle assertions exercise its rendered state. +const mockTaskDetailModalProps = vi.fn(); +vi.mock("../TaskDetailModal", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + TaskDetailModal: (props: React.ComponentProps) => { + mockTaskDetailModalProps(props); + return createElement(actual.TaskDetailModal, props); + }, + }; +}); + const mockSettingsModalProps = vi.fn(); vi.mock("../SettingsModal", () => ({ SettingsModal: (props: any) => { @@ -163,8 +178,8 @@ vi.mock("../../hooks/useProjectActions", () => ({ }), })); -// Mock @fusion/core types -vi.mock("@fusion/core", () => ({})); +// Preserve runtime core constants because the real TaskDetailModal now renders in this suite. +vi.mock("@fusion/core", async (importOriginal) => importOriginal()); // Mock ModalErrorBoundary vi.mock("../ErrorBoundary", () => ({ @@ -180,6 +195,52 @@ function render(ui: ReactElement, options?: Omit) { return rtlRender(ui, { wrapper: NavigationWrapper, ...options }); } +const integrationNoop = vi.fn(); +const integrationAsyncNoop = vi.fn(async () => ({})); + +/** Drives the same hook-owned board row into every production status renderer. */ +function PlanningStatusConvergenceHarness({ detailTask, modalManager, settings }: { + detailTask: Record; + modalManager: ModalManager; + settings: Record; +}) { + const { tasks } = useTasks({ projectId: "project-a" }); + const task = tasks[0]; + if (!task) return null; + + return ( + <> + + + + {String(detailTask.id)} + + ); +} + describe("AppModals", () => { const mockModalManager: ModalManager = { // State @@ -269,6 +330,7 @@ describe("AppModals", () => { beforeEach(() => { vi.clearAllMocks(); + sseSubscriptions.length = 0; mockTaskDetailModalProps.mockClear(); mockScheduledTasksModalProps.mockClear(); mockModelOnboardingModalProps.mockClear(); @@ -468,6 +530,111 @@ describe("AppModals", () => { expect(renderedTask.log).toEqual(detail.log); }); + it("keeps an open planning detail authoritative while the board has only live planner evidence", async () => { + const detail = { + id: "FN-8798", + title: "Revision task", + description: "", + column: "triage" as const, + status: "planning", + dependencies: [], + steps: [], + log: [], + prompt: "# Prompt", + createdAt: "2026-08-05T10:00:00.000Z", + updatedAt: "2026-08-05T10:01:00.000Z", + }; + const boardRow = { + ...detail, + status: "needs-replan", + prompt: undefined, + updatedAt: "2026-08-05T10:00:00.000Z", + recentAgentActivityAt: "2026-08-05T10:01:00.000Z", + }; + const manager = { ...mockModalManager, detailTask: detail }; + + render( + , + ); + + await waitFor(() => expect(mockTaskDetailModalProps).toHaveBeenCalled()); + expect(mockTaskDetailModalProps.mock.calls.at(-1)?.[0]?.task).toMatchObject({ + id: "FN-8798", + status: "planning", + prompt: "# Prompt", + }); + expect(screen.getByTestId("task-detail-status-badge")).toHaveTextContent("Planning"); + }); + + it("drives SSE planner activity and its authoritative status update through board, list, and the real detail host", async () => { + window.localStorage.setItem("kb:project-a:kb-dashboard-list-columns", JSON.stringify(["title", "status"])); + const parkedTask = { + id: "FN-8798", + title: "Revision task", + description: "", + column: "triage" as const, + status: "needs-replan", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + prompt: "# Prompt", + createdAt: "2026-08-05T10:00:00.000Z", + updatedAt: "2026-08-05T10:00:00.000Z", + columnMovedAt: "2026-08-05T10:00:00.000Z", + }; + const manager = { ...mockModalManager, detailTask: parkedTask }; + vi.spyOn(taskApi, "fetchTasks").mockResolvedValueOnce([parkedTask] as any); + vi.spyOn(taskApi, "fetchBoardWorkflows").mockResolvedValue({ + flagEnabled: true, + defaultWorkflowId: "builtin:coding", + workflows: [{ + id: "builtin:coding", + name: "Coding", + columns: [{ id: "triage", name: "Planning", flags: { intake: true } }], + }], + taskWorkflowIds: { [parkedTask.id]: "builtin:coding" }, + }); + + render(); + + await waitFor(() => expect(sseSubscriptions.some(({ url }) => url.startsWith("/api/events"))).toBe(true)); + const boardEvents = sseSubscriptions.find(({ url }) => url.startsWith("/api/events"))?.events; + act(() => { + boardEvents?.["agent:log"]?.({ data: JSON.stringify({ + taskId: parkedTask.id, + timestamp: "2026-08-05T10:01:00.000Z", + type: "tool", + agent: "triage", + }) }); + boardEvents?.["task:updated"]?.({ data: JSON.stringify({ + ...parkedTask, + status: "planning", + updatedAt: "2026-08-05T10:02:00.000Z", + }) }); + }); + + await waitFor(() => { + expect(document.querySelector('.card[data-id="FN-8798"] .card-status-badge')).toHaveTextContent("Planning"); + expect(document.querySelector(".list-status-badge")).toHaveTextContent("Planning"); + expect(screen.getByTestId("task-detail-status-badge")).toHaveTextContent("Planning"); + }); + }); + describe("ModelOnboardingModal wiring", () => { beforeEach(() => { mockModelOnboardingModalProps.mockClear(); @@ -718,7 +885,9 @@ describe("AppModals", () => { />, ); - fireEvent.click(screen.getByTestId("task-detail-open-detail")); + act(() => { + mockTaskDetailModalProps.mock.calls.at(-1)?.[0]?.onOpenDetail({ id: "FN-2", title: "Nested" }); + }); expect(pushStateSpy).toHaveBeenCalledTimes(1); /* FNXC:TaskDetailNav 2026-07-07-09:15: diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index 66b58321e7..0fe42eeb67 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -2816,6 +2816,37 @@ describe("ListView", () => { } }); + it("shows Planning in desktop and mobile lists when planner liveness precedes the replan status update", () => { + const task = createMockTask({ + id: "FN-8798-live-replan", + column: "triage", + status: "needs-replan", + recentAgentActivityAt: new Date().toISOString(), + }); + + const desktopViewport = mockDesktopViewport(); + try { + const { unmount } = renderListView({ tasks: [task] }); + const row = screen.getByText(task.id).closest("tr") as HTMLElement; + expect(within(row).getAllByText("Planning").find((element) => element.classList.contains("list-status-badge"))).toBeDefined(); + unmount(); + } finally { + desktopViewport.mockRestore(); + } + + const mobileViewport = mockMobileViewport(); + try { + renderListView({ tasks: [task] }); + const card = screen.getAllByText(task.id) + .map((element) => element.closest(".list-card")) + .find((element): element is HTMLElement => element instanceof HTMLElement); + expect(card).toBeDefined(); + expect(within(card!).getAllByText("Planning").find((element) => element.classList.contains("list-status-badge"))).toBeDefined(); + } finally { + mobileViewport.mockRestore(); + } + }); + it("renders paused tasks with dimmed styling", () => { const tasks = [createMockTask({ id: "FN-001", paused: true })]; diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index ff2e2cc6b0..05db802e40 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -2487,6 +2487,23 @@ describe("TaskCard", () => { expect(screen.queryByText("Replan")).not.toBeInTheDocument(); }); + it("shows Planning when a live planner log reaches a needs-replan board row before its status update", () => { + render( + , + ); + + expect(screen.getByText("Planning")).toHaveClass("card-status-badge"); + expect(screen.queryByText("Queued to revise")).not.toBeInTheDocument(); + }); + it.each([ { column: "todo" as const, status: "planning" }, { column: "in-progress" as const, status: "planning" }, diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index a9e1d5b3eb..5d353d9281 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -892,6 +892,7 @@ export function MainContent({ task={liveDetailTask} projectId={currentProject?.id} tasks={tasks} + globalPaused={globalPaused} embedded initialTab={mainPanelDetailInitialTab} /* diff --git a/packages/dashboard/app/hooks/__tests__/useTasks-hydration-freshness.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks-hydration-freshness.test.ts index 3d864c0f72..124b70b057 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks-hydration-freshness.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks-hydration-freshness.test.ts @@ -158,13 +158,61 @@ describe("task snapshot lifecycle freshness", () => { expect(afterStale.log).toEqual(queued.log); }); - it("keeps the first known lifecycle state for duplicate/equal timestamps but fills sparse fields", () => { - const queued = { ...todo, status: "queued-overlap", updatedAt: "2026-08-05T10:02:00.000Z" }; - const equalStale = { ...todo, status: "todo", updatedAt: queued.updatedAt, title: "Scheduler summary" }; + it("keeps populated detail metadata through an equal-clock sparse snapshot", () => { + const queued = { + ...todo, + status: "queued-overlap", + updatedAt: "2026-08-05T10:02:00.000Z", + overlapBlockedBy: "FN-HOLDER", + workflowStepResults: [{ stepId: "plan", status: "failed" }], + }; + const equalSparseEvent = { + ...todo, + status: "todo", + updatedAt: queued.updatedAt, + overlapBlockedBy: null, + workflowStepResults: [], + title: "Scheduler summary", + }; - const resolved = mergeTaskSnapshot(queued, equalStale); + const resolved = mergeTaskSnapshot(queued, equalSparseEvent); - expect(resolved).toMatchObject({ status: "queued-overlap", title: todo.title }); + expect(resolved).toMatchObject({ + status: "queued-overlap", + overlapBlockedBy: "FN-HOLDER", + workflowStepResults: queued.workflowStepResults, + }); + expect(resolved.title).toBe(todo.title); + }); + + it("keeps populated detail metadata when both a legacy row and sparse event lack update clocks", () => { + const current = { + ...todo, + updatedAt: undefined, + overlapBlockedBy: "FN-HOLDER", + workflowStepResults: [{ stepId: "plan", status: "failed" }], + } as unknown as Task; + const sparseEvent = { + ...todo, + updatedAt: undefined, + overlapBlockedBy: null, + workflowStepResults: [], + } as unknown as Task; + + expect(mergeTaskSnapshot(current, sparseEvent)).toMatchObject({ + overlapBlockedBy: "FN-HOLDER", + workflowStepResults: current.workflowStepResults, + }); + }); + + it("accepts equal-clock non-lifecycle fields only from a marked complete fetch", () => { + const current = { ...todo, updatedAt: "2026-08-05T10:02:00.000Z", title: "Cached title" }; + const completeFetch = { ...todo, updatedAt: current.updatedAt, title: "Fetched title" }; + + expect(mergeTaskSnapshot(current, completeFetch, { fullSnapshot: true })).toMatchObject({ + status: current.status, + title: "Fetched title", + }); }); it("accepts a genuinely newer column transition", () => { diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts index 89b8d83226..8c8c327831 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts @@ -2621,9 +2621,9 @@ describe("useTasks", () => { MockEventSource.instances[0]._emit("task:updated", staleUpdate); }); - // Should have in-progress column (from move) but updated title + // The equal-clock SSE patch cannot replace populated metadata without complete-fetch authority. expect(result.current.tasks[0].column).toBe("in-progress"); - expect(result.current.tasks[0].title).toBe("Updated Title"); + expect(result.current.tasks[0].title).toBe("Original Title"); }); }); @@ -3833,6 +3833,31 @@ describe("useTasks", () => { expect(result.current.tasks[0]?.recentAgentActivityAt).toBeUndefined(); }); + it("retains the parked replan row plus explicit live-planner evidence until the status event lands", async () => { + const initialTask = createMockTask({ + column: "triage", + status: "needs-replan", + updatedAt: "2026-08-05T10:00:00.000Z", + }); + mockFetchTasks.mockResolvedValueOnce([initialTask]); + const { result } = renderHook(() => useTasks()); + + await waitFor(() => expect(result.current.tasks).toHaveLength(1)); + act(() => { + MockEventSource.instances[0]._emit("agent:log", { + taskId: initialTask.id, + timestamp: "2026-08-05T10:00:01.000Z", + type: "tool", + agent: "triage", + }); + }); + + expect(result.current.tasks[0]).toMatchObject({ + status: "needs-replan", + recentAgentActivityAt: "2026-08-05T10:00:01.000Z", + }); + }); + /* FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion): The SOURCE of the planner-activity signal. It only stamped `recentAgentActivityAt` diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index 31a4ad00b0..ac5d864c77 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -252,22 +252,42 @@ The scheduler can refresh the board while a task-detail host holds a newer queue file-overlap snapshot. Providers (SWR, SSE, fetchTaskDetail, and local mutations) do not share an arrival order, so lifecycle rendering must use the existing timestamp evidence: a later `columnMovedAt` owns a real column transition; within that column, a later `updatedAt` owns status. Equal or absent -clock evidence retains the already-visible known lifecycle state, preventing Todo/Queued flashes. +clock evidence retains the already-visible known lifecycle state unless a complete server snapshot is +newer than the row and resolves an equal legacy move clock; sparse SSE patches never receive that tie-break. This helper intentionally merges only defined sparse fields and retains a fetched detail's prompt/log when a slim board row arrives. Every open-detail host and useTasks ingestion uses this one boundary so one provider cannot regress a modal, main panel, split detail, dock, or popup independently. */ -export function mergeTaskSnapshot(current: T, incoming: Task): T { +export interface TaskSnapshotMergeOptions { + /** A complete board/detail fetch can resolve an otherwise ambiguous legacy column clock. */ + fullSnapshot?: boolean; +} + +export function mergeTaskSnapshot( + current: T, + incoming: Task, + options: TaskSnapshotMergeOptions = {}, +): T { if (current.id !== incoming.id) return current; const merged = { ...current } as Record; const updatedAtCompare = compareTimestamps(incoming.updatedAt, current.updatedAt); - const acceptsIncomingSnapshot = updatedAtCompare > 0 || !current.updatedAt; + /* + FNXC:TaskStatusConsistency 2026-08-05-04:14: + Missing clocks are not authority: a legacy detail row and a sparse SSE patch with neither clock + must retain populated metadata rather than letting their arrival order erase queue/workflow state. + Only a strictly newer update clock, or an explicitly complete equal-clock fetch below, may replace it. + */ + const acceptsIncomingSnapshot = updatedAtCompare > 0; + // A fetch is explicitly marked complete at its call site. Equal clocks can only fill an absent + // field from a sparse event; they replace populated fields only for a complete fetch. + const acceptsEqualClockFields = options.fullSnapshot === true && updatedAtCompare === 0; for (const [key, value] of Object.entries(incoming)) { - // Older/equal scheduler payloads may enrich only fields that are still absent; they never - // rewrite a known snapshot simply because they arrived later. - if (value !== undefined && (acceptsIncomingSnapshot || merged[key] === undefined)) { + const canMergeField = acceptsIncomingSnapshot + || acceptsEqualClockFields + || (updatedAtCompare === 0 && merged[key] === undefined); + if (value !== undefined && canMergeField) { merged[key] = value; } } @@ -277,6 +297,9 @@ export function mergeTaskSnapshot(current: T, incoming: Task): T const incomingMovesColumn = incoming.column !== undefined && (current.column === undefined || columnMovedAtCompare > 0 + // A full server snapshot is more complete than an SSE patch, so its newer task clock can + // resolve a legacy equal move clock without letting a sparse event move the card. + || (options.fullSnapshot === true && columnMovedAtCompare === 0 && updatedAtCompare > 0) // Older rows have no column-move clock. A newer task timestamp is still evidence for a real move. || (!current.columnMovedAt && !incoming.columnMovedAt && updatedAtCompare > 0)); const incomingUpdatesStatus = incoming.status !== undefined @@ -290,6 +313,16 @@ export function mergeTaskSnapshot(current: T, incoming: Task): T merged.awaitingPlanning = acceptsIncomingSnapshot ? carryAwaitingPlanning(current, incoming) : current.awaitingPlanning; + /* + FNXC:TaskStatusConsistency 2026-08-05-04:05: + `recentAgentActivityAt` is a client-only bridge from an agent-log event to the next task snapshot. + Preserve it while a stale/equal payload is rejected so live Planning does not flash back to Queued, + but clear it when a newer authoritative row arrives without the marker. Equal-clock sparse events + may fill missing fields but cannot replace populated detail metadata; only complete fetches may do so. + */ + merged.recentAgentActivityAt = acceptsIncomingSnapshot + ? incoming.recentAgentActivityAt + : current.recentAgentActivityAt; if ("prompt" in current && incoming.prompt === undefined) { merged.prompt = current.prompt; @@ -299,8 +332,8 @@ export function mergeTaskSnapshot(current: T, incoming: Task): T return merged as T; } -function mergeIncomingTask(current: Task, incoming: Task): Task { - return mergeTaskSnapshot(current, incoming); +function mergeIncomingTask(current: Task, incoming: Task, options?: TaskSnapshotMergeOptions): Task { + return mergeTaskSnapshot(current, incoming, options); } export interface UseTasksOptions { @@ -544,7 +577,7 @@ export function useTasks(options?: UseTasksOptions) { // (or its board source) back merely because this fetch callback arrived last. const reconciledFetchedTasks = normalizedFetchedTasks.map((fetched) => { const current = previous.find((candidate) => candidate.id === fetched.id); - return current ? mergeIncomingTask(current, fetched) : fetched; + return current ? mergeIncomingTask(current, fetched, { fullSnapshot: true }) : fetched; }); if (!shouldCarryOverArchived) return reconciledFetchedTasks; diff --git a/packages/dashboard/app/utils/__tests__/taskStatusBadgeLabel.test.ts b/packages/dashboard/app/utils/__tests__/taskStatusBadgeLabel.test.ts index bd3fc2f517..a5636ae28a 100644 --- a/packages/dashboard/app/utils/__tests__/taskStatusBadgeLabel.test.ts +++ b/packages/dashboard/app/utils/__tests__/taskStatusBadgeLabel.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { TFunction } from "i18next"; -import { getTaskStatusBadgeLabel, hasTaskStatusBadge } from "../taskStatusBadgeLabel"; +import { getTaskStatusBadgeLabel, hasTaskStatusBadge, isTaskPlanningActive, PLANNER_ACTIVITY_LIVE_WINDOW_MS } from "../taskStatusBadgeLabel"; const t = ((key: string, fallback?: string) => fallback ?? key) as TFunction<"app">; @@ -24,6 +24,26 @@ describe("hasTaskStatusBadge", () => { }); }); +describe("isTaskPlanningActive", () => { + it("accepts planning status or fresh, unpaused planner activity on a replan card only", () => { + const now = Date.parse("2026-08-05T10:05:00.000Z"); + expect(isTaskPlanningActive({ status: "planning" }, { globalPaused: true, now })).toBe(true); + expect(isTaskPlanningActive({ status: "needs-replan", recentAgentActivityAt: "2026-08-05T10:01:00.000Z" }, { now })).toBe(true); + expect(isTaskPlanningActive({ status: "needs-replan", recentAgentActivityAt: "2026-08-05T10:01:00.000Z" }, { globalPaused: true, now })).toBe(false); + expect(isTaskPlanningActive({ status: "needs-replan" }, { now })).toBe(false); + expect(isTaskPlanningActive({ status: undefined, recentAgentActivityAt: "2026-08-05T10:01:00.000Z" }, { now })).toBe(false); + }); + + it("expires historical or malformed planner activity", () => { + const now = Date.parse("2026-08-05T10:05:00.000Z"); + expect(isTaskPlanningActive( + { status: "needs-replan", recentAgentActivityAt: new Date(now - PLANNER_ACTIVITY_LIVE_WINDOW_MS - 1).toISOString() }, + { now }, + )).toBe(false); + expect(isTaskPlanningActive({ status: "needs-replan", recentAgentActivityAt: "not-a-date" }, { now })).toBe(false); + }); +}); + describe("getTaskStatusBadgeLabel", () => { it("maps the full AI merge pipeline to Merging…", () => { for (const status of ["merging", "merging-pr", "reviewing", "landing"]) { diff --git a/packages/dashboard/app/utils/taskStatusBadgeLabel.ts b/packages/dashboard/app/utils/taskStatusBadgeLabel.ts index cf026452a5..10c237ceaa 100644 --- a/packages/dashboard/app/utils/taskStatusBadgeLabel.ts +++ b/packages/dashboard/app/utils/taskStatusBadgeLabel.ts @@ -3,6 +3,7 @@ FNXC:MergeQueue 2026-07-15-10:45: AI merge sets task.status to reviewing/landing for most of the live merge window. Board/list badges must never show those raw engine strings; map the full active-merge pipeline to operator-facing Merging… (and Merging fixes… for merging-fix). */ import type { TFunction } from "i18next"; +import type { Task } from "@fusion/core"; import { isActiveMergeStatus } from "../../../core/src/merge/active-merge-status"; /* @@ -15,6 +16,41 @@ export function hasTaskStatusBadge(status: string | null | undefined): boolean { return typeof status === "string" && status.trim().length > 0; } +/* +FNXC:TaskStatusConsistency 2026-08-05-03:49: +A planner agent-log event can reach the shared board snapshot before the status update that changes +`needs-replan` to `planning`. Its `recentAgentActivityAt` is written only after the log proves it is +newer than the row, so this is explicit live-planner evidence rather than arrival-order guessing. +Use it only for the parked-revision token while the event is inside the five-minute live window and +the engine is not globally paused: undefined status remains unknown and an idle needs-replan card +remains Queued to revise. TaskCard, both ListView paths, and task detail share this predicate so a +modal that has already fetched planning cannot contradict the board during that short SSE gap. + +FNXC:TaskStatusConsistency 2026-08-05-04:54: +A parseable historical log is not liveness. The transient `needs-replan` override expires after the +shared five-minute heartbeat window and is suppressed during global pause, returning the operator to +Queued to revise instead of leaving every affected surface stuck on Planning. +*/ +export const PLANNER_ACTIVITY_LIVE_WINDOW_MS = 5 * 60_000; + +export interface TaskPlanningActivityOptions { + globalPaused?: boolean; + now?: number; +} + +export function isTaskPlanningActive( + task: Pick, + { globalPaused = false, now = Date.now() }: TaskPlanningActivityOptions = {}, +): boolean { + if (task.status === "planning") return true; + if (globalPaused || task.status !== "needs-replan" || typeof task.recentAgentActivityAt !== "string") return false; + + const activityAt = Date.parse(task.recentAgentActivityAt); + return Number.isFinite(activityAt) + && activityAt <= now + && now - activityAt <= PLANNER_ACTIVITY_LIVE_WINDOW_MS; +} + /* FNXC:TaskStatusBadge 2026-08-01-01:30 (operator: "make the badges more descriptive"): Three waiting states read as ACTIVITY on the board and made healthy queueing look broken: