feat(FN-3954): blocker staleness fanout and escalation for tasks

Merges FN-3954: adds a blocker fanout system where task staleness automatically escalates to upstream blocking tasks, with a new `blocker-fanout` core module, dashboard hooks wiring that surfaces escalation status on the board and executor status bar, and a new settings toggle to disable escalation.

Fusion-Task-Id: FN-3954
This commit is contained in:
Fusion
2026-05-10 19:47:07 -07:00
committed by gsxdsm
parent 0c5c47933a
commit 2aec3474b0
24 changed files with 411 additions and 143 deletions

View File

@@ -684,6 +684,7 @@ function AppInner() {
globalPaused,
enginePaused,
taskStuckTimeoutMs,
staleHighFanoutBlockerAgeThresholdMs,
showQuickChatFAB,
prAuthAvailable,
settingsLoaded,
@@ -1433,6 +1434,7 @@ function AppInner() {
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
staleHighFanoutBlockerAgeThresholdMs={staleHighFanoutBlockerAgeThresholdMs}
onOpenMission={handleOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
/>
@@ -1630,6 +1632,7 @@ function AppInner() {
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
projectId={currentProject.id}
taskStuckTimeoutMs={taskStuckTimeoutMs}
staleHighFanoutBlockerAgeThresholdMs={staleHighFanoutBlockerAgeThresholdMs}
backgroundSessions={bgSessions}
backgroundGenerating={bgGenerating}
backgroundNeedsInput={bgNeedsInput}

View File

@@ -51,6 +51,8 @@ interface BoardProps {
taskStuckTimeoutMs?: number;
/** Called when user clicks a mission badge on a task card */
onOpenMission?: (missionId: string) => void;
/** Age threshold in milliseconds before high fan-out blockers escalate in dashboard surfaces. */
staleHighFanoutBlockerAgeThresholdMs?: number;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
lastFetchTimeMs?: number;
}
@@ -71,13 +73,15 @@ function areWorkflowNameLookupsEqual(previous: ReadonlyMap<string, string>, next
return true;
}
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs }: BoardProps) {
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
const archivedLoadedRef = useRef(false);
const { fetchBatch } = useBatchBadgeFetch(projectId);
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState<ReadonlyMap<string, string>>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP);
const blockerFanoutMap = useBlockerFanout(tasks);
const blockerFanoutMap = useBlockerFanout(tasks, {
staleHighFanoutAgeThresholdMs: staleHighFanoutBlockerAgeThresholdMs,
});
// Normalized search-active signal: trimmed and non-empty
const isSearchActive = searchQuery.trim() !== "";
const tasksByColumnCacheRef = useRef<Record<ColumnType, Task[]>>({

View File

@@ -48,7 +48,7 @@
}
.executor-status-bar__segment--fanout {
color: var(--color-warning);
color: var(--color-error);
min-width: 0;
}
@@ -97,7 +97,7 @@
}
.executor-status-bar__indicator--fanout {
background: var(--color-warning);
background: var(--color-error);
}
/* Numeric count display */

View File

@@ -1,6 +1,10 @@
import "./ExecutorStatusBar.css";
import { useMemo, useState } from "react";
import { HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, type Task } from "@fusion/core";
import {
HIGH_FANOUT_BLOCKER_TODO_THRESHOLD,
STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS,
type Task,
} from "@fusion/core";
import { AlertTriangle, Clock, Folder, Pause, Play, Zap } from "lucide-react";
import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
import { useExecutorStats } from "../hooks/useExecutorStats";
@@ -14,6 +18,8 @@ interface ExecutorStatusBarProps {
projectId?: string;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
/** Age threshold in milliseconds before high fan-out blockers escalate in dashboard surfaces. */
staleHighFanoutBlockerAgeThresholdMs?: number;
/** Background AI sessions */
backgroundSessions?: AiSessionSummary[];
backgroundGenerating?: number;
@@ -79,7 +85,7 @@ function getStateDisplay(state: ExecutorState): { label: string; color: string;
* - Executor state badge (idle/running/paused)
* - Last activity timestamp
*/
export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, backgroundSessions, backgroundGenerating, backgroundNeedsInput, onOpenBackgroundSession, onDismissBackgroundSession, lastFetchTimeMs, currentProjectPath, onOpenProjectDirectory, keyboardOpen }: ExecutorStatusBarProps) {
export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, staleHighFanoutBlockerAgeThresholdMs, backgroundSessions, backgroundGenerating, backgroundNeedsInput, onOpenBackgroundSession, onDismissBackgroundSession, lastFetchTimeMs, currentProjectPath, onOpenProjectDirectory, keyboardOpen }: ExecutorStatusBarProps) {
if (keyboardOpen) return null;
const { stats, loading, error } = useExecutorStats(tasks, projectId, taskStuckTimeoutMs, lastFetchTimeMs);
const [isProjectPathVisible, setIsProjectPathVisible] = useState(false);
@@ -88,29 +94,23 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, backgr
const relativeTime = useMemo(() => formatRelativeTime(stats.lastActivityAt), [stats.lastActivityAt]);
const highestFanoutBlocker = useMemo(() => {
const fanoutMap = computeBlockerFanoutMap(tasks);
const candidates = tasks
.filter((task) => task.column === "in-progress" || task.column === "in-review")
.map((task) => {
const fanout = fanoutMap.get(task.id);
if (!fanout || !fanout.isHighFanout) return null;
return {
id: task.id,
activeTodoCount: fanout.activeTodoCount,
totalCount: fanout.totalCount,
staleCount: fanout.staleBlockedByDependentIds.length,
};
})
.filter((entry): entry is { id: string; activeTodoCount: number; totalCount: number; staleCount: number } => Boolean(entry))
const highestEscalatedBlocker = useMemo(() => {
const fanoutMap = computeBlockerFanoutMap(tasks, {
staleHighFanoutAgeThresholdMs:
staleHighFanoutBlockerAgeThresholdMs ?? STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS,
});
const candidates = Array.from(fanoutMap.values())
.map((entry) => entry.escalation)
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
.sort((a, b) => {
if (b.activeTodoCount !== a.activeTodoCount) return b.activeTodoCount - a.activeTodoCount;
if (b.totalCount !== a.totalCount) return b.totalCount - a.totalCount;
return a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" });
if (b.totalActiveCount !== a.totalActiveCount) return b.totalActiveCount - a.totalActiveCount;
if (b.blockingAgeMs !== a.blockingAgeMs) return b.blockingAgeMs - a.blockingAgeMs;
return a.blockerId.localeCompare(b.blockerId, "en", { numeric: true, sensitivity: "base" });
});
return candidates[0] ?? null;
}, [tasks]);
}, [tasks, staleHighFanoutBlockerAgeThresholdMs]);
const StateIcon = stateDisplay.icon;
@@ -212,18 +212,17 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, backgr
<span className="executor-status-bar__count">{stats.inReviewCount}</span>
</div>
{highestFanoutBlocker && (
{highestEscalatedBlocker && (
<>
<span className="executor-status-bar__divider" aria-hidden="true" />
<div className="executor-status-bar__segment executor-status-bar__segment--fanout">
<span className="executor-status-bar__indicator executor-status-bar__indicator--fanout executor-status-bar__indicator--active" aria-hidden="true" />
<span className="executor-status-bar__label">High Fan-out</span>
<span className="executor-status-bar__label">Escalated</span>
<span
className="executor-status-bar__fanout-summary"
title={`Top blocker ${highestFanoutBlocker.id}: ${highestFanoutBlocker.activeTodoCount} todo waiting (threshold ${HIGH_FANOUT_BLOCKER_TODO_THRESHOLD}), ${highestFanoutBlocker.totalCount} active total`}
title={`Escalated blocker ${highestEscalatedBlocker.blockerId}: ${highestEscalatedBlocker.activeTodoCount} todo waiting (threshold ${HIGH_FANOUT_BLOCKER_TODO_THRESHOLD}), ${highestEscalatedBlocker.totalActiveCount} active total`}
>
{highestFanoutBlocker.id} · {highestFanoutBlocker.activeTodoCount} todo
{highestFanoutBlocker.staleCount > 0 ? ` · ${highestFanoutBlocker.staleCount} stale` : ""}
{highestEscalatedBlocker.blockerId} · {highestEscalatedBlocker.activeTodoCount} todo
</span>
</div>
</>

View File

@@ -2958,6 +2958,25 @@ export function SettingsModal({
/>
<small>Timeout in minutes for detecting stuck tasks. When a task&apos;s agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10.</small>
</div>
<div className="form-group">
<label htmlFor="staleHighFanoutBlockerAgeThresholdMs">Stale High Fan-out Escalation (hours)</label>
<input
id="staleHighFanoutBlockerAgeThresholdMs"
type="number"
min={1}
step={1}
value={form.staleHighFanoutBlockerAgeThresholdMs ? Math.round(form.staleHighFanoutBlockerAgeThresholdMs / 3600000) : ""}
onChange={(e) => {
const val = e.target.value;
const num = Number(val);
setForm((f) => ({
...f,
staleHighFanoutBlockerAgeThresholdMs: val && num > 0 ? num * 3600000 : undefined,
}));
}}
/>
<small>Escalate high fan-out blockers only after they remain in in-progress or in-review for this many hours (age source: columnMovedAt, fallback updatedAt). Default: 2 hours.</small>
</div>
<div className="form-group">
<label htmlFor="preserveProgressOnStuckRequeue" className="checkbox-label">
<input

View File

@@ -444,6 +444,11 @@
color: var(--text);
}
.card-fanout-badge--escalated {
color: var(--color-error);
background: color-mix(in srgb, var(--color-error) 16%, transparent);
}
.card-scope-badge[data-tooltip]:hover::after,
.card-fanout-badge[data-tooltip]:hover::after {
content: attr(data-tooltip);

View File

@@ -414,6 +414,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.fanout?.totalCount === next.fanout?.totalCount &&
previous.fanout?.activeTodoCount === next.fanout?.activeTodoCount &&
previous.fanout?.isHighFanout === next.fanout?.isHighFanout &&
previous.fanout?.escalation?.blockingAgeMs === next.fanout?.escalation?.blockingAgeMs &&
areTaskDependenciesEqual(previous.fanout?.dependentIds ?? [], next.fanout?.dependentIds ?? []) &&
areTaskDependenciesEqual(previous.fanout?.staleBlockedByDependentIds ?? [], next.fanout?.staleBlockedByDependentIds ?? []) &&
previousTask.id === nextTask.id &&
@@ -1648,12 +1649,12 @@ function TaskCardComponent({
)}
{fanout && fanout.totalCount > 0 && (
<span
className={`card-fanout-badge${fanout.staleBlockedByDependentIds.length > 0 ? " card-fanout-badge--stale" : ""}${fanout.isHighFanout ? " card-fanout-badge--high-impact" : ""}`}
data-tooltip={`Blocking ${fanout.totalCount} active task(s); ${fanout.activeTodoCount} waiting in todo${fanout.isHighFanout ? ` (high fan-out threshold: ${HIGH_FANOUT_BLOCKER_TODO_THRESHOLD})` : ""}`}
className={`card-fanout-badge${fanout.staleBlockedByDependentIds.length > 0 ? " card-fanout-badge--stale" : ""}${fanout.isHighFanout ? " card-fanout-badge--high-impact" : ""}${fanout.escalation ? " card-fanout-badge--escalated" : ""}`}
data-tooltip={`Blocking ${fanout.totalCount} active task(s); ${fanout.activeTodoCount} waiting in todo${fanout.isHighFanout ? ` (high fan-out threshold: ${HIGH_FANOUT_BLOCKER_TODO_THRESHOLD})` : ""}${fanout.escalation ? ` · escalated after ${Math.floor(fanout.escalation.blockingAgeMs / 60000)}m in blocking column` : ""}`}
>
<GitBranch size={12} style={{ verticalAlign: "middle" }} />
<span>
{fanout.isHighFanout ? "High fan-out" : "Blocks"}{" "}
{fanout.escalation ? "Escalated" : fanout.isHighFanout ? "High fan-out" : "Blocks"}{" "}
<span className="card-fanout-count">{fanout.totalCount}</span>
{fanout.isHighFanout ? ` (${fanout.activeTodoCount} todo)` : ""}
{fanout.staleBlockedByDependentIds.length > 0 ? ` (${fanout.staleBlockedByDependentIds.length} stale)` : ""}

View File

@@ -66,13 +66,13 @@ describe("ExecutorStatusBar", () => {
expect(statusBar).toHaveTextContent("Blocked");
expect(statusBar).toHaveTextContent("Queued");
expect(statusBar).toHaveTextContent("In Review");
expect(statusBar).not.toHaveTextContent("High Fan-out");
expect(statusBar).not.toHaveTextContent("Escalated");
});
it("shows highest high fan-out blocker summary with stable tie-break ordering", () => {
it("shows highest escalated blocker summary with stable tie-break ordering", () => {
const tasks = [
makeTask("FN-010", "in-progress"),
makeTask("FN-002", "in-review"),
makeTask("FN-010", "in-progress", { columnMovedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }),
makeTask("FN-002", "in-review", { columnMovedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }),
makeTask("FN-101", "todo", { dependencies: ["FN-010"] }),
makeTask("FN-102", "todo", { dependencies: ["FN-010"] }),
makeTask("FN-103", "todo", { dependencies: ["FN-010"] }),
@@ -85,14 +85,19 @@ describe("ExecutorStatusBar", () => {
makeTask("FN-205", "todo", { dependencies: ["FN-002"] }),
];
render(<ExecutorStatusBar tasks={tasks} />);
render(
<ExecutorStatusBar
tasks={tasks}
staleHighFanoutBlockerAgeThresholdMs={60 * 60 * 1000}
/>,
);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveTextContent("High Fan-out");
expect(statusBar).toHaveTextContent("Escalated");
expect(statusBar).toHaveTextContent("FN-002 · 5 todo");
});
it("does not show high fan-out summary for ordinary chains below threshold", () => {
it("does not show escalated summary for ordinary chains below threshold", () => {
const tasks = [
makeTask("FN-500", "in-progress"),
makeTask("FN-501", "todo", { dependencies: ["FN-500"] }),
@@ -103,7 +108,7 @@ describe("ExecutorStatusBar", () => {
render(<ExecutorStatusBar tasks={tasks} />);
expect(screen.getByRole("status")).not.toHaveTextContent("High Fan-out");
expect(screen.getByRole("status")).not.toHaveTextContent("Escalated");
});
it("displays running task count", () => {

View File

@@ -1416,6 +1416,19 @@ describe("SettingsModal", () => {
expect(input.value).toBe("");
});
it("allows configuring stale high fan-out escalation threshold in hours", async () => {
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Scheduling"));
const input = screen.getByLabelText("Stale High Fan-out Escalation (hours)") as HTMLInputElement;
expect(input).toBeDefined();
await userEvent.clear(input);
await userEvent.type(input, "3");
expect(input.value).toBe("3");
});
it("allows clearing maxWorktrees without leaving a stuck zero", async () => {
renderModal();
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());

View File

@@ -238,7 +238,14 @@ describe("TaskCard", () => {
const { rerender } = render(
<TaskCard
task={makeTask({ column: "in-progress" })}
fanout={{ totalCount: 8, activeTodoCount: 5, dependentIds: ["FN-003"], staleBlockedByDependentIds: [], isHighFanout: true }}
fanout={{
totalCount: 8,
activeTodoCount: 5,
dependentIds: ["FN-003"],
staleBlockedByDependentIds: [],
isHighFanout: true,
escalation: { blockerId: "FN-001", activeTodoCount: 5, totalActiveCount: 8, blockingAgeMs: 3_600_000 },
}}
onOpenDetail={noop}
addToast={noop}
/>,
@@ -246,7 +253,8 @@ describe("TaskCard", () => {
let badge = document.querySelector(".card-fanout-badge--high-impact") as HTMLElement;
expect(badge).not.toBeNull();
expect(badge.textContent).toContain("High fan-out");
expect(badge).toHaveClass("card-fanout-badge--escalated");
expect(badge.textContent).toContain("Escalated");
expect(badge.textContent).toContain("(5 todo)");
rerender(

View File

@@ -28,6 +28,7 @@ describe("useAppSettings", () => {
enginePaused: false,
prAuthAvailable: true,
taskStuckTimeoutMs: 600000,
staleHighFanoutBlockerAgeThresholdMs: 7200000,
showQuickChatFAB: false,
} as never);
@@ -46,6 +47,7 @@ describe("useAppSettings", () => {
expect(result.current.prAuthAvailable).toBe(true);
expect(result.current.settingsLoaded).toBe(true);
expect(result.current.taskStuckTimeoutMs).toBe(600000);
expect(result.current.staleHighFanoutBlockerAgeThresholdMs).toBe(7200000);
expect(result.current.showQuickChatFAB).toBe(false);
});

View File

@@ -42,6 +42,7 @@ describe("computeBlockerFanoutMap", () => {
dependentIds: ["FN-2"],
staleBlockedByDependentIds: [],
isHighFanout: false,
escalation: undefined,
});
});
@@ -58,6 +59,7 @@ describe("computeBlockerFanoutMap", () => {
dependentIds: ["FN-2", "FN-3"],
staleBlockedByDependentIds: [],
isHighFanout: false,
escalation: undefined,
});
});
@@ -75,6 +77,7 @@ describe("computeBlockerFanoutMap", () => {
dependentIds: ["FN-2", "FN-3", "FN-4"],
staleBlockedByDependentIds: [],
isHighFanout: false,
escalation: undefined,
});
});
@@ -90,6 +93,7 @@ describe("computeBlockerFanoutMap", () => {
dependentIds: ["FN-2", "FN-3"],
staleBlockedByDependentIds: ["FN-3"],
isHighFanout: false,
escalation: undefined,
});
});
@@ -131,6 +135,7 @@ describe("computeBlockerFanoutMap", () => {
dependentIds: ["D1", "D2", "D3", "D4"],
staleBlockedByDependentIds: [],
isHighFanout: false,
escalation: undefined,
});
});
@@ -151,6 +156,7 @@ describe("computeBlockerFanoutMap", () => {
dependentIds: ["D1", "D2", "D3", "D4", "D5", "DONE"],
staleBlockedByDependentIds: [],
isHighFanout: true,
escalation: undefined,
});
});
@@ -170,9 +176,48 @@ describe("computeBlockerFanoutMap", () => {
dependentIds: ["D1", "D2", "D3", "D4", "ARCH"],
staleBlockedByDependentIds: [],
isHighFanout: false,
escalation: undefined,
});
});
it("escalates aged high fan-out blockers only when old enough", () => {
const tasks = [
createTask("B", "in-progress", { columnMovedAt: "2026-01-01T00:00:00.000Z" }),
createTask("D1", "todo", { dependencies: ["B"] }),
createTask("D2", "todo", { dependencies: ["B"] }),
createTask("D3", "todo", { dependencies: ["B"] }),
createTask("D4", "todo", { dependencies: ["B"] }),
createTask("D5", "todo", { dependencies: ["B"] }),
];
const entry = computeBlockerFanoutMap(tasks, {
staleHighFanoutAgeThresholdMs: 60 * 60 * 1000,
}).get("B");
expect(entry?.isHighFanout).toBe(true);
expect(entry?.escalation?.blockerId).toBe("B");
expect(entry?.escalation?.activeTodoCount).toBe(5);
expect((entry?.escalation?.blockingAgeMs ?? 0) / (60 * 60 * 1000)).toBeGreaterThanOrEqual(1);
});
it("keeps short-lived high fan-out blockers quiet", () => {
const tasks = [
createTask("B", "in-progress", { columnMovedAt: new Date().toISOString() }),
createTask("D1", "todo", { dependencies: ["B"] }),
createTask("D2", "todo", { dependencies: ["B"] }),
createTask("D3", "todo", { dependencies: ["B"] }),
createTask("D4", "todo", { dependencies: ["B"] }),
createTask("D5", "todo", { dependencies: ["B"] }),
];
const entry = computeBlockerFanoutMap(tasks, {
staleHighFanoutAgeThresholdMs: 60 * 60 * 1000,
}).get("B");
expect(entry?.isHighFanout).toBe(true);
expect(entry?.escalation).toBeUndefined();
});
it("keeps MAX_AUTO_MERGE_RETRIES aligned with engine self-healing source", () => {
const testDir = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(resolve(testDir, "../../../../engine/src/self-healing.ts"), "utf8");

View File

@@ -12,6 +12,7 @@ export interface UseAppSettingsResult {
globalPaused: boolean;
enginePaused: boolean;
taskStuckTimeoutMs: number | undefined;
staleHighFanoutBlockerAgeThresholdMs: number;
showQuickChatFAB: boolean;
prAuthAvailable: boolean;
settingsLoaded: boolean;
@@ -40,6 +41,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [globalPaused, setGlobalPaused] = useState(false);
const [enginePaused, setEnginePaused] = useState(false);
const [taskStuckTimeoutMs, setTaskStuckTimeoutMs] = useState<number | undefined>(undefined);
const [staleHighFanoutBlockerAgeThresholdMs, setStaleHighFanoutBlockerAgeThresholdMs] = useState(2 * 60 * 60 * 1000);
const [showQuickChatFAB, setShowQuickChatFAB] = useState(false);
const [prAuthAvailable, setPrAuthAvailable] = useState(false);
const [settingsLoaded, setSettingsLoaded] = useState(false);
@@ -72,6 +74,9 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
setEnginePaused(Boolean(settings.enginePaused));
setPrAuthAvailable(Boolean(settings.prAuthAvailable));
setTaskStuckTimeoutMs(settings.taskStuckTimeoutMs);
setStaleHighFanoutBlockerAgeThresholdMs(
settings.staleHighFanoutBlockerAgeThresholdMs ?? 2 * 60 * 60 * 1000,
);
setShowQuickChatFAB(settings.showQuickChatFAB === true);
setExperimentalFeatures(settings.experimentalFeatures ?? {});
const features = settings.experimentalFeatures ?? {};
@@ -168,6 +173,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
globalPaused,
enginePaused,
taskStuckTimeoutMs,
staleHighFanoutBlockerAgeThresholdMs,
showQuickChatFAB,
prAuthAvailable,
settingsLoaded,

View File

@@ -1,104 +1,34 @@
import { useMemo } from "react";
import { HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, type Task } from "@fusion/core";
import { type Task } from "@fusion/core";
import {
computeBlockerFanoutMap as computeBlockerFanoutMapCore,
type BlockerFanoutEntry,
} from "../../../core/src/blocker-fanout";
export interface BlockerFanoutEntry {
totalCount: number;
activeTodoCount: number;
dependentIds: string[];
staleBlockedByDependentIds: string[];
isHighFanout: boolean;
}
export type { BlockerFanoutEntry };
// Keep in sync with packages/engine/src/self-healing.ts
export const MAX_AUTO_MERGE_RETRIES = 3;
const ACTIVE_COLUMNS = new Set<Task["column"]>(["triage", "todo", "in-progress", "in-review"]);
function isStaleBlockedByBlocker(blocker: Task | undefined): boolean {
if (!blocker) {
return true;
}
if (blocker.column === "done" || blocker.column === "archived") {
return true;
}
if (blocker.column === "in-review" && blocker.paused === true) {
return true;
}
if (
blocker.column === "in-review" &&
blocker.status === "failed" &&
(blocker.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES
) {
return true;
}
return false;
export interface UseBlockerFanoutOptions {
staleHighFanoutAgeThresholdMs?: number;
}
interface MutableEntry {
dependentIds: string[];
blockedByDependentIds: string[];
activeCount: number;
activeTodoCount: number;
export function computeBlockerFanoutMap(
tasks: Task[],
options: UseBlockerFanoutOptions = {},
): Map<string, BlockerFanoutEntry> {
return computeBlockerFanoutMapCore(tasks, MAX_AUTO_MERGE_RETRIES, {
staleHighFanoutAgeThresholdMs: options.staleHighFanoutAgeThresholdMs,
});
}
export function computeBlockerFanoutMap(tasks: Task[]): Map<string, BlockerFanoutEntry> {
const taskById = new Map(tasks.map((task) => [task.id, task]));
const fanout = new Map<string, MutableEntry>();
const ensureEntry = (blockerId: string): MutableEntry => {
let entry = fanout.get(blockerId);
if (!entry) {
entry = { dependentIds: [], blockedByDependentIds: [], activeCount: 0, activeTodoCount: 0 };
fanout.set(blockerId, entry);
}
return entry;
};
for (const task of tasks) {
const active = ACTIVE_COLUMNS.has(task.column);
const isTodo = task.column === "todo";
const dependencyIds = task.dependencies ?? [];
for (const depId of dependencyIds) {
if (!depId) continue;
const entry = ensureEntry(depId);
entry.dependentIds.push(task.id);
if (active) entry.activeCount += 1;
if (isTodo) entry.activeTodoCount += 1;
}
if (task.blockedBy) {
const entry = ensureEntry(task.blockedBy);
entry.dependentIds.push(task.id);
entry.blockedByDependentIds.push(task.id);
if (active) entry.activeCount += 1;
if (isTodo) entry.activeTodoCount += 1;
}
}
const result = new Map<string, BlockerFanoutEntry>();
for (const [blockerId, entry] of fanout) {
const blocker = taskById.get(blockerId);
const staleBlockedByDependentIds = isStaleBlockedByBlocker(blocker)
? [...entry.blockedByDependentIds]
: [];
result.set(blockerId, {
totalCount: entry.activeCount,
activeTodoCount: entry.activeTodoCount,
dependentIds: entry.dependentIds,
staleBlockedByDependentIds,
isHighFanout: entry.activeTodoCount >= HIGH_FANOUT_BLOCKER_TODO_THRESHOLD,
});
}
return result;
}
export function useBlockerFanout(tasks: Task[]): Map<string, BlockerFanoutEntry> {
return useMemo(() => computeBlockerFanoutMap(tasks), [tasks]);
export function useBlockerFanout(
tasks: Task[],
options: UseBlockerFanoutOptions = {},
): Map<string, BlockerFanoutEntry> {
return useMemo(
() => computeBlockerFanoutMap(tasks, options),
[tasks, options.staleHighFanoutAgeThresholdMs],
);
}