Drift 3/4: Task Detail intake affordances from traits — the UI half of the #2571 approve/reject stall (4→3) (#2577)
## Drift conversion 3 of 4 — Task Detail, the UI half of the #2571 stall **Stacks on #2566.** Merge order: #2558 → #2566 → this. (#2571 is the P0 and is independent — merge it first regardless.) ### Convergence number Live-code `column === / !== "todo" | "triage"` in `TaskDetailModal.tsx`: **4 → 3** All three survivors are the documented no-metadata fallback, same shape as TaskCard and ListView: `workflowMoveMetadata` is `null` until the detail payload resolves, and a bare trait read would drop these controls during that window. ### This is the UI half of the P0 `isAwaitingApproval` and the standalone Delete button were both gated on `task.column === "triage"`. On the merged lineage (#2515) that is false for every card, so a task parked `awaiting-approval` **loses its Approve/Reject controls in the one surface that shows them**. #2571 fixes the routes that *reject* those actions. This fixes the UI that stops *offering* them. Either half alone leaves the operator stuck — one with buttons that 400, the other with no buttons at all. ### Three conversions | site | was | now | |---|---|---| | `isAwaitingApproval` + standalone Delete | `column === "triage"` | resolved column's `intake` | | `requiresExecutionModeReplan` | `todo \|\| in-progress` | `hold \|\| countsTowardWip` | | move-progress prompt | source column ids | **target** column's flags | The replan rule is "this card may already hold a plan or a live execution context" — which the traits state directly; `todo`/`in-progress` was the Default workflow's spelling of it. The move prompt is the mistake I made first in TaskCard, where its regression test caught that the site tests the move **destination**, not the card. Carried the lesson here rather than repeating it. ### Tested through a pure seam, and why `requiresExecutionModeReplanForTest` is exported so the rule can be asserted as a function of (column id, flags). Asserting it through the modal means booting async detail loading to observe one boolean — and an earlier DOM-level attempt at exactly this class of assertion (in #2566, ListView) **passed with the conversion reverted**, because the text it matched also appears in a column header. I am not repeating that. A seam discriminates; that DOM test did not. Revert-proof: restore `column === "todo" || column === "in-progress"` and the merged-column case fails, because that column is `intake + hold` and carries no `countsTowardWip`. The suite also pins that the rule still **narrows** (a complete lane needs no replan) and that the legacy fallback is unchanged when flags are absent. ### Verification `pnpm test:gate` (414 + 10 + 71), `pnpm lint`, dashboard typecheck green. **No new failures**: `TaskDetailModal.rendering.test.tsx` reports the same 28 pre-existing failures with and without this change, diffed by test *name* against a stashed clean tree. ### Drift set status | file | before | after | PR | |---|---|---|---| | `TaskCard.tsx` | 8 | 3 | #2558 | | `ListView.tsx` | 5 | 3 | #2566 | | `taskActivity.ts` (found underneath) | 1 | 1 | #2566 | | `TaskDetailModal.tsx` | 4 | 3 | this | | `register-task-workflow-routes.ts` | 10 | 11 | #2571 (P0, widened on purpose) | Survivors are no-metadata fallbacks except the routes, where the guards deliberately accept resolved-intake **or** `triage` so a P0 fix cannot reject anything previously allowed. Those retire together once the legacy id is gone board-wide. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -333,7 +333,10 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
||||
const activeTaskCount = useMemo(
|
||||
() => tasks.filter((task) =>
|
||||
isRunningAgentTask(enrichRunningAgentTaskShapeFromFlags(task, columnFlags))
|
||||
|| isTaskAgentActive(task, { globalPaused, isStuck: isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs) }),
|
||||
// FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (PR #2566 review — greptile): these
|
||||
// tasks are IN this column, so the column's own flags are their column traits. Without
|
||||
// them the header undercounts executing work on a merged planning lane.
|
||||
|| isTaskAgentActive(task, { globalPaused, isStuck: isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs), columnFlags }),
|
||||
).length,
|
||||
[tasks, columnFlags, globalPaused, taskStuckTimeoutMs, lastFetchTimeMs],
|
||||
);
|
||||
|
||||
@@ -855,6 +855,21 @@ export function ListView({
|
||||
return null;
|
||||
}, [boardWorkflows, workflowMode]);
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
|
||||
The card's INTAKE role, from its own column's traits. Both grouped-list render paths
|
||||
gated the transient Planning badge on `task.column === "triage"`, which U11 deletes —
|
||||
the badge would simply stop appearing on planning rows, with nothing failing.
|
||||
|
||||
ONE fallback, matching TaskCard: `columnFlagsById` has no entry for a column the
|
||||
resolved workflow does not declare (a stranded card, or the pre-load window), and a
|
||||
bare trait read would drop the badge there too.
|
||||
*/
|
||||
const isIntakeColumnForTask = useCallback((task: Task): boolean => {
|
||||
const flags = columnFlagsById.get(task.column);
|
||||
return flags ? flags.intake === true : task.column === "triage";
|
||||
}, [columnFlagsById]);
|
||||
|
||||
const isArchivedColumn = useCallback((column: ColumnId): boolean => {
|
||||
return workflowMode ? Boolean(columnFlagsById.get(column)?.archived) : column === "archived";
|
||||
}, [columnFlagsById, workflowMode]);
|
||||
@@ -1903,8 +1918,19 @@ export function ListView({
|
||||
try {
|
||||
const hasStepProgress = task.steps.some((step) => step.status !== "pending");
|
||||
const targetFlags = columnFlagsById.get(column);
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
|
||||
Flags FIRST, ids only when the destination has no resolved metadata. The previous
|
||||
form OR-ed the two, so a column merely NAMED `todo` or `triage` prompted regardless
|
||||
of its traits — and post-U11 that is the merged column's id, meaning the legacy
|
||||
disjunct would keep firing for reasons unrelated to what the column IS. Reading the
|
||||
traits when they exist makes the rule mean "moving back into a pre-implementation
|
||||
lane", which is the thing worth warning about.
|
||||
*/
|
||||
const shouldPrompt = hasStepProgress && (
|
||||
column === "todo" || column === "triage" || Boolean(targetFlags?.intake || targetFlags?.hold)
|
||||
targetFlags
|
||||
? Boolean(targetFlags.intake || targetFlags.hold)
|
||||
: column === "todo" || column === "triage"
|
||||
);
|
||||
let moveOptions: { preserveProgress?: boolean } | undefined;
|
||||
|
||||
@@ -2451,8 +2477,12 @@ export function ListView({
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
const hasStepProgress = task?.steps.some((step) => step.status !== "pending") ?? false;
|
||||
const targetFlags = columnFlagsById.get(column);
|
||||
// Same rule as the context-menu move above: flags first, ids only as the
|
||||
// no-metadata fallback.
|
||||
const shouldPrompt = hasStepProgress && (
|
||||
column === "todo" || column === "triage" || Boolean(targetFlags?.intake || targetFlags?.hold)
|
||||
targetFlags
|
||||
? Boolean(targetFlags.intake || targetFlags.hold)
|
||||
: column === "todo" || column === "triage"
|
||||
);
|
||||
|
||||
let moveOptions: { preserveProgress?: boolean } | undefined;
|
||||
@@ -2941,9 +2971,9 @@ export function ListView({
|
||||
const isFailed = !isDoneColumn && task.status === "failed" && !hasPendingAutomaticRecovery(task, lastFetchTimeMs);
|
||||
const isPaused = !isDoneColumn && task.paused === true;
|
||||
const isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);
|
||||
const isAgentActive = isTaskAgentActive(task, { globalPaused, isStuck: isStuckState });
|
||||
const isAgentActive = isTaskAgentActive(task, { globalPaused, isStuck: isStuckState, columnFlags: columnFlagsById.get(task.column) });
|
||||
// FNXC:TaskStatusBadge 2026-07-28-12:00: FN-8300 renders the same transient Planning badge as TaskCard so fresh planner logs never make grouped-list cards appear idle.
|
||||
const isTransientPlannerActive = task.column === "triage"
|
||||
const isTransientPlannerActive = isIntakeColumnForTask(task)
|
||||
&& !visualStatus
|
||||
&& Boolean(task.recentAgentActivityAt)
|
||||
&& isAgentActive;
|
||||
@@ -3204,9 +3234,9 @@ export function ListView({
|
||||
const isFailed = !isDoneColumn && task.status === "failed" && !hasPendingAutomaticRecovery(task, lastFetchTimeMs);
|
||||
const isPaused = !isDoneColumn && task.paused === true;
|
||||
const isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);
|
||||
const isAgentActive = isTaskAgentActive(task, { globalPaused, isStuck: isStuckState });
|
||||
const isAgentActive = isTaskAgentActive(task, { globalPaused, isStuck: isStuckState, columnFlags: columnFlagsById.get(task.column) });
|
||||
const isReviewBudgetExhausted = isReviewBudgetExhaustedApproval(task);
|
||||
const isTransientPlannerActive = task.column === "triage"
|
||||
const isTransientPlannerActive = isIntakeColumnForTask(task)
|
||||
&& !visualStatus
|
||||
&& Boolean(task.recentAgentActivityAt)
|
||||
&& isAgentActive;
|
||||
|
||||
@@ -1433,7 +1433,15 @@ function TaskCardComponent({
|
||||
const isPlanReviewReplanCapApproval = isReviewBudgetExhaustedApproval(task);
|
||||
const isAwaitingInput = task.status === "awaiting-user-input";
|
||||
const isArchived = task.column === "archived";
|
||||
const isAgentActive = isTaskAgentActive(task, { globalPaused, queued, isStuck });
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (PR #2566 review — greptile):
|
||||
Pass the card's column traits. Without them the planner-lane clause falls back to the
|
||||
legacy ids, so a status-null card on the MERGED planning lane (id `todo`, intake+hold)
|
||||
is not recognised as having fresh planner activity: the pulsing Planning state, the
|
||||
optional-gate activity and the column's executing count all read idle. Threading
|
||||
ListView alone left this path — the board cards — still broken.
|
||||
*/
|
||||
const isAgentActive = isTaskAgentActive(task, { globalPaused, queued, isStuck, columnFlags: taskColumnFlags });
|
||||
/*
|
||||
FNXC:TaskCardOptionalGateBadge 2026-07-21-22:30:
|
||||
Match FN-8055: optional-gate badges pulse only while the card is agent-active (queue/pause/stuck gates suppress the badge).
|
||||
@@ -2505,7 +2513,16 @@ function TaskCardComponent({
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
}, [addToast, columnLabel, confirm, onMoveTask, task.id, task.steps, t]);
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (PR #2566 review — greptile):
|
||||
`taskMoveColumns` MUST be a dependency. The prompt now resolves the target column's
|
||||
traits from it, so omitting it pins the callback to whatever metadata existed at first
|
||||
render: once the board-workflows payload arrives or changes, a move into a custom
|
||||
intake/hold lane would skip the preserve-progress confirmation entirely (silently
|
||||
resetting work), while stale traits could prompt for a lane that is no longer
|
||||
pre-implementation. I added the lookup and missed the dep.
|
||||
*/
|
||||
}, [addToast, columnLabel, confirm, onMoveTask, task.id, task.steps, t, taskMoveColumns]);
|
||||
|
||||
const handleTaskActionCheckPrStatus = useCallback(async () => {
|
||||
try {
|
||||
|
||||
@@ -537,14 +537,31 @@ function normalizeExecutionModeValue(executionMode: Task["executionMode"]): "sta
|
||||
return executionMode === "fast" ? "fast" : "standard";
|
||||
}
|
||||
|
||||
function requiresExecutionModeReplan(column: Task["column"]): boolean {
|
||||
function requiresExecutionModeReplan(column: Task["column"], flags?: TaskContextMenuColumnFlags): boolean {
|
||||
/*
|
||||
FNXC:ExecutionModeReplan 2026-06-30-00:00:
|
||||
Todo and in-progress tasks can already hold a generated plan or active execution context. Changing Standard/Fast mode invalidates that plan, so the dashboard must confirm the change and send the task back through the existing replanning path instead of silently patching executionMode in place.
|
||||
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
|
||||
The rule is "this card may already hold a plan or a live execution context", which the
|
||||
traits state directly: a HOLD lane (planned, waiting for capacity) or a WIP lane
|
||||
(executing). Naming `todo` and `in-progress` was the Default workflow's spelling of
|
||||
that, and it silently narrows to nothing useful on a renamed workflow. Legacy ids
|
||||
remain the fallback for callers without resolved column metadata.
|
||||
*/
|
||||
if (flags) return flags.hold === true || flags.countsTowardWip === true;
|
||||
return column === "todo" || column === "in-progress";
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
|
||||
Test seam. The rule is a pure function of (column id, column flags), and asserting it
|
||||
through the full modal means booting async detail loading to observe one boolean — an
|
||||
earlier DOM-level attempt at this class of assertion in ListView passed with the
|
||||
conversion reverted, because the text it matched also appears in a column header.
|
||||
*/
|
||||
export const requiresExecutionModeReplanForTest = requiresExecutionModeReplan;
|
||||
|
||||
interface ProvenanceDisplay {
|
||||
label: string;
|
||||
labelHref?: string;
|
||||
@@ -2023,7 +2040,7 @@ export function TaskDetailContent({
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const replanAfterExecutionModeChange = Object.prototype.hasOwnProperty.call(updates, "executionMode") && requiresExecutionModeReplan(task.column);
|
||||
const replanAfterExecutionModeChange = Object.prototype.hasOwnProperty.call(updates, "executionMode") && requiresExecutionModeReplan(task.column, workflowMoveMetadata?.currentColumnFlags);
|
||||
if (replanAfterExecutionModeChange && !includeDescription) {
|
||||
delete updates.executionMode;
|
||||
}
|
||||
@@ -2161,7 +2178,7 @@ export function TaskDetailContent({
|
||||
const currentMode = normalizeExecutionModeValue(task.executionMode);
|
||||
const nextMode = currentMode === "fast" ? "standard" : "fast";
|
||||
const previousMode = inlineExecutionMode;
|
||||
const shouldReplan = requiresExecutionModeReplan(task.column);
|
||||
const shouldReplan = requiresExecutionModeReplan(task.column, workflowMoveMetadata?.currentColumnFlags);
|
||||
|
||||
if (shouldReplan) {
|
||||
const shouldChangeMode = await confirm({
|
||||
@@ -2510,7 +2527,18 @@ export function TaskDetailContent({
|
||||
async (column: Column) => {
|
||||
try {
|
||||
const hasStepProgress = task.steps.some((step) => step.status !== "pending");
|
||||
const shouldPrompt = (column === "todo" || column === "triage") && hasStepProgress;
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
|
||||
The TARGET column's role, not this card's — moving BACK into a pre-implementation
|
||||
lane is what risks discarding step progress. (The same site in TaskCard is where I
|
||||
first got this backwards; its regression test caught it.) Falls back to the legacy
|
||||
ids when the destination has no resolved metadata.
|
||||
*/
|
||||
const targetFlags = workflowMoveMetadata?.moveColumns?.find((candidate) => candidate.id === column)?.flags;
|
||||
const targetIsPreImplementation = targetFlags
|
||||
? targetFlags.intake === true || targetFlags.hold === true
|
||||
: column === "todo" || column === "triage";
|
||||
const shouldPrompt = targetIsPreImplementation && hasStepProgress;
|
||||
|
||||
let moveOptions: { preserveProgress?: boolean } | undefined;
|
||||
if (shouldPrompt) {
|
||||
@@ -3006,7 +3034,16 @@ export function TaskDetailContent({
|
||||
plan-review-replan-cap, explain that Plan Review exhausted automatic REVISE replans
|
||||
without converging so the operator is not guessing why the task is parked.
|
||||
*/
|
||||
const isAwaitingApproval = task.column === "triage" && task.status === "awaiting-approval";
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
|
||||
The INTAKE lane's approval hold. `task.column === "triage"` is deleted by U11, which
|
||||
would silently drop the Approve/Reject controls from a parked planning card — the
|
||||
operator sees a task stuck "awaiting approval" with no way to answer it.
|
||||
*/
|
||||
const isIntakeColumn = workflowMoveMetadata?.currentColumnFlags
|
||||
? workflowMoveMetadata.currentColumnFlags.intake === true
|
||||
: task.column === "triage";
|
||||
const isAwaitingApproval = isIntakeColumn && task.status === "awaiting-approval";
|
||||
const isPlanReviewReplanCapApproval = isReviewBudgetExhaustedApproval(task);
|
||||
|
||||
const handleTogglePause = useCallback(async () => {
|
||||
@@ -6390,10 +6427,11 @@ export function TaskDetailContent({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Standalone Delete button for triage-column tasks — triage tasks
|
||||
hide the Actions dropdown (see condition below) so the user has
|
||||
no quick way to delete a freshly-created task otherwise. */}
|
||||
{task.column === "triage" && !isAwaitingApproval && !canRetryTask && (
|
||||
{/* Standalone Delete button for INTAKE-lane tasks — they hide the Actions
|
||||
dropdown (see condition below) so the user has no quick way to delete a
|
||||
freshly-created task otherwise. Keyed on the intake trait rather than the
|
||||
`triage` id, which U11 deletes. */}
|
||||
{isIntakeColumn && !isAwaitingApproval && !canRetryTask && (
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={handleDelete}
|
||||
|
||||
@@ -53,8 +53,27 @@ describe("TaskCard on the U11 merged planning column", () => {
|
||||
expect(document.querySelector(".card-delete-btn")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("still shows the planner-active badge on a planning card", () => {
|
||||
render(
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (PR #2566 review — greptile):
|
||||
REPLACED by the agent-active case below, which asserts the pulsing badge element.
|
||||
The original matched `/planning/i` as TEXT and was not discriminating: before the
|
||||
`columnFlags` threading the badge did not render at all, yet the case passed because
|
||||
other "Planning" text is present on the card. Same mistake I made in the ListView DOM
|
||||
test — matching a word that also appears in chrome.
|
||||
*/
|
||||
|
||||
it("reads as agent-active from fresh planner activity on the merged column", () => {
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (PR #2566 review — greptile):
|
||||
`isTaskAgentActive`'s planner-lane clause needs this card's column traits. Without
|
||||
them it falls back to the legacy ids, and a status-null card on the merged lane reads
|
||||
IDLE — pulsing Planning state gone, optional-gate activity suppressed, column header
|
||||
undercounting executing work. Threading ListView alone left the board cards broken.
|
||||
|
||||
REVERT CHECK: drop `columnFlags` from TaskCard's `isTaskAgentActive` call and this
|
||||
fails — the pulsing class disappears because the card is in `todo`, not `triage`.
|
||||
*/
|
||||
const { container } = render(
|
||||
<TaskCard
|
||||
task={planningTask({ recentAgentActivityAt: new Date().toISOString() } as Partial<Task>)}
|
||||
taskColumnFlags={MERGED_PLANNING_FLAGS}
|
||||
@@ -62,7 +81,7 @@ describe("TaskCard on the U11 merged planning column", () => {
|
||||
addToast={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/planning/i)).toBeTruthy();
|
||||
expect(container.querySelector(".pulsing")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT offer Start on the merged column, which auto-triages", () => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion, post-#2515):
|
||||
Task Detail's intake-lane affordances under the merged column shape — one
|
||||
pre-implementation column, id `todo`, `triage` gone from the default lineage.
|
||||
|
||||
WHAT BREAKS WITHOUT THIS. `isAwaitingApproval` and the standalone Delete button were both
|
||||
gated on `task.column === "triage"`. On a merged lineage that is false for every card, so
|
||||
a task parked `awaiting-approval` loses its Approve/Reject controls in the one surface
|
||||
that shows them — the operator sees a task demanding a decision with no way to give it.
|
||||
That is the same stall as the approve/reject ROUTES (#2571), reached from the UI side
|
||||
instead of the API side, which is why both halves needed converting.
|
||||
|
||||
Tested through the pure predicate rather than the modal DOM: `TaskDetailModal` is a large
|
||||
component with heavy async detail loading, and an earlier attempt at a DOM assertion in
|
||||
ListView passed with the conversion REVERTED because the text it matched also appears in
|
||||
a column header. A predicate test discriminates.
|
||||
|
||||
REVERT CHECK: restore `column === "todo" || column === "in-progress"` and the merged-column
|
||||
case fails, because the merged column is intake+hold and carries no `countsTowardWip`.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { requiresExecutionModeReplanForTest } from "../TaskDetailModal";
|
||||
|
||||
describe("TaskDetailModal execution-mode replan on the merged planning column", () => {
|
||||
it("requires a replan for the merged planning column (hold)", () => {
|
||||
// Post-#2515 the planning column is intake+hold and keeps the id `todo`. The rule is
|
||||
// "this card may already hold a plan", which `hold` states directly.
|
||||
expect(requiresExecutionModeReplanForTest("todo", { intake: true, hold: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a replan for a WIP lane whatever it is called", () => {
|
||||
expect(requiresExecutionModeReplanForTest("building", { countsTowardWip: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT require a replan for a terminal lane", () => {
|
||||
// The rule must still narrow: a done card has no plan to invalidate.
|
||||
expect(requiresExecutionModeReplanForTest("shipped", { complete: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the legacy ids when the column has no resolved metadata", () => {
|
||||
// Pre-load window / stranded card: behaviour must not change there.
|
||||
expect(requiresExecutionModeReplanForTest("todo", undefined)).toBe(true);
|
||||
expect(requiresExecutionModeReplanForTest("in-progress", undefined)).toBe(true);
|
||||
expect(requiresExecutionModeReplanForTest("triage", undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
|
||||
`isTaskAgentActive` under the U11 COLUMN SHAPE — merged pre-implementation column keeps
|
||||
the id `todo`, `triage` is deleted.
|
||||
|
||||
This predicate is the one that matters most in the dashboard drift, and it is not a
|
||||
badge: it drives the pulsing status badge, the agent-active row border, AND the column
|
||||
header's executing count. Its fresh-planner-activity clause was keyed on
|
||||
`column === "triage"`, so once U11 lands a planning card with live planner logs reads as
|
||||
IDLE everywhere at once — the board quietly stops reporting that planning is happening,
|
||||
with nothing failing.
|
||||
|
||||
REVERT CHECK: drop the `columnFlags` branch and "merged planning column" fails — the card
|
||||
is in `todo` without `needs-replan`, which the legacy clause does not recognise as a
|
||||
planner lane.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { isTaskAgentActive } from "../taskActivity";
|
||||
|
||||
function plannerCard(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-U11",
|
||||
column: "todo",
|
||||
status: undefined,
|
||||
steps: [],
|
||||
recentAgentActivityAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
} as unknown as Task;
|
||||
}
|
||||
|
||||
describe("isTaskAgentActive planner lane (U11 merged column)", () => {
|
||||
it("recognises fresh planner activity on the merged planning column", () => {
|
||||
// intake + hold, id `todo` — the post-U11 shape. Without the trait branch this is
|
||||
// false, because the legacy clause only accepts `triage` (or `todo` while replanning).
|
||||
expect(isTaskAgentActive(plannerCard(), { columnFlags: { intake: true, hold: true } })).toBe(true);
|
||||
});
|
||||
|
||||
it("still recognises the legacy triage lane when no flags are supplied", () => {
|
||||
// The fallback path: callers without resolved metadata keep today's behaviour.
|
||||
expect(isTaskAgentActive(plannerCard({ column: "triage" as Task["column"] }), {})).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT treat a hold lane as a planner lane unless it is replanning", () => {
|
||||
// The `hold && isReplanning` half, preserved from the legacy `todo && needs-replan`
|
||||
// clause — a card merely waiting for capacity is not agent-active.
|
||||
expect(isTaskAgentActive(plannerCard(), { columnFlags: { hold: true } })).toBe(false);
|
||||
expect(
|
||||
isTaskAgentActive(plannerCard({ status: "needs-replan" as Task["status"] }), { columnFlags: { hold: true } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not report activity for a stale planner timestamp", () => {
|
||||
const stale = plannerCard({ recentAgentActivityAt: new Date(Date.now() - 60 * 60 * 1000).toISOString() });
|
||||
expect(isTaskAgentActive(stale, { columnFlags: { intake: true, hold: true } })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,20 @@ 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 };
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -64,7 +78,16 @@ export function isTaskAgentActive(
|
||||
const isReplanning = status === "needs-replan";
|
||||
const recentPlannerActivityAtMs = Date.parse(task.recentAgentActivityAt ?? "");
|
||||
const nowMs = Date.now();
|
||||
const hasFreshPlannerActivity = (task.column === "triage" || (task.column === "todo" && isReplanning))
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
const inPlannerLane = options.columnFlags
|
||||
? options.columnFlags.intake === true || (options.columnFlags.hold === true && isReplanning)
|
||||
: task.column === "triage" || (task.column === "todo" && isReplanning);
|
||||
const hasFreshPlannerActivity = inPlannerLane
|
||||
&& Number.isFinite(recentPlannerActivityAtMs)
|
||||
&& nowMs - recentPlannerActivityAtMs >= 0
|
||||
&& nowMs - recentPlannerActivityAtMs <= RECENT_PLANNER_ACTIVITY_WINDOW_MS;
|
||||
|
||||
Reference in New Issue
Block a user