mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 21:03:08 +00:00
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:
@@ -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');
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user