diff --git a/apps/server/tests/unit/services/concurrency-manager.test.ts b/apps/server/tests/unit/services/concurrency-manager.test.ts new file mode 100644 index 00000000..2810120a --- /dev/null +++ b/apps/server/tests/unit/services/concurrency-manager.test.ts @@ -0,0 +1,612 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ConcurrencyManager, type RunningFeature } from '@/services/concurrency-manager.js'; + +// Mock git-utils to control getCurrentBranch behavior +vi.mock('@automaker/git-utils', () => ({ + getCurrentBranch: vi.fn(), +})); + +import { getCurrentBranch } from '@automaker/git-utils'; +const mockGetCurrentBranch = vi.mocked(getCurrentBranch); + +describe('ConcurrencyManager', () => { + let manager: ConcurrencyManager; + + beforeEach(() => { + vi.clearAllMocks(); + manager = new ConcurrencyManager(); + // Default: primary branch is 'main' + mockGetCurrentBranch.mockResolvedValue('main'); + }); + + describe('acquire', () => { + it('should create new entry with leaseCount: 1 on first acquire', () => { + const result = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + expect(result.featureId).toBe('feature-1'); + expect(result.projectPath).toBe('/test/project'); + expect(result.isAutoMode).toBe(true); + expect(result.leaseCount).toBe(1); + expect(result.worktreePath).toBeNull(); + expect(result.branchName).toBeNull(); + expect(result.startTime).toBeDefined(); + expect(result.abortController).toBeInstanceOf(AbortController); + }); + + it('should increment leaseCount when allowReuse is true for existing feature', () => { + // First acquire + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + // Second acquire with allowReuse + const result = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + expect(result.leaseCount).toBe(2); + }); + + it('should throw "already running" when allowReuse is false for existing feature', () => { + // First acquire + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + // Second acquire without allowReuse + expect(() => + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }) + ).toThrow('already running'); + }); + + it('should throw "already running" when allowReuse is explicitly false', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + expect(() => + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: false, + }) + ).toThrow('already running'); + }); + + it('should use provided abortController', () => { + const customAbortController = new AbortController(); + + const result = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + abortController: customAbortController, + }); + + expect(result.abortController).toBe(customAbortController); + }); + + it('should return the existing entry when allowReuse is true', () => { + const first = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + const second = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + // Should be the same object reference + expect(second).toBe(first); + }); + + it('should allow multiple nested acquire calls with allowReuse', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + const result = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + expect(result.leaseCount).toBe(3); + }); + }); + + describe('release', () => { + it('should decrement leaseCount on release', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + manager.release('feature-1'); + + const entry = manager.getRunningFeature('feature-1'); + expect(entry?.leaseCount).toBe(1); + }); + + it('should delete entry when leaseCount reaches 0', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.release('feature-1'); + + expect(manager.isRunning('feature-1')).toBe(false); + expect(manager.getRunningFeature('feature-1')).toBeUndefined(); + }); + + it('should delete entry immediately when force is true regardless of leaseCount', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + // leaseCount is 3, but force should still delete + manager.release('feature-1', { force: true }); + + expect(manager.isRunning('feature-1')).toBe(false); + }); + + it('should do nothing when releasing non-existent feature', () => { + // Should not throw + manager.release('non-existent-feature'); + manager.release('non-existent-feature', { force: true }); + }); + + it('should only delete entry after all leases are released', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + allowReuse: true, + }); + + // leaseCount is 3 + manager.release('feature-1'); + expect(manager.isRunning('feature-1')).toBe(true); + + manager.release('feature-1'); + expect(manager.isRunning('feature-1')).toBe(true); + + manager.release('feature-1'); + expect(manager.isRunning('feature-1')).toBe(false); + }); + }); + + describe('isRunning', () => { + it('should return false when feature is not running', () => { + expect(manager.isRunning('feature-1')).toBe(false); + }); + + it('should return true when feature is running', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + expect(manager.isRunning('feature-1')).toBe(true); + }); + + it('should return false after feature is released', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.release('feature-1'); + + expect(manager.isRunning('feature-1')).toBe(false); + }); + }); + + describe('getRunningFeature', () => { + it('should return undefined for non-existent feature', () => { + expect(manager.getRunningFeature('feature-1')).toBeUndefined(); + }); + + it('should return the RunningFeature entry', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + const entry = manager.getRunningFeature('feature-1'); + expect(entry).toBeDefined(); + expect(entry?.featureId).toBe('feature-1'); + expect(entry?.projectPath).toBe('/test/project'); + }); + }); + + describe('getRunningCount (project-level)', () => { + it('should return 0 when no features are running', () => { + expect(manager.getRunningCount('/test/project')).toBe(0); + }); + + it('should count features for specific project', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-2', + projectPath: '/test/project', + isAutoMode: false, + }); + + expect(manager.getRunningCount('/test/project')).toBe(2); + }); + + it('should only count features for the specified project', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/project-a', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-2', + projectPath: '/project-b', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-3', + projectPath: '/project-a', + isAutoMode: false, + }); + + expect(manager.getRunningCount('/project-a')).toBe(2); + expect(manager.getRunningCount('/project-b')).toBe(1); + expect(manager.getRunningCount('/project-c')).toBe(0); + }); + }); + + describe('getRunningCountForWorktree', () => { + it('should return 0 when no features are running', async () => { + const count = await manager.getRunningCountForWorktree('/test/project', null); + expect(count).toBe(0); + }); + + it('should count features with null branchName as main worktree', async () => { + const entry = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + // entry.branchName is null by default + + const count = await manager.getRunningCountForWorktree('/test/project', null); + expect(count).toBe(1); + }); + + it('should count features matching primary branch as main worktree', async () => { + mockGetCurrentBranch.mockResolvedValue('main'); + + const entry = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-1', { branchName: 'main' }); + + const count = await manager.getRunningCountForWorktree('/test/project', null); + expect(count).toBe(1); + }); + + it('should count features with exact branch match for feature worktrees', async () => { + const entry = manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-1', { branchName: 'feature-branch' }); + + manager.acquire({ + featureId: 'feature-2', + projectPath: '/test/project', + isAutoMode: true, + }); + // feature-2 has null branchName + + const featureBranchCount = await manager.getRunningCountForWorktree( + '/test/project', + 'feature-branch' + ); + expect(featureBranchCount).toBe(1); + + const mainWorktreeCount = await manager.getRunningCountForWorktree('/test/project', null); + expect(mainWorktreeCount).toBe(1); + }); + + it('should respect branch normalization (main is treated as null)', async () => { + mockGetCurrentBranch.mockResolvedValue('main'); + + // Feature with branchName 'main' should count as main worktree + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-1', { branchName: 'main' }); + + // Feature with branchName null should also count as main worktree + manager.acquire({ + featureId: 'feature-2', + projectPath: '/test/project', + isAutoMode: true, + }); + + const mainCount = await manager.getRunningCountForWorktree('/test/project', null); + expect(mainCount).toBe(2); + }); + + it('should filter by both projectPath and branchName', async () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/project-a', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-1', { branchName: 'feature-x' }); + + manager.acquire({ + featureId: 'feature-2', + projectPath: '/project-b', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-2', { branchName: 'feature-x' }); + + const countA = await manager.getRunningCountForWorktree('/project-a', 'feature-x'); + const countB = await manager.getRunningCountForWorktree('/project-b', 'feature-x'); + + expect(countA).toBe(1); + expect(countB).toBe(1); + }); + }); + + describe('getAllRunning', () => { + it('should return empty array when no features are running', () => { + expect(manager.getAllRunning()).toEqual([]); + }); + + it('should return array with all running features', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/project-a', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-2', + projectPath: '/project-b', + isAutoMode: false, + }); + + const running = manager.getAllRunning(); + expect(running).toHaveLength(2); + expect(running.map((r) => r.featureId)).toContain('feature-1'); + expect(running.map((r) => r.featureId)).toContain('feature-2'); + }); + + it('should include feature metadata', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/project-a', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-1', { model: 'claude-sonnet-4', provider: 'claude' }); + + const running = manager.getAllRunning(); + expect(running[0].model).toBe('claude-sonnet-4'); + expect(running[0].provider).toBe('claude'); + }); + }); + + describe('updateRunningFeature', () => { + it('should update worktreePath and branchName', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.updateRunningFeature('feature-1', { + worktreePath: '/worktrees/feature-1', + branchName: 'feature-1-branch', + }); + + const entry = manager.getRunningFeature('feature-1'); + expect(entry?.worktreePath).toBe('/worktrees/feature-1'); + expect(entry?.branchName).toBe('feature-1-branch'); + }); + + it('should update model and provider', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.updateRunningFeature('feature-1', { + model: 'claude-opus-4-5-20251101', + provider: 'claude', + }); + + const entry = manager.getRunningFeature('feature-1'); + expect(entry?.model).toBe('claude-opus-4-5-20251101'); + expect(entry?.provider).toBe('claude'); + }); + + it('should do nothing for non-existent feature', () => { + // Should not throw + manager.updateRunningFeature('non-existent', { model: 'test' }); + }); + + it('should preserve other properties when updating partial fields', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + const original = manager.getRunningFeature('feature-1'); + const originalStartTime = original?.startTime; + + manager.updateRunningFeature('feature-1', { model: 'claude-sonnet-4' }); + + const updated = manager.getRunningFeature('feature-1'); + expect(updated?.startTime).toBe(originalStartTime); + expect(updated?.projectPath).toBe('/test/project'); + expect(updated?.isAutoMode).toBe(true); + expect(updated?.model).toBe('claude-sonnet-4'); + }); + }); + + describe('edge cases', () => { + it('should handle multiple features for same project', () => { + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-2', + projectPath: '/test/project', + isAutoMode: true, + }); + + manager.acquire({ + featureId: 'feature-3', + projectPath: '/test/project', + isAutoMode: false, + }); + + expect(manager.getRunningCount('/test/project')).toBe(3); + expect(manager.isRunning('feature-1')).toBe(true); + expect(manager.isRunning('feature-2')).toBe(true); + expect(manager.isRunning('feature-3')).toBe(true); + }); + + it('should handle features across different worktrees', async () => { + // Main worktree feature + manager.acquire({ + featureId: 'feature-1', + projectPath: '/test/project', + isAutoMode: true, + }); + + // Worktree A feature + manager.acquire({ + featureId: 'feature-2', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-2', { + worktreePath: '/worktrees/a', + branchName: 'branch-a', + }); + + // Worktree B feature + manager.acquire({ + featureId: 'feature-3', + projectPath: '/test/project', + isAutoMode: true, + }); + manager.updateRunningFeature('feature-3', { + worktreePath: '/worktrees/b', + branchName: 'branch-b', + }); + + expect(await manager.getRunningCountForWorktree('/test/project', null)).toBe(1); + expect(await manager.getRunningCountForWorktree('/test/project', 'branch-a')).toBe(1); + expect(await manager.getRunningCountForWorktree('/test/project', 'branch-b')).toBe(1); + expect(manager.getRunningCount('/test/project')).toBe(3); + }); + + it('should return 0 counts and empty arrays for empty state', () => { + expect(manager.getRunningCount('/any/project')).toBe(0); + expect(manager.getAllRunning()).toEqual([]); + expect(manager.isRunning('any-feature')).toBe(false); + expect(manager.getRunningFeature('any-feature')).toBeUndefined(); + }); + }); +});