fix(dashboard): planner activity was never stamped on a renamed intake lane (#3017)

## 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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-31 00:50:07 -07:00
committed by GitHub
parent eecc87c31e
commit 5bdb8a1102
4 changed files with 126 additions and 4 deletions

View File

@@ -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.

View File

@@ -540,14 +540,36 @@ function AppInner() {
// FNXC:DashboardLiveUpdates 2026-06-26-01:08: // 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. // 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"; 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( 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 } : {}), ...(currentProject ? { projectId: currentProject.id } : {}),
searchQuery: searchQuery || undefined, searchQuery: searchQuery || undefined,
sseEnabled: taskSseEnabled, sseEnabled: taskSseEnabled,
resolveColumnFlags: resolveTaskColumnFlagsForActivity,
} }
); );
const { boardWorkflows: footerBoardWorkflows } = useBoardWorkflows({ projectId: currentProject?.id });
const footerTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks; const footerTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks;
const footerColumnFlagsByTaskId = useMemo(() => { const footerColumnFlagsByTaskId = useMemo(() => {
const index = new Map<string, ExecutorColumnFlags>(); const index = new Map<string, ExecutorColumnFlags>();

View File

@@ -3873,6 +3873,66 @@ describe("useTasks", () => {
expect(result.current.tasks[0]?.recentAgentActivityAt).toBe("2026-07-28T12:00:01.000Z"); 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 () => { 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. // The stamp must still NARROW: an executing card is not planner activity.
const initialTask = createMockTask({ const initialTask = createMockTask({

View File

@@ -9,6 +9,7 @@ import { clearCache, readCache, readCacheSavedAt, SWR_CACHE_KEYS, SWR_TASKS_MAX_
import { pushTrace } from "../utils/dashboardTraceBuffer"; import { pushTrace } from "../utils/dashboardTraceBuffer";
import { recordResumeEvent } from "../utils/resumeInstrumentation"; import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { isLikelyTabSuspensionError } from "./visibilitySuspension"; import { isLikelyTabSuspensionError } from "./visibilitySuspension";
import { isIntakeColumnRole, isHoldColumnRole, type ColumnRoleFlags } from "../utils/columnRoles";
const loggedTaskCacheHitProjects = new Set<string>(); const loggedTaskCacheHitProjects = new Set<string>();
/* /*
@@ -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"]); 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 ( if (
!PLANNER_ACTIVITY_COLUMN_IDS.has(task.column) !isPlannerActivityLane(task, flags)
|| task.status === "planning" || task.status === "planning"
|| entry.agent !== PLANNER_AGENT_ROLE || entry.agent !== PLANNER_AGENT_ROLE
|| !hasFreshAgentLog(task, entry) || !hasFreshAgentLog(task, entry)
@@ -274,6 +298,14 @@ function mergeIncomingTask(current: Task, incoming: Task): Task {
} }
export interface UseTasksOptions { 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. * When provided, fetches tasks only for this project.
* SSE events from other project contexts are ignored. * SSE events from other project contexts are ignored.
@@ -295,6 +327,7 @@ export interface UseTasksOptions {
export function useTasks(options?: UseTasksOptions) { export function useTasks(options?: UseTasksOptions) {
const projectId = options?.projectId; const projectId = options?.projectId;
const resolveColumnFlags = options?.resolveColumnFlags;
const searchQuery = options?.searchQuery; const searchQuery = options?.searchQuery;
const sseEnabled = options?.sseEnabled ?? true; const sseEnabled = options?.sseEnabled ?? true;
/* /*
@@ -967,7 +1000,7 @@ export function useTasks(options?: UseTasksOptions) {
let changed = false; let changed = false;
const next = prev.map((task) => { const next = prev.map((task) => {
const cleared = clearInReviewStallForFreshAgentLog(task, entry); const cleared = clearInReviewStallForFreshAgentLog(task, entry);
const updated = addRecentPlannerActivityForFreshAgentLog(cleared, entry); const updated = addRecentPlannerActivityForFreshAgentLog(cleared, entry, resolveColumnFlags?.(cleared));
if (updated !== task) changed = true; if (updated !== task) changed = true;
return updated; return updated;
}); });