FN-8798: synchronize planning status across task views
Keep board, list, and task detail planning indicators consistent with fresh lifecycle state. - preserve authoritative task snapshots across SSE and detail refreshes - render transient replan planning only for fresh, unpaused planner activity - pass global pause state to every task-detail host and cover status convergence Files changed: packages/dashboard/app/App.tsx | 2 + packages/dashboard/app/components/AppModals.tsx | 3 + packages/dashboard/app/components/ListView.tsx | 11 +- packages/dashboard/app/components/TaskCard.tsx | 7 +- .../dashboard/app/components/TaskDetailModal.tsx | 27 ++- .../app/components/__tests__/AppModals.test.tsx | 201 +++++++++++++++++++-- .../app/components/__tests__/ListView.test.tsx | 31 ++++ .../app/components/__tests__/TaskCard.test.tsx | 17 ++ .../app/components/dashboard/MainContent.tsx | 1 + .../__tests__/useTasks-hydration-freshness.test.ts | 58 +++++- .../dashboard/app/hooks/__tests__/useTasks.test.ts | 29 ++- packages/dashboard/app/hooks/useTasks.ts | 51 +++++- .../utils/__tests__/taskStatusBadgeLabel.test.ts | 22 ++- .../dashboard/app/utils/taskStatusBadgeLabel.ts | 36 ++++ 14 files changed, 453 insertions(+), 43 deletions(-) Fusion-Task-Id: FN-8798 Fusion-Task-Lineage: 7defae4e-fb39-4d2c-943a-ef6c1a164bfc Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -46,6 +46,7 @@ interface AppModalsProps {
|
||||
tasks: Task[];
|
||||
/* Per-task lifecycle traits, forwarded to Task Detail's blocker fan-out. */
|
||||
columnFlagsByTaskId?: ReadonlyMap<string, BlockerFanoutColumnFlags>;
|
||||
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"}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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({
|
||||
<span className={`detail-column-badge badge-${workingTask.column}`}>
|
||||
{workflowColumnDisplayName ?? columnLabel(workingTask.column)}
|
||||
</span>
|
||||
{hasTaskStatusBadge(workingTask.status) && (
|
||||
<span className="card-status-badge" data-testid="task-detail-status-badge">
|
||||
{taskStatusBadgeLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-header-actions">
|
||||
{!isEditing && canEdit && (
|
||||
|
||||
@@ -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 (
|
||||
<button data-testid="task-detail-open-detail" onClick={() => props.onOpenDetail?.({ id: "FN-2", title: "Nested" })}>
|
||||
open detail
|
||||
</button>
|
||||
);
|
||||
},
|
||||
const sseSubscriptions = vi.hoisted(() => [] as Array<{
|
||||
url: string;
|
||||
events?: Record<string, (event: { data: string }) => void>;
|
||||
}>);
|
||||
vi.mock("../../sse-bus", () => ({
|
||||
subscribeSse: vi.fn((url: string, options: { events?: Record<string, (event: { data: string }) => 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<typeof import("../TaskDetailModal")>();
|
||||
return {
|
||||
...actual,
|
||||
TaskDetailModal: (props: React.ComponentProps<typeof actual.TaskDetailModal>) => {
|
||||
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<typeof import("@fusion/core")>());
|
||||
|
||||
// Mock ModalErrorBoundary
|
||||
vi.mock("../ErrorBoundary", () => ({
|
||||
@@ -180,6 +195,52 @@ function render(ui: ReactElement, options?: Omit<RenderOptions, "wrapper">) {
|
||||
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<string, unknown>;
|
||||
modalManager: ModalManager;
|
||||
settings: Record<string, unknown>;
|
||||
}) {
|
||||
const { tasks } = useTasks({ projectId: "project-a" });
|
||||
const task = tasks[0];
|
||||
if (!task) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TaskCard task={task} onOpenDetail={integrationNoop} addToast={integrationNoop} projectId="project-a" />
|
||||
<ListView
|
||||
tasks={tasks}
|
||||
projectId="project-a"
|
||||
onMoveTask={integrationAsyncNoop}
|
||||
onDeleteTask={integrationAsyncNoop}
|
||||
onArchiveTask={integrationAsyncNoop}
|
||||
onMergeTask={integrationAsyncNoop}
|
||||
onOpenDetail={integrationNoop}
|
||||
addToast={integrationNoop}
|
||||
/>
|
||||
<AppModals
|
||||
projectId="project-a"
|
||||
tasks={tasks}
|
||||
projects={[]}
|
||||
currentProject={null}
|
||||
addToast={integrationNoop}
|
||||
toasts={[]}
|
||||
removeToast={integrationNoop}
|
||||
modalManager={modalManager}
|
||||
projectActions={{ handleAddProject: integrationNoop, handleSetupComplete: integrationNoop, handleModelOnboardingComplete: integrationNoop }}
|
||||
taskHandlers={{ handleModalCreate: integrationAsyncNoop, handlePlanningTaskCreated: integrationNoop, handlePlanningTasksCreated: integrationNoop, handleSubtaskTasksCreated: integrationNoop, handleGitHubImport: integrationNoop }}
|
||||
taskOperations={{ moveTask: integrationAsyncNoop, deleteTask: integrationAsyncNoop, mergeTask: integrationAsyncNoop, retryTask: integrationAsyncNoop, duplicateTask: integrationAsyncNoop }}
|
||||
deepLink={{ handleDetailClose: integrationNoop }}
|
||||
settings={settings as any}
|
||||
/>
|
||||
<output data-testid="planning-harness-detail-id">{String(detailTask.id)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<AppModals
|
||||
projectId="project-a"
|
||||
tasks={[boardRow]}
|
||||
projects={[]}
|
||||
currentProject={null}
|
||||
addToast={vi.fn()}
|
||||
toasts={mockToasts}
|
||||
removeToast={vi.fn()}
|
||||
modalManager={manager}
|
||||
projectActions={{ handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }}
|
||||
taskHandlers={{ handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }}
|
||||
taskOperations={{ moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }}
|
||||
deepLink={{ handleDetailClose: vi.fn() }}
|
||||
settings={mockSettings}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<PlanningStatusConvergenceHarness detailTask={parkedTask} modalManager={manager} settings={mockSettings} />);
|
||||
|
||||
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:
|
||||
|
||||
@@ -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 })];
|
||||
|
||||
|
||||
@@ -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(
|
||||
<TaskCard
|
||||
task={makeTask({
|
||||
column: "triage",
|
||||
status: "needs-replan",
|
||||
recentAgentActivityAt: new Date().toISOString(),
|
||||
})}
|
||||
onOpenDetail={noop}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
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" },
|
||||
|
||||
@@ -892,6 +892,7 @@ export function MainContent({
|
||||
task={liveDetailTask}
|
||||
projectId={currentProject?.id}
|
||||
tasks={tasks}
|
||||
globalPaused={globalPaused}
|
||||
embedded
|
||||
initialTab={mainPanelDetailInitialTab}
|
||||
/*
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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<T extends Task>(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<T extends Task>(
|
||||
current: T,
|
||||
incoming: Task,
|
||||
options: TaskSnapshotMergeOptions = {},
|
||||
): T {
|
||||
if (current.id !== incoming.id) return current;
|
||||
|
||||
const merged = { ...current } as Record<string, unknown>;
|
||||
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<T extends Task>(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<T extends Task>(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<T extends Task>(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;
|
||||
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -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<Task, "status" | "recentAgentActivityAt">,
|
||||
{ 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:
|
||||
|
||||
Reference in New Issue
Block a user