refactor(01-01): wire ConcurrencyManager into AutoModeService

- AutoModeService now delegates to ConcurrencyManager for all running feature tracking
- Constructor accepts optional ConcurrencyManager for dependency injection
- Remove local RunningFeature interface (imported from ConcurrencyManager)
- Migrate all this.runningFeatures usages to concurrencyManager methods
- Update tests to use concurrencyManager.acquire() instead of direct Map access
- ConcurrencyManager accepts getCurrentBranch function for testability

BREAKING: AutoModeService no longer exposes runningFeatures Map directly.
Tests must use concurrencyManager.acquire() to add running features.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-27 14:44:03 +01:00
parent b2b2d65587
commit 55dcdaa476
4 changed files with 135 additions and 190 deletions

View File

@@ -63,6 +63,11 @@ import {
validateWorkingDirectory, validateWorkingDirectory,
} from '../lib/sdk-options.js'; } from '../lib/sdk-options.js';
import { FeatureLoader } from './feature-loader.js'; import { FeatureLoader } from './feature-loader.js';
import {
ConcurrencyManager,
type RunningFeature,
type GetCurrentBranchFn,
} from './concurrency-manager.js';
import type { SettingsService } from './settings-service.js'; import type { SettingsService } from './settings-service.js';
import { pipelineService, PipelineService } from './pipeline-service.js'; import { pipelineService, PipelineService } from './pipeline-service.js';
import { import {
@@ -341,19 +346,6 @@ interface FeatureWithPlanning extends Feature {
requirePlanApproval?: boolean; requirePlanApproval?: boolean;
} }
interface RunningFeature {
featureId: string;
projectPath: string;
worktreePath: string | null;
branchName: string | null;
abortController: AbortController;
isAutoMode: boolean;
startTime: number;
leaseCount: number;
model?: string;
provider?: ModelProvider;
}
interface AutoLoopState { interface AutoLoopState {
projectPath: string; projectPath: string;
maxConcurrency: number; maxConcurrency: number;
@@ -429,7 +421,7 @@ const FAILURE_WINDOW_MS = 60000; // Failures within 1 minute count as consecutiv
export class AutoModeService { export class AutoModeService {
private events: EventEmitter; private events: EventEmitter;
private runningFeatures = new Map<string, RunningFeature>(); private concurrencyManager: ConcurrencyManager;
private autoLoop: AutoLoopState | null = null; private autoLoop: AutoLoopState | null = null;
private featureLoader = new FeatureLoader(); private featureLoader = new FeatureLoader();
// Per-project autoloop state (supports multiple concurrent projects) // Per-project autoloop state (supports multiple concurrent projects)
@@ -446,15 +438,20 @@ export class AutoModeService {
// Track if idle event has been emitted (legacy, now per-project in autoLoopsByProject) // Track if idle event has been emitted (legacy, now per-project in autoLoopsByProject)
private hasEmittedIdleEvent = false; private hasEmittedIdleEvent = false;
constructor(events: EventEmitter, settingsService?: SettingsService) { constructor(
events: EventEmitter,
settingsService?: SettingsService,
concurrencyManager?: ConcurrencyManager
) {
this.events = events; this.events = events;
this.settingsService = settingsService ?? null; this.settingsService = settingsService ?? null;
// Pass the getCurrentBranch function to ConcurrencyManager for worktree counting
this.concurrencyManager = concurrencyManager ?? new ConcurrencyManager(getCurrentBranch);
} }
/** /**
* Acquire a slot in the runningFeatures map for a feature. * Acquire a slot in the runningFeatures map for a feature.
* Implements reference counting via leaseCount to support nested calls * Delegates to ConcurrencyManager for lease-based reference counting.
* (e.g., resumeFeature -> executeFeature).
* *
* @param params.featureId - ID of the feature to track * @param params.featureId - ID of the feature to track
* @param params.projectPath - Path to the project * @param params.projectPath - Path to the project
@@ -471,53 +468,18 @@ export class AutoModeService {
allowReuse?: boolean; allowReuse?: boolean;
abortController?: AbortController; abortController?: AbortController;
}): RunningFeature { }): RunningFeature {
const existing = this.runningFeatures.get(params.featureId); return this.concurrencyManager.acquire(params);
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. * Release a slot in the runningFeatures map for a feature.
* Decrements leaseCount and only removes the entry when it reaches zero, * Delegates to ConcurrencyManager for lease-based reference counting.
* unless force option is used.
* *
* @param featureId - ID of the feature to release * @param featureId - ID of the feature to release
* @param options.force - If true, immediately removes the entry regardless of leaseCount * @param options.force - If true, immediately removes the entry regardless of leaseCount
*/ */
private releaseRunningFeature(featureId: string, options?: { force?: boolean }): void { private releaseRunningFeature(featureId: string, options?: { force?: boolean }): void {
const entry = this.runningFeatures.get(featureId); this.concurrencyManager.release(featureId, options);
if (!entry) {
return;
}
if (options?.force) {
this.runningFeatures.delete(featureId);
return;
}
entry.leaseCount -= 1;
if (entry.leaseCount <= 0) {
this.runningFeatures.delete(featureId);
}
} }
/** /**
@@ -969,7 +931,7 @@ export class AutoModeService {
// Find a feature not currently running and not yet finished // Find a feature not currently running and not yet finished
const nextFeature = pendingFeatures.find( const nextFeature = pendingFeatures.find(
(f) => !this.runningFeatures.has(f.id) && !this.isFeatureFinished(f) (f) => !this.concurrencyManager.isRunning(f.id) && !this.isFeatureFinished(f)
); );
if (nextFeature) { if (nextFeature) {
@@ -1005,19 +967,15 @@ export class AutoModeService {
/** /**
* Get count of running features for a specific project * Get count of running features for a specific project
* Delegates to ConcurrencyManager.
*/ */
private getRunningCountForProject(projectPath: string): number { private getRunningCountForProject(projectPath: string): number {
let count = 0; return this.concurrencyManager.getRunningCount(projectPath);
for (const [, feature] of this.runningFeatures) {
if (feature.projectPath === projectPath) {
count++;
}
}
return count;
} }
/** /**
* Get count of running features for a specific worktree * Get count of running features for a specific worktree
* Delegates to ConcurrencyManager.
* @param projectPath - The project path * @param projectPath - The project path
* @param branchName - The branch name, or null for main worktree (features without branchName or matching primary branch) * @param branchName - The branch name, or null for main worktree (features without branchName or matching primary branch)
*/ */
@@ -1025,28 +983,7 @@ export class AutoModeService {
projectPath: string, projectPath: string,
branchName: string | null branchName: string | null
): Promise<number> { ): Promise<number> {
// Get the actual primary branch name for the project return this.concurrencyManager.getRunningCountForWorktree(projectPath, branchName);
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;
} }
/** /**
@@ -1127,9 +1064,10 @@ export class AutoModeService {
try { try {
await ensureAutomakerDir(projectPath); await ensureAutomakerDir(projectPath);
const statePath = getExecutionStatePath(projectPath); const statePath = getExecutionStatePath(projectPath);
const runningFeatureIds = Array.from(this.runningFeatures.entries()) const runningFeatureIds = this.concurrencyManager
.filter(([, f]) => f.projectPath === projectPath) .getAllRunning()
.map(([id]) => id); .filter((f) => f.projectPath === projectPath)
.map((f) => f.featureId);
const state: ExecutionState = { const state: ExecutionState = {
version: 1, version: 1,
@@ -1210,7 +1148,8 @@ export class AutoModeService {
) { ) {
try { try {
// Check if we have capacity // Check if we have capacity
if (this.runningFeatures.size >= (this.config?.maxConcurrency || DEFAULT_MAX_CONCURRENCY)) { const totalRunning = this.concurrencyManager.getAllRunning().length;
if (totalRunning >= (this.config?.maxConcurrency || DEFAULT_MAX_CONCURRENCY)) {
await this.sleep(5000); await this.sleep(5000);
continue; continue;
} }
@@ -1220,7 +1159,7 @@ export class AutoModeService {
if (pendingFeatures.length === 0) { if (pendingFeatures.length === 0) {
// Emit idle event only once when backlog is empty AND no features are running // Emit idle event only once when backlog is empty AND no features are running
const runningCount = this.runningFeatures.size; const runningCount = this.concurrencyManager.getAllRunning().length;
if (runningCount === 0 && !this.hasEmittedIdleEvent) { if (runningCount === 0 && !this.hasEmittedIdleEvent) {
this.emitAutoModeEvent('auto_mode_idle', { this.emitAutoModeEvent('auto_mode_idle', {
message: 'No pending features - auto mode idle', message: 'No pending features - auto mode idle',
@@ -1240,7 +1179,7 @@ export class AutoModeService {
} }
// Find a feature not currently running // Find a feature not currently running
const nextFeature = pendingFeatures.find((f) => !this.runningFeatures.has(f.id)); const nextFeature = pendingFeatures.find((f) => !this.concurrencyManager.isRunning(f.id));
if (nextFeature) { if (nextFeature) {
// Reset idle event flag since we're doing work again // Reset idle event flag since we're doing work again
@@ -1292,7 +1231,7 @@ export class AutoModeService {
}); });
} }
return this.runningFeatures.size; return this.concurrencyManager.getAllRunning().length;
} }
/** /**
@@ -1841,7 +1780,7 @@ Complete the pipeline step instructions above. Review the previous work and appl
* Stop a specific feature * Stop a specific feature
*/ */
async stopFeature(featureId: string): Promise<boolean> { async stopFeature(featureId: string): Promise<boolean> {
const running = this.runningFeatures.get(featureId); const running = this.concurrencyManager.getRunningFeature(featureId);
if (!running) { if (!running) {
return false; return false;
} }
@@ -2840,10 +2779,11 @@ Format your response as a structured markdown document.`;
runningFeatures: string[]; runningFeatures: string[];
runningCount: number; runningCount: number;
} { } {
const allRunning = this.concurrencyManager.getAllRunning();
return { return {
isRunning: this.runningFeatures.size > 0, isRunning: allRunning.length > 0,
runningFeatures: Array.from(this.runningFeatures.keys()), runningFeatures: allRunning.map((rf) => rf.featureId),
runningCount: this.runningFeatures.size, runningCount: allRunning.length,
}; };
} }
@@ -2864,14 +2804,10 @@ Format your response as a structured markdown document.`;
} { } {
const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName); const worktreeKey = getWorktreeAutoLoopKey(projectPath, branchName);
const projectState = this.autoLoopsByProject.get(worktreeKey); const projectState = this.autoLoopsByProject.get(worktreeKey);
const runningFeatures: string[] = []; const runningFeatures = this.concurrencyManager
.getAllRunning()
for (const [featureId, feature] of this.runningFeatures) { .filter((f) => f.projectPath === projectPath && f.branchName === branchName)
// Filter by project path AND branchName to get worktree-specific features .map((f) => f.featureId);
if (feature.projectPath === projectPath && feature.branchName === branchName) {
runningFeatures.push(featureId);
}
}
return { return {
isAutoLoopRunning: projectState?.isRunning ?? false, isAutoLoopRunning: projectState?.isRunning ?? false,
@@ -2929,7 +2865,7 @@ Format your response as a structured markdown document.`;
}> }>
> { > {
const agents = await Promise.all( const agents = await Promise.all(
Array.from(this.runningFeatures.values()).map(async (rf) => { this.concurrencyManager.getAllRunning().map(async (rf) => {
// Try to fetch feature data to get title, description, and branchName // Try to fetch feature data to get title, description, and branchName
let title: string | undefined; let title: string | undefined;
let description: string | undefined; let description: string | undefined;
@@ -3350,7 +3286,8 @@ Format your response as a structured markdown document.`;
* @returns Promise that resolves when all features have been marked as interrupted * @returns Promise that resolves when all features have been marked as interrupted
*/ */
async markAllRunningFeaturesInterrupted(reason?: string): Promise<void> { async markAllRunningFeaturesInterrupted(reason?: string): Promise<void> {
const runningCount = this.runningFeatures.size; const allRunning = this.concurrencyManager.getAllRunning();
const runningCount = allRunning.length;
if (runningCount === 0) { if (runningCount === 0) {
logger.info('No running features to mark as interrupted'); logger.info('No running features to mark as interrupted');
@@ -3362,13 +3299,15 @@ Format your response as a structured markdown document.`;
const markPromises: Promise<void>[] = []; const markPromises: Promise<void>[] = [];
for (const [featureId, runningFeature] of this.runningFeatures) { for (const runningFeature of allRunning) {
markPromises.push( markPromises.push(
this.markFeatureInterrupted(runningFeature.projectPath, featureId, logReason).catch( this.markFeatureInterrupted(
(error) => { runningFeature.projectPath,
logger.error(`Failed to mark feature ${featureId} as interrupted:`, error); runningFeature.featureId,
} logReason
) ).catch((error) => {
logger.error(`Failed to mark feature ${runningFeature.featureId} as interrupted:`, error);
})
); );
} }
@@ -3401,7 +3340,7 @@ Format your response as a structured markdown document.`;
* @returns true if the feature is currently running, false otherwise * @returns true if the feature is currently running, false otherwise
*/ */
isFeatureRunning(featureId: string): boolean { isFeatureRunning(featureId: string): boolean {
return this.runningFeatures.has(featureId); return this.concurrencyManager.isRunning(featureId);
} }
/** /**
@@ -5344,13 +5283,14 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
try { try {
await ensureAutomakerDir(projectPath); await ensureAutomakerDir(projectPath);
const statePath = getExecutionStatePath(projectPath); const statePath = getExecutionStatePath(projectPath);
const runningFeatureIds = this.concurrencyManager.getAllRunning().map((rf) => rf.featureId);
const state: ExecutionState = { const state: ExecutionState = {
version: 1, version: 1,
autoLoopWasRunning: this.autoLoopRunning, autoLoopWasRunning: this.autoLoopRunning,
maxConcurrency: this.config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, maxConcurrency: this.config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
projectPath, projectPath,
branchName: null, // Legacy global auto mode uses main worktree branchName: null, // Legacy global auto mode uses main worktree
runningFeatureIds: Array.from(this.runningFeatures.keys()), runningFeatureIds,
savedAt: new Date().toISOString(), savedAt: new Date().toISOString(),
}; };
await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8'); await secureFs.writeFile(statePath, JSON.stringify(state, null, 2), 'utf-8');

View File

@@ -13,7 +13,12 @@
*/ */
import type { ModelProvider } from '@automaker/types'; import type { ModelProvider } from '@automaker/types';
import { getCurrentBranch } from '@automaker/git-utils';
/**
* Function type for getting the current branch of a project.
* Injected to allow for testing and decoupling from git operations.
*/
export type GetCurrentBranchFn = (projectPath: string) => Promise<string | null>;
/** /**
* Represents a running feature execution with all tracking metadata * Represents a running feature execution with all tracking metadata
@@ -50,6 +55,15 @@ export interface AcquireParams {
*/ */
export class ConcurrencyManager { export class ConcurrencyManager {
private runningFeatures = new Map<string, RunningFeature>(); private runningFeatures = new Map<string, RunningFeature>();
private getCurrentBranch: GetCurrentBranchFn;
/**
* @param getCurrentBranch - Function to get the current branch for a project.
* If not provided, defaults to returning 'main'.
*/
constructor(getCurrentBranch?: GetCurrentBranchFn) {
this.getCurrentBranch = getCurrentBranch ?? (() => Promise.resolve('main'));
}
/** /**
* Acquire a slot in the runningFeatures map for a feature. * Acquire a slot in the runningFeatures map for a feature.
@@ -163,7 +177,7 @@ export class ConcurrencyManager {
branchName: string | null branchName: string | null
): Promise<number> { ): Promise<number> {
// Get the actual primary branch name for the project // Get the actual primary branch name for the project
const primaryBranch = await getCurrentBranch(projectPath); const primaryBranch = await this.getCurrentBranch(projectPath);
let count = 0; let count = 0;
for (const [, feature] of this.runningFeatures) { for (const [, feature] of this.runningFeatures) {

View File

@@ -69,12 +69,16 @@ describe('auto-mode-service.ts', () => {
}); });
describe('getRunningAgents', () => { describe('getRunningAgents', () => {
// Helper to access private runningFeatures Map // Helper to access private concurrencyManager
const getRunningFeaturesMap = (svc: AutoModeService) => const getConcurrencyManager = (svc: AutoModeService) => (svc as any).concurrencyManager;
(svc as any).runningFeatures as Map<
string, // Helper to add a running feature via concurrencyManager
{ featureId: string; projectPath: string; isAutoMode: boolean } const addRunningFeature = (
>; svc: AutoModeService,
feature: { featureId: string; projectPath: string; isAutoMode: boolean }
) => {
getConcurrencyManager(svc).acquire(feature);
};
// Helper to get the featureLoader and mock its get method // Helper to get the featureLoader and mock its get method
const mockFeatureLoaderGet = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => { const mockFeatureLoaderGet = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
@@ -88,9 +92,8 @@ describe('auto-mode-service.ts', () => {
}); });
it('should return running agents with basic info when feature data is not available', async () => { it('should return running agents with basic info when feature data is not available', async () => {
// Arrange: Add a running feature to the Map // Arrange: Add a running feature via concurrencyManager
const runningFeaturesMap = getRunningFeaturesMap(service); addRunningFeature(service, {
runningFeaturesMap.set('feature-123', {
featureId: 'feature-123', featureId: 'feature-123',
projectPath: '/test/project/path', projectPath: '/test/project/path',
isAutoMode: true, isAutoMode: true,
@@ -117,8 +120,7 @@ describe('auto-mode-service.ts', () => {
it('should return running agents with title and description when feature data is available', async () => { it('should return running agents with title and description when feature data is available', async () => {
// Arrange // Arrange
const runningFeaturesMap = getRunningFeaturesMap(service); addRunningFeature(service, {
runningFeaturesMap.set('feature-456', {
featureId: 'feature-456', featureId: 'feature-456',
projectPath: '/home/user/my-project', projectPath: '/home/user/my-project',
isAutoMode: false, isAutoMode: false,
@@ -152,13 +154,12 @@ describe('auto-mode-service.ts', () => {
it('should handle multiple running agents', async () => { it('should handle multiple running agents', async () => {
// Arrange // Arrange
const runningFeaturesMap = getRunningFeaturesMap(service); addRunningFeature(service, {
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1', featureId: 'feature-1',
projectPath: '/project-a', projectPath: '/project-a',
isAutoMode: true, isAutoMode: true,
}); });
runningFeaturesMap.set('feature-2', { addRunningFeature(service, {
featureId: 'feature-2', featureId: 'feature-2',
projectPath: '/project-b', projectPath: '/project-b',
isAutoMode: false, isAutoMode: false,
@@ -188,8 +189,7 @@ describe('auto-mode-service.ts', () => {
it('should silently handle errors when fetching feature data', async () => { it('should silently handle errors when fetching feature data', async () => {
// Arrange // Arrange
const runningFeaturesMap = getRunningFeaturesMap(service); addRunningFeature(service, {
runningFeaturesMap.set('feature-error', {
featureId: 'feature-error', featureId: 'feature-error',
projectPath: '/project-error', projectPath: '/project-error',
isAutoMode: true, isAutoMode: true,
@@ -215,8 +215,7 @@ describe('auto-mode-service.ts', () => {
it('should handle feature with title but no description', async () => { it('should handle feature with title but no description', async () => {
// Arrange // Arrange
const runningFeaturesMap = getRunningFeaturesMap(service); addRunningFeature(service, {
runningFeaturesMap.set('feature-title-only', {
featureId: 'feature-title-only', featureId: 'feature-title-only',
projectPath: '/project', projectPath: '/project',
isAutoMode: false, isAutoMode: false,
@@ -239,8 +238,7 @@ describe('auto-mode-service.ts', () => {
it('should handle feature with description but no title', async () => { it('should handle feature with description but no title', async () => {
// Arrange // Arrange
const runningFeaturesMap = getRunningFeaturesMap(service); addRunningFeature(service, {
runningFeaturesMap.set('feature-desc-only', {
featureId: 'feature-desc-only', featureId: 'feature-desc-only',
projectPath: '/project', projectPath: '/project',
isAutoMode: false, isAutoMode: false,
@@ -263,8 +261,7 @@ describe('auto-mode-service.ts', () => {
it('should extract projectName from nested paths correctly', async () => { it('should extract projectName from nested paths correctly', async () => {
// Arrange // Arrange
const runningFeaturesMap = getRunningFeaturesMap(service); addRunningFeature(service, {
runningFeaturesMap.set('feature-nested', {
featureId: 'feature-nested', featureId: 'feature-nested',
projectPath: '/home/user/workspace/projects/my-awesome-project', projectPath: '/home/user/workspace/projects/my-awesome-project',
isAutoMode: true, isAutoMode: true,
@@ -282,9 +279,8 @@ describe('auto-mode-service.ts', () => {
it('should fetch feature data in parallel for multiple agents', async () => { it('should fetch feature data in parallel for multiple agents', async () => {
// Arrange: Add multiple running features // Arrange: Add multiple running features
const runningFeaturesMap = getRunningFeaturesMap(service);
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {
runningFeaturesMap.set(`feature-${i}`, { addRunningFeature(service, {
featureId: `feature-${i}`, featureId: `feature-${i}`,
projectPath: `/project-${i}`, projectPath: `/project-${i}`,
isAutoMode: i % 2 === 0, isAutoMode: i % 2 === 0,
@@ -581,12 +577,16 @@ describe('auto-mode-service.ts', () => {
}); });
describe('markAllRunningFeaturesInterrupted', () => { describe('markAllRunningFeaturesInterrupted', () => {
// Helper to access private runningFeatures Map // Helper to access private concurrencyManager
const getRunningFeaturesMap = (svc: AutoModeService) => const getConcurrencyManager = (svc: AutoModeService) => (svc as any).concurrencyManager;
(svc as any).runningFeatures as Map<
string, // Helper to add a running feature via concurrencyManager
{ featureId: string; projectPath: string; isAutoMode: boolean } const addRunningFeatureForInterrupt = (
>; svc: AutoModeService,
feature: { featureId: string; projectPath: string; isAutoMode: boolean }
) => {
getConcurrencyManager(svc).acquire(feature);
};
// Helper to mock updateFeatureStatus // Helper to mock updateFeatureStatus
const mockUpdateFeatureStatus = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => { const mockUpdateFeatureStatus = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
@@ -608,8 +608,7 @@ describe('auto-mode-service.ts', () => {
}); });
it('should mark a single running feature as interrupted', async () => { it('should mark a single running feature as interrupted', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service); addRunningFeatureForInterrupt(service, {
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1', featureId: 'feature-1',
projectPath: '/project/path', projectPath: '/project/path',
isAutoMode: true, isAutoMode: true,
@@ -626,18 +625,17 @@ describe('auto-mode-service.ts', () => {
}); });
it('should mark multiple running features as interrupted', async () => { it('should mark multiple running features as interrupted', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service); addRunningFeatureForInterrupt(service, {
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1', featureId: 'feature-1',
projectPath: '/project-a', projectPath: '/project-a',
isAutoMode: true, isAutoMode: true,
}); });
runningFeaturesMap.set('feature-2', { addRunningFeatureForInterrupt(service, {
featureId: 'feature-2', featureId: 'feature-2',
projectPath: '/project-b', projectPath: '/project-b',
isAutoMode: false, isAutoMode: false,
}); });
runningFeaturesMap.set('feature-3', { addRunningFeatureForInterrupt(service, {
featureId: 'feature-3', featureId: 'feature-3',
projectPath: '/project-a', projectPath: '/project-a',
isAutoMode: true, isAutoMode: true,
@@ -657,9 +655,8 @@ describe('auto-mode-service.ts', () => {
}); });
it('should mark features in parallel', async () => { it('should mark features in parallel', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service);
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {
runningFeaturesMap.set(`feature-${i}`, { addRunningFeatureForInterrupt(service, {
featureId: `feature-${i}`, featureId: `feature-${i}`,
projectPath: `/project-${i}`, projectPath: `/project-${i}`,
isAutoMode: true, isAutoMode: true,
@@ -686,13 +683,12 @@ describe('auto-mode-service.ts', () => {
}); });
it('should continue marking other features when one fails', async () => { it('should continue marking other features when one fails', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service); addRunningFeatureForInterrupt(service, {
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1', featureId: 'feature-1',
projectPath: '/project-a', projectPath: '/project-a',
isAutoMode: true, isAutoMode: true,
}); });
runningFeaturesMap.set('feature-2', { addRunningFeatureForInterrupt(service, {
featureId: 'feature-2', featureId: 'feature-2',
projectPath: '/project-b', projectPath: '/project-b',
isAutoMode: false, isAutoMode: false,
@@ -713,8 +709,7 @@ describe('auto-mode-service.ts', () => {
}); });
it('should use provided reason in logging', async () => { it('should use provided reason in logging', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service); addRunningFeatureForInterrupt(service, {
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1', featureId: 'feature-1',
projectPath: '/project/path', projectPath: '/project/path',
isAutoMode: true, isAutoMode: true,
@@ -731,8 +726,7 @@ describe('auto-mode-service.ts', () => {
}); });
it('should use default reason when none provided', async () => { it('should use default reason when none provided', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service); addRunningFeatureForInterrupt(service, {
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1', featureId: 'feature-1',
projectPath: '/project/path', projectPath: '/project/path',
isAutoMode: true, isAutoMode: true,
@@ -749,18 +743,17 @@ describe('auto-mode-service.ts', () => {
}); });
it('should preserve pipeline statuses for running features', async () => { it('should preserve pipeline statuses for running features', async () => {
const runningFeaturesMap = getRunningFeaturesMap(service); addRunningFeatureForInterrupt(service, {
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1', featureId: 'feature-1',
projectPath: '/project-a', projectPath: '/project-a',
isAutoMode: true, isAutoMode: true,
}); });
runningFeaturesMap.set('feature-2', { addRunningFeatureForInterrupt(service, {
featureId: 'feature-2', featureId: 'feature-2',
projectPath: '/project-b', projectPath: '/project-b',
isAutoMode: false, isAutoMode: false,
}); });
runningFeaturesMap.set('feature-3', { addRunningFeatureForInterrupt(service, {
featureId: 'feature-3', featureId: 'feature-3',
projectPath: '/project-c', projectPath: '/project-c',
isAutoMode: true, isAutoMode: true,
@@ -791,20 +784,23 @@ describe('auto-mode-service.ts', () => {
}); });
describe('isFeatureRunning', () => { describe('isFeatureRunning', () => {
// Helper to access private runningFeatures Map // Helper to access private concurrencyManager
const getRunningFeaturesMap = (svc: AutoModeService) => const getConcurrencyManager = (svc: AutoModeService) => (svc as any).concurrencyManager;
(svc as any).runningFeatures as Map<
string, // Helper to add a running feature via concurrencyManager
{ featureId: string; projectPath: string; isAutoMode: boolean } const addRunningFeatureForIsRunning = (
>; svc: AutoModeService,
feature: { featureId: string; projectPath: string; isAutoMode: boolean }
) => {
getConcurrencyManager(svc).acquire(feature);
};
it('should return false when no features are running', () => { it('should return false when no features are running', () => {
expect(service.isFeatureRunning('feature-123')).toBe(false); expect(service.isFeatureRunning('feature-123')).toBe(false);
}); });
it('should return true when the feature is running', () => { it('should return true when the feature is running', () => {
const runningFeaturesMap = getRunningFeaturesMap(service); addRunningFeatureForIsRunning(service, {
runningFeaturesMap.set('feature-123', {
featureId: 'feature-123', featureId: 'feature-123',
projectPath: '/project/path', projectPath: '/project/path',
isAutoMode: true, isAutoMode: true,
@@ -814,8 +810,7 @@ describe('auto-mode-service.ts', () => {
}); });
it('should return false for non-running feature when others are running', () => { it('should return false for non-running feature when others are running', () => {
const runningFeaturesMap = getRunningFeaturesMap(service); addRunningFeatureForIsRunning(service, {
runningFeaturesMap.set('feature-other', {
featureId: 'feature-other', featureId: 'feature-other',
projectPath: '/project/path', projectPath: '/project/path',
isAutoMode: true, isAutoMode: true,
@@ -825,13 +820,12 @@ describe('auto-mode-service.ts', () => {
}); });
it('should correctly track multiple running features', () => { it('should correctly track multiple running features', () => {
const runningFeaturesMap = getRunningFeaturesMap(service); addRunningFeatureForIsRunning(service, {
runningFeaturesMap.set('feature-1', {
featureId: 'feature-1', featureId: 'feature-1',
projectPath: '/project-a', projectPath: '/project-a',
isAutoMode: true, isAutoMode: true,
}); });
runningFeaturesMap.set('feature-2', { addRunningFeatureForIsRunning(service, {
featureId: 'feature-2', featureId: 'feature-2',
projectPath: '/project-b', projectPath: '/project-b',
isAutoMode: false, isAutoMode: false,

View File

@@ -1,22 +1,19 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import { ConcurrencyManager, type RunningFeature } from '@/services/concurrency-manager.js'; import {
ConcurrencyManager,
// Mock git-utils to control getCurrentBranch behavior type RunningFeature,
vi.mock('@automaker/git-utils', () => ({ type GetCurrentBranchFn,
getCurrentBranch: vi.fn(), } from '@/services/concurrency-manager.js';
}));
import { getCurrentBranch } from '@automaker/git-utils';
const mockGetCurrentBranch = vi.mocked(getCurrentBranch);
describe('ConcurrencyManager', () => { describe('ConcurrencyManager', () => {
let manager: ConcurrencyManager; let manager: ConcurrencyManager;
let mockGetCurrentBranch: Mock<GetCurrentBranchFn>;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
manager = new ConcurrencyManager();
// Default: primary branch is 'main' // Default: primary branch is 'main'
mockGetCurrentBranch.mockResolvedValue('main'); mockGetCurrentBranch = vi.fn().mockResolvedValue('main');
manager = new ConcurrencyManager(mockGetCurrentBranch);
}); });
describe('acquire', () => { describe('acquire', () => {