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
JavaScript
/**
* 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();
});
});
});