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:
7
.changeset/planner-activity-renamed-intake.md
Normal file
7
.changeset/planner-activity-renamed-intake.md
Normal 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.
|
||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user