From 5bdb8a1102fd607bfe11def61683e0c0ba12602a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 31 Jul 2026 00:50:07 -0700 Subject: [PATCH] fix(dashboard): planner activity was never stamped on a renamed intake lane (#3017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## How this was found — by re-testing a claim of mine The learnings doc records "named legacy-id collections" as **measured and clean**: 48 declarations, all fallback vocabularies, builtin column lists, or already-converted seams. #3014 disproved that conclusion — `TIME_INDICATOR_COLUMNS` was in that population and was a live defect. So I re-measured over the shape that actually matters: **collections used as a membership gate against a column.** Nine exist. | site | verdict | |---|---| | `columnRoles.ts` ×2, `useSessionFiles.ts` | the no-flags fallback *inside* the role helpers — correct by design | | `branch-group-ops.ts` | seeds the legacy pair then unions resolved lanes — already converted | | `DocumentsView.tsx` | marked `DELIBERATE-LITERAL` fallback chain | | `TaskCard.tsx` ×2 | fixed in #3014 | | `plugins/…/reconciler.ts` | plugin with no trait source — same class as #3003 | | **`useTasks.ts`** | **no flags path anywhere in the file** | ## The defect `useTasks` stamps `recentAgentActivityAt` only for cards in `{triage, todo}`. The note at that set argues over-stamping is harmless because every consumer re-checks for an intake lane before showing anything. That's true, and it **only protects against false positives**. On a board whose intake and hold lanes are renamed, the pair matches nothing — so no stamp is ever written, and a correct downstream role check has nothing to filter. The planning border and pulsing badge never appear while the planner is actively working the card. ## The supplier ships with the seam An optional resolver with no caller is the first failure shape in the learnings doc, and my own gate would flag it — so `App` supplies it in the same commit. `useBoardWorkflows` moved above `useTasks` to make that expressible; it depends on `projectId` alone, nothing about tasks, so reading it first is safe. Remote rows deliberately get **no** flags — they belong to another store, and local board-workflow metadata must never be applied to their ids. That's the rule the footer index already follows. ## Measured | check | result | |---|---| | `useTasks` suite | 124 → **126**, all green | | reverting the gate to the legacy pair | fails exactly the renamed case; the negative (renamed WIP is not planning) still passes | | `App.test` + `useTasks` together | **269 green** | | gates | all five green; lint and `tsc` clean | ## One observation I could not reproduce The `App`+`useTasks` pair failed once, on a single unnamed test, and passed on **four** subsequent runs including three consecutive. The captured output showed jsdom URL-parse noise from `MissionManager` fetches rather than an assertion failure, and the same pair is green on unmodified `main`. I'm not quarantining another file's test on one unreproducible observation, but recording it rather than letting a green rerun bury it — if it resurfaces in CI, this is the prior sighting. Co-authored-by: Claude Opus 5 (1M context) --- .changeset/planner-activity-renamed-intake.md | 7 +++ packages/dashboard/app/App.tsx | 24 +++++++- .../app/hooks/__tests__/useTasks.test.ts | 60 +++++++++++++++++++ packages/dashboard/app/hooks/useTasks.ts | 39 +++++++++++- 4 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 .changeset/planner-activity-renamed-intake.md diff --git a/.changeset/planner-activity-renamed-intake.md b/.changeset/planner-activity-renamed-intake.md new file mode 100644 index 0000000000..1df54ad819 --- /dev/null +++ b/.changeset/planner-activity-renamed-intake.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: The planning border and pulsing badge now appear for cards in renamed intake lanes. +category: fix +dev: `useTasks` gated its planner-activity stamp on the literal `{triage, todo}` pair; it now takes an optional per-task flags resolver supplied by App, with that pair kept as the no-flags fallback. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index bef0963ad4..c3885027b1 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -540,14 +540,36 @@ function AppInner() { // FNXC:DashboardLiveUpdates 2026-06-26-01:08: // SSE remains enabled only for board/list views to free connection slots for mission detail fetches. The false→true missed-event catch-up lives inside useTasks so App keeps the routing gate only and cannot double-fetch on task-view re-entry. const taskSseEnabled = taskView === "board" || taskView === "list"; + /* + FNXC:WorkflowResolvedColumns 2026-07-31-03:50: + HOISTED above `useTasks` so its planner-activity stamp can be a role question. + + The board-workflow payload is the only per-task trait source on this screen, and it depends on + `projectId` alone — nothing about tasks — so reading it first is safe. `useTasks` previously gated + that stamp on the literal `{triage, todo}` pair, which matches nothing on a renamed board, so the + planning border and pulsing badge never appeared while the planner was working the card. + + REMOTE NODES GET NO FLAGS, deliberately: their rows belong to another store, so local + board-workflow metadata must never be applied to their ids — the same rule the footer index below + already follows. They keep the legacy fallback. + */ + const { boardWorkflows: footerBoardWorkflows } = useBoardWorkflows({ projectId: currentProject?.id }); + const resolveTaskColumnFlagsForActivity = useCallback((task: Task) => { + if (isRemote || !footerBoardWorkflows) return undefined; + const workflowId = footerBoardWorkflows.taskWorkflowIds[task.id] ?? footerBoardWorkflows.defaultWorkflowId; + return footerBoardWorkflows.workflows + .find((workflow) => workflow.id === workflowId) + ?.columns.find((column) => column.id === task.column)?.flags; + }, [footerBoardWorkflows, isRemote]); + const { tasks, isStale, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, bypassReview, resetTask, updateTask, duplicateTask, archiveTask, unarchiveTask, revertTask, archiveAllDone, loadArchivedTasks, loadMoreArchivedTasks, archivedHasMore, archivedLoadingMore, ingestCreatedTasks, lastFetchTimeMs } = useTasks( { ...(currentProject ? { projectId: currentProject.id } : {}), searchQuery: searchQuery || undefined, sseEnabled: taskSseEnabled, + resolveColumnFlags: resolveTaskColumnFlagsForActivity, } ); - const { boardWorkflows: footerBoardWorkflows } = useBoardWorkflows({ projectId: currentProject?.id }); const footerTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks; const footerColumnFlagsByTaskId = useMemo(() => { const index = new Map(); diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts index d712388a15..1d37ee7d28 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts @@ -3873,6 +3873,66 @@ describe("useTasks", () => { expect(result.current.tasks[0]?.recentAgentActivityAt).toBe("2026-07-28T12:00:01.000Z"); }); + /* + FNXC:WorkflowResolvedColumns 2026-07-31-03:55: + THE SAME SOURCE, one vocabulary further out. + + The note above fixed the stamp for the MERGED default lane. It still gated on the literal pair + `{triage, todo}`, so on a board whose intake lane is named anything else the stamp is never + written — and the same consumers have nothing to act on, however correctly they resolve traits. + The existing note argues over-stamping is harmless because consumers re-check for an intake lane; + that protects against false positives and says nothing about this direction. + + REVERT CHECK: drop `resolveColumnFlags` from the options and this fails — `drafting` is not in the + legacy pair, so nothing is stamped. + */ + it("stamps planner activity for a card in a RENAMED intake lane", async () => { + const initialTask = createMockTask({ + column: "drafting", + status: null, + updatedAt: "2026-07-28T12:00:00.000Z", + }); + mockFetchTasks.mockResolvedValueOnce([initialTask]); + const { result } = renderHook(() => useTasks({ + resolveColumnFlags: () => ({ intake: true, hold: true }), + })); + + await waitFor(() => expect(result.current.tasks).toHaveLength(1)); + act(() => { + MockEventSource.instances[0]._emit("agent:log", { + taskId: initialTask.id, + timestamp: "2026-07-28T12:00:01.000Z", + type: "tool", + agent: "triage", + }); + }); + expect(result.current.tasks[0]?.recentAgentActivityAt).toBe("2026-07-28T12:00:01.000Z"); + }); + + /* The paired negative: resolved traits must still NARROW. A renamed WIP lane is not planning. */ + it("does not stamp planner activity for a card in a RENAMED wip lane", async () => { + const initialTask = createMockTask({ + column: "building", + status: null, + updatedAt: "2026-07-28T12:00:00.000Z", + }); + mockFetchTasks.mockResolvedValueOnce([initialTask]); + const { result } = renderHook(() => useTasks({ + resolveColumnFlags: () => ({ countsTowardWip: true }), + })); + + await waitFor(() => expect(result.current.tasks).toHaveLength(1)); + act(() => { + MockEventSource.instances[0]._emit("agent:log", { + taskId: initialTask.id, + timestamp: "2026-07-28T12:00:01.000Z", + type: "tool", + agent: "triage", + }); + }); + expect(result.current.tasks[0]?.recentAgentActivityAt).toBeUndefined(); + }); + it("does not stamp planner activity for a card outside any planning lane", async () => { // The stamp must still NARROW: an executing card is not planner activity. const initialTask = createMockTask({ diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index e448c86706..7b1c0664a0 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -9,6 +9,7 @@ import { clearCache, readCache, readCacheSavedAt, SWR_CACHE_KEYS, SWR_TASKS_MAX_ import { pushTrace } from "../utils/dashboardTraceBuffer"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; import { isLikelyTabSuspensionError } from "./visibilitySuspension"; +import { isIntakeColumnRole, isHoldColumnRole, type ColumnRoleFlags } from "../utils/columnRoles"; const loggedTaskCacheHitProjects = new Set(); /* @@ -183,9 +184,32 @@ INTAKE lane before showing anything, so the extra timestamps are filtered downst */ const PLANNER_ACTIVITY_COLUMN_IDS = new Set(["triage", "todo"]); -function addRecentPlannerActivityForFreshAgentLog(task: Task, entry: AgentLogActivityEvent): Task { +/* +FNXC:WorkflowResolvedColumns 2026-07-31-03:45: +THE STAMP MISSED RENAMED INTAKE LANES ENTIRELY, which the note above does not cover. + +That note argues over-stamping is harmless because every consumer re-checks for an INTAKE lane +before showing anything. True, and it only protects against false POSITIVES. On a board whose intake +and hold lanes are renamed, `{triage, todo}` matches nothing, so no stamp is ever written — and a +correct downstream role check has nothing to filter. The planning border and pulsing badge never +appear while the planner is actively working the card. + +Resolved traits win; the legacy pair stays as the no-flags fallback, so an unconverted caller and the +remote-node path are byte-identical. Intake OR hold, mirroring what the pair meant: pre-merge +`triage` was intake and post-merge `todo` is the hold lane. +*/ +function isPlannerActivityLane(task: Task, flags: ColumnRoleFlags | undefined): boolean { + if (!flags) return PLANNER_ACTIVITY_COLUMN_IDS.has(task.column); + return isIntakeColumnRole(flags, task.column) || isHoldColumnRole(flags, task.column); +} + +function addRecentPlannerActivityForFreshAgentLog( + task: Task, + entry: AgentLogActivityEvent, + flags: ColumnRoleFlags | undefined, +): Task { if ( - !PLANNER_ACTIVITY_COLUMN_IDS.has(task.column) + !isPlannerActivityLane(task, flags) || task.status === "planning" || entry.agent !== PLANNER_AGENT_ROLE || !hasFreshAgentLog(task, entry) @@ -274,6 +298,14 @@ function mergeIncomingTask(current: Task, incoming: Task): Task { } export interface UseTasksOptions { + /* + FNXC:WorkflowResolvedColumns 2026-07-31-03:40: + Resolves a task's own column traits, so the planner-activity stamp below is a ROLE question. + + Supplied by App from the board-workflow payload. Absent (remote nodes, pre-load) the stamp falls + back to the legacy id pair, which is the behaviour that shipped. + */ + resolveColumnFlags?: (task: Task) => ColumnRoleFlags | undefined; /** * When provided, fetches tasks only for this project. * SSE events from other project contexts are ignored. @@ -295,6 +327,7 @@ export interface UseTasksOptions { export function useTasks(options?: UseTasksOptions) { const projectId = options?.projectId; + const resolveColumnFlags = options?.resolveColumnFlags; const searchQuery = options?.searchQuery; const sseEnabled = options?.sseEnabled ?? true; /* @@ -967,7 +1000,7 @@ export function useTasks(options?: UseTasksOptions) { let changed = false; const next = prev.map((task) => { const cleared = clearInReviewStallForFreshAgentLog(task, entry); - const updated = addRecentPlannerActivityForFreshAgentLog(cleared, entry); + const updated = addRecentPlannerActivityForFreshAgentLog(cleared, entry, resolveColumnFlags?.(cleared)); if (updated !== task) changed = true; return updated; });