feat: add RALPH_STATUS block parsing and circuit breaker pattern

Phase 1 Ralph enhancement implementation:
- Parse RALPH_STATUS blocks from Claude output (status, tasks, files, tests)
- Circuit breaker state machine (CLOSED → HALF_OPEN → OPEN) for stuck detection
- Dual-condition exit gate (completion indicators >= 2 AND EXIT_SIGNAL)
- Frontend: circuit breaker badge (yellow warning, red stuck), status block display
- API: circuit breaker reset endpoint, ralph status endpoint
- Notifications when circuit breaker opens or exit gate is met

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-27 00:29:38 +01:00
co-authored by Claude Opus 4.5
parent b896478ca6
commit 11d4666d19
8 changed files with 1699 additions and 1 deletions
+447
View File
@@ -18,7 +18,13 @@ import {
RalphTrackerState,
RalphTodoItem,
RalphTodoStatus,
RalphStatusBlock,
RalphStatusValue,
RalphTestsStatus,
RalphWorkType,
CircuitBreakerStatus,
createInitialRalphTrackerState,
createInitialCircuitBreakerStatus,
} from './types.js';
// ========== Configuration Constants ==========
@@ -220,6 +226,78 @@ const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
/** Maximum number of task number to content mappings to track */
const MAX_TASK_MAPPINGS = 100;
// ---------- RALPH_STATUS Block Patterns ----------
// Based on Ralph Claude Code structured status reporting
/**
* Matches the start of a RALPH_STATUS block
* Pattern: ---RALPH_STATUS---
*/
const RALPH_STATUS_START_PATTERN = /^---RALPH_STATUS---\s*$/;
/**
* Matches the end of a RALPH_STATUS block
* Pattern: ---END_RALPH_STATUS---
*/
const RALPH_STATUS_END_PATTERN = /^---END_RALPH_STATUS---\s*$/;
/**
* Matches STATUS field in RALPH_STATUS block
* Captures: IN_PROGRESS | COMPLETE | BLOCKED
*/
const RALPH_STATUS_FIELD_PATTERN = /^STATUS:\s*(IN_PROGRESS|COMPLETE|BLOCKED)\s*$/i;
/**
* Matches TASKS_COMPLETED_THIS_LOOP field
* Captures: number
*/
const RALPH_TASKS_COMPLETED_PATTERN = /^TASKS_COMPLETED_THIS_LOOP:\s*(\d+)\s*$/i;
/**
* Matches FILES_MODIFIED field
* Captures: number
*/
const RALPH_FILES_MODIFIED_PATTERN = /^FILES_MODIFIED:\s*(\d+)\s*$/i;
/**
* Matches TESTS_STATUS field
* Captures: PASSING | FAILING | NOT_RUN
*/
const RALPH_TESTS_STATUS_PATTERN = /^TESTS_STATUS:\s*(PASSING|FAILING|NOT_RUN)\s*$/i;
/**
* Matches WORK_TYPE field
* Captures: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING
*/
const RALPH_WORK_TYPE_PATTERN = /^WORK_TYPE:\s*(IMPLEMENTATION|TESTING|DOCUMENTATION|REFACTORING)\s*$/i;
/**
* Matches EXIT_SIGNAL field
* Captures: true | false
*/
const RALPH_EXIT_SIGNAL_PATTERN = /^EXIT_SIGNAL:\s*(true|false)\s*$/i;
/**
* Matches RECOMMENDATION field
* Captures: any text
*/
const RALPH_RECOMMENDATION_PATTERN = /^RECOMMENDATION:\s*(.+)$/i;
// ---------- Completion Indicator Patterns (for dual-condition exit) ----------
/**
* Patterns that indicate potential completion (natural language)
* Count >= 2 along with EXIT_SIGNAL: true triggers exit
*/
const COMPLETION_INDICATOR_PATTERNS = [
/all\s+(?:tasks?|items?|work)\s+(?:are\s+)?(?:completed?|done|finished)/i,
/(?:completed?|finished)\s+all\s+(?:tasks?|items?|work)/i,
/nothing\s+(?:left|remaining)\s+to\s+do/i,
/no\s+more\s+(?:tasks?|items?|work)/i,
/everything\s+(?:is\s+)?(?:completed?|done)/i,
/project\s+(?:is\s+)?(?:completed?|done|finished)/i,
];
// ========== Event Types ==========
/**
@@ -228,6 +306,9 @@ const MAX_TASK_MAPPINGS = 100;
* @event todoUpdate - Fired when todo list changes (items added, status changed)
* @event completionDetected - Fired when completion phrase is detected (task complete)
* @event enabled - Fired when tracker auto-enables due to Ralph pattern detection
* @event statusBlockDetected - Fired when a RALPH_STATUS block is parsed
* @event circuitBreakerUpdate - Fired when circuit breaker state changes
* @event exitGateMet - Fired when dual-condition exit gate is met
*/
export interface RalphTrackerEvents {
/** Emitted when loop state changes */
@@ -238,6 +319,12 @@ export interface RalphTrackerEvents {
completionDetected: (phrase: string) => void;
/** Emitted when tracker auto-enables from disabled state */
enabled: () => void;
/** Emitted when a RALPH_STATUS block is parsed */
statusBlockDetected: (block: RalphStatusBlock) => void;
/** Emitted when circuit breaker state changes */
circuitBreakerUpdate: (status: CircuitBreakerStatus) => void;
/** Emitted when dual-condition exit gate is met (completion indicators >= 2 AND EXIT_SIGNAL: true) */
exitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void;
}
/**
@@ -315,6 +402,32 @@ export class RalphTracker extends EventEmitter {
/** Maps task numbers from "✔ Task #N" format to their content for status updates */
private _taskNumberToContent: Map<number, string> = new Map();
// ========== RALPH_STATUS Block State ==========
/** Circuit breaker state tracking */
private _circuitBreaker: CircuitBreakerStatus;
/** Buffer for RALPH_STATUS block lines */
private _statusBlockBuffer: string[] = [];
/** Flag indicating we're inside a RALPH_STATUS block */
private _inStatusBlock: boolean = false;
/** Last parsed RALPH_STATUS block */
private _lastStatusBlock: RalphStatusBlock | null = null;
/** Count of completion indicators detected (for dual-condition exit) */
private _completionIndicators: number = 0;
/** Whether dual-condition exit gate has been met */
private _exitGateMet: boolean = false;
/** Cumulative files modified across all iterations */
private _totalFilesModified: number = 0;
/** Cumulative tasks completed across all iterations */
private _totalTasksCompleted: number = 0;
/**
* Creates a new RalphTracker instance.
* Starts in disabled state until Ralph patterns are detected.
@@ -322,6 +435,7 @@ export class RalphTracker extends EventEmitter {
constructor() {
super();
this._loopState = createInitialRalphTrackerState();
this._circuitBreaker = createInitialCircuitBreakerStatus();
}
/**
@@ -411,6 +525,15 @@ export class RalphTracker extends EventEmitter {
this._completionPhraseCount.clear();
this._taskNumberToContent.clear();
this._lineBuffer = '';
// Reset RALPH_STATUS block state
this._statusBlockBuffer = [];
this._inStatusBlock = false;
this._lastStatusBlock = null;
this._completionIndicators = 0;
this._exitGateMet = false;
this._totalFilesModified = 0;
this._totalTasksCompleted = 0;
// Keep circuit breaker state on soft reset (it tracks across iterations)
// Emit immediately on reset (no debounce)
this.emit('loopUpdate', this.loopState);
this.emit('todoUpdate', this.todos);
@@ -432,6 +555,15 @@ export class RalphTracker extends EventEmitter {
this._completionPhraseCount.clear();
this._taskNumberToContent.clear();
this._lineBuffer = '';
// Reset all RALPH_STATUS block and circuit breaker state
this._statusBlockBuffer = [];
this._inStatusBlock = false;
this._lastStatusBlock = null;
this._completionIndicators = 0;
this._exitGateMet = false;
this._totalFilesModified = 0;
this._totalTasksCompleted = 0;
this._circuitBreaker = createInitialCircuitBreakerStatus();
// Emit immediately on reset (no debounce)
this.emit('loopUpdate', this.loopState);
this.emit('todoUpdate', this.todos);
@@ -689,6 +821,12 @@ export class RalphTracker extends EventEmitter {
const trimmed = line.trim();
if (!trimmed) return;
// Check for RALPH_STATUS block (structured status reporting)
this.processStatusBlockLine(trimmed);
// Check for completion indicators (for dual-condition exit gate)
this.detectCompletionIndicators(trimmed);
// Check for completion phrase
this.detectCompletionPhrase(trimmed);
@@ -1493,6 +1631,15 @@ export class RalphTracker extends EventEmitter {
this._taskNumberToContent.clear();
this._lineBuffer = '';
this._completionPhraseCount.clear();
// Clear RALPH_STATUS block and circuit breaker state
this._statusBlockBuffer = [];
this._inStatusBlock = false;
this._lastStatusBlock = null;
this._completionIndicators = 0;
this._exitGateMet = false;
this._totalFilesModified = 0;
this._totalTasksCompleted = 0;
this._circuitBreaker = createInitialCircuitBreakerStatus();
this.emit('loopUpdate', this.loopState);
this.emit('todoUpdate', this.todos);
}
@@ -1555,4 +1702,304 @@ export class RalphTracker extends EventEmitter {
this._todos.set(todo.id, { ...todo });
}
}
// ========== RALPH_STATUS Block Detection ==========
/**
* Process a line for RALPH_STATUS block detection.
* Buffers lines between ---RALPH_STATUS--- and ---END_RALPH_STATUS---
* then parses the complete block.
*
* @param line - Single line to process (already trimmed)
* @fires statusBlockDetected - When a complete block is parsed
*/
private processStatusBlockLine(line: string): void {
// Check for block start
if (RALPH_STATUS_START_PATTERN.test(line)) {
this._inStatusBlock = true;
this._statusBlockBuffer = [];
return;
}
// Check for block end
if (this._inStatusBlock && RALPH_STATUS_END_PATTERN.test(line)) {
this._inStatusBlock = false;
this.parseStatusBlock(this._statusBlockBuffer);
this._statusBlockBuffer = [];
return;
}
// Buffer lines while in block
if (this._inStatusBlock) {
this._statusBlockBuffer.push(line);
}
}
/**
* Parse buffered RALPH_STATUS block lines into structured data.
*
* @param lines - Array of lines between block markers
* @fires statusBlockDetected - When parsing succeeds
*/
private parseStatusBlock(lines: string[]): void {
const block: Partial<RalphStatusBlock> = {
parsedAt: Date.now(),
};
for (const line of lines) {
// STATUS field
const statusMatch = line.match(RALPH_STATUS_FIELD_PATTERN);
if (statusMatch) {
block.status = statusMatch[1].toUpperCase() as RalphStatusValue;
continue;
}
// TASKS_COMPLETED_THIS_LOOP field
const tasksMatch = line.match(RALPH_TASKS_COMPLETED_PATTERN);
if (tasksMatch) {
block.tasksCompletedThisLoop = parseInt(tasksMatch[1], 10);
continue;
}
// FILES_MODIFIED field
const filesMatch = line.match(RALPH_FILES_MODIFIED_PATTERN);
if (filesMatch) {
block.filesModified = parseInt(filesMatch[1], 10);
continue;
}
// TESTS_STATUS field
const testsMatch = line.match(RALPH_TESTS_STATUS_PATTERN);
if (testsMatch) {
block.testsStatus = testsMatch[1].toUpperCase() as RalphTestsStatus;
continue;
}
// WORK_TYPE field
const workMatch = line.match(RALPH_WORK_TYPE_PATTERN);
if (workMatch) {
block.workType = workMatch[1].toUpperCase() as RalphWorkType;
continue;
}
// EXIT_SIGNAL field
const exitMatch = line.match(RALPH_EXIT_SIGNAL_PATTERN);
if (exitMatch) {
block.exitSignal = exitMatch[1].toLowerCase() === 'true';
continue;
}
// RECOMMENDATION field
const recMatch = line.match(RALPH_RECOMMENDATION_PATTERN);
if (recMatch) {
block.recommendation = recMatch[1].trim();
continue;
}
}
// Only process if we have at least the STATUS field
if (block.status !== undefined) {
// Fill in defaults for missing fields
const fullBlock: RalphStatusBlock = {
status: block.status,
tasksCompletedThisLoop: block.tasksCompletedThisLoop ?? 0,
filesModified: block.filesModified ?? 0,
testsStatus: block.testsStatus ?? 'NOT_RUN',
workType: block.workType ?? 'IMPLEMENTATION',
exitSignal: block.exitSignal ?? false,
recommendation: block.recommendation ?? '',
parsedAt: block.parsedAt!,
};
this._lastStatusBlock = fullBlock;
this.handleStatusBlock(fullBlock);
}
}
/**
* Handle a parsed RALPH_STATUS block.
* Updates circuit breaker, checks exit conditions.
*
* @param block - Parsed status block
* @fires statusBlockDetected - With the block data
* @fires circuitBreakerUpdate - If state changes
* @fires exitGateMet - If dual-condition exit triggered
*/
private handleStatusBlock(block: RalphStatusBlock): void {
// Auto-enable tracker when we see a status block
if (!this._loopState.enabled && !this._autoEnableDisabled) {
this.enable();
}
// Update cumulative counts
this._totalFilesModified += block.filesModified;
this._totalTasksCompleted += block.tasksCompletedThisLoop;
// Check for progress (for circuit breaker)
const hasProgress = block.filesModified > 0 || block.tasksCompletedThisLoop > 0;
// Update circuit breaker
this.updateCircuitBreaker(hasProgress, block.testsStatus, block.status);
// Check completion indicators
if (block.status === 'COMPLETE') {
this._completionIndicators++;
}
// Check dual-condition exit gate
if (block.exitSignal && this._completionIndicators >= 2 && !this._exitGateMet) {
this._exitGateMet = true;
this.emit('exitGateMet', {
completionIndicators: this._completionIndicators,
exitSignal: true,
});
}
// Update loop state
this._loopState.lastActivity = Date.now();
// Emit the status block
this.emit('statusBlockDetected', block);
this.emitLoopUpdateDebounced();
}
// ========== Circuit Breaker ==========
/**
* Update circuit breaker state based on iteration results.
*
* @param hasProgress - Whether this iteration made progress
* @param testsStatus - Current test status
* @param status - Overall status from RALPH_STATUS
* @fires circuitBreakerUpdate - If state changes
*/
private updateCircuitBreaker(
hasProgress: boolean,
testsStatus: RalphTestsStatus,
status: RalphStatusValue
): void {
const prevState = this._circuitBreaker.state;
if (hasProgress) {
// Progress detected - reset counters, possibly close circuit
this._circuitBreaker.consecutiveNoProgress = 0;
this._circuitBreaker.consecutiveSameError = 0;
this._circuitBreaker.lastProgressIteration = this._loopState.cycleCount;
if (this._circuitBreaker.state === 'HALF_OPEN') {
this._circuitBreaker.state = 'CLOSED';
this._circuitBreaker.reason = 'Progress detected, circuit closed';
this._circuitBreaker.reasonCode = 'progress_detected';
}
} else {
// No progress
this._circuitBreaker.consecutiveNoProgress++;
// State transitions based on consecutive no-progress
if (this._circuitBreaker.state === 'CLOSED') {
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
this._circuitBreaker.state = 'OPEN';
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
this._circuitBreaker.reasonCode = 'no_progress_open';
} else if (this._circuitBreaker.consecutiveNoProgress >= 2) {
this._circuitBreaker.state = 'HALF_OPEN';
this._circuitBreaker.reason = 'Warning: no progress detected';
this._circuitBreaker.reasonCode = 'no_progress_warning';
}
} else if (this._circuitBreaker.state === 'HALF_OPEN') {
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
this._circuitBreaker.state = 'OPEN';
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
this._circuitBreaker.reasonCode = 'no_progress_open';
}
}
}
// Track tests failure
if (testsStatus === 'FAILING') {
this._circuitBreaker.consecutiveTestsFailure++;
if (this._circuitBreaker.consecutiveTestsFailure >= 5 && this._circuitBreaker.state !== 'OPEN') {
this._circuitBreaker.state = 'OPEN';
this._circuitBreaker.reason = `Tests failing for ${this._circuitBreaker.consecutiveTestsFailure} iterations`;
this._circuitBreaker.reasonCode = 'tests_failing_too_long';
}
} else {
this._circuitBreaker.consecutiveTestsFailure = 0;
}
// Track blocked status
if (status === 'BLOCKED' && this._circuitBreaker.state !== 'OPEN') {
this._circuitBreaker.state = 'OPEN';
this._circuitBreaker.reason = 'Claude reported BLOCKED status';
this._circuitBreaker.reasonCode = 'same_error_repeated';
}
// Emit if state changed
if (prevState !== this._circuitBreaker.state) {
this._circuitBreaker.lastTransitionAt = Date.now();
this.emit('circuitBreakerUpdate', { ...this._circuitBreaker });
}
}
/**
* Manually reset circuit breaker to CLOSED state.
* Use when user acknowledges the issue is resolved.
*
* @fires circuitBreakerUpdate
*/
resetCircuitBreaker(): void {
this._circuitBreaker = createInitialCircuitBreakerStatus();
this._circuitBreaker.reason = 'Manual reset';
this._circuitBreaker.reasonCode = 'manual_reset';
this.emit('circuitBreakerUpdate', { ...this._circuitBreaker });
}
/**
* Get current circuit breaker status.
*/
get circuitBreakerStatus(): CircuitBreakerStatus {
return { ...this._circuitBreaker };
}
/**
* Get last parsed RALPH_STATUS block.
*/
get lastStatusBlock(): RalphStatusBlock | null {
return this._lastStatusBlock ? { ...this._lastStatusBlock } : null;
}
/**
* Get cumulative stats from status blocks.
*/
get cumulativeStats(): { filesModified: number; tasksCompleted: number; completionIndicators: number } {
return {
filesModified: this._totalFilesModified,
tasksCompleted: this._totalTasksCompleted,
completionIndicators: this._completionIndicators,
};
}
/**
* Whether dual-condition exit gate has been met.
*/
get exitGateMet(): boolean {
return this._exitGateMet;
}
// ========== Completion Indicator Detection ==========
/**
* Check line for completion indicators (natural language patterns).
* Used for dual-condition exit gate.
*
* @param line - Line to check
*/
private detectCompletionIndicators(line: string): void {
for (const pattern of COMPLETION_INDICATOR_PATTERNS) {
if (pattern.test(line)) {
this._completionIndicators++;
break; // Only count once per line
}
}
}
}
+18
View File
@@ -319,6 +319,12 @@ export interface SessionEvents {
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
/** Ralph completion phrase detected */
ralphCompletionDetected: (phrase: string) => void;
/** RALPH_STATUS block detected */
ralphStatusBlockDetected: (block: import('./types.js').RalphStatusBlock) => void;
/** Circuit breaker state changed */
ralphCircuitBreakerUpdate: (status: import('./types.js').CircuitBreakerStatus) => void;
/** Dual-condition exit gate met */
ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void;
/** Bash tool with file paths started */
bashToolStart: (tool: ActiveBashTool) => void;
/** Bash tool completed */
@@ -441,6 +447,9 @@ export class Session extends EventEmitter {
loopUpdate: (state: RalphTrackerState) => void;
todoUpdate: (todos: RalphTodoItem[]) => void;
completionDetected: (phrase: string) => void;
statusBlockDetected: (block: import('./types.js').RalphStatusBlock) => void;
circuitBreakerUpdate: (status: import('./types.js').CircuitBreakerStatus) => void;
exitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void;
} | null = null;
// Bash tool tracking (file paths for live log viewing)
@@ -494,10 +503,16 @@ export class Session extends EventEmitter {
loopUpdate: (state) => this.emit('ralphLoopUpdate', state),
todoUpdate: (todos) => this.emit('ralphTodoUpdate', todos),
completionDetected: (phrase) => this.emit('ralphCompletionDetected', phrase),
statusBlockDetected: (block) => this.emit('ralphStatusBlockDetected', block),
circuitBreakerUpdate: (status) => this.emit('ralphCircuitBreakerUpdate', status),
exitGateMet: (data) => this.emit('ralphExitGateMet', data),
};
this._ralphTracker.on('loopUpdate', this._ralphHandlers.loopUpdate);
this._ralphTracker.on('todoUpdate', this._ralphHandlers.todoUpdate);
this._ralphTracker.on('completionDetected', this._ralphHandlers.completionDetected);
this._ralphTracker.on('statusBlockDetected', this._ralphHandlers.statusBlockDetected);
this._ralphTracker.on('circuitBreakerUpdate', this._ralphHandlers.circuitBreakerUpdate);
this._ralphTracker.on('exitGateMet', this._ralphHandlers.exitGateMet);
// Initialize Bash tool parser and forward events (store handlers for cleanup)
this._bashToolParser = new BashToolParser({ sessionId: this.id, workingDir: this.workingDir });
@@ -1739,6 +1754,9 @@ export class Session extends EventEmitter {
this._ralphTracker.off('loopUpdate', this._ralphHandlers.loopUpdate);
this._ralphTracker.off('todoUpdate', this._ralphHandlers.todoUpdate);
this._ralphTracker.off('completionDetected', this._ralphHandlers.completionDetected);
this._ralphTracker.off('statusBlockDetected', this._ralphHandlers.statusBlockDetected);
this._ralphTracker.off('circuitBreakerUpdate', this._ralphHandlers.circuitBreakerUpdate);
this._ralphTracker.off('exitGateMet', this._ralphHandlers.exitGateMet);
this._ralphHandlers = null;
}
+133
View File
@@ -753,6 +753,139 @@ export interface RalphStateRecord {
[sessionId: string]: RalphSessionState;
}
// ========== RALPH_STATUS Block Types ==========
/**
* Status values from RALPH_STATUS block.
* - IN_PROGRESS: Work is ongoing
* - COMPLETE: All tasks finished
* - BLOCKED: Needs human intervention
*/
export type RalphStatusValue = 'IN_PROGRESS' | 'COMPLETE' | 'BLOCKED';
/**
* Test status from RALPH_STATUS block.
*/
export type RalphTestsStatus = 'PASSING' | 'FAILING' | 'NOT_RUN';
/**
* Work type classification for current iteration.
*/
export type RalphWorkType = 'IMPLEMENTATION' | 'TESTING' | 'DOCUMENTATION' | 'REFACTORING';
/**
* Parsed RALPH_STATUS block from Claude output.
*
* Claude outputs this at the end of every response:
* ```
* ---RALPH_STATUS---
* STATUS: IN_PROGRESS
* TASKS_COMPLETED_THIS_LOOP: 3
* FILES_MODIFIED: 5
* TESTS_STATUS: PASSING
* WORK_TYPE: IMPLEMENTATION
* EXIT_SIGNAL: false
* RECOMMENDATION: Continue with database migration
* ---END_RALPH_STATUS---
* ```
*/
export interface RalphStatusBlock {
/** Overall loop status */
status: RalphStatusValue;
/** Number of tasks completed in current iteration */
tasksCompletedThisLoop: number;
/** Number of files modified in current iteration */
filesModified: number;
/** Current state of tests */
testsStatus: RalphTestsStatus;
/** Type of work being performed */
workType: RalphWorkType;
/** Whether Claude is signaling completion */
exitSignal: boolean;
/** Claude's recommendation for next steps */
recommendation: string;
/** Timestamp when this block was parsed */
parsedAt: number;
}
// ========== Circuit Breaker Types ==========
/**
* Circuit breaker states for detecting stuck loops.
* - CLOSED: Normal operation, all checks passing
* - HALF_OPEN: Warning state, some checks failing
* - OPEN: Loop is stuck, requires intervention
*/
export type CircuitBreakerState = 'CLOSED' | 'HALF_OPEN' | 'OPEN';
/**
* Reason codes for circuit breaker state transitions.
*/
export type CircuitBreakerReason =
| 'normal_operation'
| 'no_progress_warning'
| 'no_progress_open'
| 'same_error_repeated'
| 'tests_failing_too_long'
| 'progress_detected'
| 'manual_reset';
/**
* Circuit breaker status for tracking loop health.
*
* Transitions:
* - CLOSED -> HALF_OPEN: consecutive_no_progress >= 2
* - CLOSED -> OPEN: consecutive_no_progress >= 3 OR consecutive_same_error >= 5
* - HALF_OPEN -> CLOSED: progress detected
* - HALF_OPEN -> OPEN: consecutive_no_progress >= 3
* - OPEN -> CLOSED: manual reset only
*/
export interface CircuitBreakerStatus {
/** Current state of the circuit breaker */
state: CircuitBreakerState;
/** Number of consecutive iterations with no progress */
consecutiveNoProgress: number;
/** Number of consecutive iterations with the same error */
consecutiveSameError: number;
/** Number of consecutive iterations with failing tests */
consecutiveTestsFailure: number;
/** Last iteration number that showed progress */
lastProgressIteration: number;
/** Human-readable reason for current state */
reason: string;
/** Reason code for programmatic handling */
reasonCode: CircuitBreakerReason;
/** Timestamp of last state transition */
lastTransitionAt: number;
/** Last error message seen (for same-error tracking) */
lastErrorMessage: string | null;
}
/**
* Creates initial circuit breaker status.
*/
export function createInitialCircuitBreakerStatus(): CircuitBreakerStatus {
return {
state: 'CLOSED',
consecutiveNoProgress: 0,
consecutiveSameError: 0,
consecutiveTestsFailure: 0,
lastProgressIteration: 0,
reason: 'Initial state',
reasonCode: 'normal_operation',
lastTransitionAt: Date.now(),
lastErrorMessage: null,
};
}
// ========== Priority Todo Types ==========
/**
* Priority levels for todo items.
* Matches @fix_plan.md format.
*/
export type RalphTodoPriority = 'P0' | 'P1' | 'P2' | null;
/**
* Creates initial Ralph tracker state
* @returns Fresh Ralph tracker state with defaults
+167 -1
View File
@@ -1523,6 +1523,43 @@ class ClaudemanApp {
});
});
// RALPH_STATUS block and circuit breaker events
this.eventSource.addEventListener('session:ralphStatusUpdate', (e) => {
const data = JSON.parse(e.data);
this.updateRalphState(data.sessionId, { statusBlock: data.block });
});
this.eventSource.addEventListener('session:circuitBreakerUpdate', (e) => {
const data = JSON.parse(e.data);
this.updateRalphState(data.sessionId, { circuitBreaker: data.status });
// Notify if circuit breaker opens
if (data.status.state === 'OPEN') {
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'critical',
category: 'circuit-breaker',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId?.slice(0, 8),
title: 'Circuit Breaker Open',
message: data.status.reason || 'Loop stuck - no progress detected',
});
}
});
this.eventSource.addEventListener('session:exitGateMet', (e) => {
const data = JSON.parse(e.data);
// Notify when exit gate is met
const session = this.sessions.get(data.sessionId);
this.notificationManager?.notify({
urgency: 'warning',
category: 'exit-gate',
sessionId: data.sessionId,
sessionName: session?.name || data.sessionId?.slice(0, 8),
title: 'Exit Gate Met',
message: `Loop ready to exit (indicators: ${data.completionIndicators})`,
});
});
// Active Bash tool events (for clickable file paths)
this.eventSource.addEventListener('session:bashToolStart', (e) => {
const data = JSON.parse(e.data);
@@ -5185,8 +5222,10 @@ class ClaudemanApp {
const isEnabled = state?.loop?.enabled === true;
const hasLoop = state?.loop?.active || state?.loop?.completionPhrase;
const hasTodos = state?.todos?.length > 0;
const hasCircuitBreaker = state?.circuitBreaker && state.circuitBreaker.state !== 'CLOSED';
const hasStatusBlock = state?.statusBlock !== undefined;
if (!isEnabled && !hasLoop && !hasTodos) {
if (!isEnabled && !hasLoop && !hasTodos && !hasCircuitBreaker && !hasStatusBlock) {
panel.style.display = 'none';
return;
}
@@ -5208,6 +5247,9 @@ class ClaudemanApp {
// Update stats
this.updateRalphStats(state?.loop, completed, total);
// Update circuit breaker badge
this.updateCircuitBreakerBadge(state?.circuitBreaker);
// Handle collapsed/expanded state
if (this.ralphStatePanelCollapsed) {
panel.classList.add('collapsed');
@@ -5269,6 +5311,67 @@ class ClaudemanApp {
}
}
updateCircuitBreakerBadge(circuitBreaker) {
// Find or create the circuit breaker badge container
let cbContainer = this.$('ralphCircuitBreakerBadge');
if (!cbContainer) {
// Create container if it doesn't exist (we'll add it dynamically)
const summary = this.$('ralphSummary');
if (!summary) return;
// Check if it already exists
cbContainer = summary.querySelector('.ralph-circuit-breaker');
if (!cbContainer) {
cbContainer = document.createElement('div');
cbContainer.id = 'ralphCircuitBreakerBadge';
cbContainer.className = 'ralph-circuit-breaker';
// Insert after the status badge
const statusBadge = this.$('ralphStatusBadge');
if (statusBadge && statusBadge.nextSibling) {
statusBadge.parentNode.insertBefore(cbContainer, statusBadge.nextSibling);
} else {
summary.appendChild(cbContainer);
}
}
}
// Hide if no circuit breaker state or CLOSED
if (!circuitBreaker || circuitBreaker.state === 'CLOSED') {
cbContainer.style.display = 'none';
return;
}
cbContainer.style.display = '';
cbContainer.classList.remove('half-open', 'open');
if (circuitBreaker.state === 'HALF_OPEN') {
cbContainer.classList.add('half-open');
cbContainer.innerHTML = `<span class="cb-icon">⚠</span><span class="cb-text">Warning</span>`;
cbContainer.title = circuitBreaker.reason || 'Circuit breaker warning';
} else if (circuitBreaker.state === 'OPEN') {
cbContainer.classList.add('open');
cbContainer.innerHTML = `<span class="cb-icon">🛑</span><span class="cb-text">Stuck</span>`;
cbContainer.title = circuitBreaker.reason || 'Loop appears stuck';
}
// Add click handler to reset
cbContainer.onclick = () => this.resetCircuitBreaker();
}
async resetCircuitBreaker() {
if (!this.activeSessionId) return;
try {
const response = await fetch(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, {
method: 'POST',
});
if (response.ok) {
console.log('Circuit breaker reset');
}
} catch (err) {
console.error('Failed to reset circuit breaker:', err);
}
}
updateRalphStats(loop, completed, total) {
// Time stat
const timeEl = this.$('ralphStatTime');
@@ -5352,6 +5455,69 @@ class ClaudemanApp {
// Render task cards
this.renderRalphTasks(todos);
// Render RALPH_STATUS block if present
this.renderRalphStatusBlock(state?.statusBlock);
}
renderRalphStatusBlock(statusBlock) {
// Find or create the status block container
let container = this.$('ralphStatusBlockDisplay');
const expandedContent = this.$('ralphExpandedContent');
if (!statusBlock) {
// Remove container if no status block
if (container) {
container.remove();
}
return;
}
if (!container && expandedContent) {
container = document.createElement('div');
container.id = 'ralphStatusBlockDisplay';
container.className = 'ralph-status-block';
// Insert at the top of expanded content
expandedContent.insertBefore(container, expandedContent.firstChild);
}
if (!container) return;
// Build status class
const statusClass = statusBlock.status === 'IN_PROGRESS' ? 'in-progress'
: statusBlock.status === 'COMPLETE' ? 'complete'
: statusBlock.status === 'BLOCKED' ? 'blocked' : '';
// Build tests status icon
const testsIcon = statusBlock.testsStatus === 'PASSING' ? '✅'
: statusBlock.testsStatus === 'FAILING' ? '❌'
: '⏸';
// Build work type icon
const workIcon = statusBlock.workType === 'IMPLEMENTATION' ? '🔧'
: statusBlock.workType === 'TESTING' ? '🧪'
: statusBlock.workType === 'DOCUMENTATION' ? '📝'
: statusBlock.workType === 'REFACTORING' ? '♻️' : '📋';
let html = `
<div class="ralph-status-block-header">
<span>RALPH_STATUS</span>
<span class="ralph-status-block-status ${statusClass}">${statusBlock.status}</span>
${statusBlock.exitSignal ? '<span style="color: #4caf50;">🚪 EXIT</span>' : ''}
</div>
<div class="ralph-status-block-stats">
<span>${workIcon} ${statusBlock.workType}</span>
<span>📁 ${statusBlock.filesModified} files</span>
<span>✓ ${statusBlock.tasksCompletedThisLoop} tasks</span>
<span>${testsIcon} Tests: ${statusBlock.testsStatus}</span>
</div>
`;
if (statusBlock.recommendation) {
html += `<div class="ralph-status-block-recommendation">${statusBlock.recommendation}</div>`;
}
container.innerHTML = html;
}
renderRalphTasks(todos) {
+111
View File
@@ -843,6 +843,117 @@ body {
50% { opacity: 0.6; transform: scale(1.2); }
}
/* Circuit Breaker Badge */
.ralph-circuit-breaker {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-left: 0.5rem;
}
.ralph-circuit-breaker.half-open {
background: rgba(255, 193, 7, 0.2);
color: #ffc107;
border: 1px solid rgba(255, 193, 7, 0.4);
}
.ralph-circuit-breaker.half-open:hover {
background: rgba(255, 193, 7, 0.3);
}
.ralph-circuit-breaker.open {
background: rgba(244, 67, 54, 0.2);
color: #f44336;
border: 1px solid rgba(244, 67, 54, 0.4);
animation: circuit-breaker-pulse 1s ease-in-out infinite;
}
.ralph-circuit-breaker.open:hover {
background: rgba(244, 67, 54, 0.3);
}
.ralph-circuit-breaker .cb-icon {
font-size: 0.8rem;
}
.ralph-circuit-breaker .cb-text {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
@keyframes circuit-breaker-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* RALPH_STATUS Block Display */
.ralph-status-block {
background: var(--bg-input);
border-radius: 4px;
padding: 0.5rem 0.75rem;
margin-top: 0.5rem;
font-size: 0.75rem;
}
.ralph-status-block-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
font-weight: 600;
color: var(--text-bright);
}
.ralph-status-block-status {
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
}
.ralph-status-block-status.in-progress {
background: rgba(33, 150, 243, 0.2);
color: #2196f3;
}
.ralph-status-block-status.complete {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
}
.ralph-status-block-status.blocked {
background: rgba(244, 67, 54, 0.2);
color: #f44336;
}
.ralph-status-block-stats {
display: flex;
gap: 1rem;
color: var(--text-dim);
}
.ralph-status-block-stats span {
display: flex;
align-items: center;
gap: 0.25rem;
}
.ralph-status-block-recommendation {
margin-top: 0.4rem;
padding-top: 0.4rem;
border-top: 1px solid var(--border);
color: var(--text-muted);
font-style: italic;
}
/* Summary Stats */
.ralph-summary-stats {
display: flex;
+138
View File
@@ -118,6 +118,24 @@ const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
const CTRL_L_PATTERN = /\x0c/g;
const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
/**
* Formats uptime in seconds to a human-readable string.
*/
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
if (secs > 0 || parts.length === 0) parts.push(`${secs}s`);
return parts.join(' ');
}
/**
* Sanitizes hook event data before broadcasting via SSE.
* Extracts only relevant fields and limits total size to prevent
@@ -444,6 +462,50 @@ export class WebServer extends EventEmitter {
return { success: true, config: this.store.getConfig() };
});
// Debug/monitoring endpoint - lightweight, only runs when called
this.app.get('/api/debug/memory', async () => {
const mem = process.memoryUsage();
const spawnState = this.spawnOrchestrator.getState();
return {
memory: {
rss: mem.rss,
rssMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
heapUsed: mem.heapUsed,
heapUsedMB: Math.round(mem.heapUsed / 1024 / 1024 * 10) / 10,
heapTotal: mem.heapTotal,
heapTotalMB: Math.round(mem.heapTotal / 1024 / 1024 * 10) / 10,
external: mem.external,
externalMB: Math.round(mem.external / 1024 / 1024 * 10) / 10,
arrayBuffers: mem.arrayBuffers,
arrayBuffersMB: Math.round(mem.arrayBuffers / 1024 / 1024 * 10) / 10,
},
counts: {
sessions: this.sessions.size,
sseClients: this.sseClients.size,
respawnControllers: this.respawnControllers.size,
runSummaryTrackers: this.runSummaryTrackers.size,
scheduledRuns: this.scheduledRuns.size,
terminalBatches: this.terminalBatches.size,
outputBatches: this.outputBatches.size,
pendingRespawnStarts: this.pendingRespawnStarts.size,
respawnTimers: this.respawnTimers.size,
subagents: subagentWatcher.getSubagents().length,
},
spawn: {
activeAgents: spawnState.activeCount,
queuedAgents: spawnState.queuedCount,
totalSpawned: spawnState.totalSpawned,
totalCompleted: spawnState.totalCompleted,
totalFailed: spawnState.totalFailed,
},
uptime: {
seconds: Math.round(process.uptime()),
formatted: formatUptime(process.uptime()),
},
timestamp: Date.now(),
};
});
// Session management
this.app.get('/api/sessions', async () => this.getSessionsState());
@@ -759,6 +821,39 @@ export class WebServer extends EventEmitter {
return { success: true };
});
// Reset circuit breaker for Ralph tracker
this.app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => {
const { id } = req.params as { id: string };
const session = this.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
session.ralphTracker.resetCircuitBreaker();
return { success: true };
});
// Get Ralph status block and circuit breaker state
this.app.get('/api/sessions/:id/ralph-status', async (req) => {
const { id } = req.params as { id: string };
const session = this.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
return {
success: true,
data: {
lastStatusBlock: session.ralphTracker.lastStatusBlock,
circuitBreaker: session.ralphTracker.circuitBreakerStatus,
cumulativeStats: session.ralphTracker.cumulativeStats,
exitGateMet: session.ralphTracker.exitGateMet,
}
};
});
// Run prompt in session
this.app.post('/api/sessions/:id/run', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
@@ -2348,6 +2443,49 @@ export class WebServer extends EventEmitter {
if (tracker) tracker.recordRalphCompletion(phrase);
});
// RALPH_STATUS block events
session.on('ralphStatusBlockDetected', (block: import('../types.js').RalphStatusBlock) => {
this.broadcast('session:ralphStatusUpdate', { sessionId: session.id, block });
// Track in run summary
const tracker = this.runSummaryTrackers.get(session.id);
if (tracker) {
tracker.addEvent(
block.status === 'BLOCKED' ? 'warning' : 'idle_detected',
block.status === 'BLOCKED' ? 'warning' : 'info',
`Ralph Status: ${block.status}`,
`Tasks: ${block.tasksCompletedThisLoop}, Files: ${block.filesModified}, Tests: ${block.testsStatus}`
);
}
});
session.on('ralphCircuitBreakerUpdate', (status: import('../types.js').CircuitBreakerStatus) => {
this.broadcast('session:circuitBreakerUpdate', { sessionId: session.id, status });
// Track state changes in run summary
const tracker = this.runSummaryTrackers.get(session.id);
if (tracker && status.state === 'OPEN') {
tracker.addEvent(
'warning',
'warning',
'Circuit Breaker Opened',
status.reason
);
}
});
session.on('ralphExitGateMet', (data: { completionIndicators: number; exitSignal: boolean }) => {
this.broadcast('session:exitGateMet', { sessionId: session.id, ...data });
// Track in run summary
const tracker = this.runSummaryTrackers.get(session.id);
if (tracker) {
tracker.addEvent(
'ralph_completion',
'success',
'Exit Gate Met',
`Indicators: ${data.completionIndicators}, EXIT_SIGNAL: ${data.exitSignal}`
);
}
});
// Bash tool tracking events (for clickable file paths)
session.on('bashToolStart', (tool: ActiveBashTool) => {
this.broadcast('session:bashToolStart', { sessionId: session.id, tool });