feat(FN-4398): complete remaining dashboard visibility and docs
Fusion-Task-Id: FN-4398 Fusion-Task-Lineage: 8b898b2e-3468-4fa7-8546-b8f6ba52cf46
This commit is contained in:
5
.changeset/fn-4398-retries-burned.md
Normal file
5
.changeset/fn-4398-retries-burned.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Add per-task retry observability and guardrails across core, engine, and dashboard surfaces. Tasks now expose a derived `retrySummary` breakdown (including new branch-conflict recovery, reviewer context retry, and reviewer fallback retry counters), the engine emits structured `retry-burned` logs, and retry caps can hard-fail with `RetryStormError` when `maxTotalRetriesBeforeFail` is exceeded. The dashboard now surfaces retry totals on cards, list view, and task detail breakdowns, and existing databases auto-migrate schema version `72 -> 73` on startup.
|
||||
@@ -529,6 +529,7 @@ When debugging agent execution issues (agents stuck on "starting"), check these
|
||||
10. **`[auto-claim-snapshot] rebuild generated=N reason=<ttl|invalidate>`** — Confirms project-wide auto-claim snapshot rebuild cadence
|
||||
11. **`[auto-claim-prompt] agent=<id> chars=<n> count=<n>`** — Tracks rendered no-task candidate section size
|
||||
12. **`[prompt-size] prompt-size { agentId, role, runId, template, systemChars, execChars, totalChars, isNoTaskRun }`** — Per-heartbeat prompt-size audit record
|
||||
13. **`[retry-burned] retry-burned { taskId, agentId, role, category, attempt, total, breakdown }`** — Unified retry-burn telemetry and retry-cap circuit-breaker context
|
||||
|
||||
### Semaphore Resilience
|
||||
|
||||
|
||||
@@ -1513,3 +1513,9 @@ UI contract boundary:
|
||||
- `PrSection` owns branch/PR lifecycle metadata and automation status.
|
||||
- `TaskReviewTab` owns review decisions, detailed review items, selection, and addressing progress.
|
||||
- `TaskComments` remains separate for general discussion.
|
||||
|
||||
## Retry observability
|
||||
|
||||
Fusion derives a per-task `retrySummary` at read time by aggregating retry counters (stuck-kill, recovery, task_done, workflow-step, verification, post-review-fix, merge-conflict bounce, branch-conflict recovery, reviewer context retry, reviewer fallback retry). The engine emits a structured `retry-burned` log channel with `{ taskId, agentId, role, category, attempt, total, breakdown }` so token-cost telemetry can correlate retry burn with spend.
|
||||
|
||||
Project settings expose per-category caps (`maxBranchConflictRecoveries`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries`) plus a master cap (`maxTotalRetriesBeforeFail`). When a cap is exceeded, engine code throws `RetryStormError`; executor terminal failure handling serializes this into `task.error` so dashboard surfaces can render structured failure details.
|
||||
|
||||
@@ -275,6 +275,10 @@ Override precedence for direct merges is:
|
||||
| `autoUnpauseBaseDelayMs` | `number` | `300000` | Base unpause delay in ms (5 min). |
|
||||
| `autoUnpauseMaxDelayMs` | `number` | `3600000` | Max auto-unpause delay in ms (1 hour). |
|
||||
| `maxStuckKills` | `number` | `6` | Max stuck-task terminations before permanent failure. |
|
||||
| `maxBranchConflictRecoveries` | `number` | `5` | Max branch-conflict recovery retries before retry-storm failure handling triggers. |
|
||||
| `maxReviewerContextRetries` | `number` | `2` | Max reviewer context-compaction retries (FN-4082) per task. |
|
||||
| `maxReviewerFallbackRetries` | `number` | `2` | Max reviewer fallback-model retries (FN-4092) per task. |
|
||||
| `maxTotalRetriesBeforeFail` | `number` | `25` | Master retry budget across all tracked retry counters; exceeding this fails the task with `RetryStormError`. |
|
||||
| `maxPostReviewFixes` | `number` | `1` | Max auto-revival attempts for in-review tasks failing pre-merge workflow steps. |
|
||||
| `maxSpawnedAgentsPerParent` | `number` | `5` | Max child agents per parent task. |
|
||||
| `maxSpawnedAgentsGlobal` | `number` | `20` | Max spawned agents across one executor instance. |
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { RetrySummary, TaskDetail } from "./types.js";
|
||||
import type { RetrySummary, Task, TaskDetail } from "./types.js";
|
||||
|
||||
export const RETRY_STORM_WARNING_RATIO = 0.8;
|
||||
|
||||
const toCount = (value: number | null | undefined): number => (typeof value === "number" ? value : 0);
|
||||
|
||||
export function computeRetrySummary(task: TaskDetail): RetrySummary {
|
||||
export function computeRetrySummary(task: Pick<Task | TaskDetail, "stuckKillCount" | "recoveryRetryCount" | "taskDoneRetryCount" | "workflowStepRetries" | "verificationFailureCount" | "postReviewFixCount" | "mergeConflictBounceCount" | "branchConflictRecoveryCount" | "reviewerContextRetryCount" | "reviewerFallbackRetryCount">): RetrySummary {
|
||||
const stuckKill = toCount(task.stuckKillCount);
|
||||
const recovery = toCount(task.recoveryRetryCount);
|
||||
const taskDone = toCount(task.taskDoneRetryCount);
|
||||
|
||||
@@ -1426,6 +1426,9 @@ export interface Task {
|
||||
/** Number of reviewer fallback retries consumed by FN-4092 fallback-model
|
||||
* and same-model strict-prompt retry paths. */
|
||||
reviewerFallbackRetryCount?: number;
|
||||
/** Derived retry aggregation computed at read time from retry counters.
|
||||
* This field is not persisted to SQLite. */
|
||||
retrySummary?: RetrySummary;
|
||||
/** ISO-8601 timestamp indicating when the task becomes eligible for the next
|
||||
* recovery retry. Scheduler and triage processor skip tasks whose
|
||||
* `nextRecoveryAt` is still in the future. Cleared alongside `recoveryRetryCount`. */
|
||||
|
||||
@@ -68,6 +68,7 @@ import { useRemoteNodeEvents } from "./hooks/useRemoteNodeEvents";
|
||||
import { NodeProvider, useNodeContext } from "./context/NodeContext";
|
||||
import { FileBrowserProvider } from "./context/FileBrowserContext";
|
||||
import { ShellProvider } from "./context/ShellContext";
|
||||
import { RetryWarningProvider } from "./context/RetryWarningContext";
|
||||
import { ShellHostProvider, useShellHostContext } from "./context/ShellHostContext";
|
||||
import { useShellConnection } from "./hooks/useShellConnection";
|
||||
import { NativeShellOnboardingModal } from "./components/NativeShellOnboardingModal";
|
||||
@@ -143,6 +144,7 @@ const BASE_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-base-branch-filter";
|
||||
const NO_BRANCH_FILTER_VALUE = "__fusion:no-branch__";
|
||||
const APPROVAL_BANNER_DISMISSED_STORAGE_KEY = "fusion:approval-banner-dismissed";
|
||||
const CAPACITY_RISK_DISMISSED_KEY = "kb-capacity-risk-banner-dismissed";
|
||||
const RETRY_WARNING_RATIO = 0.8;
|
||||
|
||||
interface ApprovalBannerCandidate {
|
||||
dedupeKey: string;
|
||||
@@ -736,6 +738,7 @@ function AppInner() {
|
||||
capacityRiskBannerEnabled,
|
||||
capacityRiskTodoThreshold,
|
||||
showQuickChatFAB,
|
||||
maxTotalRetriesBeforeFail,
|
||||
prAuthAvailable,
|
||||
settingsLoaded,
|
||||
experimentalFeatures,
|
||||
@@ -941,7 +944,7 @@ function AppInner() {
|
||||
addToast,
|
||||
});
|
||||
|
||||
const handleOpenDetailWithTab = useCallback((task: Task | TaskDetail, initialTab: "changes") => {
|
||||
const handleOpenDetailWithTab = useCallback((task: Task | TaskDetail, initialTab: "changes" | "retries") => {
|
||||
if (initialTab === "changes") {
|
||||
modalManager.openDetailWithChangesTab(task);
|
||||
} else {
|
||||
@@ -1605,6 +1608,7 @@ function AppInner() {
|
||||
return (
|
||||
<NavigationHistoryProvider value={{ pushNav, replaceCurrent }}>
|
||||
<FileBrowserProvider openFile={openFileInBrowser}>
|
||||
<RetryWarningProvider value={maxTotalRetriesBeforeFail * RETRY_WARNING_RATIO}>
|
||||
{!initialLoadComplete ? (
|
||||
<>
|
||||
<DashboardLoader stage={loadingStage} />
|
||||
@@ -1891,6 +1895,7 @@ function AppInner() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</RetryWarningProvider>
|
||||
</FileBrowserProvider>
|
||||
</NavigationHistoryProvider>
|
||||
);
|
||||
|
||||
@@ -41,7 +41,7 @@ interface BoardProps {
|
||||
* Called when the user clicks the "Subtask" button in the inline create card.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes") => void;
|
||||
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries") => void;
|
||||
favoriteProviders?: string[];
|
||||
favoriteModels?: string[];
|
||||
onToggleFavorite?: (provider: string) => void;
|
||||
|
||||
@@ -52,7 +52,7 @@ interface ColumnProps {
|
||||
* Called when the user clicks the "Subtask" button in the inline create card.
|
||||
*/
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes") => void;
|
||||
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries") => void;
|
||||
favoriteProviders?: string[];
|
||||
favoriteModels?: string[];
|
||||
onToggleFavorite?: (provider: string) => void;
|
||||
|
||||
@@ -29,7 +29,7 @@ const COLUMN_COLOR_MAP: Record<Column, string> = {
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
|
||||
|
||||
type SortField = "title" | "status" | "column";
|
||||
type SortField = "title" | "status" | "column" | "retries";
|
||||
|
||||
function getTaskStatusLabel(status: string): string {
|
||||
if (status === "merging-fix") return "Merging fixes…";
|
||||
@@ -38,8 +38,8 @@ function getTaskStatusLabel(status: string): string {
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
// Column visibility types
|
||||
const ALL_LIST_COLUMNS = ["title", "status", "column", "dependencies", "progress"] as const;
|
||||
const DEFAULT_LIST_COLUMNS = ["title", "status", "column"] as const;
|
||||
const ALL_LIST_COLUMNS = ["title", "status", "column", "retries", "dependencies", "progress"] as const;
|
||||
const DEFAULT_LIST_COLUMNS = ["title", "status", "column", "retries"] as const;
|
||||
type ListColumn = typeof ALL_LIST_COLUMNS[number];
|
||||
|
||||
function getNodeStatusLabel(status: NodeInfo["status"]): string {
|
||||
@@ -570,6 +570,9 @@ export function ListView({
|
||||
case "column":
|
||||
comparison = a.column.localeCompare(b.column);
|
||||
break;
|
||||
case "retries":
|
||||
comparison = (a.retrySummary?.total ?? 0) - (b.retrySummary?.total ?? 0);
|
||||
break;
|
||||
}
|
||||
return sortDirection === "asc" ? comparison : -comparison;
|
||||
});
|
||||
@@ -1661,6 +1664,11 @@ export function ListView({
|
||||
Column {getSortIcon("column")}
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.has("retries") && (
|
||||
<th className="list-header-cell" onClick={() => handleSort("retries")}>
|
||||
Retries {getSortIcon("retries")}
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.has("dependencies") && (
|
||||
<th className="list-header-cell">Dependencies</th>
|
||||
)}
|
||||
@@ -1809,6 +1817,9 @@ export function ListView({
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
{visibleColumns.has("retries") && (
|
||||
<td className="list-cell">{(task.retrySummary?.total ?? 0) > 0 ? (task.retrySummary?.total ?? 0) : "—"}</td>
|
||||
)}
|
||||
{visibleColumns.has("dependencies") && (
|
||||
<td className="list-cell list-cell-deps">
|
||||
{task.dependencies && task.dependencies.length > 0 ? (
|
||||
|
||||
@@ -130,6 +130,36 @@
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card-retry-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--space-xs) / 2);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: calc(var(--space-xs) / 2) var(--space-sm);
|
||||
border: var(--btn-border-width) solid transparent;
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-retry-badge:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.card-retry-badge--warning {
|
||||
color: var(--color-warning);
|
||||
background: color-mix(in srgb, var(--color-warning) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-warning) 45%, transparent);
|
||||
}
|
||||
|
||||
.card-retry-badge--error {
|
||||
color: var(--color-error);
|
||||
background: color-mix(in srgb, var(--color-error) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-error) 45%, transparent);
|
||||
}
|
||||
|
||||
.card-status-badge,
|
||||
.card-priority-badge,
|
||||
.card-size-badge,
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { ToastType } from "../hooks/useToast";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
|
||||
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
||||
import { useRetryWarning } from "../context/RetryWarningContext";
|
||||
|
||||
// ── Mission title caching ───────────────────────────────────────────────────
|
||||
|
||||
@@ -274,7 +275,7 @@ interface TaskCardProps {
|
||||
onUnarchiveTask?: (id: string) => Promise<Task>;
|
||||
onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean; githubIssueAction?: GithubIssueAction }) => Promise<Task>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes") => void;
|
||||
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries") => void;
|
||||
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||
taskStuckTimeoutMs?: number;
|
||||
/** Called when user clicks the mission badge on a task card. */
|
||||
@@ -464,6 +465,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
||||
previousTask.missionId === nextTask.missionId &&
|
||||
previousTask.assignedAgentId === nextTask.assignedAgentId &&
|
||||
previousTask.mergeRetries === nextTask.mergeRetries &&
|
||||
previousTask.retrySummary?.total === nextTask.retrySummary?.total &&
|
||||
previousTask.sourceType === nextTask.sourceType &&
|
||||
previousTask.sourceAgentId === nextTask.sourceAgentId &&
|
||||
previousTask.sourceMetadata?.issueUrl === nextTask.sourceMetadata?.issueUrl &&
|
||||
@@ -535,6 +537,7 @@ function TaskCardComponent({
|
||||
const [isInViewport, setIsInViewport] = useState(false);
|
||||
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket(projectId);
|
||||
const { confirm } = useConfirm();
|
||||
const retryWarningThreshold = useRetryWarning();
|
||||
|
||||
// Touch gesture detection refs
|
||||
const touchStartPosRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
||||
@@ -1223,6 +1226,11 @@ function TaskCardComponent({
|
||||
onOpenDetailWithTab?.(task, "changes");
|
||||
}, [task, onOpenDetailWithTab]);
|
||||
|
||||
const handleOpenRetries = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onOpenDetailWithTab?.(task, "retries");
|
||||
}, [task, onOpenDetailWithTab]);
|
||||
|
||||
const handleToggleSteps = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
setShowSteps((current) => !current);
|
||||
@@ -1761,7 +1769,7 @@ function TaskCardComponent({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy || (fanout && fanout.totalCount > 0)) && (
|
||||
{(((task.retrySummary?.total ?? 0) > 0) || (task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy || (fanout && fanout.totalCount > 0)) && (
|
||||
<div className="card-meta">
|
||||
{task.dependencies && task.dependencies.length > 0 && (
|
||||
<div className="card-dep-list">
|
||||
@@ -1795,6 +1803,26 @@ function TaskCardComponent({
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{(task.retrySummary?.total ?? 0) > 0 && (
|
||||
<span
|
||||
className={`card-retry-badge${(retryWarningThreshold != null && (task.retrySummary?.total ?? 0) >= retryWarningThreshold) ? " card-retry-badge--error" : " card-retry-badge--warning"}`}
|
||||
onClick={handleOpenRetries}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onOpenDetailWithTab?.(task, "retries");
|
||||
}
|
||||
}}
|
||||
aria-label={`${task.retrySummary?.total ?? 0} retries`}
|
||||
title="Open retry breakdown"
|
||||
>
|
||||
<RotateCw size={11} />
|
||||
<span>{task.retrySummary?.total ?? 0}</span>
|
||||
</span>
|
||||
)}
|
||||
{(queued || task.status === "queued") && task.column !== "in-progress" && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: "middle" }} /> Queued</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1862,3 +1862,13 @@
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-retries-grid dd {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-retries-warning {
|
||||
margin: var(--space-sm) 0 0;
|
||||
color: var(--color-error);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ function formatDurationCompact(ageMs: number): string {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
type TabId = "definition" | "logs" | "changes" | "review" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | `plugin-${string}`;
|
||||
type TabId = "definition" | "logs" | "changes" | "review" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | `plugin-${string}`;
|
||||
|
||||
export interface TaskDetailModalProps {
|
||||
task: Task | TaskDetail;
|
||||
@@ -463,7 +463,7 @@ export function TaskDetailContent({
|
||||
embedded = false,
|
||||
onRequestClose,
|
||||
}: TaskDetailContentProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
|
||||
const [activeTab, setActiveTab] = useState<TabId>(initialTab === "retries" ? "definition" : initialTab);
|
||||
|
||||
// ── Async detail loading ──────────────────────────────────────────────────
|
||||
// When opened optimistically with a Task (no prompt), fetch the full
|
||||
@@ -539,7 +539,10 @@ export function TaskDetailContent({
|
||||
|
||||
// Sync activeTab when the caller changes initialTab (e.g. opening a different tab)
|
||||
useEffect(() => {
|
||||
setActiveTab(initialTab);
|
||||
setActiveTab(initialTab === "retries" ? "definition" : initialTab);
|
||||
if (initialTab === "retries") {
|
||||
setRetriesExpanded(true);
|
||||
}
|
||||
}, [initialTab]);
|
||||
|
||||
// Reset description expanded state when task changes
|
||||
@@ -620,6 +623,7 @@ export function TaskDetailContent({
|
||||
const [showMoveMenu, setShowMoveMenu] = useState(false);
|
||||
const [showActionsMenu, setShowActionsMenu] = useState(false);
|
||||
const [sourceIssueExpanded, setSourceIssueExpanded] = useState(false);
|
||||
const [retriesExpanded, setRetriesExpanded] = useState(initialTab === "retries");
|
||||
const [githubTrackingExpanded, setGithubTrackingExpanded] = useState(false);
|
||||
const [githubRepoOverrideDraft, setGithubRepoOverrideDraft] = useState(task.githubTracking?.repoOverride ?? "");
|
||||
const [githubTrackingEnabledDraft, setGithubTrackingEnabledDraft] = useState<boolean | null>(null);
|
||||
@@ -889,6 +893,19 @@ export function TaskDetailContent({
|
||||
&& !githubTrackingDetailPending
|
||||
&& (!githubTrackingEnabled || (isSavingGithubTracking && workingTask.githubTracking?.enabled !== true));
|
||||
const showGithubTrackingSection = canEditGithubTracking || githubTrackingEnabled || Boolean(githubTrackedIssue);
|
||||
const retrySummary = task.retrySummary;
|
||||
const retryRows = [
|
||||
{ key: "stuckKillCount", label: "Stuck kills", title: "Stuck-task detector forced agent kill retries", value: retrySummary?.stuckKillCount ?? 0 },
|
||||
{ key: "recoveryRetryCount", label: "Recovery retries", title: "Transient executor recovery retries", value: retrySummary?.recoveryRetryCount ?? 0 },
|
||||
{ key: "taskDoneRetryCount", label: "task_done retries", title: "Agent exited without task_done and task was retried", value: retrySummary?.taskDoneRetryCount ?? 0 },
|
||||
{ key: "workflowStepRetries", label: "Workflow retries", title: "Workflow step failure retries", value: retrySummary?.workflowStepRetries ?? 0 },
|
||||
{ key: "verificationFailureCount", label: "Verification bounces", title: "Verification failure bounce retries", value: retrySummary?.verificationFailureCount ?? 0 },
|
||||
{ key: "postReviewFixCount", label: "Post-review fixes", title: "Post-review remediation retries", value: retrySummary?.postReviewFixCount ?? 0 },
|
||||
{ key: "mergeConflictBounceCount", label: "Merge conflict bounces", title: "Merge conflict bounce retries", value: retrySummary?.mergeConflictBounceCount ?? 0 },
|
||||
{ key: "branchConflictRecoveryCount", label: "Branch conflict recovery", title: "FN-4068 branch-conflict recovery retries", value: retrySummary?.branchConflictRecoveryCount ?? 0 },
|
||||
{ key: "reviewerContextRetryCount", label: "Reviewer context retries", title: "FN-4082 compact reviewer retry", value: retrySummary?.reviewerContextRetryCount ?? 0 },
|
||||
{ key: "reviewerFallbackRetryCount", label: "Reviewer fallback retries", title: "FN-4092 fallback-model retry", value: retrySummary?.reviewerFallbackRetryCount ?? 0 },
|
||||
].filter((row) => row.value > 0);
|
||||
const githubTrackingStatus = githubTrackingDetailPending
|
||||
? "Loading"
|
||||
: githubTrackedIssue
|
||||
@@ -2507,6 +2524,38 @@ export function TaskDetailContent({
|
||||
</div>
|
||||
)}
|
||||
<MergeDetails task={task} />
|
||||
{(retrySummary?.total ?? 0) > 0 && (
|
||||
<div className="detail-section detail-retries-section">
|
||||
<div className="detail-source-header">
|
||||
<div className="detail-source-summary">
|
||||
<span className="detail-source-label">Retries</span>
|
||||
<span className="detail-source-number">{retrySummary?.total ?? 0}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="detail-source-toggle"
|
||||
aria-expanded={retriesExpanded}
|
||||
aria-label={retriesExpanded ? "Collapse retries details" : "Expand retries details"}
|
||||
onClick={() => setRetriesExpanded((expanded) => !expanded)}
|
||||
>
|
||||
<ChevronRight size={16} className={retriesExpanded ? "detail-source-chevron--expanded" : undefined} />
|
||||
</button>
|
||||
</div>
|
||||
{retriesExpanded && (
|
||||
<dl className="detail-source-grid detail-retries-grid">
|
||||
{retryRows.map((row) => (
|
||||
<div key={row.key}>
|
||||
<dt title={row.title}>{row.label}</dt>
|
||||
<dd>{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
{settings?.maxTotalRetriesBeforeFail != null && (retrySummary?.total ?? 0) >= settings.maxTotalRetriesBeforeFail && (
|
||||
<p className="detail-retries-warning">Retry cap reached for this task.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{task.sourceIssue && (
|
||||
<div className="detail-section detail-source-section">
|
||||
<div className="detail-source-header">
|
||||
|
||||
@@ -18,7 +18,7 @@ interface WorktreeGroupProps {
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
) => Promise<Task>;
|
||||
onRetryTask?: (id: string) => Promise<Task>;
|
||||
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes") => void;
|
||||
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries") => void;
|
||||
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||
taskStuckTimeoutMs?: number;
|
||||
/** Called when user clicks a mission badge on a task card */
|
||||
|
||||
@@ -1227,18 +1227,18 @@ describe("ListView", () => {
|
||||
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||
|
||||
// Verify each section header has colSpan that includes the checkbox column
|
||||
// Default visible columns: title, status, column (3 columns)
|
||||
// Plus checkbox column = 4 total
|
||||
// Default visible columns: title, status, column, retries (4 columns)
|
||||
// Plus checkbox column = 5 total
|
||||
for (const header of sectionHeaders) {
|
||||
const th = header.querySelector("th.list-section-cell");
|
||||
expect(th).not.toBeNull();
|
||||
expect(th!.getAttribute("colSpan")).toBe("4"); // visibleColumns.size (3) + 1 for checkbox
|
||||
expect(th!.getAttribute("colSpan")).toBe("5"); // visibleColumns.size (4) + 1 for checkbox
|
||||
}
|
||||
|
||||
// Also verify empty section cells span full width
|
||||
const emptyCells = screen.getAllByRole("cell").filter(c => c.className.includes("list-empty-cell"));
|
||||
for (const cell of emptyCells) {
|
||||
expect(cell.getAttribute("colSpan")).toBe("4");
|
||||
expect(cell.getAttribute("colSpan")).toBe("5");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
13
packages/dashboard/app/context/RetryWarningContext.tsx
Normal file
13
packages/dashboard/app/context/RetryWarningContext.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
const RetryWarningContext = createContext<number | undefined>(undefined);
|
||||
|
||||
export function RetryWarningProvider(
|
||||
{ value, children }: { value: number | undefined; children: ReactNode },
|
||||
) {
|
||||
return <RetryWarningContext.Provider value={value}>{children}</RetryWarningContext.Provider>;
|
||||
}
|
||||
|
||||
export function useRetryWarning(): number | undefined {
|
||||
return useContext(RetryWarningContext);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export interface UseAppSettingsResult {
|
||||
capacityRiskBannerEnabled: boolean;
|
||||
capacityRiskTodoThreshold: number;
|
||||
showQuickChatFAB: boolean;
|
||||
maxTotalRetriesBeforeFail: number;
|
||||
prAuthAvailable: boolean;
|
||||
settingsLoaded: boolean;
|
||||
experimentalFeatures: Record<string, boolean>;
|
||||
@@ -47,6 +48,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
const [capacityRiskBannerEnabled, setCapacityRiskBannerEnabled] = useState(false);
|
||||
const [capacityRiskTodoThreshold, setCapacityRiskTodoThreshold] = useState(20);
|
||||
const [showQuickChatFAB, setShowQuickChatFAB] = useState(false);
|
||||
const [maxTotalRetriesBeforeFail, setMaxTotalRetriesBeforeFail] = useState(25);
|
||||
const [prAuthAvailable, setPrAuthAvailable] = useState(false);
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
const [experimentalFeatures, setExperimentalFeatures] = useState<Record<string, boolean>>({});
|
||||
@@ -82,6 +84,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
settings.staleHighFanoutBlockerAgeThresholdMs ?? 2 * 60 * 60 * 1000,
|
||||
);
|
||||
setShowQuickChatFAB(settings.showQuickChatFAB === true);
|
||||
setMaxTotalRetriesBeforeFail(settings.maxTotalRetriesBeforeFail ?? 25);
|
||||
setCapacityRiskBannerEnabled(settings.capacityRiskBannerEnabled === true);
|
||||
setCapacityRiskTodoThreshold(settings.capacityRiskTodoThreshold ?? 20);
|
||||
setExperimentalFeatures(settings.experimentalFeatures ?? {});
|
||||
@@ -183,6 +186,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
||||
capacityRiskBannerEnabled,
|
||||
capacityRiskTodoThreshold,
|
||||
showQuickChatFAB,
|
||||
maxTotalRetriesBeforeFail,
|
||||
prAuthAvailable,
|
||||
settingsLoaded,
|
||||
experimentalFeatures,
|
||||
|
||||
@@ -9,7 +9,8 @@ export type DetailTaskTab =
|
||||
| "changes"
|
||||
| "comments"
|
||||
| "model"
|
||||
| "workflow";
|
||||
| "workflow"
|
||||
| "retries";
|
||||
|
||||
export type DetailTaskOrigin = "list-mobile";
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { ReactNode } from "react";
|
||||
import type { Task, TaskDetail, WorkflowStep } from "@fusion/core";
|
||||
|
||||
/** Tab identifiers for the task detail modal. Mirrors the dashboard's local enum. */
|
||||
export type DetailTaskTab = "definition" | "logs" | "changes" | "comments" | "model" | "workflow";
|
||||
export type DetailTaskTab = "definition" | "logs" | "changes" | "comments" | "model" | "workflow" | "retries";
|
||||
|
||||
export type PluginToastType = "success" | "error" | "warning" | "info";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
RetryStormError,
|
||||
serializeRetryStormError,
|
||||
type TaskDetail,
|
||||
@@ -50,6 +51,7 @@ describe("executor retry storm integration", () => {
|
||||
await recordRetry({
|
||||
store: store as never,
|
||||
settings: {
|
||||
...DEFAULT_PROJECT_SETTINGS,
|
||||
maxReviewerContextRetries: 2,
|
||||
maxTotalRetriesBeforeFail: 25,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { RetryStormError, type TaskDetail } from "@fusion/core";
|
||||
import { DEFAULT_PROJECT_SETTINGS, RetryStormError, type TaskDetail } from "@fusion/core";
|
||||
import { recordRetry } from "../retry-burned-logger.js";
|
||||
|
||||
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||
@@ -22,6 +22,7 @@ function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||
|
||||
describe("recordRetry", () => {
|
||||
const baseSettings = {
|
||||
...DEFAULT_PROJECT_SETTINGS,
|
||||
maxBranchConflictRecoveries: 5,
|
||||
maxReviewerContextRetries: 2,
|
||||
maxReviewerFallbackRetries: 2,
|
||||
|
||||
@@ -4294,7 +4294,7 @@ export class TaskExecutor {
|
||||
task: taskForRetry,
|
||||
category: "branchConflict",
|
||||
role: "executor",
|
||||
agentId: this.agentId,
|
||||
agentId: task.assignedAgentId ?? undefined,
|
||||
attempt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -261,6 +261,8 @@ export interface ReviewOptions {
|
||||
store?: TaskStore;
|
||||
/** Task ID for agent log persistence. Required alongside `store`. */
|
||||
taskId?: string;
|
||||
/** Optional reviewer agent id for retry-burn telemetry. */
|
||||
agentId?: string;
|
||||
/** Optional task title for fallback-used notification context. */
|
||||
taskTitle?: string;
|
||||
/** Task with optional assignedAgentId for skill selection. */
|
||||
@@ -595,12 +597,12 @@ export async function reviewStep(
|
||||
? "code review hit context limit — retrying with compacted request"
|
||||
: `${reviewType} review hit context limit — retrying with compacted request`;
|
||||
reviewerLog.warn(`${taskId}: ${retryLogMessage}`);
|
||||
if (options.store && options.taskId) {
|
||||
if (options.store && options.taskId && retrySettings && typeof options.store.getTask === "function") {
|
||||
await options.store.logEntry(options.taskId, retryLogMessage).catch(() => undefined);
|
||||
const taskForRetry = await options.store.getTask(options.taskId);
|
||||
await recordRetry({
|
||||
store: options.store,
|
||||
settings: liveSettings ?? options.settings ?? {},
|
||||
settings: retrySettings,
|
||||
task: taskForRetry,
|
||||
category: "reviewerContext",
|
||||
role: "reviewer",
|
||||
@@ -657,6 +659,7 @@ export async function reviewStep(
|
||||
};
|
||||
|
||||
const hasConfiguredFallback = Boolean(validatorFallbackProvider && validatorFallbackModelId);
|
||||
const retrySettings = liveSettings ?? options.settings;
|
||||
|
||||
let firstAttempt: { verdict: ReviewVerdict; summary: string; review: string };
|
||||
try {
|
||||
@@ -664,11 +667,11 @@ export async function reviewStep(
|
||||
} catch (err) {
|
||||
if (hasConfiguredFallback) {
|
||||
await logFallbackRetry("reviewer error", `${validatorFallbackProvider}/${validatorFallbackModelId}`);
|
||||
if (options.store && options.taskId) {
|
||||
if (options.store && options.taskId && retrySettings && typeof options.store.getTask === "function") {
|
||||
const taskForRetry = await options.store.getTask(options.taskId);
|
||||
await recordRetry({
|
||||
store: options.store,
|
||||
settings: liveSettings ?? options.settings ?? {},
|
||||
settings: retrySettings,
|
||||
task: taskForRetry,
|
||||
category: "reviewerFallback",
|
||||
role: "reviewer",
|
||||
@@ -686,11 +689,11 @@ export async function reviewStep(
|
||||
}
|
||||
|
||||
await logFallbackRetry("reviewer error", "same-model strict prompt");
|
||||
if (options.store && options.taskId) {
|
||||
if (options.store && options.taskId && retrySettings && typeof options.store.getTask === "function") {
|
||||
const taskForRetry = await options.store.getTask(options.taskId);
|
||||
await recordRetry({
|
||||
store: options.store,
|
||||
settings: liveSettings ?? options.settings ?? {},
|
||||
settings: retrySettings,
|
||||
task: taskForRetry,
|
||||
category: "reviewerFallback",
|
||||
role: "reviewer",
|
||||
@@ -710,11 +713,11 @@ export async function reviewStep(
|
||||
|
||||
if (hasConfiguredFallback) {
|
||||
await logFallbackRetry("UNAVAILABLE verdict", `${validatorFallbackProvider}/${validatorFallbackModelId}`);
|
||||
if (options.store && options.taskId) {
|
||||
if (options.store && options.taskId && retrySettings && typeof options.store.getTask === "function") {
|
||||
const taskForRetry = await options.store.getTask(options.taskId);
|
||||
await recordRetry({
|
||||
store: options.store,
|
||||
settings: liveSettings ?? options.settings ?? {},
|
||||
settings: retrySettings,
|
||||
task: taskForRetry,
|
||||
category: "reviewerFallback",
|
||||
role: "reviewer",
|
||||
@@ -728,11 +731,11 @@ export async function reviewStep(
|
||||
}
|
||||
|
||||
await logFallbackRetry("UNAVAILABLE verdict", "same-model strict prompt");
|
||||
if (options.store && options.taskId) {
|
||||
if (options.store && options.taskId && retrySettings && typeof options.store.getTask === "function") {
|
||||
const taskForRetry = await options.store.getTask(options.taskId);
|
||||
await recordRetry({
|
||||
store: options.store,
|
||||
settings: liveSettings ?? options.settings ?? {},
|
||||
settings: retrySettings,
|
||||
task: taskForRetry,
|
||||
category: "reviewerFallback",
|
||||
role: "reviewer",
|
||||
|
||||
@@ -743,8 +743,8 @@ describe("RoadmapStore", () => {
|
||||
});
|
||||
|
||||
describe("schema version", () => {
|
||||
it("schema version is 77 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(77);
|
||||
it("schema version is 78 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(78);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user