claude-flow-novice
Version:
Claude Flow Novice - Advanced orchestration platform for multi-agent AI workflows with CFN Loop architecture Includes Local RuVector Accelerator and all CFN skills for complete functionality.
332 lines (331 loc) • 12 kB
JavaScript
/**
* Orphan Workspace Detector
*
* Detects and cleans up orphaned workspaces (no active agent process).
* Implements grace period logic to prevent premature cleanup during restarts.
*
* Part of Task P2-1.3: Supervised Workspace Cleanup (Phase 2)
*
* Features:
* - Detect orphaned workspaces (no active process)
* - Process monitoring (PID tracking)
* - Cleanup stale workspaces
* - Grace period (10 minutes default before cleanup)
* - Automatic background scanning
* - Audit trail for orphan cleanup
*
* Usage:
* const detector = new OrphanDetector({
* workspaceRoot: '/tmp/cfn-workspaces',
* gracePeriodMinutes: 10
* });
*
* const orphans = await detector.detectOrphans();
* console.log(`Found ${orphans.length} orphaned workspaces`);
*
* const stats = await detector.cleanupOrphans();
* console.log(`Cleaned up ${stats.cleanedCount} workspaces`);
*/ import * as fs from 'fs/promises';
import * as path from 'path';
import { createLogger } from './logging.js';
import { createError, ErrorCode } from './errors.js';
const logger = createLogger('orphan-detector');
/**
* OrphanDetector: Detects and cleans up orphaned workspaces
*/ export class OrphanDetector {
config;
scanInterval = null;
constructor(config){
this.config = {
gracePeriodMinutes: 10,
scanIntervalMinutes: 30,
...config
};
}
/**
* Start background orphan detection scanner
*/ start() {
if (this.scanInterval) {
return; // Already running
}
const intervalMs = (this.config.scanIntervalMinutes || 30) * 60 * 1000;
this.scanInterval = setInterval(async ()=>{
try {
const stats = await this.cleanupOrphans();
if (stats.cleanedCount > 0) {
logger.info('Background orphan cleanup completed', {
cleanedCount: stats.cleanedCount,
totalFreed: stats.totalSizeFreed
});
}
} catch (error) {
logger.error('Error in orphan detection scan', {
error: String(error)
});
}
}, intervalMs);
logger.info('Orphan detector started', {
gracePeriodMinutes: this.config.gracePeriodMinutes,
scanIntervalMinutes: this.config.scanIntervalMinutes
});
}
/**
* Stop background scanner
*/ stop() {
if (this.scanInterval) {
clearInterval(this.scanInterval);
this.scanInterval = null;
logger.info('Orphan detector stopped');
}
}
/**
* Detect orphaned workspaces
*/ async detectOrphans() {
const orphans = [];
const gracePeriodMs = (this.config.gracePeriodMinutes || 10) * 60 * 1000;
const now = Date.now();
try {
const entries = await fs.readdir(this.config.workspaceRoot, {
withFileTypes: true
});
for (const entry of entries){
if (!entry.isDirectory()) continue;
try {
const workspaceInfo = await this.analyzeWorkspace(entry.path);
if (!workspaceInfo) continue;
// Check if process is still active
const isProcessActive = this.isProcessActive(workspaceInfo.processId);
if (!isProcessActive) {
// Check grace period
const timeSinceLastAccess = now - workspaceInfo.lastAccessedAt.getTime();
if (timeSinceLastAccess > gracePeriodMs) {
orphans.push(workspaceInfo);
}
}
} catch (error) {
logger.warn('Error analyzing workspace', {
path: entry.path,
error: String(error)
});
}
}
logger.info('Orphan detection scan completed', {
found: orphans.length,
gracePeriodMs
});
} catch (error) {
logger.error('Error scanning for orphans', {
error: String(error)
});
}
return orphans;
}
/**
* Clean up detected orphaned workspaces
*/ async cleanupOrphans() {
const orphans = await this.detectOrphans();
let cleanedCount = 0;
let totalSizeFreed = 0;
let totalFilesRemoved = 0;
let gracePeriodCount = 0;
const gracePeriodMs = (this.config.gracePeriodMinutes || 10) * 60 * 1000;
const now = Date.now();
for (const orphan of orphans){
try {
// Double-check if in grace period
const timeSinceAccess = now - orphan.lastAccessedAt.getTime();
if (timeSinceAccess <= gracePeriodMs) {
gracePeriodCount++;
continue;
}
// Clean up workspace
const sizeFreed = await this.removeWorkspace(orphan.path);
cleanedCount++;
totalSizeFreed += sizeFreed;
totalFilesRemoved += orphan.fileCount;
logger.info('Cleaned orphaned workspace', {
id: orphan.id,
agentId: orphan.agentId,
sizeFreed
});
} catch (error) {
logger.error('Error cleaning up orphan workspace', {
id: orphan.id,
error: String(error)
});
}
}
return {
cleanedCount,
totalSizeFreed,
filesRemoved: totalFilesRemoved,
gracePeriodCount
};
}
/**
* Force cleanup of specific orphan (skip grace period)
*/ async forceCleanupOrphan(workspacePath) {
try {
const size = await this.removeWorkspace(workspacePath);
logger.info('Force cleaned orphan workspace', {
path: workspacePath,
size
});
return size;
} catch (error) {
logger.error('Error force cleaning orphan', {
path: workspacePath,
error: String(error)
});
throw createError(ErrorCode.FILE_WRITE_FAILED, 'Failed to cleanup orphan workspace', {
cause: String(error)
});
}
}
// ============================================================================
// Private Helper Methods
// ============================================================================
/**
* Analyze workspace to extract metadata
*/ async analyzeWorkspace(workspacePath) {
try {
const stats = await fs.stat(workspacePath);
const name = path.basename(workspacePath);
// Parse workspace name: sanitizedAgentId-sanitizedTaskId-uuid
// UUID format: 8hex-4hex-4hex-4hex-12hex (36 chars total)
// We need to find where the UUID starts
const uuidRegex = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
const uuidMatch = name.match(uuidRegex);
if (!uuidMatch) {
return null;
}
const id = uuidMatch[0];
const prefix = name.substring(0, name.length - id.length - 1); // -1 for the dash before UUID
// Parse agent and task IDs from prefix
// Format: sanitizedAgentId-sanitizedTaskId
// Since both can contain characters, we use a simple heuristic:
// Find "task" in the prefix and assume that's where taskId starts
let agentId = '';
let taskId = '';
const taskStartIndex = prefix.indexOf('task-');
if (taskStartIndex > 0) {
agentId = prefix.substring(0, taskStartIndex - 1); // -1 to remove trailing dash
taskId = prefix.substring(taskStartIndex);
} else {
// Fallback: just use the whole prefix as agentId
agentId = prefix;
taskId = 'unknown';
}
// Get workspace metadata if it exists
let processId;
let lastAccessedAt = stats.mtime;
try {
const metadataPath = path.join(workspacePath, '.metadata.json');
const metadata = await fs.readFile(metadataPath, 'utf-8');
const parsed = JSON.parse(metadata);
processId = parsed.processId;
if (parsed.lastAccessedAt) {
lastAccessedAt = new Date(parsed.lastAccessedAt);
}
} catch (e) {
// Metadata file doesn't exist - use current mtime
}
const size = await this.getDirectorySize(workspacePath);
const fileCount = await this.countFiles(workspacePath);
return {
id,
agentId,
taskId,
path: workspacePath,
createdAt: stats.birthtime || stats.mtime,
lastAccessedAt,
processId,
sizeBytes: size,
fileCount
};
} catch (error) {
logger.debug('Error analyzing workspace', {
path: workspacePath,
error: String(error)
});
return null;
}
}
/**
* Check if process is still active
*/ isProcessActive(processId) {
if (!processId) {
return false; // No process ID = definitely orphaned
}
try {
// Send signal 0 (check if process exists without sending signal)
// Returns true if process is still running
process.kill(processId, 0);
return true;
} catch (error) {
// Process does not exist
return false;
}
}
/**
* Remove workspace directory and return freed size
*/ async removeWorkspace(workspacePath) {
const size = await this.getDirectorySize(workspacePath);
try {
await fs.rm(workspacePath, {
recursive: true,
force: true
});
logger.debug('Removed workspace directory', {
path: workspacePath
});
return size;
} catch (error) {
logger.error('Error removing workspace', {
path: workspacePath,
error: String(error)
});
throw error;
}
}
/**
* Get directory size in bytes
*/ async getDirectorySize(dir) {
try {
const entries = await fs.readdir(dir, {
recursive: true,
withFileTypes: false
});
let totalSize = 0;
for (const file of entries){
try {
const filePath = path.join(dir, file);
const stats = await fs.stat(filePath).catch(()=>null);
if (stats?.isFile()) {
totalSize += stats.size;
}
} catch (e) {
// Ignore inaccessible files
}
}
return totalSize;
} catch (error) {
return 0;
}
}
/**
* Count files in directory
*/ async countFiles(dir) {
try {
const entries = await fs.readdir(dir, {
recursive: true,
withFileTypes: false
});
return Array.isArray(entries) ? entries.length : 0;
} catch (error) {
return 0;
}
}
}
export default OrphanDetector;
//# sourceMappingURL=orphan-detector.js.map