UNPKG

claude-code-collective

Version:

Sub-agent collective framework for Claude Code with TDD validation, hub-spoke coordination, and automated handoffs

730 lines (605 loc) 24 kB
/** * Claude Code Sub-Agent Collective - Registry Persistence Tests * * Phase 7: Dynamic Agent Creation * * Comprehensive test suite for validating agent registry persistence across system restarts * and recovery capabilities including orphaned agent detection and data migration. * * @author Claude Code Sub-Agent Collective * @version 1.0.0 */ const fs = require('fs-extra'); const path = require('path'); const AgentRegistry = require('../lib/AgentRegistry'); const AgentSpawner = require('../lib/AgentSpawner'); describe('Registry Persistence and Recovery Tests', () => { let testDir; let registryFile; let registries = []; // Track all registries for cleanup beforeEach(async () => { // Create unique test directory for each test testDir = path.join(__dirname, `temp-registry-test-${Date.now()}`); registryFile = path.join(testDir, 'state', 'agent-registry.json'); await fs.ensureDir(testDir); registries = []; // Reset registry tracking }); afterEach(async () => { // Shutdown all registries to prevent timer leaks for (const registry of registries) { try { await registry.shutdown(); } catch (error) { // Ignore shutdown errors - registry might already be shut down } } // Wait a bit to ensure timers are cleared await new Promise(resolve => setTimeout(resolve, 10)); // Cleanup test directory await fs.remove(testDir); }); // Helper function to create and track registries const createRegistry = (options) => { const registry = new AgentRegistry(options); registries.push(registry); return registry; }; describe('Registry Persistence', () => { test('should persist registry data to disk', async () => { const registry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true }); await registry.initialize(); // Register test agent const agentInfo = { id: 'test-persist-agent', name: 'Test Persistence Agent', template: 'base', path: '/test/path/agent.md', metadata: { registeredAt: new Date().toISOString(), version: '1.0.0', tools: ['Read', 'Write'], capabilities: ['basic-processing'] } }; await registry.register(agentInfo); // Verify file was created expect(await fs.pathExists(registryFile)).toBe(true); // Verify file contents const savedData = await fs.readJson(registryFile); expect(savedData.version).toBe('1.0.0'); expect(savedData.agents).toHaveLength(1); expect(savedData.agents[0].id).toBe('test-persist-agent'); await registry.shutdown(); }); test('should load registry data from disk on initialization', async () => { // Create registry and register agent let registry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true }); await registry.initialize(); const agentInfo = { id: 'test-load-agent', name: 'Test Load Agent', template: 'base', path: '/test/path/agent.md', metadata: { registeredAt: new Date().toISOString(), version: '1.0.0', tools: ['Read', 'Write'], capabilities: ['basic-processing'] } }; await registry.register(agentInfo); await registry.shutdown(); // Create new registry instance registry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true }); await registry.initialize(); // Verify agent was loaded const loadedAgent = registry.getAgent('test-load-agent'); expect(loadedAgent).toBeDefined(); expect(loadedAgent.id).toBe('test-load-agent'); expect(loadedAgent.name).toBe('Test Load Agent'); await registry.shutdown(); }); test('should handle missing registry file gracefully', async () => { const registry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true }); // Initialize without existing file await expect(registry.initialize()).resolves.not.toThrow(); // Verify empty registry const agents = registry.query(); expect(agents).toHaveLength(0); await registry.shutdown(); }); test('should auto-save on registry changes', async () => { const registry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true }); await registry.initialize(); const agentInfo = { id: 'auto-save-agent', name: 'Auto Save Agent', template: 'base', path: '/test/path/agent.md', metadata: { registeredAt: new Date().toISOString(), version: '1.0.0' } }; // Register agent - should auto-save await registry.register(agentInfo); // Update status - should auto-save await registry.updateStatus('auto-save-agent', 'processing', { reason: 'test update' }); // Update activity - should auto-save await registry.updateActivity('auto-save-agent', { processingTime: 500, success: true }); // Verify file was updated expect(await fs.pathExists(registryFile)).toBe(true); const savedData = await fs.readJson(registryFile); const agent = savedData.agents.find(a => a.id === 'auto-save-agent'); expect(agent.status).toBe('processing'); expect(agent.activity.successCount).toBe(1); expect(agent.performance.avgResponseTime).toBe(500); await registry.shutdown(); }); }); describe('Registry Recovery', () => { test('should recover complete registry state after restart', async () => { // Phase 1: Create registry with multiple agents let registry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true, backupInterval: 0 // Disable automatic backups for testing }); await registry.initialize(); const agents = [ { id: 'recovery-agent-1', name: 'Recovery Agent 1', template: 'base', path: '/test/path/agent1.md', metadata: { registeredAt: new Date().toISOString(), version: '1.0.0' } }, { id: 'recovery-agent-2', name: 'Recovery Agent 2', template: 'research-agent', path: '/test/path/agent2.md', metadata: { registeredAt: new Date().toISOString(), version: '1.0.0' } }, { id: 'recovery-agent-3', name: 'Recovery Agent 3', template: 'implementation-agent', path: '/test/path/agent3.md', metadata: { registeredAt: new Date().toISOString(), version: '1.0.0' } } ]; // Register all agents with different statuses and activities for (const agent of agents) { await registry.register(agent); } await registry.updateStatus('recovery-agent-1', 'processing'); await registry.updateStatus('recovery-agent-2', 'completed'); await registry.updateActivity('recovery-agent-1', { processingTime: 1000, success: true }); await registry.updateActivity('recovery-agent-2', { processingTime: 500, success: false }); const originalStats = registry.getStatistics(); await registry.shutdown(); // Phase 2: Restart registry and verify recovery registry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true }); await registry.initialize(); // Verify all agents recovered expect(registry.query()).toHaveLength(3); const agent1 = registry.getAgent('recovery-agent-1'); const agent2 = registry.getAgent('recovery-agent-2'); const agent3 = registry.getAgent('recovery-agent-3'); expect(agent1.status).toBe('processing'); expect(agent2.status).toBe('completed'); expect(agent3.status).toBe('active'); expect(agent1.performance.avgResponseTime).toBe(1000); expect(agent1.performance.successRate).toBe(1.0); expect(agent2.performance.successRate).toBe(0.0); // Verify statistics recovered const recoveredStats = registry.getStatistics(); expect(recoveredStats.currentStats.totalAgents).toBe(originalStats.currentStats.totalAgents); await registry.shutdown(); }); test('should handle corrupted registry file', async () => { // Create corrupted registry file await fs.ensureDir(path.dirname(registryFile)); await fs.writeFile(registryFile, '{ invalid json content }'); // Mock console.error to verify it's called but not display the message const originalConsoleError = console.error; const errorMessages = []; console.error = (...args) => { errorMessages.push(args.join(' ')); }; const registry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true }); // Should initialize without crashing await expect(registry.initialize()).resolves.not.toThrow(); // Verify error was logged expect(errorMessages.length).toBe(1); expect(errorMessages[0]).toContain('Failed to load registry'); // Restore console.error console.error = originalConsoleError; // Should have empty registry expect(registry.query()).toHaveLength(0); // Should create backup of corrupted file const backupDir = path.join(testDir, 'state', 'backups'); const backupFiles = await fs.readdir(backupDir); const corruptedBackup = backupFiles.find(f => f.startsWith('corrupted-')); expect(corruptedBackup).toBeDefined(); await registry.shutdown(); }); test('should migrate registry data format', async () => { // Create old format registry file const oldFormat = { version: '0.9.0', agents: [{ id: 'migration-test-agent', name: 'Migration Test', // Missing some new fields registeredAt: new Date().toISOString(), status: 'active' }] }; await fs.ensureDir(path.dirname(registryFile)); await fs.writeJson(registryFile, oldFormat); const registry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true }); await registry.initialize(); // Should load and migrate data const agent = registry.getAgent('migration-test-agent'); expect(agent).toBeDefined(); // Should have default values for missing fields expect(agent.activity).toBeDefined(); expect(agent.performance).toBeDefined(); expect(agent.health).toBeDefined(); expect(agent.resources).toBeDefined(); await registry.shutdown(); }); }); describe('Orphaned Agent Detection', () => { test('should detect agents with missing files', async () => { const registry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true }); await registry.initialize(); const agentInfo = { id: 'missing-file-agent', name: 'Missing File Agent', template: 'base', path: path.join(testDir, 'agents', 'missing-agent.md'), // File doesn't exist metadata: { registeredAt: new Date().toISOString(), version: '1.0.0' } }; await registry.register(agentInfo); // Run health check - should detect missing file const healthResult = await registry.checkHealth('missing-file-agent'); expect(healthResult.status).toBe('unhealthy'); expect(healthResult.issues).toContain('Agent file not found'); await registry.shutdown(); }); test('should cleanup orphaned registry entries', async () => { const registry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true }); await registry.initialize(); // Register agents with missing files const orphanedAgents = [ { id: 'orphaned-agent-1', name: 'Orphaned Agent 1', template: 'base', path: path.join(testDir, 'agents', 'orphaned1.md'), metadata: { registeredAt: new Date().toISOString(), version: '1.0.0' } }, { id: 'orphaned-agent-2', name: 'Orphaned Agent 2', template: 'base', path: path.join(testDir, 'agents', 'orphaned2.md'), metadata: { registeredAt: new Date().toISOString(), version: '1.0.0' } } ]; for (const agent of orphanedAgents) { await registry.register(agent); // Update status to archived (old agents) await registry.updateStatus(agent.id, 'archived'); } // Cleanup old inactive agents const cleanupResult = await registry.cleanupInactive(0); // 0 days = cleanup immediately expect(cleanupResult.removed).toBe(2); expect(cleanupResult.agents).toEqual(['orphaned-agent-1', 'orphaned-agent-2']); // Verify agents were removed expect(registry.getAgent('orphaned-agent-1')).toBeUndefined(); expect(registry.getAgent('orphaned-agent-2')).toBeUndefined(); await registry.shutdown(); }); }); describe('Concurrent Access', () => { test('should handle concurrent registry operations', async () => { const registry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true }); await registry.initialize(); // Concurrent operations const operations = []; // Register multiple agents concurrently for (let i = 0; i < 10; i++) { operations.push(registry.register({ id: `concurrent-agent-${i}`, name: `Concurrent Agent ${i}`, template: 'base', path: `/test/path/agent${i}.md`, metadata: { registeredAt: new Date().toISOString(), version: '1.0.0' } })); } // Mix in some status updates and activity updates operations.push( new Promise(resolve => setTimeout(() => resolve(registry.updateStatus('concurrent-agent-0', 'processing')), 10)), new Promise(resolve => setTimeout(() => resolve(registry.updateActivity('concurrent-agent-1', { processingTime: 100 })), 20)) ); const results = await Promise.allSettled(operations); // Most operations should succeed const successful = results.filter(r => r.status === 'fulfilled'); expect(successful.length).toBeGreaterThanOrEqual(8); // Verify registry consistency const agents = registry.query(); expect(agents.length).toBeLessThanOrEqual(10); // Verify unique IDs const ids = agents.map(a => a.id); const uniqueIds = new Set(ids); expect(uniqueIds.size).toBe(agents.length); await registry.shutdown(); }); }); describe('Backup and Recovery', () => { test('should create and restore from backups', async () => { const registry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true, backupInterval: 100, // 100ms for testing maxBackups: 3 }); await registry.initialize(); // Register test data const agentInfo = { id: 'backup-test-agent', name: 'Backup Test Agent', template: 'base', path: '/test/path/agent.md', metadata: { registeredAt: new Date().toISOString(), version: '1.0.0' } }; await registry.register(agentInfo); // Wait for backup to be created await new Promise(resolve => setTimeout(resolve, 200)); // Verify backup was created const backupDir = path.join(testDir, 'state', 'backups'); const backupFiles = await fs.readdir(backupDir); const registryBackup = backupFiles.find(f => f.startsWith('registry-backup-')); expect(registryBackup).toBeDefined(); // Verify backup contents const backupData = await fs.readJson(path.join(backupDir, registryBackup)); expect(backupData.agents).toHaveLength(1); expect(backupData.agents[0].id).toBe('backup-test-agent'); await registry.shutdown(); }); test('should enforce backup limits', async () => { const registry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true, backupInterval: 50, // Fast backup for testing maxBackups: 2 }); await registry.initialize(); // Create multiple registry changes to trigger backups for (let i = 0; i < 5; i++) { await registry.register({ id: `backup-limit-agent-${i}`, name: `Backup Limit Agent ${i}`, template: 'base', path: `/test/path/agent${i}.md`, metadata: { registeredAt: new Date().toISOString(), version: '1.0.0' } }); await new Promise(resolve => setTimeout(resolve, 60)); // Wait for backup } // Wait for backup cleanup to complete await new Promise(resolve => setTimeout(resolve, 500)); // Check that only maxBackups files exist const backupDir = path.join(testDir, 'state', 'backups'); const backupFiles = await fs.readdir(backupDir); const registryBackups = backupFiles.filter(f => f.startsWith('registry-backup-')); expect(registryBackups.length).toBeLessThanOrEqual(2); await registry.shutdown(); }, 10000); }); describe('Performance and Scalability', () => { test('should handle large registry efficiently', async () => { const registry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true }); await registry.initialize(); const agentCount = 1000; const startTime = Date.now(); // Register many agents const registerPromises = []; for (let i = 0; i < agentCount; i++) { registerPromises.push(registry.register({ id: `perf-agent-${i}`, name: `Performance Agent ${i}`, template: i % 3 === 0 ? 'base' : i % 3 === 1 ? 'research-agent' : 'implementation-agent', path: `/test/path/agent${i}.md`, metadata: { registeredAt: new Date().toISOString(), version: '1.0.0' } })); } await Promise.all(registerPromises); const registrationTime = Date.now() - startTime; // Test query performance const queryStart = Date.now(); const activeAgents = registry.query({ status: 'active' }); const queryTime = Date.now() - queryStart; // Test persistence performance const persistStart = Date.now(); await registry.saveRegistry(); const persistTime = Date.now() - persistStart; // Verify all agents registered expect(activeAgents.length).toBe(agentCount); // Performance assertions expect(registrationTime).toBeLessThan(5000); // 5 seconds expect(queryTime).toBeLessThan(100); // 100ms expect(persistTime).toBeLessThan(1000); // 1 second console.log(`Performance metrics for ${agentCount} agents:`); console.log(`Registration: ${registrationTime}ms`); console.log(`Query: ${queryTime}ms`); console.log(`Persistence: ${persistTime}ms`); await registry.shutdown(); }, 15000); test('should load large registry quickly', async () => { // First create a large registry let registry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true }); await registry.initialize(); const agentCount = 500; for (let i = 0; i < agentCount; i++) { await registry.register({ id: `load-test-agent-${i}`, name: `Load Test Agent ${i}`, template: 'base', path: `/test/path/agent${i}.md`, metadata: { registeredAt: new Date().toISOString(), version: '1.0.0' } }); } await registry.shutdown(); // Test loading performance const loadStart = Date.now(); registry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true }); await registry.initialize(); const loadTime = Date.now() - loadStart; // Verify all agents loaded const loadedAgents = registry.query(); expect(loadedAgents.length).toBe(agentCount); // Performance assertion expect(loadTime).toBeLessThan(2000); // 2 seconds console.log(`Load performance for ${agentCount} agents: ${loadTime}ms`); await registry.shutdown(); }, 10000); }); describe('Data Integrity', () => { test('should maintain data consistency across operations', async () => { const registry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true }); await registry.initialize(); const agentInfo = { id: 'integrity-test-agent', name: 'Integrity Test Agent', template: 'base', path: '/test/path/agent.md', metadata: { registeredAt: new Date().toISOString(), version: '1.0.0' } }; await registry.register(agentInfo); // Perform various operations await registry.updateStatus('integrity-test-agent', 'processing'); await registry.updateActivity('integrity-test-agent', { processingTime: 500, success: true }); await registry.updateActivity('integrity-test-agent', { processingTime: 300, success: false }); await registry.checkHealth('integrity-test-agent'); // Verify data consistency const agent = registry.getAgent('integrity-test-agent'); expect(agent.activity.invocations).toBe(2); expect(agent.activity.successCount).toBe(1); expect(agent.activity.errorCount).toBe(1); expect(agent.performance.successRate).toBe(0.5); expect(agent.performance.avgResponseTime).toBe(400); // (500 + 300) / 2 // Save and reload to verify persistence consistency await registry.shutdown(); const newRegistry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true }); await newRegistry.initialize(); const reloadedAgent = newRegistry.getAgent('integrity-test-agent'); expect(reloadedAgent.activity.invocations).toBe(2); expect(reloadedAgent.performance.successRate).toBe(0.5); expect(reloadedAgent.performance.avgResponseTime).toBe(400); await newRegistry.shutdown(); }); test('should handle partial write failures', async () => { const registry = createRegistry({ registryDir: path.join(testDir, 'state'), persistenceEnabled: true }); await registry.initialize(); // Register agent const agentInfo = { id: 'partial-write-test', name: 'Partial Write Test', template: 'base', path: '/test/path/agent.md', metadata: { registeredAt: new Date().toISOString(), version: '1.0.0' } }; await registry.register(agentInfo); // Simulate write failure by making directory read-only const registryDir = path.dirname(registryFile); // This test is OS-dependent, so we'll just verify the registry // handles errors gracefully rather than simulating filesystem issues // Verify agent is still accessible in memory const agent = registry.getAgent('partial-write-test'); expect(agent).toBeDefined(); await registry.shutdown(); }); }); });