Seven fixes that were sitting on separate handoff branches with no owner while `main` moved. Consolidated, rebased onto current `main`, and verified **together** rather than only per-branch. The individual branches remain if a subset is preferred. This is the same consolidation that got `batch-core` and #2787 adopted. **Close it if it breaks queue policy** — the branch keeps the work safe either way. ## Where these came from #2787's review found an optional parameter whose production caller never passed it. That is a class, so I ran it against everything I had landed and found five more. **All five turned out to have their real defect in the CALLER, not the parameter** — in four of them the parameter was unreachable: | unwired parameter | what was actually wrong | |---|---| | `blocker-fanout.escalationColumns` | the hold default made the count zero — **no bottleneck warning was emitted at all** | | analytics `columnFlagsByName` | routes never built a map — **0 in-progress / 0 in-review beside correct cost totals** | | `isLegacyAutoMergeStampCandidate` | the read **queried a column a renamed board does not have**, so the backfill iterated nothing | | `rankAssignedTasksForWakeDelta` | `getTasksByAssignedAgent`'s `excludeArchived` used the literal — **archived cards returned as open work** | | `duplicate-intake.columnFlagsByColumnId` | intake could **archive or soft-delete a newly created task** as a duplicate of finished work | The heuristic worth keeping: **an optional parameter no production caller fills is a marker pointing at an unexamined caller.** The census cannot see any of these five — every gate is a `Set`/array literal or a query filter, i.e. a definition rather than a comparison. ## Also included - **`executor.ts`** — the stale-spec guard did the exact thing its own comment forbids: on a renamed board it ran on a LIVE task and pulled it out of execution into replan. `activeMergeStatuses` protected merging cards *by accident*, which is why the symptom looked arbitrary. - **`register-project-routes.ts`** — project health reported **0 active tasks**; its list also still contained `triage`, dead since U11. - **`dashboard/app/utils/taskTiming.ts`** — a **second copy** of `getTotalAgentActiveMs`. Core's was converted; the card chip imports this one, so the census counted the site as done while the rendered number stayed keyed on `"in-progress"`. ## Verification Verified as a set: `pnpm test:gate` **161 / 13 / 487 / 71** · core suites **15 passed** · engine **7** · dashboard **12** · four `tsc` targets clean · lint clean · census `--strict` exits 0. Each fix is revert-proven individually; the specific case that fails is named in each test header. ## Two honesty notes **Three guards here are structural, not behavioural, and say so in their headers.** `sanitizeAgentTaskLinks` is a closure inside `createApiRoutes`; the analytics aggregators need a live `AsyncDataLayer`; the stale-spec guard sits deep inside `execute()`. Each ratchet fails on revert — verified — but none is an end-to-end proof, and the headers state which half they cover. **One of my behavioural test sets would have lied.** The intake-dedup cases drive `findSameAgentDuplicates` directly; I removed the wiring to measure the revert and **they stayed green**, because they pin the predicate and not the caller. That is the exact illusion this audit was chasing, reproduced in my own file. The forward now has its own structural check. ## Deliberately not included `worktree-pool.ts:1205` — it **fails safe** (a missed match protects a branch from cleanup rather than deleting it) and sits in the merger's branch-reaping path where the opposite error destroys work. That deserves its owner's judgement, not a drive-by conversion. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
162 lines
5.9 KiB
TypeScript
162 lines
5.9 KiB
TypeScript
import { isWipColumnRole, type ColumnRoleFlags } from "./columnRoles";
|
|
import type { Task, TaskLogEntry, WorkflowStepResult } from "@fusion/core";
|
|
|
|
export interface TimingEvent {
|
|
timestamp: string;
|
|
durationMs?: number;
|
|
summary: string;
|
|
}
|
|
|
|
function summarizeTimingLabel(entry: TaskLogEntry): string {
|
|
const timingText = entry.action || entry.outcome || "";
|
|
const stripped = timingText
|
|
.replace(/^\[timing\]\s*/i, "")
|
|
.replace(/^\[[^\]]+\]\s*/i, "")
|
|
.replace(/\s+in\s+\d+(?:\.\d+)?ms\b/i, "")
|
|
.replace(/\s+after\s+\d+(?:\.\d+)?ms\b/i, "")
|
|
.trim();
|
|
return stripped || "Timing event";
|
|
}
|
|
|
|
export function extractTimingEvents(logEntries: TaskLogEntry[]): TimingEvent[] {
|
|
return logEntries
|
|
.filter((entry) => {
|
|
const actionText = typeof entry.action === "string" ? entry.action : "";
|
|
const outcomeText = typeof entry.outcome === "string" ? entry.outcome : "";
|
|
return actionText.includes("[timing]") || outcomeText.includes("[timing]");
|
|
})
|
|
.map((entry) => {
|
|
const haystack = `${entry.action ?? ""}\n${entry.outcome ?? ""}`;
|
|
const durationMatch = haystack.match(/(\d+(?:\.\d+)?)ms\b/i);
|
|
const durationMs = durationMatch ? Number(durationMatch[1]) : undefined;
|
|
return {
|
|
timestamp: entry.timestamp,
|
|
durationMs: Number.isFinite(durationMs) ? durationMs : undefined,
|
|
summary: summarizeTimingLabel(entry),
|
|
};
|
|
});
|
|
}
|
|
|
|
export function getTimedDurationMs(logEntries: TaskLogEntry[] | undefined): number | null {
|
|
if (!logEntries || logEntries.length === 0) return null;
|
|
let total = 0;
|
|
let counted = 0;
|
|
for (const event of extractTimingEvents(logEntries)) {
|
|
if (typeof event.durationMs !== "number") continue;
|
|
total += event.durationMs;
|
|
counted += 1;
|
|
}
|
|
return counted > 0 ? total : null;
|
|
}
|
|
|
|
export function parseTimestampToMs(value?: string): number | null {
|
|
if (!value) return null;
|
|
const parsed = Date.parse(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
export function getWorkflowRuntimeMs(results: WorkflowStepResult[] | undefined, nowMs: number): number | null {
|
|
if (!results || results.length === 0) return null;
|
|
|
|
let total = 0;
|
|
let counted = 0;
|
|
for (const step of results) {
|
|
if (!step.startedAt) continue;
|
|
const startedMs = parseTimestampToMs(step.startedAt);
|
|
if (startedMs == null) continue;
|
|
|
|
let endMs: number;
|
|
if (step.completedAt) {
|
|
const completedMs = parseTimestampToMs(step.completedAt);
|
|
if (completedMs == null || completedMs < startedMs) continue;
|
|
endMs = completedMs;
|
|
} else {
|
|
endMs = Math.max(startedMs, nowMs);
|
|
}
|
|
|
|
total += endMs - startedMs;
|
|
counted += 1;
|
|
}
|
|
|
|
return counted > 0 ? total : null;
|
|
}
|
|
|
|
export function getEndToEndDurationMs(
|
|
executionStartedAt: string | undefined,
|
|
executionCompletedAt: string | undefined,
|
|
nowMs: number,
|
|
): number | null {
|
|
const startedMs = parseTimestampToMs(executionStartedAt);
|
|
if (startedMs == null) return null;
|
|
|
|
const completedMs = parseTimestampToMs(executionCompletedAt);
|
|
const endMs = completedMs != null && completedMs >= startedMs ? completedMs : nowMs;
|
|
return Math.max(0, endMs - startedMs);
|
|
}
|
|
|
|
export function getActiveRuntimeMs(
|
|
task: Pick<Task, "column" | "cumulativeActiveMs" | "executionStartedAt" | "columnMovedAt">,
|
|
nowMs: number,
|
|
/*
|
|
FNXC:WorkflowLifecycleColumns 2026-07-31-10:10:
|
|
THE DASHBOARD HAS ITS OWN COPY OF THIS FUNCTION, and converting core's did not touch it.
|
|
|
|
`@fusion/core`'s `task-timing.ts` exports a `getTotalAgentActiveMs` that was converted onto
|
|
`isWipColumnRole` — but the card chip imports THIS module instead, so that conversion never
|
|
reached the surface an operator actually looks at. Two implementations of one calculation, one
|
|
converted and one not, is the same drift `column-roles.ts` was created to end.
|
|
|
|
Keyed on the literal, the LIVE execution segment was dropped on a renamed board, so the card
|
|
under-reported the run in flight by exactly its elapsed time — and healed itself the moment the
|
|
card moved on and the segment was persisted.
|
|
|
|
Omitted flags keep the legacy id via `isWipColumnRole`'s own degraded mode.
|
|
*/
|
|
columnFlags?: ColumnRoleFlags,
|
|
): number | null {
|
|
const persisted = task.cumulativeActiveMs;
|
|
const base = persisted ?? 0;
|
|
|
|
if (isWipColumnRole(columnFlags, task.column)) {
|
|
const startedMs = parseTimestampToMs(task.executionStartedAt);
|
|
if (startedMs != null) {
|
|
return base + Math.max(0, nowMs - startedMs);
|
|
}
|
|
}
|
|
|
|
if (persisted != null) {
|
|
return Math.max(0, persisted);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** FNXC:TaskTiming 2026-07-20-10:00: rendered task totals include planning AI
|
|
* segments while getActiveRuntimeMs intentionally remains execution-only. */
|
|
export function getTotalAgentActiveMs(
|
|
task: Pick<Task, "column" | "cumulativeActiveMs" | "executionStartedAt" | "cumulativePlanningMs" | "planningStartedAt">,
|
|
nowMs: number,
|
|
/** Resolved trait flags for the card's column; omitted keeps the legacy id. */
|
|
columnFlags?: ColumnRoleFlags,
|
|
): number | null {
|
|
const execution = getActiveRuntimeMs(task as never, nowMs, columnFlags) ?? 0;
|
|
const planningStart = parseTimestampToMs(task.planningStartedAt);
|
|
const planning = Math.max(0, task.cumulativePlanningMs ?? 0) + (planningStart != null ? Math.max(0, nowMs - planningStart) : 0);
|
|
return task.cumulativeActiveMs != null || task.cumulativePlanningMs != null || (isWipColumnRole(columnFlags, task.column) && parseTimestampToMs(task.executionStartedAt) != null) || planningStart != null
|
|
? execution + planning
|
|
: null;
|
|
}
|
|
|
|
export function getWallClockSinceFirstExecutionMs(
|
|
firstExecutionAt: string | undefined,
|
|
executionCompletedAt: string | undefined,
|
|
nowMs: number,
|
|
): number | null {
|
|
const firstMs = parseTimestampToMs(firstExecutionAt);
|
|
if (firstMs == null) return null;
|
|
|
|
const completedMs = parseTimestampToMs(executionCompletedAt);
|
|
const endMs = completedMs != null ? completedMs : nowMs;
|
|
return Math.max(0, endMs - firstMs);
|
|
}
|