Files
fusion/packages/dashboard/app/components/TaskTokenStatsPanel.tsx
gsxdsm 38841695c8 fix: stop triage loop at completion-summary node and i18n object-key crashes (#1863)
Two distinct v0.52.0 regressions reported in issue #1863.

1. Triage loop (engine): the best-effort completion-summary graph node is
   wired into every built-in workflow with a success-only edge. A thrown
   handler exception or a failed summary projection write bypassed the
   advisory `!blocking -> success` coercion, terminated the graph at
   'completion-summary', and routeGraphFailureToExecutionResume bounced the
   in-review task back to todo forever (token usage 0, execution NOT STARTED).
   The graph executor now degrades a completion-summary node failure to
   success (ensureWorkflowCompletionSummary still backfills task.summary), with
   a routeGraphFailureToExecutionResume backstop. Shared isCompletionSummaryNode
   predicate exported from @fusion/core.

2. i18n object-key crashes (dashboard): three views called t() with keys that
   resolve to nested objects (taskDetail.executionMode, routing.source,
   nodes.dockerHost), so i18next returned "returned an object instead of
   string" and crashed the render. Added leaf label keys across all locales and
   switched the callers.

Tests: engine non-fatal completion-summary regression (fails without the fix),
dashboard invariant guard scanning t("literal") callers against real en/app.json,
and a Stats-panel reproduction against the real bundle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:43:08 -07:00

331 lines
16 KiB
TypeScript

import { useTranslation } from "react-i18next";
import type { Task, TaskTokenUsage, WorkflowStepResult } from "@fusion/core";
import { extractTimingEvents, getActiveRuntimeMs, getEndToEndDurationMs, getTimedDurationMs, getWallClockSinceFirstExecutionMs, getWorkflowRuntimeMs, type TimingEvent } from "../utils/taskTiming";
import "./TaskTokenStatsPanel.css";
interface TaskTokenStatsPanelProps {
tokenUsage?: TaskTokenUsage;
loading: boolean;
task?: Pick<
Task,
| "log"
| "timedExecutionMs"
| "workflowStepResults"
| "executionMode"
| "status"
| "paused"
| "currentStep"
| "steps"
| "mergeRetries"
| "workflowStepRetries"
| "stuckKillCount"
| "postReviewFixCount"
| "recoveryRetryCount"
| "taskDoneRetryCount"
| "nextRecoveryAt"
| "checkedOutBy"
| "assignedAgentId"
| "blockedBy"
| "sessionFile"
| "executionStartedAt"
| "executionCompletedAt"
| "firstExecutionAt"
| "cumulativeActiveMs"
| "column"
| "columnMovedAt"
>;
}
interface WorkflowTimingSummary {
timedStepCount: number;
totalDurationMs: number;
longestStep?: { name: string; durationMs: number };
}
function formatTokenCount(value: number): string {
return value.toLocaleString();
}
function formatHitRatio(ratio: number): string {
return `${(ratio * 100).toFixed(1)}%`;
}
function formatTimestamp(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return parsed.toLocaleString();
}
function formatDuration(valueMs: number): string {
if (valueMs < 1000) {
return `${Math.round(valueMs)} ms`;
}
const valueSeconds = valueMs / 1000;
if (valueSeconds < 60) {
return `${valueSeconds.toFixed(1)} s`;
}
const minutes = Math.floor(valueSeconds / 60);
const seconds = Math.round(valueSeconds % 60);
return `${minutes}m ${seconds}s`;
}
function summarizeWorkflowTiming(results: WorkflowStepResult[]): WorkflowTimingSummary {
const nowMs = Date.now();
const timedResults = results
.map((step) => {
if (!step.startedAt) {
return null;
}
const startedMs = new Date(step.startedAt).getTime();
if (Number.isNaN(startedMs)) {
return null;
}
let endMs: number;
if (step.completedAt) {
const completedMs = new Date(step.completedAt).getTime();
if (Number.isNaN(completedMs) || completedMs < startedMs) {
return null;
}
endMs = completedMs;
} else {
endMs = Math.max(startedMs, nowMs);
}
return {
name: step.workflowStepName || step.workflowStepId,
durationMs: endMs - startedMs,
};
})
.filter((value): value is { name: string; durationMs: number } => value !== null);
const totalDurationMs = getWorkflowRuntimeMs(results, nowMs) ?? 0;
const longestStep = timedResults.reduce<{ name: string; durationMs: number } | undefined>((longest, step) => {
if (!longest || step.durationMs > longest.durationMs) {
return step;
}
return longest;
}, undefined);
return {
timedStepCount: timedResults.length,
totalDurationMs,
longestStep,
};
}
export function TaskTokenStatsPanel({ tokenUsage, loading, task }: TaskTokenStatsPanelProps) {
const { t } = useTranslation("app");
const nowMs = Date.now();
const timingEvents = extractTimingEvents(task?.log ?? []);
const timedTimingEvents = timingEvents.filter((event) => typeof event.durationMs === "number");
const logTimingDurationMs = timedTimingEvents.reduce((sum, event) => sum + (event.durationMs ?? 0), 0);
const parsedTimingDurationMs = getTimedDurationMs(task?.log) ?? 0;
const totalTimingDurationMs = typeof task?.timedExecutionMs === "number"
? task.timedExecutionMs
: Math.max(logTimingDurationMs, parsedTimingDurationMs);
const longestTimingEvent = timedTimingEvents.reduce<TimingEvent | undefined>((longest, event) => {
if (!longest || (event.durationMs ?? 0) > (longest.durationMs ?? 0)) {
return event;
}
return longest;
}, undefined);
const workflowTiming = summarizeWorkflowTiming(task?.workflowStepResults ?? []);
const activeRuntimeMs = task ? getActiveRuntimeMs(task, nowMs) : null;
const endToEndDurationMs = getEndToEndDurationMs(task?.executionStartedAt, task?.executionCompletedAt, nowMs);
const wallClockSinceFirstExecutionMs = getWallClockSinceFirstExecutionMs(
task?.firstExecutionAt,
task?.executionCompletedAt,
nowMs,
);
// Canonical fallback order for Task Detail Stats total runtime:
// 1) durable wall-clock execution window (`executionStartedAt` → `executionCompletedAt`),
// 2) server aggregate `timedExecutionMs` when present,
// 3) legacy local aggregate (`[timing]` sum + workflow runtime).
// This avoids double counting when workflow timings appear in both `[timing]`
// logs and `workflowStepResults`.
const totalExecutionMs = activeRuntimeMs
?? (endToEndDurationMs
?? (typeof task?.timedExecutionMs === "number"
? task.timedExecutionMs
: totalTimingDurationMs + workflowTiming.totalDurationMs));
const showWallClockSinceFirstExecution =
wallClockSinceFirstExecutionMs != null
&& wallClockSinceFirstExecutionMs !== totalExecutionMs;
const taskStepCount = task?.steps?.length ?? 0;
return (
<section className="task-token-stats-panel" aria-label={t("taskDetail.executionStatsAria", "Task execution statistics")}>
<h4>{t("taskDetail.executionAndTokenStats", "Execution & Token Stats")}</h4>
<div className="task-token-stats-panel__section">
<h5>{t("taskDetail.executionTiming", "Execution Timing")}</h5>
<div className="task-token-stats-panel__grid" role="list" aria-label={t("taskDetail.executionTimingMetricsAria", "Execution timing metrics")}>
<div className="task-token-stats-panel__metric" role="listitem">
<span className="task-token-stats-panel__label">{t("taskDetail.timingEvents", "Timing events")}</span>
<span className="task-token-stats-panel__value">{timingEvents.length.toLocaleString()}</span>
</div>
<div className="task-token-stats-panel__metric" role="listitem">
<span className="task-token-stats-panel__label">{t("taskDetail.timedDuration", "Timed duration")}</span>
<span className="task-token-stats-panel__value">{formatDuration(totalTimingDurationMs)}</span>
</div>
<div className="task-token-stats-panel__metric" role="listitem">
<span className="task-token-stats-panel__label">{t("taskDetail.workflowTimedSteps", "Workflow timed steps")}</span>
<span className="task-token-stats-panel__value">{workflowTiming.timedStepCount.toLocaleString()}</span>
</div>
<div className="task-token-stats-panel__metric" role="listitem">
<span className="task-token-stats-panel__label">{t("taskDetail.workflowRuntime", "Workflow runtime")}</span>
<span className="task-token-stats-panel__value">{formatDuration(workflowTiming.totalDurationMs)}</span>
</div>
<div className="task-token-stats-panel__metric" role="listitem">
<span className="task-token-stats-panel__label">{t("taskDetail.totalExecutionTime", "Total execution time")}</span>
<span className="task-token-stats-panel__value">{formatDuration(totalExecutionMs)}</span>
</div>
{showWallClockSinceFirstExecution ? (
<div className="task-token-stats-panel__metric" role="listitem">
<span className="task-token-stats-panel__label">{t("taskDetail.wallClockSinceFirst", "Wall-clock since first execution")}</span>
<span className="task-token-stats-panel__value">{formatDuration(wallClockSinceFirstExecutionMs)}</span>
</div>
) : null}
</div>
<dl className="task-token-stats-panel__timestamps">
<div className="task-token-stats-panel__timestamp-row">
<dt>{t("taskDetail.longestTimingEvent", "Longest timing event")}</dt>
<dd>
{longestTimingEvent?.durationMs
? `${longestTimingEvent.summary} (${formatDuration(longestTimingEvent.durationMs)})`
: t("taskDetail.noTimedEvents", "No timed events recorded yet.")}
</dd>
</div>
<div className="task-token-stats-panel__timestamp-row">
<dt>{t("taskDetail.longestWorkflowStep", "Longest workflow step")}</dt>
<dd>
{workflowTiming.longestStep
? `${workflowTiming.longestStep.name} (${formatDuration(workflowTiming.longestStep.durationMs)})`
: t("taskDetail.noWorkflowStepTimings", "No completed workflow step timings yet.")}
</dd>
</div>
</dl>
</div>
<div className="task-token-stats-panel__section">
<h5>{t("taskDetail.executionDetails", "Execution Details")}</h5>
<dl className="task-token-stats-panel__details">
<div className="task-token-stats-panel__detail-row">
{/*
FNXC:TaskStats 2026-07-01-00:00:
Use the leaf `taskDetail.executionModeLabel`, not `taskDetail.executionMode`, which is a nested object (ariaLabel/fast/standard/replan* copy for the inline mode toggle). Calling `t()` on the object key makes i18next return "key 'taskDetail.executionMode (en)' returned an object instead of string" and crashes the Stats tab render (issue #1863).
*/}
<dt>{t("taskDetail.executionModeLabel", "Execution mode")}</dt>
<dd>{task?.executionMode === "fast" ? t("taskDetail.executionModeFast", "Fast") : t("taskDetail.executionModeStandard", "Standard")}</dd>
</div>
<div className="task-token-stats-panel__detail-row">
<dt>{t("taskDetail.runtimeStatus", "Runtime status")}</dt>
<dd>{task?.status ?? t("taskDetail.notSet", "Not set")}</dd>
</div>
<div className="task-token-stats-panel__detail-row">
<dt>{t("taskDetail.paused", "Paused")}</dt>
<dd>{task?.paused ? t("taskDetail.yes", "Yes") : t("taskDetail.no", "No")}</dd>
</div>
<div className="task-token-stats-panel__detail-row">
<dt>{t("taskDetail.stepProgress", "Step progress")}</dt>
<dd>{taskStepCount > 0 ? `${Math.min((task?.currentStep ?? 0) + 1, taskStepCount)} / ${taskStepCount}` : t("taskDetail.noSteps", "No steps")}</dd>
</div>
<div className="task-token-stats-panel__detail-row">
<dt>{t("taskDetail.retriesLabel", "Retries (recovery / workflow / merge / task_done)")}</dt>
<dd>{`${task?.recoveryRetryCount ?? 0} / ${task?.workflowStepRetries ?? 0} / ${task?.mergeRetries ?? 0} / ${task?.taskDoneRetryCount ?? 0}`}</dd>
</div>
<div className="task-token-stats-panel__detail-row">
<dt>{t("taskDetail.recoveryState", "Recovery state")}</dt>
<dd>
{task?.nextRecoveryAt
? t("taskDetail.nextRecoveryAt", "Next recovery at {{time}}", { time: formatTimestamp(task.nextRecoveryAt) })
: t("taskDetail.noScheduledRecovery", "No scheduled recovery")}
</dd>
</div>
<div className="task-token-stats-panel__detail-row">
<dt>{t("taskDetail.selfHealCounters", "Self-heal counters")}</dt>
<dd>{t("taskDetail.selfHealValues", "stuck kills: {{stuckKills}}, post-review fixes: {{postReviewFixes}}", { stuckKills: task?.stuckKillCount ?? 0, postReviewFixes: task?.postReviewFixCount ?? 0 })}</dd>
</div>
<div className="task-token-stats-panel__detail-row">
<dt>{t("taskDetail.runtimeLinks", "Runtime links")}</dt>
<dd>
{[
task?.assignedAgentId ? t("taskDetail.agentLink", "agent {{id}}", { id: task.assignedAgentId }) : null,
task?.checkedOutBy ? t("taskDetail.checkoutLink", "checkout {{id}}", { id: task.checkedOutBy }) : null,
task?.blockedBy ? t("taskDetail.blockedByLink", "blocked by {{id}}", { id: task.blockedBy }) : null,
task?.sessionFile ? t("taskDetail.hasSession", "has session") : null,
].filter(Boolean).join(", ") || t("taskDetail.noRuntimeLinks", "No runtime links")}
</dd>
</div>
</dl>
</div>
<div className="task-token-stats-panel__section">
<h5>{t("taskDetail.tokenUsage", "Token Usage")}</h5>
{!tokenUsage && loading ? (
<div className="task-token-stats-panel__loading" role="status" aria-live="polite">
{t("taskDetail.loadingTokenStats", "Loading token statistics…")}
</div>
) : !tokenUsage ? (
<div className="task-token-stats-panel__empty" role="status">
{t("taskDetail.noTokenUsage", "No token usage recorded for this task yet.")}
</div>
) : (
<>
<div className="task-token-stats-panel__grid" role="list" aria-label={t("taskDetail.tokenTotalsAria", "Task token totals")}>
<div className="task-token-stats-panel__metric" role="listitem">
<span className="task-token-stats-panel__label">{t("taskDetail.inputTokens", "Input")}</span>
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.inputTokens)}</span>
</div>
<div className="task-token-stats-panel__metric" role="listitem">
<span className="task-token-stats-panel__label">{t("taskDetail.outputTokens", "Output")}</span>
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.outputTokens)}</span>
</div>
<div className="task-token-stats-panel__metric" role="listitem">
<span className="task-token-stats-panel__label">{t("taskDetail.cacheRead", "Cache read")}</span>
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.cachedTokens)}</span>
</div>
<div className="task-token-stats-panel__metric" role="listitem">
<span className="task-token-stats-panel__label">{t("taskDetail.cacheWrite", "Cache write")}</span>
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.cacheWriteTokens ?? 0)}</span>
</div>
<div className="task-token-stats-panel__metric" role="listitem">
<span className="task-token-stats-panel__label">{t("taskDetail.totalTokens", "Total")}</span>
<span className="task-token-stats-panel__value">{formatTokenCount(tokenUsage.totalTokens)}</span>
</div>
</div>
<div className="task-token-stats-panel__cache-ratio">
<span className="task-token-stats-panel__cache-ratio-label">{t("taskDetail.cacheHitRatio", "Cache hit ratio:")}</span>{" "}
<span className="task-token-stats-panel__cache-ratio-value">
{(tokenUsage.inputTokens + tokenUsage.cachedTokens) > 0
? formatHitRatio(tokenUsage.cachedTokens / (tokenUsage.inputTokens + tokenUsage.cachedTokens))
: "—"}
</span>
</div>
<div className="task-token-stats-panel__cache-breakdown">
{t("taskDetail.cacheBreakdown", "(read {{read}} / write {{write}} / input {{input}})", { read: formatTokenCount(tokenUsage.cachedTokens), write: formatTokenCount(tokenUsage.cacheWriteTokens ?? 0), input: formatTokenCount(tokenUsage.inputTokens) })}
</div>
<dl className="task-token-stats-panel__timestamps">
<div className="task-token-stats-panel__timestamp-row">
<dt>{t("taskDetail.firstUsed", "First used")}</dt>
<dd>
<time dateTime={tokenUsage.firstUsedAt}>{formatTimestamp(tokenUsage.firstUsedAt)}</time>
</dd>
</div>
<div className="task-token-stats-panel__timestamp-row">
<dt>{t("taskDetail.lastUsed", "Last used")}</dt>
<dd>
<time dateTime={tokenUsage.lastUsedAt}>{formatTimestamp(tokenUsage.lastUsedAt)}</time>
</dd>
</div>
</dl>
</>
)}
</div>
</section>
);
}