mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 05:29:42 +02:00
* refactor: extract SSE event handlers into named class methods Replace ~80 inline addListener closures in connectSSE() with a declarative _SSE_HANDLER_MAP array that drives registration in a single loop. Each handler is now a named _on* method on CodemanApp, making them individually addressable for LLM navigation. Add SSE_EVENTS constant object in constants.js to eliminate magic event-type strings scattered across the frontend. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: fix inaccuracies in CLAUDE.md - Fix types barrel path: src/types.ts → src/types/index.ts - Update app.js line count: ~12K → ~11.5K - Correct route handler counts (113 → 111, per-group fixes) - Add code style, ESM gotcha, env vars, route test, lifecycle log docs - Add Node 22 CI note, test teardown timeout, port range Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add mobile screenshots and QR auth security writeup to README Add 3 mobile screenshots (landing, idle, active) and expand the mobile section with QR auth security design details and a touch-optimized interface subsection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: bundle xterm-zerolag-input as vendor IIFE and add pre-commit hook Build and postinstall now bundle the local xterm-zerolag-input package as an IIFE at vendor/xterm-zerolag-input.js with global LocalEchoOverlay shim. Add git pre-commit hook that runs prettier --check on staged .ts files to catch format issues before CI. Also bump constants.js and app.js cache-bust versions to 0.3.0 and add tunnel upload URL display row in settings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add cloudflared install support and interactive launch menu - Add optional cloudflared dependency detection and installation across 6 distro families (macOS, Debian, Fedora, Arch, Alpine, SUSE) - Add tunnel systemd service setup helper - Replace post-install instructions with interactive launch menu (run now / systemd service / skip) - Uninstall now cleans up both codeman-web and codeman-tunnel services Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: gitignore readme-preview.mjs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: WIP — SSE event constants, @fileoverview docs, CLAUDE.md compression - Migrate broadcast() string literals → SseEvent.* typed constants - Add @fileoverview with cross-domain references to all 13 type domain files - Add @fileoverview to frontend JS modules (constants, mobile, voice, etc.) - Add section dividers to route files for LLM scanability - Compress CLAUDE.md: flat file list → domain table, fix counts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: optimize codebase for LLM context window efficiency CLAUDE.md: 456 → 309 lines (32% reduction) - Merge Commands into compact table, remove redundant bash block - Convert Security section to dense table format - Merge Performance + Resource Limits, Debugging + Troubleshooting - Compress Tunnel, Memory Leak, Scripts, Screenshots sections - Remove Key Patterns that duplicate @fileoverview in source files Backend @fileoverview enhancements (10 priority files): - session.ts: key methods, events, cross-domain refs - respawn-controller.ts: state machine, idle detection layers - ralph-tracker.ts: exports, circuit breaker, events - ralph-loop.ts: lifecycle, persistence, events - subagent-watcher.ts: watched patterns, teammate detection - server.ts: coordination list, port interfaces - state-store.ts: dual-file persistence, migration - session-manager.ts: lifecycle methods, mutex guard - hooks-config.ts: hook events list, categories - sse-events.ts: category breakdown (~90 events, 17 categories) Frontend app.js: add 6 section dividers, update @fileoverview line refs Fix: escape glob `*/` in JSDoc that broke ESLint parser Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR #29 review bugs - server.ts: replace hardcoded 'session:needsRefresh' with SseEvent constant - install.sh: fix Alpine cloudflared install for non-root (download to tmpfile first) - install.sh: replace Arch pacman (AUR-only) with direct binary download - index.html: bump all 8 remaining cache-bust versions from v0.2.9 to v0.3.0 - mobile-handlers.js: fix @dependency annotation (keyboard-accessory.js, not constants.js) - types/push.ts: fix layer number (4, not 5) - subagent-watcher.ts: fix watched pattern path to include {session} segment - constants.js: fix SSE_EVENTS count in @fileoverview (~73, not ~65) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
533 lines
16 KiB
TypeScript
533 lines
16 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
|
|
*/
|
|
export interface RalphLoopEvents {
|
|
started: () => void;
|
|
stopped: () => void;
|
|
taskAssigned: (taskId: string, sessionId: string) => void;
|
|
taskCompleted: (taskId: string) => void;
|
|
taskFailed: (taskId: string, error: string) => void;
|
|
error: (error: Error) => void;
|
|
}
|
|
|
|
/**
|
|
* Configuration options for RalphLoop
|
|
*/
|
|
export 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();
|
|
});
|