## 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>
101 lines
4.7 KiB
TypeScript
101 lines
4.7 KiB
TypeScript
import type { Task } from "@fusion/core";
|
|
import { getUnifiedTaskProgress } from "./taskProgress";
|
|
|
|
/** 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.
|
|
*/
|
|
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;
|
|
|
|
return task.column === "in-progress" ||
|
|
ACTIVE_STATUSES.has(status ?? "") ||
|
|
isReplanning ||
|
|
hasFreshPlannerActivity ||
|
|
getUnifiedTaskProgress(task).items.some((item) => item.status === "running");
|
|
}
|