Unclaimed file, no overlap with any open fleet PR. Previous PR (#2756) is merged, so this is my one open PR. ## Census | | before | after | |---|---|---| | backlog | 447 | **441** | | reviewed | 38 | 44 | | this file | 6 | **0** | `--strict` exit 0, baseline re-recorded in the same commit. ## Why marked, not converted All six literals sit after a `??` or a `flags ? … :`. Each is reached **only** when the caller supplied no trait flags and no enriched shape — precisely the case `enrichRunningAgentTaskShape` (takes the IR) and `enrichRunningAgentTaskShapeFromFlags` (takes board flags) exist to remove. There is nothing to resolve from there, so the choice is not convert-vs-literal; it is **known legacy answer vs a different guess.** **And the guess is not neutral.** Running and Waiting are *complements* over the same rows: ```ts isWaitingAgentTask = !running && (columnIsIntakeOrHold ?? isLegacyPreImplementationColumn(column)) ``` A card matching neither arm is reported as **neither running nor waiting**, so the footer's queued total silently under-reports it. Guessing "not WIP" or "not review" loses cards from the count; the legacy id at least matches every pre-rename board. That is why this file already carries a `DELIBERATE-LITERAL` marker above `isLegacyPreImplementationColumn` with the same argument — this PR extends it to the three functions holding the remaining fallbacks (`enrichRunningAgentTaskShapeFromFlags`, `terminalKind`, `isRunningAgentTask`). **The fix for a renamed board is at the CALLER** — pass flags, or use the IR-taking enricher. Noted at the site. ## Pattern note for the fleet This is the third file I have taken where `N → 0` is reached by marking rather than converting, and they share a shape worth naming: **a literal after `??` or in the `else` of a `flags ?` ternary is a degraded-mode answer, not an unconverted guard.** The trait path is already there and already correct; the literal is what runs when the trait path has no input. Deleting it does not remove a decision — it substitutes a different one, silently, in exactly the states where nobody is looking (first paint, un-enriched callers, pre-rename data). ## Verification Core typecheck clean · `live-agent-count.test.ts` 11/11 · `--strict` exit 0 · comments only, no behavior change. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
216 lines
12 KiB
TypeScript
216 lines
12 KiB
TypeScript
import { ACTIVE_MERGE_PIPELINE_STATUSES } from "./active-merge-status.js";
|
|
import type { TraitFlags } from "./trait-types.js";
|
|
import type { Task } from "./types.js";
|
|
import type { WorkflowIr } from "./workflow-ir-types.js";
|
|
import { columnHasFlag } from "./workflow-lifecycle-traits.js";
|
|
|
|
export type RunningAgentCountSource = (projectIds: readonly string[]) => Promise<Record<string, number>> | Record<string, number>;
|
|
|
|
/** Terminal classification supplied by a workflow-IR or board-flags enricher. */
|
|
export type ColumnTerminalKind = "none" | "complete" | "archived";
|
|
|
|
/**
|
|
* The deliberately small, pure shape used by all top-level live-agent counts.
|
|
* Store- and board-backed callers must attach trait-derived fields first.
|
|
*/
|
|
export type RunningAgentTaskShape = Pick<Task, "column" | "status" | "paused" | "userPaused" | "sessionFile" | "checkedOutBy"> & Partial<Pick<Task, "workflowStepResults">> & {
|
|
columnTerminalKind?: ColumnTerminalKind;
|
|
/** Trait-derived intake/hold membership, used by {@link isWaitingAgentTask}. */
|
|
columnIsIntakeOrHold?: boolean;
|
|
/** Trait-derived WIP membership; legacy fixtures fall back to in-progress. */
|
|
columnCountsTowardWip?: boolean;
|
|
/** Trait-derived review/merge membership; active merge statuses are live only here. */
|
|
columnIsReviewOrMerge?: boolean;
|
|
};
|
|
|
|
/*
|
|
FNXC:ConcurrencyIndicators 2026-08-03-12:00:
|
|
FN-8453 / GitHub #2359 defines Running as a live top-level working agent, not a
|
|
board-column or worktree-holder count. Every production store- or board-backed
|
|
consumer enriches this pure shape from workflow traits before it counts; the
|
|
literal terminal fallback exists only for legacy fixtures while no IR is loaded.
|
|
|
|
FNXC:ConcurrencyIndicators 2026-07-21-19:00:
|
|
Unpaused WIP membership is sufficient for execute Running. sessionFile is not a
|
|
persisted task column (absent from TaskRow, listTasks slim, and board payloads),
|
|
so requiring sessionFile/checkedOutBy undercounted footer Running (e.g. 1 of 23)
|
|
and under-claimed admission capacity. Pause/user-pause and terminal traits still
|
|
exclude parked shells; durable session/checkout remain optional positive signals
|
|
but are not required for WIP.
|
|
*/
|
|
const ACTIVE_IN_REVIEW_AGENT_STATUSES = new Set([
|
|
...ACTIVE_MERGE_PIPELINE_STATUSES,
|
|
"fixing",
|
|
]);
|
|
|
|
let runningAgentCountSource: RunningAgentCountSource | undefined;
|
|
|
|
export function setRunningAgentCountSource(fn: RunningAgentCountSource | undefined): void {
|
|
runningAgentCountSource = fn;
|
|
}
|
|
|
|
export function getRunningAgentCountSource(): RunningAgentCountSource | undefined {
|
|
return runningAgentCountSource;
|
|
}
|
|
|
|
export interface RunningAgentCounts {
|
|
currentlyActive: number;
|
|
projectsActive: Record<string, number>;
|
|
}
|
|
|
|
/** Resolve the terminal classification of one column from its workflow IR. */
|
|
export function resolveColumnTerminalKind(columnId: string, ir: WorkflowIr): ColumnTerminalKind {
|
|
if (columnHasFlag(ir, columnId, "archived")) return "archived";
|
|
if (columnHasFlag(ir, columnId, "complete")) return "complete";
|
|
return "none";
|
|
}
|
|
|
|
/*
|
|
FNXC:WorkflowLifecycleColumns 2026-07-30-10:20 (Phase C convergence — live-agent-count.ts):
|
|
|
|
THE PRE-IMPLEMENTATION FALLBACK, named once instead of spelled out at two call sites.
|
|
|
|
DELIBERATE-LITERAL, and the reason is not "we ran out of time": this is the answer used when
|
|
the caller supplies NO trait flags at all. There is nothing to resolve from. `enrich...FromFlags`
|
|
exists precisely for callers that have board-column flags rather than an IR (the dashboard
|
|
footer), and a column missing from that flag map is the renamed-or-undeclared case.
|
|
|
|
Converting it would mean deciding what an ABSENT flag set means, and "not intake" is as much a
|
|
guess as "todo is intake" — either choice silently moves an operator-visible count. The two
|
|
counts this feeds (Running and Waiting) are complements over the same rows, so a card matching
|
|
neither arm is reported as neither running nor waiting and the footer's queued total
|
|
under-reports it. Guessing here is worse than the known legacy answer.
|
|
|
|
The real fix for a renamed board is at the CALLER: supply flags (or use
|
|
`enrichRunningAgentTaskShape`, which takes the IR and resolves every role by trait). This
|
|
fallback only has to keep behaving exactly as it did for legacy rows.
|
|
|
|
Both former literal sites now share this function, so the pair cannot drift apart — they were
|
|
two hand-written copies of one rule, and line 84 answering differently from line 143 would put
|
|
a card in both counts or neither.
|
|
*/
|
|
const LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS: ReadonlySet<string> = new Set(["triage", "todo"]);
|
|
|
|
/** Legacy-vocabulary "is this column a planner lane?", for callers that supply no traits. */
|
|
function isLegacyPreImplementationColumn(columnId: string): boolean {
|
|
return LEGACY_PRE_IMPLEMENTATION_COLUMN_IDS.has(columnId);
|
|
}
|
|
|
|
/** Attach the workflow traits required by the pure Running and Waiting predicates. */
|
|
export function enrichRunningAgentTaskShape<T extends RunningAgentTaskShape>(task: T, ir: WorkflowIr): T & Required<Pick<RunningAgentTaskShape, "columnTerminalKind" | "columnIsIntakeOrHold" | "columnCountsTowardWip" | "columnIsReviewOrMerge">> {
|
|
return {
|
|
...task,
|
|
columnTerminalKind: resolveColumnTerminalKind(task.column, ir),
|
|
columnIsIntakeOrHold: columnHasFlag(ir, task.column, "intake") || columnHasFlag(ir, task.column, "hold"),
|
|
columnCountsTowardWip: columnHasFlag(ir, task.column, "countsTowardWip"),
|
|
columnIsReviewOrMerge: columnHasFlag(ir, task.column, "mergeOrchestration") || columnHasFlag(ir, task.column, "mergeBlocker"),
|
|
};
|
|
}
|
|
|
|
/** Attach the same traits from dashboard board-column flags without loading an IR. */
|
|
/*
|
|
FNXC:ConcurrencyIndicators 2026-07-30-03:40 DELIBERATE-LITERAL: the no-enrichment fallback only.
|
|
Every literal below sits after a `??` or a `flags ? … :` — it is reached ONLY when the caller
|
|
supplied no trait flags and no enriched shape, which is the case the enrichers exist to remove.
|
|
There is nothing to resolve from in that state, so converting is not possible; the choice is only
|
|
between the known legacy answer and a different guess.
|
|
|
|
That choice is not neutral here. Running and Waiting are COMPLEMENTS over the same rows, so a card
|
|
matching neither arm is reported as neither running nor waiting and the footer's queued total
|
|
silently under-reports it. Guessing "not WIP" or "not review" is therefore worse than the legacy id,
|
|
which at least matches every pre-rename board.
|
|
|
|
The fix for a renamed board is at the CALLER — pass flags, or use `enrichRunningAgentTaskShape`,
|
|
which takes the IR and resolves every role by trait. Same reasoning as the marker above
|
|
`isLegacyPreImplementationColumn`, which this file already records.
|
|
*/
|
|
export function enrichRunningAgentTaskShapeFromFlags<T extends RunningAgentTaskShape>(task: T, flags?: Pick<TraitFlags, "complete" | "archived" | "intake" | "hold" | "countsTowardWip" | "mergeOrchestration" | "mergeBlocker">): T & Required<Pick<RunningAgentTaskShape, "columnTerminalKind" | "columnIsIntakeOrHold" | "columnCountsTowardWip" | "columnIsReviewOrMerge">> {
|
|
return {
|
|
...task,
|
|
columnTerminalKind: flags?.archived ? "archived" : flags?.complete ? "complete" : "none",
|
|
columnIsIntakeOrHold: flags ? flags.intake === true || flags.hold === true : isLegacyPreImplementationColumn(task.column),
|
|
columnCountsTowardWip: flags ? flags.countsTowardWip === true : task.column === "in-progress",
|
|
/*
|
|
FNXC:WorkflowLifecycleColumns 2026-07-29-23:10 (reason now at
|
|
`isLegacyPreImplementationColumn`): these id fallbacks are REACHABLE, not fixture-only —
|
|
a column absent from the board's flag map is the renamed-or-undeclared case. Supply flags
|
|
rather than relying on them.
|
|
*/
|
|
columnIsReviewOrMerge: flags ? flags.mergeOrchestration === true || flags.mergeBlocker === true : task.column === "in-review",
|
|
};
|
|
}
|
|
|
|
/*
|
|
FNXC:ConcurrencyIndicators 2026-07-22-05:45:
|
|
Lane-owned optional gates (Code Review / Browser Verification / Plan Review) run their reviewer
|
|
session with task.status left null — the durable live signal is the step's `pending`
|
|
workflow-step-result lease (U3/KTD-4; FN-8492 fails orphaned leases, so pending ≈ live).
|
|
Without counting it, an In Review column with one MERGING task and one live CODE REVIEW task
|
|
showed 1/2 processing, and admission under-counted the live reviewer. A pending lease on an
|
|
unpaused, non-terminal row counts as Running everywhere the shared predicate is used.
|
|
*/
|
|
function hasLiveWorkflowStepLease(task: RunningAgentTaskShape): boolean {
|
|
return task.workflowStepResults?.some((result) => result.status === "pending") === true;
|
|
}
|
|
|
|
/*
|
|
FNXC:ConcurrencyIndicators 2026-07-30-03:40 DELIBERATE-LITERAL: the no-enrichment fallback only.
|
|
Full rationale at the first marker of this name above (enrichRunningAgentTaskShapeFromFlags): these
|
|
legacy-id literals are the flag-less fallback the enrichers exist to remove; converting here would
|
|
guess, and a wrong guess under-reports the queued total. Fix at the CALLER by passing flags/IR.
|
|
*/
|
|
function terminalKind(task: RunningAgentTaskShape): ColumnTerminalKind {
|
|
// Legacy literals are intentionally fixture-only degradation when workflow IR is unavailable.
|
|
return task.columnTerminalKind ?? (task.column === "done" ? "complete" : task.column === "archived" ? "archived" : "none");
|
|
}
|
|
|
|
/**
|
|
* Returns true only for a live, unpaused top-level agent.
|
|
* Planning may run in any non-terminal workflow column. Unpaused WIP columns
|
|
* count as execute holders (sessionFile is not on the board/DB row path).
|
|
* Active review/merge statuses count only in review/merge columns.
|
|
* A live `pending` workflow-step lease (e.g. an in-flight Code Review gate)
|
|
* counts in any non-terminal column, since gate sessions run with null status.
|
|
*/
|
|
/*
|
|
FNXC:ConcurrencyIndicators 2026-07-30-03:40 DELIBERATE-LITERAL: the no-enrichment fallback only.
|
|
Full rationale at the first marker of this name above (enrichRunningAgentTaskShapeFromFlags): these
|
|
legacy-id literals are the flag-less fallback the enrichers exist to remove; converting here would
|
|
guess, and a wrong guess under-reports the queued total. Fix at the CALLER by passing flags/IR.
|
|
*/
|
|
export function isRunningAgentTask(task: RunningAgentTaskShape): boolean {
|
|
if (task.paused || task.userPaused || terminalKind(task) !== "none") return false;
|
|
if (task.status === "planning") return true;
|
|
// Review statuses are not globally live: a stale status in intake/WIP must not consume capacity.
|
|
if (ACTIVE_IN_REVIEW_AGENT_STATUSES.has(String(task.status ?? ""))) {
|
|
return task.columnIsReviewOrMerge ?? task.column === "in-review";
|
|
}
|
|
// A live gate-session lease (pending step result) is Running even with a null status.
|
|
if (hasLiveWorkflowStepLease(task)) return true;
|
|
const isWip = task.columnCountsTowardWip ?? task.column === "in-progress";
|
|
return isWip;
|
|
}
|
|
|
|
/** Exact footer waiting membership: unpaused, non-terminal intake/hold work that is not live. */
|
|
export function isWaitingAgentTask(task: RunningAgentTaskShape): boolean {
|
|
if (task.paused || task.userPaused || terminalKind(task) !== "none" || isRunningAgentTask(task)) return false;
|
|
return task.columnIsIntakeOrHold ?? isLegacyPreImplementationColumn(task.column);
|
|
}
|
|
|
|
export function countRunningAgentTasks(tasks: readonly RunningAgentTaskShape[]): number {
|
|
return tasks.filter(isRunningAgentTask).length;
|
|
}
|
|
|
|
export function deriveRunningAgentCounts(perProject: Record<string, number>): RunningAgentCounts {
|
|
const projectsActive: Record<string, number> = {};
|
|
let currentlyActive = 0;
|
|
for (const [projectId, rawCount] of Object.entries(perProject)) {
|
|
const count = Number.isFinite(rawCount) ? Math.max(0, Math.trunc(rawCount)) : 0;
|
|
currentlyActive += count;
|
|
if (count > 0) projectsActive[projectId] = count;
|
|
}
|
|
return { currentlyActive, projectsActive };
|
|
}
|
|
|
|
|