From b2b2d655871af6700531eb1dff44914b66986817 Mon Sep 17 00:00:00 2001 From: Shirone Date: Tue, 27 Jan 2026 14:33:22 +0100 Subject: [PATCH] feat(01-01): extract ConcurrencyManager class from AutoModeService - Lease-based reference counting for nested execution support - acquire() creates entry with leaseCount: 1 or increments existing - release() decrements leaseCount, deletes at 0 or with force:true - Project and worktree-level running counts - RunningFeature interface exported for type sharing Co-Authored-By: Claude Opus 4.5 --- .../src/services/concurrency-manager.ts | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 apps/server/src/services/concurrency-manager.ts diff --git a/apps/server/src/services/concurrency-manager.ts b/apps/server/src/services/concurrency-manager.ts new file mode 100644 index 00000000..8ef80aa3 --- /dev/null +++ b/apps/server/src/services/concurrency-manager.ts @@ -0,0 +1,212 @@ +/** + * ConcurrencyManager - Manages running feature slots with lease-based reference counting + * + * Extracted from AutoModeService to provide a standalone service for tracking + * running feature execution with proper lease counting to support nested calls + * (e.g., resumeFeature -> executeFeature). + * + * Key behaviors: + * - acquire() with existing entry + allowReuse: increment leaseCount, return existing + * - acquire() with existing entry + no allowReuse: throw Error('already running') + * - release() decrements leaseCount, only deletes at 0 + * - release() with force:true bypasses leaseCount check + */ + +import type { ModelProvider } from '@automaker/types'; +import { getCurrentBranch } from '@automaker/git-utils'; + +/** + * Represents a running feature execution with all tracking metadata + */ +export interface RunningFeature { + featureId: string; + projectPath: string; + worktreePath: string | null; + branchName: string | null; + abortController: AbortController; + isAutoMode: boolean; + startTime: number; + leaseCount: number; + model?: string; + provider?: ModelProvider; +} + +/** + * Parameters for acquiring a running feature slot + */ +export interface AcquireParams { + featureId: string; + projectPath: string; + isAutoMode: boolean; + allowReuse?: boolean; + abortController?: AbortController; +} + +/** + * ConcurrencyManager manages the running features Map with lease-based reference counting. + * + * This supports nested execution patterns where a feature may be acquired multiple times + * (e.g., during resume operations) and should only be released when all references are done. + */ +export class ConcurrencyManager { + private runningFeatures = new Map(); + + /** + * Acquire a slot in the runningFeatures map for a feature. + * Implements reference counting via leaseCount to support nested calls + * (e.g., resumeFeature -> executeFeature). + * + * @param params.featureId - ID of the feature to track + * @param params.projectPath - Path to the project + * @param params.isAutoMode - Whether this is an auto-mode execution + * @param params.allowReuse - If true, allows incrementing leaseCount for already-running features + * @param params.abortController - Optional abort controller to use + * @returns The RunningFeature entry (existing or newly created) + * @throws Error if feature is already running and allowReuse is false + */ + acquire(params: AcquireParams): RunningFeature { + const existing = this.runningFeatures.get(params.featureId); + if (existing) { + if (!params.allowReuse) { + throw new Error('already running'); + } + existing.leaseCount += 1; + return existing; + } + + const abortController = params.abortController ?? new AbortController(); + const entry: RunningFeature = { + featureId: params.featureId, + projectPath: params.projectPath, + worktreePath: null, + branchName: null, + abortController, + isAutoMode: params.isAutoMode, + startTime: Date.now(), + leaseCount: 1, + }; + this.runningFeatures.set(params.featureId, entry); + return entry; + } + + /** + * Release a slot in the runningFeatures map for a feature. + * Decrements leaseCount and only removes the entry when it reaches zero, + * unless force option is used. + * + * @param featureId - ID of the feature to release + * @param options.force - If true, immediately removes the entry regardless of leaseCount + */ + release(featureId: string, options?: { force?: boolean }): void { + const entry = this.runningFeatures.get(featureId); + if (!entry) { + return; + } + + if (options?.force) { + this.runningFeatures.delete(featureId); + return; + } + + entry.leaseCount -= 1; + if (entry.leaseCount <= 0) { + this.runningFeatures.delete(featureId); + } + } + + /** + * Check if a feature is currently running + * + * @param featureId - ID of the feature to check + * @returns true if the feature is in the runningFeatures map + */ + isRunning(featureId: string): boolean { + return this.runningFeatures.has(featureId); + } + + /** + * Get the RunningFeature entry for a feature + * + * @param featureId - ID of the feature + * @returns The RunningFeature entry or undefined if not running + */ + getRunningFeature(featureId: string): RunningFeature | undefined { + return this.runningFeatures.get(featureId); + } + + /** + * Get count of running features for a specific project + * + * @param projectPath - The project path to count features for + * @returns Number of running features for the project + */ + getRunningCount(projectPath: string): number { + let count = 0; + for (const [, feature] of this.runningFeatures) { + if (feature.projectPath === projectPath) { + count++; + } + } + return count; + } + + /** + * Get count of running features for a specific worktree + * + * @param projectPath - The project path + * @param branchName - The branch name, or null for main worktree + * (features without branchName or matching primary branch) + * @returns Number of running features for the worktree + */ + async getRunningCountForWorktree( + projectPath: string, + branchName: string | null + ): Promise { + // Get the actual primary branch name for the project + const primaryBranch = await getCurrentBranch(projectPath); + + let count = 0; + for (const [, feature] of this.runningFeatures) { + // Filter by project path AND branchName to get accurate worktree-specific count + const featureBranch = feature.branchName ?? null; + if (branchName === null) { + // Main worktree: match features with branchName === null OR branchName matching primary branch + const isPrimaryBranch = + featureBranch === null || (primaryBranch && featureBranch === primaryBranch); + if (feature.projectPath === projectPath && isPrimaryBranch) { + count++; + } + } else { + // Feature worktree: exact match + if (feature.projectPath === projectPath && featureBranch === branchName) { + count++; + } + } + } + return count; + } + + /** + * Get all currently running features + * + * @returns Array of all RunningFeature entries + */ + getAllRunning(): RunningFeature[] { + return Array.from(this.runningFeatures.values()); + } + + /** + * Update properties of a running feature + * + * @param featureId - ID of the feature to update + * @param updates - Partial RunningFeature properties to update + */ + updateRunningFeature(featureId: string, updates: Partial): void { + const entry = this.runningFeatures.get(featureId); + if (!entry) { + return; + } + + Object.assign(entry, updates); + } +}