aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
366 lines (325 loc) • 9.54 kB
JavaScript
/**
* Loop Registry - Central tracking for multi-loop Ralph
*
* Manages registry of all active Ralph loops to enable:
* - Concurrent loop execution without conflicts
* - MAX_CONCURRENT_LOOPS enforcement (REF-086, REF-088)
* - Cross-loop coordination and learning
* - Recovery from crashed loops
*
* Based on:
* - REF-086: Coordination Tax (17.2x error trap, 4-agent threshold)
* - REF-088: DEV Guide 2026 (optimal 3-7 agents, n*(n-1)/2 paths)
* - REF-082: Multi-Agent Orchestration (ConcurrencyManager pattern)
*
* @module tools/ralph/registry
*/
import fs from 'fs';
import path from 'path';
import { randomUUID } from 'crypto';
const MAX_CONCURRENT_LOOPS = 4; // Research-backed limit (REF-086, REF-088)
/**
* Loop Registry
*
* Manages the central registry of active Ralph loops with file locking
* to prevent concurrent modification conflicts.
*/
export class LoopRegistry {
/**
* @param {string} baseDir - Base directory for registry (usually project root)
*/
constructor(baseDir) {
this.baseDir = baseDir;
this.ralphDir = path.join(baseDir, '.aiwg', 'ralph');
this.registryPath = path.join(this.ralphDir, 'registry.json');
this.lockPath = `${this.registryPath}.lock`;
// Ensure directories exist
if (!fs.existsSync(this.ralphDir)) {
fs.mkdirSync(this.ralphDir, { recursive: true });
}
}
/**
* Load registry data (creates if doesn't exist)
*/
load() {
if (!fs.existsSync(this.registryPath)) {
const initialRegistry = {
version: '2.0.0',
max_concurrent_loops: MAX_CONCURRENT_LOOPS,
updated_at: new Date().toISOString(),
active_loops: [],
total_active: 0,
total_completed: 0,
total_aborted: 0,
};
fs.writeFileSync(
this.registryPath,
JSON.stringify(initialRegistry, null, 2)
);
return initialRegistry;
}
return JSON.parse(fs.readFileSync(this.registryPath, 'utf8'));
}
/**
* Save registry data
*/
save(data) {
data.updated_at = new Date().toISOString();
data.total_active = data.active_loops.length;
fs.writeFileSync(this.registryPath, JSON.stringify(data, null, 2));
}
/**
* Execute function with registry lock
*
* Implements lease-based locking with stale detection per Kleppmann's
* distributed locking guide.
*
* @param {Function} fn - Function to execute while holding lock
* @returns {Promise<any>} Result of fn
*/
async withLock(fn) {
const LEASE_MS = 30000; // 30 second lease
const RETRY_MS = 100;
const MAX_ATTEMPTS = 300; // 30 seconds total
let attempts = 0;
while (attempts < MAX_ATTEMPTS) {
try {
// Try to acquire lock (atomic file creation)
const lockData = {
pid: process.pid,
timestamp: Date.now(),
leaseExpiry: Date.now() + LEASE_MS,
};
fs.writeFileSync(
this.lockPath,
JSON.stringify(lockData, null, 2),
{ flag: 'wx' } // Exclusive create
);
// Lock acquired, execute function
try {
return await fn();
} finally {
// Release lock
this.releaseLock();
}
} catch (err) {
if (err.code === 'EEXIST') {
// Lock exists - check if stale
try {
const existingLock = JSON.parse(
fs.readFileSync(this.lockPath, 'utf8')
);
// Check if lease expired
if (Date.now() > existingLock.leaseExpiry) {
fs.unlinkSync(this.lockPath);
continue; // Retry immediately
}
// Check if process dead
if (!this.processExists(existingLock.pid)) {
fs.unlinkSync(this.lockPath);
continue; // Retry immediately
}
// Lock is valid, wait and retry
await this.sleep(RETRY_MS);
attempts++;
} catch {
// Lock file corrupted, remove it
try {
fs.unlinkSync(this.lockPath);
} catch {
// Ignore
}
continue;
}
} else {
throw err;
}
}
}
throw new Error('Failed to acquire registry lock after maximum attempts');
}
/**
* Release lock (only if we own it)
*/
releaseLock() {
try {
if (fs.existsSync(this.lockPath)) {
const lockData = JSON.parse(fs.readFileSync(this.lockPath, 'utf8'));
if (lockData.pid === process.pid) {
fs.unlinkSync(this.lockPath);
}
}
} catch {
// Ignore errors during cleanup
}
}
/**
* Check if process exists (cross-platform)
*/
processExists(pid) {
try {
process.kill(pid, 0); // Signal 0 = check existence
return true;
} catch {
return false;
}
}
/**
* Sleep helper
*/
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Register a new loop
*
* @param {string} loopId - Unique loop identifier
* @param {object} config - Loop configuration
* @param {object} options - Options (e.g., { force: true })
*/
register(loopId, config, options = {}) {
return this.withLock(() => {
const registry = this.load();
// Check MAX_CONCURRENT_LOOPS
if (
registry.active_loops.length >= MAX_CONCURRENT_LOOPS &&
!options.force
) {
const paths = this.calculateCommunicationPaths(
registry.active_loops.length + 1
);
throw new Error(
`Cannot create loop: ${registry.active_loops.length} loops already active (max: ${MAX_CONCURRENT_LOOPS}).\n` +
`Adding another would create ${paths} communication paths (REF-088).\n` +
`Use --force to override or complete/abort an existing loop.`
);
}
// Warn if exceeding limit with --force
if (
registry.active_loops.length >= MAX_CONCURRENT_LOOPS &&
options.force
) {
const paths = this.calculateCommunicationPaths(
registry.active_loops.length + 1
);
console.warn(
`WARNING: Exceeding recommended MAX_CONCURRENT_LOOPS (${MAX_CONCURRENT_LOOPS})`
);
console.warn(
`Communication paths: ${paths} (overhead increases quadratically)`
);
}
// Add to registry
const entry = {
loop_id: loopId,
task_summary: config.task_summary || config.task || 'Unnamed task',
status: config.status || 'running',
started_at: new Date().toISOString(),
owner: config.owner || 'unknown',
pid: config.pid || process.pid,
iteration: config.iteration || 0,
max_iterations: config.max_iterations || config.maxIterations || 10,
completion_criteria: config.completion || null,
priority: config.priority || 'medium',
tags: config.tags || [],
};
registry.active_loops.push(entry);
this.save(registry);
return entry;
});
}
/**
* Unregister a loop (on completion or abort)
*
* @param {string} loopId - Loop to unregister
*/
unregister(loopId) {
return this.withLock(() => {
const registry = this.load();
const index = registry.active_loops.findIndex(
(l) => l.loop_id === loopId
);
if (index === -1) {
return false;
}
registry.active_loops.splice(index, 1);
registry.total_completed++;
this.save(registry);
return true;
});
}
/**
* Get loop entry by ID
*
* @param {string} loopId - Loop ID to find
* @returns {object|null} Loop entry or null
*/
get(loopId) {
const registry = this.load();
return registry.active_loops.find((l) => l.loop_id === loopId) || null;
}
/**
* Check if loop exists
*
* @param {string} loopId - Loop ID to check
* @returns {boolean} True if exists
*/
exists(loopId) {
return this.get(loopId) !== null;
}
/**
* List all active loops
*
* @returns {Array} Active loop entries
*/
listActive() {
const registry = this.load();
return registry.active_loops;
}
/**
* Calculate communication paths: n * (n - 1) / 2
*
* Based on REF-088 DEV Multi-Agent Guide 2026
*
* @param {number} n - Number of loops
* @returns {number} Communication paths
*/
calculateCommunicationPaths(n) {
return (n * (n - 1)) / 2;
}
/**
* Generate unique loop ID
*
* Pattern: ralph-{task-slug}-{short-uuid}
*
* @param {string} task - Task description
* @returns {string} Generated loop ID
*/
generateLoopId(task) {
const slug = task
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with dash
.replace(/^-+|-+$/g, '') // Trim leading/trailing dashes
.slice(0, 30); // Limit length
const shortUuid = randomUUID().split('-')[0]; // First 8 chars
return `ralph-${slug}-${shortUuid}`;
}
/**
* Update loop entry
*
* @param {string} loopId - Loop to update
* @param {object} updates - Fields to update
*/
update(loopId, updates) {
return this.withLock(() => {
const registry = this.load();
const loop = registry.active_loops.find((l) => l.loop_id === loopId);
if (!loop) {
throw new Error(`Loop not found: ${loopId}`);
}
Object.assign(loop, updates);
this.save(registry);
return loop;
});
}
}