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 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-27 14:33:22 +01:00
parent 94f455b6a0
commit b2b2d65587

View File

@@ -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<string, RunningFeature>();
/**
* 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<number> {
// 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<RunningFeature>): void {
const entry = this.runningFeatures.get(featureId);
if (!entry) {
return;
}
Object.assign(entry, updates);
}
}