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:
5
.changeset/fn-3954-stale-fanout-escalation.md
Normal file
5
.changeset/fn-3954-stale-fanout-escalation.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add age-based escalation for high fan-out blockers in the dashboard. High fan-out visibility still appears immediately, and blockers are now explicitly escalated only after they stay in blocking columns past the configurable stale threshold.
|
||||||
@@ -413,14 +413,16 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou
|
|||||||
Use blocker fan-out signals on task cards and in the footer status bar to spot blockers with high downstream impact:
|
Use blocker fan-out signals on task cards and in the footer status bar to spot blockers with high downstream impact:
|
||||||
|
|
||||||
- `Blocks N` counts active downstream dependents in `triage`, `todo`, `in-progress`, or `in-review`.
|
- `Blocks N` counts active downstream dependents in `triage`, `todo`, `in-progress`, or `in-review`.
|
||||||
- A card is escalated to **High fan-out** when it has at least **5 active `todo` dependents** (`activeTodoCount >= 5`).
|
- FN-3942 immediate signal: blockers with at least **5 active `todo` dependents** (`activeTodoCount >= 5`) are marked **High fan-out**.
|
||||||
- Done and archived downstream tasks remain visible for debugging context but do **not** count toward the 5-todo alert threshold.
|
- FN-3954 escalation signal: a high-fan-out blocker is upgraded to **Escalated** only after it remains in `in-progress`/`in-review` past `staleHighFanoutBlockerAgeThresholdMs` (age source: `columnMovedAt ?? updatedAt`).
|
||||||
- The badge tooltip shows total active dependents plus how many are currently waiting in `todo`.
|
- Escalation payload surfaced in UI includes blocker ID, active todo downstream count, total active downstream count, and computed blocking age.
|
||||||
|
- Done and archived downstream tasks remain visible for debugging context but do **not** count toward the todo threshold.
|
||||||
|
- The badge tooltip shows active totals and, when escalated, the computed blocking age context.
|
||||||
- `(stale)` markers mean the dependent is blocked through `blockedBy` and matches stale conditions that `clearStaleBlockedBy` self-healing should clear automatically.
|
- `(stale)` markers mean the dependent is blocked through `blockedBy` and matches stale conditions that `clearStaleBlockedBy` self-healing should clear automatically.
|
||||||
- Stale `dependencies[]` links are shown for awareness but are not auto-cleared by `clearStaleBlockedBy`.
|
- Stale `dependencies[]` links are shown for awareness but are not auto-cleared by `clearStaleBlockedBy`.
|
||||||
- The executor footer shows the current worst high fan-out blocker (in-progress/in-review only), ranked by highest todo fan-out, then highest total fan-out, then stable task ID order.
|
- The executor footer summarizes the top escalated blocker (deterministic rank: highest todo fan-out, then highest active total, then oldest age, then stable task ID).
|
||||||
|
|
||||||
Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low; when a blocker crosses the 5-todo threshold, prioritize unblocking first (reassign, split, or resolve immediately) before lower-impact tasks.
|
Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, high-fan-out blockers stand out immediately, and only long-lived high-impact blockers trigger explicit escalation.
|
||||||
|
|
||||||
### Logs → Agent Log view
|
### Logs → Agent Log view
|
||||||
|
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
|
|||||||
| `specStalenessEnabled` | `boolean` | `false` | Enforce automatic re-planning for stale plans. |
|
| `specStalenessEnabled` | `boolean` | `false` | Enforce automatic re-planning for stale plans. |
|
||||||
| `specStalenessMaxAgeMs` | `number` | `21600000` | Spec staleness threshold in ms (6 hours). |
|
| `specStalenessMaxAgeMs` | `number` | `21600000` | Spec staleness threshold in ms (6 hours). |
|
||||||
| `taskStuckTimeoutMs` | `number` | `undefined` | Inactivity timeout for stuck-task recovery. |
|
| `taskStuckTimeoutMs` | `number` | `undefined` | Inactivity timeout for stuck-task recovery. |
|
||||||
|
| `staleHighFanoutBlockerAgeThresholdMs` | `number` | `7200000` | Age threshold (ms) before high-fan-out blockers escalate in dashboard task cards/footer. Applies only to blockers currently in `in-progress`/`in-review`; age is computed from `columnMovedAt ?? updatedAt`. |
|
||||||
| `aiSessionTtlMs` | `number` | `604800000` | TTL in ms for persisted planning/subtask/mission sessions (7 days). |
|
| `aiSessionTtlMs` | `number` | `604800000` | TTL in ms for persisted planning/subtask/mission sessions (7 days). |
|
||||||
| `aiSessionCleanupIntervalMs` | `number` | `3600000` | Interval in ms for AI session cleanup sweeps (1 hour). |
|
| `aiSessionCleanupIntervalMs` | `number` | `3600000` | Interval in ms for AI session cleanup sweeps (1 hour). |
|
||||||
| `autoUnpauseEnabled` | `boolean` | `true` | Auto-unpause after rate-limit-triggered pauses; manual pauses stay paused until explicitly unpaused by the user. |
|
| `autoUnpauseEnabled` | `boolean` | `true` | Auto-unpause after rate-limit-triggered pauses; manual pauses stay paused until explicitly unpaused by the user. |
|
||||||
|
|||||||
68
packages/core/src/__tests__/blocker-fanout.test.ts
Normal file
68
packages/core/src/__tests__/blocker-fanout.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Task } from "../types.js";
|
||||||
|
import { computeBlockerFanoutMap } from "../blocker-fanout.js";
|
||||||
|
|
||||||
|
const MAX_AUTO_MERGE_RETRIES = 3;
|
||||||
|
|
||||||
|
function createTask(id: string, column: Task["column"], overrides: Partial<Task> = {}): Task {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
description: id,
|
||||||
|
column,
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
|
||||||
|
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("computeBlockerFanoutMap escalation", () => {
|
||||||
|
it("escalates high fan-out blockers when age crosses threshold", () => {
|
||||||
|
const nowMs = Date.parse("2026-01-01T06:00:00.000Z");
|
||||||
|
const blocker = createTask("B", "in-progress", { columnMovedAt: "2026-01-01T00:00:00.000Z" });
|
||||||
|
const dependents = [1, 2, 3, 4, 5].map((n) => createTask(`D${n}`, "todo", { dependencies: ["B"] }));
|
||||||
|
|
||||||
|
const entry = computeBlockerFanoutMap([blocker, ...dependents], MAX_AUTO_MERGE_RETRIES, {
|
||||||
|
nowMs,
|
||||||
|
staleHighFanoutAgeThresholdMs: 60 * 60 * 1000,
|
||||||
|
}).get("B");
|
||||||
|
|
||||||
|
expect(entry?.escalation).toEqual({
|
||||||
|
blockerId: "B",
|
||||||
|
activeTodoCount: 5,
|
||||||
|
totalActiveCount: 5,
|
||||||
|
blockingAgeMs: 6 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps short-lived high fan-out blockers quiet", () => {
|
||||||
|
const nowMs = Date.parse("2026-01-01T00:10:00.000Z");
|
||||||
|
const blocker = createTask("B", "in-progress", { columnMovedAt: "2026-01-01T00:00:00.000Z" });
|
||||||
|
const dependents = [1, 2, 3, 4, 5].map((n) => createTask(`D${n}`, "todo", { dependencies: ["B"] }));
|
||||||
|
|
||||||
|
const entry = computeBlockerFanoutMap([blocker, ...dependents], MAX_AUTO_MERGE_RETRIES, {
|
||||||
|
nowMs,
|
||||||
|
staleHighFanoutAgeThresholdMs: 60 * 60 * 1000,
|
||||||
|
}).get("B");
|
||||||
|
|
||||||
|
expect(entry?.isHighFanout).toBe(true);
|
||||||
|
expect(entry?.escalation).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not escalate sub-threshold chains", () => {
|
||||||
|
const nowMs = Date.parse("2026-01-01T10:00:00.000Z");
|
||||||
|
const blocker = createTask("B", "in-review", { columnMovedAt: "2026-01-01T00:00:00.000Z" });
|
||||||
|
const dependents = [1, 2, 3, 4].map((n) => createTask(`D${n}`, "todo", { dependencies: ["B"] }));
|
||||||
|
|
||||||
|
const entry = computeBlockerFanoutMap([blocker, ...dependents], MAX_AUTO_MERGE_RETRIES, {
|
||||||
|
nowMs,
|
||||||
|
staleHighFanoutAgeThresholdMs: 60 * 60 * 1000,
|
||||||
|
}).get("B");
|
||||||
|
|
||||||
|
expect(entry?.isHighFanout).toBe(false);
|
||||||
|
expect(entry?.escalation).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,6 +74,12 @@ describe("settings key parity", () => {
|
|||||||
expect(DEFAULT_PROJECT_SETTINGS.workflowStepTimeoutMs).toBe(360_000);
|
expect(DEFAULT_PROJECT_SETTINGS.workflowStepTimeoutMs).toBe(360_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defaults stale high fan-out blocker escalation age threshold", () => {
|
||||||
|
expect(DEFAULT_PROJECT_SETTINGS.staleHighFanoutBlockerAgeThresholdMs).toBe(2 * 60 * 60 * 1000);
|
||||||
|
expect(isProjectSettingsKey("staleHighFanoutBlockerAgeThresholdMs")).toBe(true);
|
||||||
|
expect(isGlobalSettingsKey("staleHighFanoutBlockerAgeThresholdMs")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps github tracking keys in expected scopes with documented defaults", () => {
|
it("keeps github tracking keys in expected scopes with documented defaults", () => {
|
||||||
expect(DEFAULT_PROJECT_SETTINGS.githubTrackingEnabledByDefault).toBe(false);
|
expect(DEFAULT_PROJECT_SETTINGS.githubTrackingEnabledByDefault).toBe(false);
|
||||||
expect(DEFAULT_PROJECT_SETTINGS.githubTrackingDefaultRepo).toBeUndefined();
|
expect(DEFAULT_PROJECT_SETTINGS.githubTrackingDefaultRepo).toBeUndefined();
|
||||||
|
|||||||
134
packages/core/src/blocker-fanout.ts
Normal file
134
packages/core/src/blocker-fanout.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import {
|
||||||
|
HIGH_FANOUT_BLOCKER_TODO_THRESHOLD,
|
||||||
|
STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS,
|
||||||
|
type Task,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
export interface BlockerEscalation {
|
||||||
|
blockerId: string;
|
||||||
|
activeTodoCount: number;
|
||||||
|
totalActiveCount: number;
|
||||||
|
blockingAgeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlockerFanoutEntry {
|
||||||
|
totalCount: number;
|
||||||
|
activeTodoCount: number;
|
||||||
|
dependentIds: string[];
|
||||||
|
staleBlockedByDependentIds: string[];
|
||||||
|
isHighFanout: boolean;
|
||||||
|
escalation?: BlockerEscalation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComputeBlockerFanoutOptions {
|
||||||
|
nowMs?: number;
|
||||||
|
highFanoutTodoThreshold?: number;
|
||||||
|
staleHighFanoutAgeThresholdMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BLOCKER_ESCALATION_COLUMNS = new Set<Task["column"]>(["in-progress", "in-review"]);
|
||||||
|
|
||||||
|
const ACTIVE_COLUMNS = new Set<Task["column"]>(["triage", "todo", "in-progress", "in-review"]);
|
||||||
|
|
||||||
|
interface MutableEntry {
|
||||||
|
dependentIds: string[];
|
||||||
|
blockedByDependentIds: string[];
|
||||||
|
activeCount: number;
|
||||||
|
activeTodoCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStaleBlockedByBlocker(blocker: Task | undefined, maxAutoMergeRetries: number): 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) >= maxAutoMergeRetries) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBlockingAgeMs(blocker: Task, nowMs: number): number {
|
||||||
|
const startedAt = Date.parse(blocker.columnMovedAt ?? blocker.updatedAt);
|
||||||
|
if (!Number.isFinite(startedAt)) return 0;
|
||||||
|
return Math.max(0, nowMs - startedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeBlockerFanoutMap(
|
||||||
|
tasks: Task[],
|
||||||
|
maxAutoMergeRetries: number,
|
||||||
|
options: ComputeBlockerFanoutOptions = {},
|
||||||
|
): Map<string, BlockerFanoutEntry> {
|
||||||
|
const nowMs = options.nowMs ?? Date.now();
|
||||||
|
const highFanoutTodoThreshold =
|
||||||
|
options.highFanoutTodoThreshold ?? HIGH_FANOUT_BLOCKER_TODO_THRESHOLD;
|
||||||
|
const staleHighFanoutAgeThresholdMs =
|
||||||
|
options.staleHighFanoutAgeThresholdMs ?? STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS;
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
for (const depId of task.dependencies ?? []) {
|
||||||
|
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, maxAutoMergeRetries)
|
||||||
|
? [...entry.blockedByDependentIds]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const isHighFanout = entry.activeTodoCount >= highFanoutTodoThreshold;
|
||||||
|
const blockingAgeMs = blocker ? getBlockingAgeMs(blocker, nowMs) : 0;
|
||||||
|
const blockerColumn = blocker?.column;
|
||||||
|
const shouldEscalate =
|
||||||
|
blockerColumn !== undefined &&
|
||||||
|
isHighFanout &&
|
||||||
|
BLOCKER_ESCALATION_COLUMNS.has(blockerColumn) &&
|
||||||
|
blockingAgeMs >= staleHighFanoutAgeThresholdMs;
|
||||||
|
|
||||||
|
result.set(blockerId, {
|
||||||
|
totalCount: entry.activeCount,
|
||||||
|
activeTodoCount: entry.activeTodoCount,
|
||||||
|
dependentIds: entry.dependentIds,
|
||||||
|
staleBlockedByDependentIds,
|
||||||
|
isHighFanout,
|
||||||
|
escalation: shouldEscalate
|
||||||
|
? {
|
||||||
|
blockerId,
|
||||||
|
activeTodoCount: entry.activeTodoCount,
|
||||||
|
totalActiveCount: entry.activeCount,
|
||||||
|
blockingAgeMs,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
|
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
|
||||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||||
export type { TaskReviewData, TaskReviewSummary, TaskReviewItem } from "./types.js";
|
export type { TaskReviewData, TaskReviewSummary, TaskReviewItem } from "./types.js";
|
||||||
@@ -96,6 +96,8 @@ export { DaemonTokenManager, DAEMON_TOKEN_PREFIX, DAEMON_TOKEN_HEX_LENGTH, isDae
|
|||||||
export { discoverPiExtensions, formatPiExtensionSource, getEnabledPiExtensionPaths, getFusionAgentDir, getFusionAgentSettingsPath, getLegacyPiAgentDir, getPiExtensionDiscoveryDirs, reconcileClaudeCliPaths, reconcileDroidCliPaths, resolvePiExtensionProjectRoot, updatePiExtensionDisabledIds } from "./pi-extensions.js";
|
export { discoverPiExtensions, formatPiExtensionSource, getEnabledPiExtensionPaths, getFusionAgentDir, getFusionAgentSettingsPath, getLegacyPiAgentDir, getPiExtensionDiscoveryDirs, reconcileClaudeCliPaths, reconcileDroidCliPaths, resolvePiExtensionProjectRoot, updatePiExtensionDisabledIds } from "./pi-extensions.js";
|
||||||
export type { PiExtensionEntry, PiExtensionSettings, PiExtensionSource } from "./pi-extensions.js";
|
export type { PiExtensionEntry, PiExtensionSettings, PiExtensionSource } from "./pi-extensions.js";
|
||||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||||
|
export { computeBlockerFanoutMap, BLOCKER_ESCALATION_COLUMNS, isStaleBlockedByBlocker } from "./blocker-fanout.js";
|
||||||
|
export type { BlockerFanoutEntry, BlockerEscalation, ComputeBlockerFanoutOptions } from "./blocker-fanout.js";
|
||||||
export {
|
export {
|
||||||
getTaskMergeBlocker,
|
getTaskMergeBlocker,
|
||||||
getTaskCompletionBlocker,
|
getTaskCompletionBlocker,
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
|||||||
specStalenessEnabled: false,
|
specStalenessEnabled: false,
|
||||||
specStalenessMaxAgeMs: 6 * 60 * 60 * 1000,
|
specStalenessMaxAgeMs: 6 * 60 * 60 * 1000,
|
||||||
taskStuckTimeoutMs: 600_000,
|
taskStuckTimeoutMs: 600_000,
|
||||||
|
staleHighFanoutBlockerAgeThresholdMs: 2 * 60 * 60 * 1000,
|
||||||
aiSessionTtlMs: 7 * 24 * 60 * 60 * 1000,
|
aiSessionTtlMs: 7 * 24 * 60 * 60 * 1000,
|
||||||
aiSessionCleanupIntervalMs: 60 * 60 * 1000,
|
aiSessionCleanupIntervalMs: 60 * 60 * 1000,
|
||||||
autoUnpauseEnabled: true,
|
autoUnpauseEnabled: true,
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ export const DEFAULT_TASK_PRIORITY: TaskPriority = "normal";
|
|||||||
*/
|
*/
|
||||||
export const HIGH_FANOUT_BLOCKER_TODO_THRESHOLD = 5;
|
export const HIGH_FANOUT_BLOCKER_TODO_THRESHOLD = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default age gate (ms) before a high fan-out blocker is escalated in dashboards.
|
||||||
|
*/
|
||||||
|
export const STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS = 2 * 60 * 60 * 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execution mode for task implementation.
|
* Execution mode for task implementation.
|
||||||
* Controls how the executor agent approaches the task:
|
* Controls how the executor agent approaches the task:
|
||||||
@@ -2195,6 +2200,10 @@ export interface ProjectSettings {
|
|||||||
* than this duration, the task is considered stuck and will be terminated and retried.
|
* than this duration, the task is considered stuck and will be terminated and retried.
|
||||||
* Default: 600000 (10 minutes). Set to 0 to disable. */
|
* Default: 600000 (10 minutes). Set to 0 to disable. */
|
||||||
taskStuckTimeoutMs?: number;
|
taskStuckTimeoutMs?: number;
|
||||||
|
/** Age threshold in milliseconds before a blocker with high todo fan-out is escalated.
|
||||||
|
* Blocker age is measured from columnMovedAt when available, otherwise updatedAt.
|
||||||
|
* Only blockers currently in in-progress or in-review are eligible. */
|
||||||
|
staleHighFanoutBlockerAgeThresholdMs?: number;
|
||||||
/** TTL in milliseconds for persisted AI planning/subtask/mission interview sessions.
|
/** TTL in milliseconds for persisted AI planning/subtask/mission interview sessions.
|
||||||
* Sessions older than this cutoff are expired by the dashboard session cleanup loop.
|
* Sessions older than this cutoff are expired by the dashboard session cleanup loop.
|
||||||
* Valid range: 600000 (10 minutes) to 2592000000 (30 days).
|
* Valid range: 600000 (10 minutes) to 2592000000 (30 days).
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ A persistent footer status bar at the bottom of the dashboard displays real-time
|
|||||||
- **Stuck**: Count of tasks in "in-progress" with no activity for longer than the project's `taskStuckTimeoutMs` setting (shown only when > 0 and the setting is enabled). Uses the same `isTaskStuck()` predicate as task cards and list rows, so the footer count always matches the visible stuck indicators on the board
|
- **Stuck**: Count of tasks in "in-progress" with no activity for longer than the project's `taskStuckTimeoutMs` setting (shown only when > 0 and the setting is enabled). Uses the same `isTaskStuck()` predicate as task cards and list rows, so the footer count always matches the visible stuck indicators on the board
|
||||||
- **Queued**: Count of tasks in "todo" column
|
- **Queued**: Count of tasks in "todo" column
|
||||||
- **In Review**: Count of tasks in "in-review" column
|
- **In Review**: Count of tasks in "in-review" column
|
||||||
- **High Fan-out**: Shows the worst current blocker in `in-progress`/`in-review` once it reaches **5 todo dependents**; rank order is highest todo count, then highest active total, then stable task ID.
|
- **Escalated blocker summary**: FN-3942 surfaces immediate high fan-out blockers (`activeTodoCount >= 5`); FN-3954 upgrades long-lived high fan-out blockers to an explicit **Escalated** summary in the footer, ranked by todo fan-out, active total, age, then task ID.
|
||||||
- **Executor State**: Current state badge (Idle/Running/Paused)
|
- **Executor State**: Current state badge (Idle/Running/Paused)
|
||||||
- **Last Activity**: Relative timestamp of most recent task event
|
- **Last Activity**: Relative timestamp of most recent task event
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ A persistent footer status bar at the bottom of the dashboard displays real-time
|
|||||||
|
|
||||||
**Features**:
|
**Features**:
|
||||||
- **Shared task list**: Task counts are derived from the same task list used by the board and list views, so the footer always matches the board state exactly. Stuck task detection uses a shared `isTaskStuck()` utility (see `utils/taskStuck.ts`) so the footer count and individual card/row indicators are always consistent.
|
- **Shared task list**: Task counts are derived from the same task list used by the board and list views, so the footer always matches the board state exactly. Stuck task detection uses a shared `isTaskStuck()` utility (see `utils/taskStuck.ts`) so the footer count and individual card/row indicators are always consistent.
|
||||||
- **Thresholded blocker escalation**: Task cards keep ordinary `Blocks N` visibility for non-critical chains while escalating only blockers with `activeTodoCount >= 5` to a distinct `High fan-out` signal. Done/archived downstream tasks never contribute to the threshold.
|
- **Age-based escalation**: Task cards keep ordinary `Blocks N` visibility for non-critical chains, show immediate **High fan-out** at `activeTodoCount >= 5`, and only switch to **Escalated** when that high fan-out blocker remains in blocking columns longer than `staleHighFanoutBlockerAgeThresholdMs` (`columnMovedAt ?? updatedAt`). Done/archived downstream tasks never contribute to the threshold.
|
||||||
- **Footer-safe layout**: Project-view content (board, list view, agents view) automatically reserves space for the fixed footer using a CSS custom property (`--executor-footer-height`). The `project-content--with-footer` wrapper class sets this token to 36px on desktop and 32px on mobile, ensuring all content remains fully visible and scrollable above the status bar
|
- **Footer-safe layout**: Project-view content (board, list view, agents view) automatically reserves space for the fixed footer using a CSS custom property (`--executor-footer-height`). The `project-content--with-footer` wrapper class sets this token to 36px on desktop and 32px on mobile, ensuring all content remains fully visible and scrollable above the status bar
|
||||||
- Real-time updates via 5-second polling for executor state (globalPause, enginePaused, maxConcurrent)
|
- Real-time updates via 5-second polling for executor state (globalPause, enginePaused, maxConcurrent)
|
||||||
- Responsive design: collapses labels on mobile screens (<768px); footer height reduces from 36px to 32px
|
- Responsive design: collapses labels on mobile screens (<768px); footer height reduces from 36px to 32px
|
||||||
|
|||||||
@@ -684,6 +684,7 @@ function AppInner() {
|
|||||||
globalPaused,
|
globalPaused,
|
||||||
enginePaused,
|
enginePaused,
|
||||||
taskStuckTimeoutMs,
|
taskStuckTimeoutMs,
|
||||||
|
staleHighFanoutBlockerAgeThresholdMs,
|
||||||
showQuickChatFAB,
|
showQuickChatFAB,
|
||||||
prAuthAvailable,
|
prAuthAvailable,
|
||||||
settingsLoaded,
|
settingsLoaded,
|
||||||
@@ -1433,6 +1434,7 @@ function AppInner() {
|
|||||||
onToggleFavorite={handleToggleFavorite}
|
onToggleFavorite={handleToggleFavorite}
|
||||||
onToggleModelFavorite={handleToggleModelFavorite}
|
onToggleModelFavorite={handleToggleModelFavorite}
|
||||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
|
staleHighFanoutBlockerAgeThresholdMs={staleHighFanoutBlockerAgeThresholdMs}
|
||||||
onOpenMission={handleOpenMission}
|
onOpenMission={handleOpenMission}
|
||||||
lastFetchTimeMs={lastFetchTimeMs}
|
lastFetchTimeMs={lastFetchTimeMs}
|
||||||
/>
|
/>
|
||||||
@@ -1630,6 +1632,7 @@ function AppInner() {
|
|||||||
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
|
tasks={isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks}
|
||||||
projectId={currentProject.id}
|
projectId={currentProject.id}
|
||||||
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
taskStuckTimeoutMs={taskStuckTimeoutMs}
|
||||||
|
staleHighFanoutBlockerAgeThresholdMs={staleHighFanoutBlockerAgeThresholdMs}
|
||||||
backgroundSessions={bgSessions}
|
backgroundSessions={bgSessions}
|
||||||
backgroundGenerating={bgGenerating}
|
backgroundGenerating={bgGenerating}
|
||||||
backgroundNeedsInput={bgNeedsInput}
|
backgroundNeedsInput={bgNeedsInput}
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ interface BoardProps {
|
|||||||
taskStuckTimeoutMs?: number;
|
taskStuckTimeoutMs?: number;
|
||||||
/** Called when user clicks a mission badge on a task card */
|
/** Called when user clicks a mission badge on a task card */
|
||||||
onOpenMission?: (missionId: string) => void;
|
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. */
|
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
|
||||||
lastFetchTimeMs?: number;
|
lastFetchTimeMs?: number;
|
||||||
}
|
}
|
||||||
@@ -71,13 +73,15 @@ function areWorkflowNameLookupsEqual(previous: ReadonlyMap<string, string>, next
|
|||||||
return true;
|
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 [archivedCollapsed, setArchivedCollapsed] = useState(true);
|
||||||
const archivedLoadedRef = useRef(false);
|
const archivedLoadedRef = useRef(false);
|
||||||
const { fetchBatch } = useBatchBadgeFetch(projectId);
|
const { fetchBatch } = useBatchBadgeFetch(projectId);
|
||||||
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState<ReadonlyMap<string, string>>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP);
|
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
|
// Normalized search-active signal: trimmed and non-empty
|
||||||
const isSearchActive = searchQuery.trim() !== "";
|
const isSearchActive = searchQuery.trim() !== "";
|
||||||
const tasksByColumnCacheRef = useRef<Record<ColumnType, Task[]>>({
|
const tasksByColumnCacheRef = useRef<Record<ColumnType, Task[]>>({
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.executor-status-bar__segment--fanout {
|
.executor-status-bar__segment--fanout {
|
||||||
color: var(--color-warning);
|
color: var(--color-error);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.executor-status-bar__indicator--fanout {
|
.executor-status-bar__indicator--fanout {
|
||||||
background: var(--color-warning);
|
background: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Numeric count display */
|
/* Numeric count display */
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import "./ExecutorStatusBar.css";
|
import "./ExecutorStatusBar.css";
|
||||||
import { useMemo, useState } from "react";
|
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 { AlertTriangle, Clock, Folder, Pause, Play, Zap } from "lucide-react";
|
||||||
import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
|
import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
|
||||||
import { useExecutorStats } from "../hooks/useExecutorStats";
|
import { useExecutorStats } from "../hooks/useExecutorStats";
|
||||||
@@ -14,6 +18,8 @@ interface ExecutorStatusBarProps {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
|
||||||
taskStuckTimeoutMs?: number;
|
taskStuckTimeoutMs?: number;
|
||||||
|
/** Age threshold in milliseconds before high fan-out blockers escalate in dashboard surfaces. */
|
||||||
|
staleHighFanoutBlockerAgeThresholdMs?: number;
|
||||||
/** Background AI sessions */
|
/** Background AI sessions */
|
||||||
backgroundSessions?: AiSessionSummary[];
|
backgroundSessions?: AiSessionSummary[];
|
||||||
backgroundGenerating?: number;
|
backgroundGenerating?: number;
|
||||||
@@ -79,7 +85,7 @@ function getStateDisplay(state: ExecutorState): { label: string; color: string;
|
|||||||
* - Executor state badge (idle/running/paused)
|
* - Executor state badge (idle/running/paused)
|
||||||
* - Last activity timestamp
|
* - 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;
|
if (keyboardOpen) return null;
|
||||||
const { stats, loading, error } = useExecutorStats(tasks, projectId, taskStuckTimeoutMs, lastFetchTimeMs);
|
const { stats, loading, error } = useExecutorStats(tasks, projectId, taskStuckTimeoutMs, lastFetchTimeMs);
|
||||||
const [isProjectPathVisible, setIsProjectPathVisible] = useState(false);
|
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 relativeTime = useMemo(() => formatRelativeTime(stats.lastActivityAt), [stats.lastActivityAt]);
|
||||||
|
|
||||||
const highestFanoutBlocker = useMemo(() => {
|
const highestEscalatedBlocker = useMemo(() => {
|
||||||
const fanoutMap = computeBlockerFanoutMap(tasks);
|
const fanoutMap = computeBlockerFanoutMap(tasks, {
|
||||||
const candidates = tasks
|
staleHighFanoutAgeThresholdMs:
|
||||||
.filter((task) => task.column === "in-progress" || task.column === "in-review")
|
staleHighFanoutBlockerAgeThresholdMs ?? STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS,
|
||||||
.map((task) => {
|
});
|
||||||
const fanout = fanoutMap.get(task.id);
|
const candidates = Array.from(fanoutMap.values())
|
||||||
if (!fanout || !fanout.isHighFanout) return null;
|
.map((entry) => entry.escalation)
|
||||||
return {
|
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry))
|
||||||
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))
|
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (b.activeTodoCount !== a.activeTodoCount) return b.activeTodoCount - a.activeTodoCount;
|
if (b.activeTodoCount !== a.activeTodoCount) return b.activeTodoCount - a.activeTodoCount;
|
||||||
if (b.totalCount !== a.totalCount) return b.totalCount - a.totalCount;
|
if (b.totalActiveCount !== a.totalActiveCount) return b.totalActiveCount - a.totalActiveCount;
|
||||||
return a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" });
|
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;
|
return candidates[0] ?? null;
|
||||||
}, [tasks]);
|
}, [tasks, staleHighFanoutBlockerAgeThresholdMs]);
|
||||||
|
|
||||||
const StateIcon = stateDisplay.icon;
|
const StateIcon = stateDisplay.icon;
|
||||||
|
|
||||||
@@ -212,18 +212,17 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, backgr
|
|||||||
<span className="executor-status-bar__count">{stats.inReviewCount}</span>
|
<span className="executor-status-bar__count">{stats.inReviewCount}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{highestFanoutBlocker && (
|
{highestEscalatedBlocker && (
|
||||||
<>
|
<>
|
||||||
<span className="executor-status-bar__divider" aria-hidden="true" />
|
<span className="executor-status-bar__divider" aria-hidden="true" />
|
||||||
<div className="executor-status-bar__segment executor-status-bar__segment--fanout">
|
<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__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
|
<span
|
||||||
className="executor-status-bar__fanout-summary"
|
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
|
{highestEscalatedBlocker.blockerId} · {highestEscalatedBlocker.activeTodoCount} todo
|
||||||
{highestFanoutBlocker.staleCount > 0 ? ` · ${highestFanoutBlocker.staleCount} stale` : ""}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2958,6 +2958,25 @@ export function SettingsModal({
|
|||||||
/>
|
/>
|
||||||
<small>Timeout in minutes for detecting stuck tasks. When a task's agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10.</small>
|
<small>Timeout in minutes for detecting stuck tasks. When a task'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>
|
||||||
|
<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">
|
<div className="form-group">
|
||||||
<label htmlFor="preserveProgressOnStuckRequeue" className="checkbox-label">
|
<label htmlFor="preserveProgressOnStuckRequeue" className="checkbox-label">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -444,6 +444,11 @@
|
|||||||
color: var(--text);
|
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-scope-badge[data-tooltip]:hover::after,
|
||||||
.card-fanout-badge[data-tooltip]:hover::after {
|
.card-fanout-badge[data-tooltip]:hover::after {
|
||||||
content: attr(data-tooltip);
|
content: attr(data-tooltip);
|
||||||
|
|||||||
@@ -414,6 +414,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
|
|||||||
previous.fanout?.totalCount === next.fanout?.totalCount &&
|
previous.fanout?.totalCount === next.fanout?.totalCount &&
|
||||||
previous.fanout?.activeTodoCount === next.fanout?.activeTodoCount &&
|
previous.fanout?.activeTodoCount === next.fanout?.activeTodoCount &&
|
||||||
previous.fanout?.isHighFanout === next.fanout?.isHighFanout &&
|
previous.fanout?.isHighFanout === next.fanout?.isHighFanout &&
|
||||||
|
previous.fanout?.escalation?.blockingAgeMs === next.fanout?.escalation?.blockingAgeMs &&
|
||||||
areTaskDependenciesEqual(previous.fanout?.dependentIds ?? [], next.fanout?.dependentIds ?? []) &&
|
areTaskDependenciesEqual(previous.fanout?.dependentIds ?? [], next.fanout?.dependentIds ?? []) &&
|
||||||
areTaskDependenciesEqual(previous.fanout?.staleBlockedByDependentIds ?? [], next.fanout?.staleBlockedByDependentIds ?? []) &&
|
areTaskDependenciesEqual(previous.fanout?.staleBlockedByDependentIds ?? [], next.fanout?.staleBlockedByDependentIds ?? []) &&
|
||||||
previousTask.id === nextTask.id &&
|
previousTask.id === nextTask.id &&
|
||||||
@@ -1648,12 +1649,12 @@ function TaskCardComponent({
|
|||||||
)}
|
)}
|
||||||
{fanout && fanout.totalCount > 0 && (
|
{fanout && fanout.totalCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className={`card-fanout-badge${fanout.staleBlockedByDependentIds.length > 0 ? " card-fanout-badge--stale" : ""}${fanout.isHighFanout ? " card-fanout-badge--high-impact" : ""}`}
|
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})` : ""}`}
|
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" }} />
|
<GitBranch size={12} style={{ verticalAlign: "middle" }} />
|
||||||
<span>
|
<span>
|
||||||
{fanout.isHighFanout ? "High fan-out" : "Blocks"}{" "}
|
{fanout.escalation ? "Escalated" : fanout.isHighFanout ? "High fan-out" : "Blocks"}{" "}
|
||||||
<span className="card-fanout-count">{fanout.totalCount}</span>
|
<span className="card-fanout-count">{fanout.totalCount}</span>
|
||||||
{fanout.isHighFanout ? ` (${fanout.activeTodoCount} todo)` : ""}
|
{fanout.isHighFanout ? ` (${fanout.activeTodoCount} todo)` : ""}
|
||||||
{fanout.staleBlockedByDependentIds.length > 0 ? ` (${fanout.staleBlockedByDependentIds.length} stale)` : ""}
|
{fanout.staleBlockedByDependentIds.length > 0 ? ` (${fanout.staleBlockedByDependentIds.length} stale)` : ""}
|
||||||
|
|||||||
@@ -66,13 +66,13 @@ describe("ExecutorStatusBar", () => {
|
|||||||
expect(statusBar).toHaveTextContent("Blocked");
|
expect(statusBar).toHaveTextContent("Blocked");
|
||||||
expect(statusBar).toHaveTextContent("Queued");
|
expect(statusBar).toHaveTextContent("Queued");
|
||||||
expect(statusBar).toHaveTextContent("In Review");
|
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 = [
|
const tasks = [
|
||||||
makeTask("FN-010", "in-progress"),
|
makeTask("FN-010", "in-progress", { columnMovedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }),
|
||||||
makeTask("FN-002", "in-review"),
|
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-101", "todo", { dependencies: ["FN-010"] }),
|
||||||
makeTask("FN-102", "todo", { dependencies: ["FN-010"] }),
|
makeTask("FN-102", "todo", { dependencies: ["FN-010"] }),
|
||||||
makeTask("FN-103", "todo", { dependencies: ["FN-010"] }),
|
makeTask("FN-103", "todo", { dependencies: ["FN-010"] }),
|
||||||
@@ -85,14 +85,19 @@ describe("ExecutorStatusBar", () => {
|
|||||||
makeTask("FN-205", "todo", { dependencies: ["FN-002"] }),
|
makeTask("FN-205", "todo", { dependencies: ["FN-002"] }),
|
||||||
];
|
];
|
||||||
|
|
||||||
render(<ExecutorStatusBar tasks={tasks} />);
|
render(
|
||||||
|
<ExecutorStatusBar
|
||||||
|
tasks={tasks}
|
||||||
|
staleHighFanoutBlockerAgeThresholdMs={60 * 60 * 1000}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
const statusBar = screen.getByRole("status");
|
const statusBar = screen.getByRole("status");
|
||||||
expect(statusBar).toHaveTextContent("High Fan-out");
|
expect(statusBar).toHaveTextContent("Escalated");
|
||||||
expect(statusBar).toHaveTextContent("FN-002 · 5 todo");
|
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 = [
|
const tasks = [
|
||||||
makeTask("FN-500", "in-progress"),
|
makeTask("FN-500", "in-progress"),
|
||||||
makeTask("FN-501", "todo", { dependencies: ["FN-500"] }),
|
makeTask("FN-501", "todo", { dependencies: ["FN-500"] }),
|
||||||
@@ -103,7 +108,7 @@ describe("ExecutorStatusBar", () => {
|
|||||||
|
|
||||||
render(<ExecutorStatusBar tasks={tasks} />);
|
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", () => {
|
it("displays running task count", () => {
|
||||||
|
|||||||
@@ -1416,6 +1416,19 @@ describe("SettingsModal", () => {
|
|||||||
expect(input.value).toBe("");
|
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 () => {
|
it("allows clearing maxWorktrees without leaving a stuck zero", async () => {
|
||||||
renderModal();
|
renderModal();
|
||||||
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled());
|
||||||
|
|||||||
@@ -238,7 +238,14 @@ describe("TaskCard", () => {
|
|||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<TaskCard
|
<TaskCard
|
||||||
task={makeTask({ column: "in-progress" })}
|
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}
|
onOpenDetail={noop}
|
||||||
addToast={noop}
|
addToast={noop}
|
||||||
/>,
|
/>,
|
||||||
@@ -246,7 +253,8 @@ describe("TaskCard", () => {
|
|||||||
|
|
||||||
let badge = document.querySelector(".card-fanout-badge--high-impact") as HTMLElement;
|
let badge = document.querySelector(".card-fanout-badge--high-impact") as HTMLElement;
|
||||||
expect(badge).not.toBeNull();
|
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)");
|
expect(badge.textContent).toContain("(5 todo)");
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ describe("useAppSettings", () => {
|
|||||||
enginePaused: false,
|
enginePaused: false,
|
||||||
prAuthAvailable: true,
|
prAuthAvailable: true,
|
||||||
taskStuckTimeoutMs: 600000,
|
taskStuckTimeoutMs: 600000,
|
||||||
|
staleHighFanoutBlockerAgeThresholdMs: 7200000,
|
||||||
showQuickChatFAB: false,
|
showQuickChatFAB: false,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ describe("useAppSettings", () => {
|
|||||||
expect(result.current.prAuthAvailable).toBe(true);
|
expect(result.current.prAuthAvailable).toBe(true);
|
||||||
expect(result.current.settingsLoaded).toBe(true);
|
expect(result.current.settingsLoaded).toBe(true);
|
||||||
expect(result.current.taskStuckTimeoutMs).toBe(600000);
|
expect(result.current.taskStuckTimeoutMs).toBe(600000);
|
||||||
|
expect(result.current.staleHighFanoutBlockerAgeThresholdMs).toBe(7200000);
|
||||||
expect(result.current.showQuickChatFAB).toBe(false);
|
expect(result.current.showQuickChatFAB).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ describe("computeBlockerFanoutMap", () => {
|
|||||||
dependentIds: ["FN-2"],
|
dependentIds: ["FN-2"],
|
||||||
staleBlockedByDependentIds: [],
|
staleBlockedByDependentIds: [],
|
||||||
isHighFanout: false,
|
isHighFanout: false,
|
||||||
|
escalation: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ describe("computeBlockerFanoutMap", () => {
|
|||||||
dependentIds: ["FN-2", "FN-3"],
|
dependentIds: ["FN-2", "FN-3"],
|
||||||
staleBlockedByDependentIds: [],
|
staleBlockedByDependentIds: [],
|
||||||
isHighFanout: false,
|
isHighFanout: false,
|
||||||
|
escalation: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,6 +77,7 @@ describe("computeBlockerFanoutMap", () => {
|
|||||||
dependentIds: ["FN-2", "FN-3", "FN-4"],
|
dependentIds: ["FN-2", "FN-3", "FN-4"],
|
||||||
staleBlockedByDependentIds: [],
|
staleBlockedByDependentIds: [],
|
||||||
isHighFanout: false,
|
isHighFanout: false,
|
||||||
|
escalation: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,6 +93,7 @@ describe("computeBlockerFanoutMap", () => {
|
|||||||
dependentIds: ["FN-2", "FN-3"],
|
dependentIds: ["FN-2", "FN-3"],
|
||||||
staleBlockedByDependentIds: ["FN-3"],
|
staleBlockedByDependentIds: ["FN-3"],
|
||||||
isHighFanout: false,
|
isHighFanout: false,
|
||||||
|
escalation: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,6 +135,7 @@ describe("computeBlockerFanoutMap", () => {
|
|||||||
dependentIds: ["D1", "D2", "D3", "D4"],
|
dependentIds: ["D1", "D2", "D3", "D4"],
|
||||||
staleBlockedByDependentIds: [],
|
staleBlockedByDependentIds: [],
|
||||||
isHighFanout: false,
|
isHighFanout: false,
|
||||||
|
escalation: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,6 +156,7 @@ describe("computeBlockerFanoutMap", () => {
|
|||||||
dependentIds: ["D1", "D2", "D3", "D4", "D5", "DONE"],
|
dependentIds: ["D1", "D2", "D3", "D4", "D5", "DONE"],
|
||||||
staleBlockedByDependentIds: [],
|
staleBlockedByDependentIds: [],
|
||||||
isHighFanout: true,
|
isHighFanout: true,
|
||||||
|
escalation: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,9 +176,48 @@ describe("computeBlockerFanoutMap", () => {
|
|||||||
dependentIds: ["D1", "D2", "D3", "D4", "ARCH"],
|
dependentIds: ["D1", "D2", "D3", "D4", "ARCH"],
|
||||||
staleBlockedByDependentIds: [],
|
staleBlockedByDependentIds: [],
|
||||||
isHighFanout: false,
|
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", () => {
|
it("keeps MAX_AUTO_MERGE_RETRIES aligned with engine self-healing source", () => {
|
||||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||||
const source = readFileSync(resolve(testDir, "../../../../engine/src/self-healing.ts"), "utf8");
|
const source = readFileSync(resolve(testDir, "../../../../engine/src/self-healing.ts"), "utf8");
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface UseAppSettingsResult {
|
|||||||
globalPaused: boolean;
|
globalPaused: boolean;
|
||||||
enginePaused: boolean;
|
enginePaused: boolean;
|
||||||
taskStuckTimeoutMs: number | undefined;
|
taskStuckTimeoutMs: number | undefined;
|
||||||
|
staleHighFanoutBlockerAgeThresholdMs: number;
|
||||||
showQuickChatFAB: boolean;
|
showQuickChatFAB: boolean;
|
||||||
prAuthAvailable: boolean;
|
prAuthAvailable: boolean;
|
||||||
settingsLoaded: boolean;
|
settingsLoaded: boolean;
|
||||||
@@ -40,6 +41,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
|||||||
const [globalPaused, setGlobalPaused] = useState(false);
|
const [globalPaused, setGlobalPaused] = useState(false);
|
||||||
const [enginePaused, setEnginePaused] = useState(false);
|
const [enginePaused, setEnginePaused] = useState(false);
|
||||||
const [taskStuckTimeoutMs, setTaskStuckTimeoutMs] = useState<number | undefined>(undefined);
|
const [taskStuckTimeoutMs, setTaskStuckTimeoutMs] = useState<number | undefined>(undefined);
|
||||||
|
const [staleHighFanoutBlockerAgeThresholdMs, setStaleHighFanoutBlockerAgeThresholdMs] = useState(2 * 60 * 60 * 1000);
|
||||||
const [showQuickChatFAB, setShowQuickChatFAB] = useState(false);
|
const [showQuickChatFAB, setShowQuickChatFAB] = useState(false);
|
||||||
const [prAuthAvailable, setPrAuthAvailable] = useState(false);
|
const [prAuthAvailable, setPrAuthAvailable] = useState(false);
|
||||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||||
@@ -72,6 +74,9 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
|||||||
setEnginePaused(Boolean(settings.enginePaused));
|
setEnginePaused(Boolean(settings.enginePaused));
|
||||||
setPrAuthAvailable(Boolean(settings.prAuthAvailable));
|
setPrAuthAvailable(Boolean(settings.prAuthAvailable));
|
||||||
setTaskStuckTimeoutMs(settings.taskStuckTimeoutMs);
|
setTaskStuckTimeoutMs(settings.taskStuckTimeoutMs);
|
||||||
|
setStaleHighFanoutBlockerAgeThresholdMs(
|
||||||
|
settings.staleHighFanoutBlockerAgeThresholdMs ?? 2 * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
setShowQuickChatFAB(settings.showQuickChatFAB === true);
|
setShowQuickChatFAB(settings.showQuickChatFAB === true);
|
||||||
setExperimentalFeatures(settings.experimentalFeatures ?? {});
|
setExperimentalFeatures(settings.experimentalFeatures ?? {});
|
||||||
const features = settings.experimentalFeatures ?? {};
|
const features = settings.experimentalFeatures ?? {};
|
||||||
@@ -168,6 +173,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
|||||||
globalPaused,
|
globalPaused,
|
||||||
enginePaused,
|
enginePaused,
|
||||||
taskStuckTimeoutMs,
|
taskStuckTimeoutMs,
|
||||||
|
staleHighFanoutBlockerAgeThresholdMs,
|
||||||
showQuickChatFAB,
|
showQuickChatFAB,
|
||||||
prAuthAvailable,
|
prAuthAvailable,
|
||||||
settingsLoaded,
|
settingsLoaded,
|
||||||
|
|||||||
@@ -1,104 +1,34 @@
|
|||||||
import { useMemo } from "react";
|
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 {
|
export type { BlockerFanoutEntry };
|
||||||
totalCount: number;
|
|
||||||
activeTodoCount: number;
|
|
||||||
dependentIds: string[];
|
|
||||||
staleBlockedByDependentIds: string[];
|
|
||||||
isHighFanout: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep in sync with packages/engine/src/self-healing.ts
|
// Keep in sync with packages/engine/src/self-healing.ts
|
||||||
export const MAX_AUTO_MERGE_RETRIES = 3;
|
export const MAX_AUTO_MERGE_RETRIES = 3;
|
||||||
|
|
||||||
const ACTIVE_COLUMNS = new Set<Task["column"]>(["triage", "todo", "in-progress", "in-review"]);
|
export interface UseBlockerFanoutOptions {
|
||||||
|
staleHighFanoutAgeThresholdMs?: number;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MutableEntry {
|
export function computeBlockerFanoutMap(
|
||||||
dependentIds: string[];
|
tasks: Task[],
|
||||||
blockedByDependentIds: string[];
|
options: UseBlockerFanoutOptions = {},
|
||||||
activeCount: number;
|
): Map<string, BlockerFanoutEntry> {
|
||||||
activeTodoCount: number;
|
return computeBlockerFanoutMapCore(tasks, MAX_AUTO_MERGE_RETRIES, {
|
||||||
|
staleHighFanoutAgeThresholdMs: options.staleHighFanoutAgeThresholdMs,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeBlockerFanoutMap(tasks: Task[]): Map<string, BlockerFanoutEntry> {
|
export function useBlockerFanout(
|
||||||
const taskById = new Map(tasks.map((task) => [task.id, task]));
|
tasks: Task[],
|
||||||
const fanout = new Map<string, MutableEntry>();
|
options: UseBlockerFanoutOptions = {},
|
||||||
|
): Map<string, BlockerFanoutEntry> {
|
||||||
const ensureEntry = (blockerId: string): MutableEntry => {
|
return useMemo(
|
||||||
let entry = fanout.get(blockerId);
|
() => computeBlockerFanoutMap(tasks, options),
|
||||||
if (!entry) {
|
[tasks, options.staleHighFanoutAgeThresholdMs],
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user