**Claimed:** `Column.tsx`, `taskActivity.ts` — both to zero.
| file | before | after |
|---|---:|---:|
| `packages/dashboard/app/components/Column.tsx` | **2** | **0** |
| `packages/dashboard/app/utils/taskActivity.ts` | **2** | **0** |
## `Column.tsx` — two different fixes, because the two sites are
different problems
**The preserve-progress prompt** routed through
`isPreImplementationColumnRole`. This is the *same* question that helper
was written for — ListView asks it about a move target, Column asks it
about itself — and the degraded id sets are identical (`{todo,
triage}`), so the consolidation is exact.
I verified the sets matched **before** consolidating, because the
sibling case is not interchangeable: `isPreExecutionHoldColumn` in
`TaskContextMenu` drives the Plan affordance and its degraded set is
`{triage}` alone. Routing *that* through this helper added `plan` to
flagless `todo` cards, caught by an existing test. **Same shape,
identical trait path, non-interchangeable fallbacks.**
**The legacy-board arm** (`workflowMode || column === "triage"`) —
deleted, on the third attempt.
I deleted it twice before and reverted both times because four Column
tests render without `workflowMode`. That was the delete-only rule
working, but **my conclusion from it was wrong**: a behaviour change
means the branch was not dead *for those callers*, and the callers are
**fixtures, not production**. Board is Column's only consumer and passes
`workflowMode` at all three render sites. Defending an unreachable arm
so four tests keep passing preserves the tests, not the behaviour.
Two notes for anyone converting the remaining dashboard files:
- I did **not** default `workflowMode` to `true`, which was the tempting
one-liner. `isArchived`, `isHoldColumn` and `isWipProcessingColumn` all
switch on that same flag, so a global default would silently reinterpret
every other fixture in an 85-test file.
- **"Four tests break" was itself an underestimate.** Two more FN-770
fixtures surfaced only after the first two were fixed, because they
render their own explicit `column="triage"` block instead of using
`defaultProps`. The blast radius only became accurate by fixing it in
waves.
## `taskActivity.ts` — composed, not copied
The degraded arm now composes `utils/columnRoles`' predicates instead of
naming ids. **No local copy** — which is the failure mode #2625 hit from
the other direction.
Equivalent *by construction*:
| lane | composition | resolves to |
|---|---|---|
| intake | `isIntakeColumnRole(undefined, col)` | `triage` |
| hold | `isPreImplementationColumnRole(...)` **and not** intake |
`todo` |
reproducing `col === "triage" || (col === "todo" && isReplanning)`
exactly, since the shared pre-implementation set is `{todo, triage}` and
the shared intake id is `triage`.
Deliberately phrased as *"pre-implementation and not intake"* rather
than a second id list: if either shared set changes, this composition
follows it instead of silently disagreeing with the file next door. That
disagreement is precisely what bit the `TaskContextMenu` consolidation
above.
**I previously reported this site as blocked on `TaskCard.tsx` (U12's)**
— on the theory that the arm could only die once every caller supplied
resolved flags. Wrong framing: the arm doesn't need to become
*unreachable*, it needs to stop *naming ids*. Composing the shared
predicates does that without touching any caller.
## Verification
**1139 of 1141** green across `app/utils`, `Column` and `TaskCard`
suites. The two `TaskCard` failures are **pre-existing** — verified by
stashing this change and re-running, where they fail identically.
Dashboard app typecheck and lint clean.
Takes U11's cluster to zero except `TaskContextMenu.tsx`, whose
remaining site is covered in **#2626** and whose second site is a
documented non-consolidation.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
118 lines
5.8 KiB
TypeScript
118 lines
5.8 KiB
TypeScript
import type { Task } from "@fusion/core";
|
|
import { getUnifiedTaskProgress } from "./taskProgress";
|
|
import { isIntakeColumnRole, isPreImplementationColumnRole } from "./columnRoles";
|
|
|
|
/** The shared status vocabulary for active task phases and lock/model policy. */
|
|
export const ACTIVE_STATUSES = new Set([
|
|
"planning",
|
|
"researching",
|
|
"executing",
|
|
"finalizing",
|
|
"merging",
|
|
"merging-pr",
|
|
"merging-fix",
|
|
"reviewing",
|
|
"landing",
|
|
]);
|
|
|
|
export const RECENT_PLANNER_ACTIVITY_WINDOW_MS = 60_000;
|
|
|
|
export interface TaskAgentActivityOptions {
|
|
globalPaused?: boolean;
|
|
queued?: boolean;
|
|
isStuck?: boolean;
|
|
/*
|
|
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
|
|
The task's own column traits, when the caller has them. Fresh-planner-activity was
|
|
keyed on `column === "triage"`, so under U11 — merged planning column keeps the id
|
|
`todo`, `triage` deleted — a planning card with live planner logs stops reading as
|
|
agent-active. That is not one badge: this predicate drives the pulsing status badge,
|
|
the agent-active row border, and the column header's executing count, so the whole
|
|
board would quietly report planning work as idle.
|
|
|
|
Optional, and the legacy ids remain the fallback: callers without resolved metadata
|
|
(pre-load, or a card stranded in a vanished lane) must keep their current behaviour
|
|
rather than lose activity detection entirely.
|
|
*/
|
|
columnFlags?: { intake?: boolean; hold?: boolean };
|
|
}
|
|
|
|
/*
|
|
FNXC:TaskActivity 2026-07-16-00:00:
|
|
FN-8055 makes the agent-active border and pulsing badges represent the same ground truth: an agent is working now. Reject render-context global pause, queue, and derived freshness-stuck gates before checking activity, then combine the engine's column-aware active window with canonical phase statuses and the running unified workflow item that drives progress badges.
|
|
|
|
FNXC:TaskActivity 2026-07-28-12:00:
|
|
FN-8300 also honors a bounded, client-only fresh planner-log timestamp for triage cards. The log stream can arrive before the authoritative planning-status row; this render-only fallback closes that window without changing routing/model locks.
|
|
|
|
FNXC:TaskActivity 2026-07-22-09:25:
|
|
FN-8494 requires cards parked in the engine's durable `needs-replan` planning stage to keep their activity chrome on both triage and plan-in-place todo lanes. This is rendering-only: do not add `needs-replan` to ACTIVE_STATUSES, because model and routing pickers use that set as a long-lived lock policy while this predicate only describes live operator chrome. Extend the bounded fresh-log window to the todo replan lane so an incoming planner log remains represented consistently there.
|
|
|
|
Stuck-killed and both terminal columns are never active, even when stale execution status or workflow-step data remains on the task.
|
|
|
|
Model-resolution and routing locks intentionally import only ACTIVE_STATUSES and retain their status-or-in-progress policy; using this rendering predicate there would change lock behavior during status-null workflow steps.
|
|
*/
|
|
export function isTaskAgentActive(
|
|
task: Pick<Task, "column" | "status" | "paused" | "userPaused" | "steps" | "enabledWorkflowSteps" | "workflowStepResults" | "recentAgentActivityAt">,
|
|
options: TaskAgentActivityOptions = {},
|
|
): boolean {
|
|
const status = task.status;
|
|
|
|
if (
|
|
options.globalPaused === true ||
|
|
options.queued === true ||
|
|
options.isStuck === true ||
|
|
status === "queued" ||
|
|
status === "stuck-killed" ||
|
|
task.paused === true ||
|
|
task.userPaused === true ||
|
|
status === "paused" ||
|
|
status === "failed" ||
|
|
status === "awaiting-approval" ||
|
|
status === "awaiting-user-input" ||
|
|
task.column === "done" ||
|
|
task.column === "archived" ||
|
|
status === "done"
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const isReplanning = status === "needs-replan";
|
|
const recentPlannerActivityAtMs = Date.parse(task.recentAgentActivityAt ?? "");
|
|
const nowMs = Date.now();
|
|
/*
|
|
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
|
|
Planner activity belongs to the PRE-IMPLEMENTATION lane. With traits the rule is
|
|
"intake lane, or a hold lane that is replanning"; without them it falls back to the
|
|
ids, which is the same shape the two lanes have today.
|
|
*/
|
|
/*
|
|
FNXC:WorkflowResolvedColumns 2026-07-30-20:15 (Phase B — one shared predicate):
|
|
The degraded arm now composes `utils/columnRoles`' predicates instead of naming ids, so the legacy
|
|
id list lives in exactly one place. Equivalent by construction rather than by inspection:
|
|
|
|
intake lane isIntakeColumnRole(undefined, col) -> `triage`
|
|
hold lane preImplementation AND NOT intake -> `todo`
|
|
|
|
which reproduces `col === "triage" || (col === "todo" && isReplanning)` exactly, because the
|
|
shared pre-implementation set is {todo, triage} and the shared intake id is `triage`.
|
|
|
|
Expressed as "not the intake lane" rather than a second id list, so if either shared set changes
|
|
this composition follows it instead of silently disagreeing with the file next door.
|
|
*/
|
|
const isLegacyIntakeLane = isIntakeColumnRole(undefined, task.column);
|
|
const isLegacyHoldLane = isPreImplementationColumnRole(undefined, task.column) && !isLegacyIntakeLane;
|
|
const inPlannerLane = options.columnFlags
|
|
? options.columnFlags.intake === true || (options.columnFlags.hold === true && isReplanning)
|
|
: isLegacyIntakeLane || (isLegacyHoldLane && isReplanning);
|
|
const hasFreshPlannerActivity = inPlannerLane
|
|
&& Number.isFinite(recentPlannerActivityAtMs)
|
|
&& nowMs - recentPlannerActivityAtMs >= 0
|
|
&& nowMs - recentPlannerActivityAtMs <= RECENT_PLANNER_ACTIVITY_WINDOW_MS;
|
|
|
|
return task.column === "in-progress" ||
|
|
ACTIVE_STATUSES.has(status ?? "") ||
|
|
isReplanning ||
|
|
hasFreshPlannerActivity ||
|
|
getUnifiedTaskProgress(task).items.some((item) => item.status === "running");
|
|
}
|