Files
Codeman/src/ralph-loop.ts
T
arkonandClaude Opus 4.7 02e2f3e8b5 refactor: remove dead code and narrow internal exports (knip sweep)
Knip-driven cleanup. All changes verified with tsc --noEmit, lint, and
build.

Removed (zero consumers):
- VERIFICATION_PROMPT constant + its barrel re-export
- createInitialOrchestratorPersistState factory
- transcriptWatcher singleton export
- createAnsiPatternFull / createAnsiPatternSimple factories
- TimerInfo interface + unused AiCheckResult/AiPlanCheckResult imports
  in respawn-controller.ts
- 35 unused Zod z.infer \`*Input\` types in schemas.ts
- Dead re-exports: SessionMode from session.ts, AuthSessionRecord from
  web/ports/index.ts, EnhancedPlanTask/CheckpointReview from
  ralph-tracker.ts, 7 unused entries in utils/index.ts
- 14 event/config interfaces that lived only as JSDoc hints (no TS type
  position usage): Session/Respawn/RalphLoop/RalphTracker/
  SessionManager/SessionAutoOps/Subagent/TaskQueue/TaskTracker/
  TranscriptWatcher/Image/OrchestratorLoop Events + RespawnPreset +
  SessionOutput

Narrowed to module scope (kept but no longer exported):
- buildPermissionArgs in session-cli-builder.ts
- 28 type/interface declarations used only within their own file:
  Ai{Idle,Plan}Check{Config,State}, BashToolParser{Events,Config},
  FileStream/CreateStream{Options,Result}, PlanSubagentEvent,
  SubagentCallback, RalphLoopConfig, RalphLoop{Events,Options},
  ActiveTimerInfo, DetectionStatus, ActionLogEntry, AutoOpsCallbacks,
  TunnelStatus, Timer/LRUMap/StaleExpirationMap Options, AuthState,
  SessionListenerDeps, SseStreamManagerDeps, and 8 more

Docs: CLAUDE.md advice for global-regex `lastIndex` now points to the
remaining `execPattern()` helper instead of the deleted factories.

Knip delta: unused files 42→0, unused exports 161→16, unused types 92→0.
The 16 remaining exports are a mobile-test helper toolkit intentionally
kept for upcoming tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:57:00 +02:00

525 lines
15 KiB
TypeScript

/**
* @fileoverview Ralph Loop - Autonomous task execution engine.
*
* Orchestrates autonomous Claude sessions by:
* - Polling for available tasks from the task queue
* - Assigning tasks to idle sessions
* - Monitoring completion and handling failures
* - Auto-generating follow-up tasks when min duration not reached
*
* Key exports:
* - `RalphLoop` class — the loop engine, extends EventEmitter
* - `RalphLoopEvents` interface — typed event map
* - `RalphLoopOptions` interface — configuration options
*
* Lifecycle: `start()` → poll loop → `stop()` (when all tasks done + min duration met)
*
* @dependencies session-manager (session lifecycle), task-queue (task FIFO),
* state-store (persistence), session (PTY execution), task (task model)
* @consumedby web/server (ralph routes, SSE)
* @emits started, stopped, taskAssigned, taskCompleted, taskFailed, error
* @persistence Ralph loop state saved to `~/.codeman/state.json` (ralphLoop key)
*
* @module ralph-loop
*/
import { EventEmitter } from 'node:events';
import { getSessionManager, SessionManager } from './session-manager.js';
import { getTaskQueue, TaskQueue } from './task-queue.js';
import { getStore, StateStore } from './state-store.js';
import { Session } from './session.js';
import { Task } from './task.js';
import { RalphLoopStatus, getErrorMessage } from './types.js';
/**
* Events emitted by RalphLoop
*/
/**
* Configuration options for RalphLoop
*/
interface RalphLoopOptions {
/** How often to check for new tasks (default from config) */
pollIntervalMs?: number;
/** Minimum time to run before stopping (null = no minimum) */
minDurationMs?: number;
/** Auto-generate follow-up tasks when queue is empty */
autoGenerateTasks?: boolean;
}
/**
* Autonomous task execution loop.
*
* @description
* Manages the lifecycle of task execution:
* 1. Start: Begin polling and task assignment
* 2. Run: Assign tasks to idle sessions, monitor completion
* 3. Stop: When all tasks done and min duration reached
*
* Supports time-aware loops that continue generating tasks
* until a minimum duration is reached.
*
* @extends EventEmitter
*/
export class RalphLoop extends EventEmitter {
private sessionManager: SessionManager;
private taskQueue: TaskQueue;
private store: StateStore;
private pollIntervalMs: number;
private minDurationMs: number | null;
private autoGenerateTasks: boolean;
private loopTimer: NodeJS.Timeout | null = null;
private _status: RalphLoopStatus = 'stopped';
private startedAt: number | null = null;
private tasksCompleted: number = 0;
private tasksGenerated: number = 0;
/** Bound event handlers for cleanup (prevents memory leaks) */
private sessionEventHandlers: {
completion: (sessionId: string, phrase: string) => void;
error: (sessionId: string, error: string) => void;
stopped: (sessionId: string) => void;
taskError: (sessionId: string, taskId: string, error: string) => void;
} | null = null;
constructor(options: RalphLoopOptions = {}) {
super();
this.sessionManager = getSessionManager();
this.taskQueue = getTaskQueue();
this.store = getStore();
const config = this.store.getConfig();
this.pollIntervalMs = options.pollIntervalMs ?? config.pollIntervalMs;
this.minDurationMs = options.minDurationMs ?? null;
this.autoGenerateTasks = options.autoGenerateTasks ?? true;
// Load state from store
const savedState = this.store.getRalphLoopState();
if (savedState.status === 'running') {
// If we crashed while running, reset to stopped
this._status = 'stopped';
this.store.setRalphLoopState({ status: 'stopped' });
// Reset orphaned in_progress tasks back to pending
for (const task of this.taskQueue.getRunningTasks()) {
task.reset();
this.taskQueue.updateTask(task);
}
}
this.setupEventHandlers();
}
private setupEventHandlers(): void {
// Store bound handlers for later cleanup
this.sessionEventHandlers = {
completion: (sessionId: string, phrase: string) => {
this.handleSessionCompletion(sessionId, phrase);
},
error: (sessionId: string, error: string) => {
this.handleSessionError(sessionId, error);
},
stopped: (sessionId: string) => {
this.handleSessionStopped(sessionId);
},
taskError: (sessionId: string, taskId: string, error: string) => {
this.handleSessionTaskError(sessionId, taskId, error);
},
};
this.sessionManager.on('sessionCompletion', this.sessionEventHandlers.completion);
this.sessionManager.on('sessionError', this.sessionEventHandlers.error);
this.sessionManager.on('sessionStopped', this.sessionEventHandlers.stopped);
this.sessionManager.on('sessionTaskError', this.sessionEventHandlers.taskError);
}
/** Remove event listeners to prevent memory leaks */
private cleanupEventHandlers(): void {
if (this.sessionEventHandlers) {
this.sessionManager.off('sessionCompletion', this.sessionEventHandlers.completion);
this.sessionManager.off('sessionError', this.sessionEventHandlers.error);
this.sessionManager.off('sessionStopped', this.sessionEventHandlers.stopped);
this.sessionManager.off('sessionTaskError', this.sessionEventHandlers.taskError);
this.sessionEventHandlers = null;
}
}
get status(): RalphLoopStatus {
return this._status;
}
isRunning(): boolean {
return this._status === 'running';
}
getElapsedMs(): number {
if (!this.startedAt) {
return 0;
}
return Date.now() - this.startedAt;
}
getElapsedHours(): number {
return this.getElapsedMs() / (1000 * 60 * 60);
}
isMinDurationReached(): boolean {
if (!this.minDurationMs) {
return true;
}
return this.getElapsedMs() >= this.minDurationMs;
}
getStats() {
const taskCounts = this.taskQueue.getCount();
return {
status: this._status,
elapsedMs: this.getElapsedMs(),
elapsedHours: this.getElapsedHours(),
minDurationMs: this.minDurationMs,
minDurationReached: this.isMinDurationReached(),
tasksCompleted: this.tasksCompleted,
tasksGenerated: this.tasksGenerated,
...taskCounts,
activeSessions: this.sessionManager.getSessionCount(),
idleSessions: this.sessionManager.getIdleSessions().length,
busySessions: this.sessionManager.getBusySessions().length,
};
}
/** Starts the task execution loop. */
async start(): Promise<void> {
if (this._status === 'running') {
return;
}
this._status = 'running';
this.startedAt = Date.now();
this.tasksCompleted = 0;
this.tasksGenerated = 0;
// Re-setup event handlers if they were cleaned up during stop()
if (!this.sessionEventHandlers) {
this.setupEventHandlers();
}
this.store.setRalphLoopState({
status: 'running',
startedAt: this.startedAt,
minDurationMs: this.minDurationMs,
tasksCompleted: 0,
tasksGenerated: 0,
});
this.emit('started');
this.runLoop();
}
/** Stops the task execution loop. */
stop(): void {
if (this._status === 'stopped') {
return;
}
this._status = 'stopped';
if (this.loopTimer) {
clearTimeout(this.loopTimer);
this.loopTimer = null;
}
// Clean up event handlers to prevent memory leaks
this.cleanupEventHandlers();
this.store.setRalphLoopState({
status: 'stopped',
lastCheckAt: Date.now(),
});
this.emit('stopped');
}
/** Pauses the loop (can be resumed). */
pause(): void {
if (this._status !== 'running') {
return;
}
this._status = 'paused';
if (this.loopTimer) {
clearTimeout(this.loopTimer);
this.loopTimer = null;
}
this.store.setRalphLoopState({ status: 'paused' });
}
/** Resumes a paused loop. */
resume(): void {
if (this._status !== 'paused') {
return;
}
this._status = 'running';
this.store.setRalphLoopState({ status: 'running' });
this.runLoop();
}
private runLoop(): void {
if (this._status !== 'running') {
return;
}
this.tick()
.catch((err) => {
// Only emit if still running (stop() may have been called during tick())
if (this._status === 'running') {
this.emit('error', err);
}
})
.finally(() => {
// Guard: only reschedule if still running AND no timer is pending
// (prevents race where stop() clears timer between our check and setTimeout)
if (this._status === 'running' && this.loopTimer === null) {
this.loopTimer = setTimeout(() => this.runLoop(), this.pollIntervalMs);
}
});
}
private async tick(): Promise<void> {
this.store.setRalphLoopState({ lastCheckAt: Date.now() });
// Run sequentially: timeouts first so timed-out tasks are cleaned up
// before assignTasks() picks new work (prevents race where both
// mutate the same task concurrently)
await this.checkTimeouts();
await this.assignTasks();
// Check if we should auto-generate tasks (depends on assignment results)
if (this.autoGenerateTasks && this.shouldGenerateTasks()) {
await this.generateFollowUpTasks();
}
// Check if we're done
if (this.shouldStop()) {
this.stop();
}
}
private async assignTasks(): Promise<void> {
const idleSessions = this.sessionManager.getIdleSessions();
for (const session of idleSessions) {
const task = this.taskQueue.next();
if (!task) {
break;
}
try {
await this.assignTaskToSession(task, session);
} catch (err) {
console.error(`[RalphLoop] Failed to assign task ${task.id} to session ${session.id}:`, err);
}
}
}
private async assignTaskToSession(task: Task, session: Session): Promise<void> {
try {
task.assign(session.id);
session.assignTask(task.id);
this.taskQueue.updateTask(task);
// Send the prompt to the session
await session.sendInput(task.prompt);
this.emit('taskAssigned', task.id, session.id);
} catch (err) {
task.fail(getErrorMessage(err));
session.clearTask();
this.taskQueue.updateTask(task);
this.emit('taskFailed', task.id, getErrorMessage(err));
}
}
private async checkTimeouts(): Promise<void> {
for (const task of this.taskQueue.getRunningTasks()) {
if (task.isTimedOut()) {
task.fail('Task timed out');
this.taskQueue.updateTask(task);
const session = this.sessionManager.getSession(task.assignedSessionId!);
if (session) {
session.clearTask();
}
this.emit('taskFailed', task.id, 'Task timed out');
}
}
}
private handleSessionCompletion(sessionId: string, phrase: string): void {
const session = this.sessionManager.getSession(sessionId);
if (!session) {
return;
}
const taskId = session.currentTaskId;
if (!taskId) {
return;
}
const task = this.taskQueue.getTask(taskId);
if (!task) {
return;
}
// Append output and check for completion
task.appendOutput(session.getOutput());
if (task.checkCompletion(session.getOutput()) || phrase) {
task.complete();
this.taskQueue.updateTask(task);
session.clearTask();
this.tasksCompleted++;
this.store.setRalphLoopState({ tasksCompleted: this.tasksCompleted });
this.emit('taskCompleted', task.id);
}
}
private handleSessionError(sessionId: string, error: string): void {
const session = this.sessionManager.getSession(sessionId);
if (!session) {
return;
}
const taskId = session.currentTaskId;
if (!taskId) {
return;
}
const task = this.taskQueue.getTask(taskId);
if (!task) {
return;
}
task.setError(error);
// Don't fail the task immediately on stderr - some tools write to stderr normally
}
private handleSessionStopped(sessionId: string): void {
const task = this.taskQueue.getRunningTaskForSession(sessionId);
if (task) {
task.fail('Session stopped unexpectedly');
this.taskQueue.updateTask(task);
this.emit('taskFailed', task.id, 'Session stopped unexpectedly');
}
}
private handleSessionTaskError(_sessionId: string, taskId: string, error: string): void {
const task = this.taskQueue.getTask(taskId);
if (!task) {
return;
}
task.fail(error);
this.taskQueue.updateTask(task);
this.emit('taskFailed', task.id, error);
}
private shouldGenerateTasks(): boolean {
// Generate tasks if:
// 1. No pending tasks
// 2. Min duration not reached
// 3. We have idle sessions
const counts = this.taskQueue.getCount();
return counts.pending === 0 && !this.isMinDurationReached() && this.sessionManager.getIdleSessions().length > 0;
}
private async generateFollowUpTasks(): Promise<void> {
// This is a placeholder for auto-generating follow-up tasks
// In a real implementation, this could:
// - Analyze completed tasks to find optimization opportunities
// - Generate tasks for code cleanup, tests, documentation
// - Use Claude to suggest improvements
const suggestions = [
'Review and optimize recently changed code',
'Add tests for uncovered code paths',
'Update documentation for changed APIs',
'Check for security vulnerabilities',
'Run linting and fix any issues',
];
// Only generate one task at a time
const suggestion = suggestions[this.tasksGenerated % suggestions.length];
const defaultDir = process.cwd();
this.taskQueue.addTask({
prompt: suggestion,
workingDir: defaultDir,
priority: -1, // Lower priority than user-added tasks
});
this.tasksGenerated++;
this.store.setRalphLoopState({ tasksGenerated: this.tasksGenerated });
}
private shouldStop(): boolean {
const counts = this.taskQueue.getCount();
// Don't stop if there are pending or running tasks
if (counts.pending > 0 || counts.running > 0) {
return false;
}
// Don't stop if min duration not reached and auto-generate is on
if (!this.isMinDurationReached() && this.autoGenerateTasks) {
return false;
}
// All tasks done and conditions met
return true;
}
/** Sets the minimum duration in hours before the loop can stop. */
setMinDuration(hours: number): void {
this.minDurationMs = hours * 60 * 60 * 1000;
this.store.setRalphLoopState({ minDurationMs: this.minDurationMs });
}
/**
* Destroys the loop and cleans up all resources.
* Use this for complete cleanup (e.g., in tests or before creating a new instance).
* After calling destroy(), the instance should not be reused.
*/
destroy(): void {
this.stop();
this.cleanupEventHandlers();
this.removeAllListeners();
}
}
// Singleton instance
let loopInstance: RalphLoop | null = null;
/** Gets or creates the singleton RalphLoop instance. */
export function getRalphLoop(options?: RalphLoopOptions): RalphLoop {
if (!loopInstance) {
loopInstance = new RalphLoop(options);
}
return loopInstance;
}
/** Destroys the singleton instance. Use in tests or for cleanup. */
function destroyRalphLoop(): void {
if (loopInstance) {
loopInstance.destroy();
loopInstance = null;
}
}
// Ensure cleanup on process exit (prevents orphaned event handlers and circular references)
process.on('exit', () => {
destroyRalphLoop();
});