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:
Fusion
2026-05-14 15:23:49 -07:00
committed by gsxdsm
parent 53de550792
commit 85c6869fa6
25 changed files with 210 additions and 34 deletions

View 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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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. |

View File

@@ -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);

View File

@@ -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`. */

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 ? (

View File

@@ -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,

View File

@@ -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>
)}

View File

@@ -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;
}

View File

@@ -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">

View File

@@ -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 */

View File

@@ -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");
}
});

View 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);
}

View File

@@ -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,

View File

@@ -9,7 +9,8 @@ export type DetailTaskTab =
| "changes"
| "comments"
| "model"
| "workflow";
| "workflow"
| "retries";
export type DetailTaskOrigin = "list-mobile";

View File

@@ -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";

View File

@@ -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,
},

View File

@@ -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,

View File

@@ -4294,7 +4294,7 @@ export class TaskExecutor {
task: taskForRetry,
category: "branchConflict",
role: "executor",
agentId: this.agentId,
agentId: task.assignedAgentId ?? undefined,
attempt,
});
}

View File

@@ -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",

View File

@@ -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);
});
});