mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
feat(05-01): create AutoLoopCoordinator with loop lifecycle
- Extract loop lifecycle from AutoModeService - Export AutoModeConfig, ProjectAutoLoopState, getWorktreeAutoLoopKey - Export callback types for AutoModeService integration - Methods: start/stop/isRunning/getConfig for project/worktree - Failure tracking with threshold and quota error detection - Sleep helper interruptible by abort signal
This commit is contained in:
559
apps/server/src/services/auto-loop-coordinator.ts
Normal file
559
apps/server/src/services/auto-loop-coordinator.ts
Normal file
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* AutoLoopCoordinator - Manages the auto-mode loop lifecycle and failure tracking
|
||||
*
|
||||
* Extracted from AutoModeService to isolate loop control logic (start/stop/pause)
|
||||
* into a focused service for maintainability and testability.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Loop starts per project/worktree with correct config
|
||||
* - Loop stops when user clicks stop or no work remains
|
||||
* - Failure tracking pauses loop after threshold (agent errors only)
|
||||
* - Multiple project loops run concurrently without interference
|
||||
*/
|
||||
|
||||
import type { Feature } from '@automaker/types';
|
||||
import { createLogger, classifyError } from '@automaker/utils';
|
||||
import type { TypedEventBus } from './typed-event-bus.js';
|
||||
import type { ConcurrencyManager } from './concurrency-manager.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import { DEFAULT_MAX_CONCURRENCY } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('AutoLoopCoordinator');
|
||||
|
||||
// Constants for consecutive failure tracking
|
||||
const CONSECUTIVE_FAILURE_THRESHOLD = 3; // Pause after 3 consecutive failures
|
||||
const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutive
|
||||
|
||||
/**
|
||||
* Configuration for auto-mode loop
|
||||
*/
|
||||
export interface AutoModeConfig {
|
||||
maxConcurrency: number;
|
||||
useWorktrees: boolean;
|
||||
projectPath: string;
|
||||
branchName: string | null; // null = main worktree
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-worktree autoloop state for multi-project/worktree support
|
||||
*/
|
||||
export interface ProjectAutoLoopState {
|
||||
abortController: AbortController;
|
||||
config: AutoModeConfig;
|
||||
isRunning: boolean;
|
||||
consecutiveFailures: { timestamp: number; error: string }[];
|
||||
pausedDueToFailures: boolean;
|
||||
hasEmittedIdleEvent: boolean;
|
||||
branchName: string | null; // null = main worktree
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique key for worktree-scoped auto loop state
|
||||
* @param projectPath - The project path
|
||||
* @param branchName - The branch name, or null for main worktree
|
||||
*/
|
||||
export function getWorktreeAutoLoopKey(projectPath: string, branchName: string | null): string {
|
||||
const normalizedBranch = branchName === 'main' ? null : branchName;
|
||||
return `${projectPath}::${normalizedBranch ?? '__main__'}`;
|
||||
}
|
||||
|
||||
// Callback types for AutoModeService integration
|
||||
export type ExecuteFeatureFn = (
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
useWorktrees: boolean,
|
||||
isAutoMode: boolean
|
||||
) => Promise<void>;
|
||||
|
||||
export type LoadPendingFeaturesFn = (
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
) => Promise<Feature[]>;
|
||||
|
||||
export type SaveExecutionStateFn = (
|
||||
projectPath: string,
|
||||
branchName: string | null,
|
||||
maxConcurrency: number
|
||||
) => Promise<void>;
|
||||
|
||||
export type ClearExecutionStateFn = (
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
) => Promise<void>;
|
||||
|
||||
export type ResetStuckFeaturesFn = (projectPath: string) => Promise<void>;
|
||||
|
||||
export type IsFeatureFinishedFn = (feature: Feature) => boolean;
|
||||
|
||||
/**
|
||||
* AutoLoopCoordinator manages the auto-mode loop lifecycle and failure tracking.
|
||||
* It coordinates feature execution without containing the execution logic itself.
|
||||
*/
|
||||
export class AutoLoopCoordinator {
|
||||
// Per-project autoloop state (supports multiple concurrent projects)
|
||||
private autoLoopsByProject = new Map<string, ProjectAutoLoopState>();
|
||||
|
||||
constructor(
|
||||
private eventBus: TypedEventBus,
|
||||
private concurrencyManager: ConcurrencyManager,
|
||||
private settingsService: SettingsService | null,
|
||||
private executeFeatureFn: ExecuteFeatureFn,
|
||||
private loadPendingFeaturesFn: LoadPendingFeaturesFn,
|
||||
private saveExecutionStateFn: SaveExecutionStateFn,
|
||||
private clearExecutionStateFn: ClearExecutionStateFn,
|
||||
private resetStuckFeaturesFn: ResetStuckFeaturesFn,
|
||||
private isFeatureFinishedFn: IsFeatureFinishedFn,
|
||||
private isFeatureRunningFn: (featureId: string) => boolean
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Start the auto mode loop for a specific project/worktree (supports multiple concurrent projects and worktrees)
|
||||
* @param projectPath - The project to start auto mode for
|
||||
* @param branchName - The branch name for worktree scoping, null for main worktree
|
||||
* @param maxConcurrency - Maximum concurrent features (default: DEFAULT_MAX_CONCURRENCY)
|
||||
*/
|
||||
async startAutoLoopForProject(
|
||||
projectPath: string,
|
||||
branchName: string | null = null,
|
||||
maxConcurrency?: number
|
||||
): Promise<number> {
|
||||
const resolvedMaxConcurrency = await this.resolveMaxConcurrency(
|
||||
projectPath,
|
||||
branchName,
|
||||
maxConcurrency
|
||||
);
|
||||
|
||||
// Use worktree-scoped key
|
||||
const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
|
||||
|
||||
// Check if this project/worktree already has an active autoloop
|
||||
const existingState = this.autoLoopsByProject.get(worktreeKey);
|
||||
if (existingState?.isRunning) {
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
throw new Error(
|
||||
`Auto mode is already running for ${worktreeDesc} in project: ${projectPath}`
|
||||
);
|
||||
}
|
||||
|
||||
// Create new project/worktree autoloop state
|
||||
const abortController = new AbortController();
|
||||
const config: AutoModeConfig = {
|
||||
maxConcurrency: resolvedMaxConcurrency,
|
||||
useWorktrees: true,
|
||||
projectPath,
|
||||
branchName,
|
||||
};
|
||||
|
||||
const projectState: ProjectAutoLoopState = {
|
||||
abortController,
|
||||
config,
|
||||
isRunning: true,
|
||||
consecutiveFailures: [],
|
||||
pausedDueToFailures: false,
|
||||
hasEmittedIdleEvent: false,
|
||||
branchName,
|
||||
};
|
||||
|
||||
this.autoLoopsByProject.set(worktreeKey, projectState);
|
||||
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
logger.info(
|
||||
`Starting auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}`
|
||||
);
|
||||
|
||||
// Reset any features that were stuck in transient states due to previous server crash
|
||||
try {
|
||||
await this.resetStuckFeaturesFn(projectPath);
|
||||
} catch (error) {
|
||||
logger.warn(`[startAutoLoopForProject] Error resetting stuck features:`, error);
|
||||
// Don't fail startup due to reset errors
|
||||
}
|
||||
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_started', {
|
||||
message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
|
||||
projectPath,
|
||||
branchName,
|
||||
maxConcurrency: resolvedMaxConcurrency,
|
||||
});
|
||||
|
||||
// Save execution state for recovery after restart
|
||||
await this.saveExecutionStateFn(projectPath, branchName, resolvedMaxConcurrency);
|
||||
|
||||
// Run the loop in the background
|
||||
this.runAutoLoopForProject(worktreeKey).catch((error) => {
|
||||
const worktreeDescErr = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
logger.error(`Loop error for ${worktreeDescErr} in ${projectPath}:`, error);
|
||||
const errorInfo = classifyError(error);
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_error', {
|
||||
error: errorInfo.message,
|
||||
errorType: errorInfo.type,
|
||||
projectPath,
|
||||
branchName,
|
||||
});
|
||||
});
|
||||
|
||||
return resolvedMaxConcurrency;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the auto loop for a specific project/worktree
|
||||
* @param worktreeKey - The worktree key (projectPath::branchName or projectPath::__main__)
|
||||
*/
|
||||
private async runAutoLoopForProject(worktreeKey: string): Promise<void> {
|
||||
const projectState = this.autoLoopsByProject.get(worktreeKey);
|
||||
if (!projectState) {
|
||||
logger.warn(`No project state found for ${worktreeKey}, stopping loop`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { projectPath, branchName } = projectState.config;
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
|
||||
logger.info(
|
||||
`[AutoLoop] Starting loop for ${worktreeDesc} in ${projectPath}, maxConcurrency: ${projectState.config.maxConcurrency}`
|
||||
);
|
||||
let iterationCount = 0;
|
||||
|
||||
while (projectState.isRunning && !projectState.abortController.signal.aborted) {
|
||||
iterationCount++;
|
||||
try {
|
||||
// Count running features for THIS project/worktree only
|
||||
const projectRunningCount = await this.getRunningCountForWorktree(projectPath, branchName);
|
||||
|
||||
// Check if we have capacity for this project/worktree
|
||||
if (projectRunningCount >= projectState.config.maxConcurrency) {
|
||||
logger.debug(
|
||||
`[AutoLoop] At capacity (${projectRunningCount}/${projectState.config.maxConcurrency}), waiting...`
|
||||
);
|
||||
await this.sleep(5000, projectState.abortController.signal);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load pending features for this project/worktree
|
||||
const pendingFeatures = await this.loadPendingFeaturesFn(projectPath, branchName);
|
||||
|
||||
logger.info(
|
||||
`[AutoLoop] Iteration ${iterationCount}: Found ${pendingFeatures.length} pending features, ${projectRunningCount}/${projectState.config.maxConcurrency} running for ${worktreeDesc}`
|
||||
);
|
||||
|
||||
if (pendingFeatures.length === 0) {
|
||||
// Emit idle event only once when backlog is empty AND no features are running
|
||||
if (projectRunningCount === 0 && !projectState.hasEmittedIdleEvent) {
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath,
|
||||
branchName,
|
||||
});
|
||||
projectState.hasEmittedIdleEvent = true;
|
||||
logger.info(`[AutoLoop] Backlog complete, auto mode now idle for ${worktreeDesc}`);
|
||||
} else if (projectRunningCount > 0) {
|
||||
logger.info(
|
||||
`[AutoLoop] No pending features available, ${projectRunningCount} still running, waiting...`
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
`[AutoLoop] No pending features found for ${worktreeDesc} (branchName: ${branchName === null ? 'null (main)' : branchName}). Check server logs for filtering details.`
|
||||
);
|
||||
}
|
||||
await this.sleep(10000, projectState.abortController.signal);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find a feature not currently running and not yet finished
|
||||
const nextFeature = pendingFeatures.find(
|
||||
(f) => !this.isFeatureRunningFn(f.id) && !this.isFeatureFinishedFn(f)
|
||||
);
|
||||
|
||||
if (nextFeature) {
|
||||
logger.info(`[AutoLoop] Starting feature ${nextFeature.id}: ${nextFeature.title}`);
|
||||
// Reset idle event flag since we're doing work again
|
||||
projectState.hasEmittedIdleEvent = false;
|
||||
// Start feature execution in background
|
||||
this.executeFeatureFn(
|
||||
projectPath,
|
||||
nextFeature.id,
|
||||
projectState.config.useWorktrees,
|
||||
true
|
||||
).catch((error) => {
|
||||
logger.error(`Feature ${nextFeature.id} error:`, error);
|
||||
});
|
||||
} else {
|
||||
logger.debug(`[AutoLoop] All pending features are already running`);
|
||||
}
|
||||
|
||||
await this.sleep(2000, projectState.abortController.signal);
|
||||
} catch (error) {
|
||||
// Check if this is an abort error
|
||||
if (projectState.abortController.signal.aborted) {
|
||||
break;
|
||||
}
|
||||
logger.error(`[AutoLoop] Loop iteration error for ${projectPath}:`, error);
|
||||
await this.sleep(5000, projectState.abortController.signal);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as not running when loop exits
|
||||
projectState.isRunning = false;
|
||||
logger.info(
|
||||
`[AutoLoop] Loop stopped for project: ${projectPath} after ${iterationCount} iterations`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the auto mode loop for a specific project/worktree
|
||||
* @param projectPath - The project to stop auto mode for
|
||||
* @param branchName - The branch name, or null for main worktree
|
||||
*/
|
||||
async stopAutoLoopForProject(
|
||||
projectPath: string,
|
||||
branchName: string | null = null
|
||||
): Promise<number> {
|
||||
const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
|
||||
const projectState = this.autoLoopsByProject.get(worktreeKey);
|
||||
if (!projectState) {
|
||||
const worktreeDesc = branchName ? `worktree ${branchName}` : 'main worktree';
|
||||
logger.warn(`No auto loop running for ${worktreeDesc} in project: ${projectPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const wasRunning = projectState.isRunning;
|
||||
projectState.isRunning = false;
|
||||
projectState.abortController.abort();
|
||||
|
||||
// Clear execution state when auto-loop is explicitly stopped
|
||||
await this.clearExecutionStateFn(projectPath, branchName);
|
||||
|
||||
// Emit stop event
|
||||
if (wasRunning) {
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_stopped', {
|
||||
message: 'Auto mode stopped',
|
||||
projectPath,
|
||||
branchName,
|
||||
});
|
||||
}
|
||||
|
||||
// Remove from map
|
||||
this.autoLoopsByProject.delete(worktreeKey);
|
||||
|
||||
return await this.getRunningCountForWorktree(projectPath, branchName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if auto mode is running for a specific project/worktree
|
||||
* @param projectPath - The project path
|
||||
* @param branchName - The branch name, or null for main worktree
|
||||
*/
|
||||
isAutoLoopRunningForProject(projectPath: string, branchName: string | null = null): boolean {
|
||||
const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
|
||||
const projectState = this.autoLoopsByProject.get(worktreeKey);
|
||||
return projectState?.isRunning ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auto loop config for a specific project/worktree
|
||||
* @param projectPath - The project path
|
||||
* @param branchName - The branch name, or null for main worktree
|
||||
*/
|
||||
getAutoLoopConfigForProject(
|
||||
projectPath: string,
|
||||
branchName: string | null = null
|
||||
): AutoModeConfig | null {
|
||||
const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
|
||||
const projectState = this.autoLoopsByProject.get(worktreeKey);
|
||||
return projectState?.config ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of running features for a specific worktree
|
||||
* Delegates to ConcurrencyManager.
|
||||
* @param projectPath - The project path
|
||||
* @param branchName - The branch name, or null for main worktree
|
||||
*/
|
||||
async getRunningCountForWorktree(
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
): Promise<number> {
|
||||
return this.concurrencyManager.getRunningCountForWorktree(projectPath, branchName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a failure and check if we should pause due to consecutive failures.
|
||||
* @param projectPath - The project to track failure for
|
||||
* @param errorInfo - Error information
|
||||
* @returns true if the loop should be paused
|
||||
*/
|
||||
trackFailureAndCheckPauseForProject(
|
||||
projectPath: string,
|
||||
errorInfo: { type: string; message: string }
|
||||
): boolean {
|
||||
const worktreeKey = getWorktreeAutoLoopKey(projectPath, null);
|
||||
const projectState = this.autoLoopsByProject.get(worktreeKey);
|
||||
if (!projectState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Add this failure
|
||||
projectState.consecutiveFailures.push({ timestamp: now, error: errorInfo.message });
|
||||
|
||||
// Remove old failures outside the window
|
||||
projectState.consecutiveFailures = projectState.consecutiveFailures.filter(
|
||||
(f) => now - f.timestamp < FAILURE_WINDOW_MS
|
||||
);
|
||||
|
||||
// Check if we've hit the threshold
|
||||
if (projectState.consecutiveFailures.length >= CONSECUTIVE_FAILURE_THRESHOLD) {
|
||||
return true; // Should pause
|
||||
}
|
||||
|
||||
// Also immediately pause for known quota/rate limit errors
|
||||
if (errorInfo.type === 'quota_exhausted' || errorInfo.type === 'rate_limit') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal that we should pause due to repeated failures or quota exhaustion.
|
||||
* This will pause the auto loop for a specific project.
|
||||
* @param projectPath - The project to pause
|
||||
* @param errorInfo - Error information
|
||||
*/
|
||||
signalShouldPauseForProject(
|
||||
projectPath: string,
|
||||
errorInfo: { type: string; message: string }
|
||||
): void {
|
||||
const worktreeKey = getWorktreeAutoLoopKey(projectPath, null);
|
||||
const projectState = this.autoLoopsByProject.get(worktreeKey);
|
||||
if (!projectState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (projectState.pausedDueToFailures) {
|
||||
return; // Already paused
|
||||
}
|
||||
|
||||
projectState.pausedDueToFailures = true;
|
||||
const failureCount = projectState.consecutiveFailures.length;
|
||||
logger.info(
|
||||
`Pausing auto loop for ${projectPath} after ${failureCount} consecutive failures. Last error: ${errorInfo.type}`
|
||||
);
|
||||
|
||||
// Emit event to notify UI
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_paused_failures', {
|
||||
message:
|
||||
failureCount >= CONSECUTIVE_FAILURE_THRESHOLD
|
||||
? `Auto Mode paused: ${failureCount} consecutive failures detected. This may indicate a quota limit or API issue. Please check your usage and try again.`
|
||||
: 'Auto Mode paused: Usage limit or API error detected. Please wait for your quota to reset or check your API configuration.',
|
||||
errorType: errorInfo.type,
|
||||
originalError: errorInfo.message,
|
||||
failureCount,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Stop the auto loop for this project
|
||||
this.stopAutoLoopForProject(projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset failure tracking for a specific project
|
||||
* @param projectPath - The project to reset failure tracking for
|
||||
*/
|
||||
resetFailureTrackingForProject(projectPath: string): void {
|
||||
const worktreeKey = getWorktreeAutoLoopKey(projectPath, null);
|
||||
const projectState = this.autoLoopsByProject.get(worktreeKey);
|
||||
if (projectState) {
|
||||
projectState.consecutiveFailures = [];
|
||||
projectState.pausedDueToFailures = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a successful feature completion to reset consecutive failure count for a project
|
||||
* @param projectPath - The project to record success for
|
||||
*/
|
||||
recordSuccessForProject(projectPath: string): void {
|
||||
const worktreeKey = getWorktreeAutoLoopKey(projectPath, null);
|
||||
const projectState = this.autoLoopsByProject.get(worktreeKey);
|
||||
if (projectState) {
|
||||
projectState.consecutiveFailures = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve max concurrency from provided value, settings, or default
|
||||
*/
|
||||
private async resolveMaxConcurrency(
|
||||
projectPath: string,
|
||||
branchName: string | null,
|
||||
provided?: number
|
||||
): Promise<number> {
|
||||
if (typeof provided === 'number' && Number.isFinite(provided)) {
|
||||
return provided;
|
||||
}
|
||||
|
||||
if (!this.settingsService) {
|
||||
return DEFAULT_MAX_CONCURRENCY;
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = await this.settingsService.getGlobalSettings();
|
||||
const globalMax =
|
||||
typeof settings.maxConcurrency === 'number'
|
||||
? settings.maxConcurrency
|
||||
: DEFAULT_MAX_CONCURRENCY;
|
||||
const projectId = settings.projects?.find((project) => project.path === projectPath)?.id;
|
||||
const autoModeByWorktree = settings.autoModeByWorktree;
|
||||
|
||||
if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
|
||||
// Normalize branch name to match UI convention:
|
||||
// - null/undefined -> '__main__' (main worktree)
|
||||
// - 'main' -> '__main__' (matches how UI stores it)
|
||||
// - other branch names -> as-is
|
||||
const normalizedBranch =
|
||||
branchName === null || branchName === undefined || branchName === 'main'
|
||||
? '__main__'
|
||||
: branchName;
|
||||
|
||||
// Check for worktree-specific setting using worktreeId
|
||||
const worktreeId = `${projectId}::${normalizedBranch}`;
|
||||
|
||||
if (
|
||||
worktreeId in autoModeByWorktree &&
|
||||
typeof autoModeByWorktree[worktreeId]?.maxConcurrency === 'number'
|
||||
) {
|
||||
logger.debug(
|
||||
`[resolveMaxConcurrency] Using worktree-specific maxConcurrency for ${worktreeId}: ${autoModeByWorktree[worktreeId].maxConcurrency}`
|
||||
);
|
||||
return autoModeByWorktree[worktreeId].maxConcurrency;
|
||||
}
|
||||
}
|
||||
|
||||
return globalMax;
|
||||
} catch (error) {
|
||||
logger.warn(`[resolveMaxConcurrency] Error reading settings, using default:`, error);
|
||||
return DEFAULT_MAX_CONCURRENCY;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for specified milliseconds, interruptible by abort signal
|
||||
*/
|
||||
private sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error('Aborted'));
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(resolve, ms);
|
||||
|
||||
signal?.addEventListener('abort', () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('Aborted'));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user