refactor: complete Phase 2 — migrate ralph-tracker and respawn-controller to managed timers

ralph-tracker.ts: Replace 4 manual timer/flag fields (_todoUpdateTimer, _loopUpdateTimer,
_todoUpdatePending, _loopUpdatePending) with 2 Debouncer instances.

respawn-controller.ts: Replace 10 manual timer fields (stepTimer, completionConfirmTimer,
noOutputTimer, detectionUpdateTimer, autoAcceptTimer, preFilterTimer, hookConfirmTimer,
clearFallbackTimer, stepConfirmTimer, stuckStateTimer) with CleanupManager + timerIds Map.
startTrackedTimer/cancelTrackedTimer preserved as wrappers for UI countdown display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-03-01 16:43:45 +01:00
co-authored by Claude Opus 4.6
parent a8a6c1a648
commit 71276c5d6e
3 changed files with 113 additions and 221 deletions
+4 -4
View File
@@ -877,12 +877,12 @@ execSync(`tmux kill-session -t "${this.checkMuxName}"`, { timeout: 3000 });
4. ✅ Add `z.infer` to Zod schemas (~2 hours) — `src/web/schemas.ts` now has 36 `z.infer` type exports (lines 512-547) covering all schemas 4. ✅ Add `z.infer` to Zod schemas (~2 hours) — `src/web/schemas.ts` now has 36 `z.infer` type exports (lines 512-547) covering all schemas
5. ✅ Fix 10 weak "not.toThrow()" tests (~1 hour) — All `not.toThrow()` calls now have behavior assertions: `task-tracker.test.ts` (6 instances all followed by state checks), `image-watcher.test.ts` (1 instance followed by length check), `session-manager.test.ts` (1 instance followed by count check) 5. ✅ Fix 10 weak "not.toThrow()" tests (~1 hour) — All `not.toThrow()` calls now have behavior assertions: `task-tracker.test.ts` (6 instances all followed by state checks), `image-watcher.test.ts` (1 instance followed by length check), `session-manager.test.ts` (1 instance followed by count check)
**Phase 2 - CleanupManager & Debounce (2-3 days)** ⚠️ PARTIALLY COMPLETE **Phase 2 - CleanupManager & Debounce (2-3 days)** ✅ COMPLETE
1. ✅ Create `Debouncer` utility class (~1 hour) — Created `src/utils/debouncer.ts` with `Debouncer` and `KeyedDebouncer` classes; exported from `src/utils/index.ts` 1. ✅ Create `Debouncer` utility class (~1 hour) — Created `src/utils/debouncer.ts` with `Debouncer` and `KeyedDebouncer` classes; exported from `src/utils/index.ts`
2. ⚠️ Migrate 6 of 8 files from manual debounce to Debouncer — `state-store.ts` (2 Debouncers), `push-store.ts` (1 Debouncer), `bash-tool-parser.ts` (1 Debouncer), `image-watcher.ts` (1 KeyedDebouncer), `subagent-watcher.ts` (2 KeyedDebouncers), `server.ts` (1 KeyedDebouncer for persist timers). **Not migrated**: `ralph-tracker.ts` (still has 2 manual timer fields: `_todoUpdateTimer`, `_loopUpdateTimer`) 2. ✅ Migrate all 8 files from manual debounce to Debouncer — `state-store.ts` (2 Debouncers), `push-store.ts` (1 Debouncer), `bash-tool-parser.ts` (1 Debouncer), `image-watcher.ts` (1 KeyedDebouncer), `subagent-watcher.ts` (2 KeyedDebouncers), `server.ts` (1 KeyedDebouncer for persist timers), `ralph-tracker.ts` (2 Debouncers replacing 4 manual fields: `_todoUpdateTimer`, `_loopUpdateTimer`, `_todoUpdatePending`, `_loopUpdatePending`)
3. ❌ Migrate respawn-controller to CleanupManager — **NOT DONE**. Still has 10+ manual timer fields (`stepTimer`, `completionConfirmTimer`, `noOutputTimer`, `detectionUpdateTimer`, `autoAcceptTimer`, `preFilterTimer`, `hookConfirmTimer`, `clearFallbackTimer`, `stepConfirmTimer`, `stuckStateTimer`). Uses custom `startTrackedTimer()`/`cancelTrackedTimer()` helpers instead. 3. ✅ Migrate respawn-controller to CleanupManager — 10 manual timer fields replaced with single `CleanupManager` instance + `timerIds` Map. `startTrackedTimer()`/`cancelTrackedTimer()` preserved as wrappers for UI countdown display and timer events. `clearTimers()` uses dispose-and-recreate pattern for state transitions.
4. ✅ Migrate server.ts timer cleanup to CleanupManager (~2 hours) — `private cleanup = new CleanupManager()` present; terminal batch timers and pending respawn starts left as manual Maps (complex lifecycle) 4. ✅ Migrate server.ts timer cleanup to CleanupManager (~2 hours) — `private cleanup = new CleanupManager()` present; terminal batch timers and pending respawn starts left as manual Maps (complex lifecycle)
5. ⚠️ Migrate remaining files — `bash-tool-parser.ts` (CleanupManager ✅), `subagent-watcher.ts` (CleanupManager ✅). **Not migrated**: `ralph-tracker.ts` (no CleanupManager, still uses manual timer clearing) 5. ✅ Migrate remaining files — `bash-tool-parser.ts` (CleanupManager ✅), `subagent-watcher.ts` (CleanupManager ✅), `ralph-tracker.ts` (Debouncer ✅)
**Phase 3 - server.ts Route Extraction (3-4 days)** ✅ COMPLETE **Phase 3 - server.ts Route Extraction (3-4 days)** ✅ COMPLETE
1. ✅ Created `src/web/routes/` with 12 domain route modules + index barrel (4,090 LOC total): session (909), system (768), ralph (533), plan (459), respawn (315), case, file, hook-event, mux, push, scheduled, team 1. ✅ Created `src/web/routes/` with 12 domain route modules + index barrel (4,090 LOC total): session (909), system (768), ralph (533), plan (459), respawn (315), case, file, hook-event, mux, push, scheduled, team
+15 -60
View File
@@ -33,7 +33,7 @@ import {
PlanTaskStatus, PlanTaskStatus,
TddPhase, TddPhase,
} from './types.js'; } from './types.js';
import { ANSI_ESCAPE_PATTERN_SIMPLE, fuzzyPhraseMatch, todoContentHash, stringSimilarity } from './utils/index.js'; import { ANSI_ESCAPE_PATTERN_SIMPLE, fuzzyPhraseMatch, todoContentHash, stringSimilarity, Debouncer } from './utils/index.js';
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js'; import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js';
import { MAX_TODOS_PER_SESSION } from './config/map-limits.js'; import { MAX_TODOS_PER_SESSION } from './config/map-limits.js';
import { RalphPlanTracker } from './ralph-plan-tracker.js'; import { RalphPlanTracker } from './ralph-plan-tracker.js';
@@ -454,17 +454,11 @@ export class RalphTracker extends EventEmitter {
/** Timestamp of last cleanup check for throttling */ /** Timestamp of last cleanup check for throttling */
private _lastCleanupTime: number = 0; private _lastCleanupTime: number = 0;
/** Debounce timer for todoUpdate events */ /** Debouncer for todoUpdate events */
private _todoUpdateTimer: NodeJS.Timeout | null = null; private _todoDeb = new Debouncer(EVENT_DEBOUNCE_MS);
/** Debounce timer for loopUpdate events */ /** Debouncer for loopUpdate events */
private _loopUpdateTimer: NodeJS.Timeout | null = null; private _loopDeb = new Debouncer(EVENT_DEBOUNCE_MS);
/** Flag indicating pending todoUpdate emission */
private _todoUpdatePending: boolean = false;
/** Flag indicating pending loopUpdate emission */
private _loopUpdatePending: boolean = false;
/** When true, prevents auto-enable on pattern detection */ /** When true, prevents auto-enable on pattern detection */
private _autoEnableDisabled: boolean = true; private _autoEnableDisabled: boolean = true;
@@ -996,74 +990,34 @@ export class RalphTracker extends EventEmitter {
* Clear all debounce timers. * Clear all debounce timers.
*/ */
private clearDebounceTimers(): void { private clearDebounceTimers(): void {
if (this._todoUpdateTimer) { this._todoDeb.cancel();
clearTimeout(this._todoUpdateTimer); this._loopDeb.cancel();
this._todoUpdateTimer = null;
}
if (this._loopUpdateTimer) {
clearTimeout(this._loopUpdateTimer);
this._loopUpdateTimer = null;
}
this._todoUpdatePending = false;
this._loopUpdatePending = false;
} }
/** /**
* Emit todoUpdate event with debouncing. * Emit todoUpdate event with debouncing.
*/ */
private emitTodoUpdateDebounced(): void { private emitTodoUpdateDebounced(): void {
this._todoUpdatePending = true; this._todoDeb.schedule(() => this.emit('todoUpdate', this.todos));
if (this._todoUpdateTimer) {
clearTimeout(this._todoUpdateTimer);
}
this._todoUpdateTimer = setTimeout(() => {
if (this._todoUpdatePending) {
this._todoUpdatePending = false;
this._todoUpdateTimer = null;
this.emit('todoUpdate', this.todos);
}
}, EVENT_DEBOUNCE_MS);
} }
/** /**
* Emit loopUpdate event with debouncing. * Emit loopUpdate event with debouncing.
*/ */
private emitLoopUpdateDebounced(): void { private emitLoopUpdateDebounced(): void {
this._loopUpdatePending = true; this._loopDeb.schedule(() => this.emit('loopUpdate', this.loopState));
if (this._loopUpdateTimer) {
clearTimeout(this._loopUpdateTimer);
}
this._loopUpdateTimer = setTimeout(() => {
if (this._loopUpdatePending) {
this._loopUpdatePending = false;
this._loopUpdateTimer = null;
this.emit('loopUpdate', this.loopState);
}
}, EVENT_DEBOUNCE_MS);
} }
/** /**
* Flush all pending debounced events immediately. * Flush all pending debounced events immediately.
*/ */
flushPendingEvents(): void { flushPendingEvents(): void {
if (this._todoUpdatePending) { if (this._todoDeb.isPending) {
this._todoUpdatePending = false; this._todoDeb.cancel();
if (this._todoUpdateTimer) {
clearTimeout(this._todoUpdateTimer);
this._todoUpdateTimer = null;
}
this.emit('todoUpdate', this.todos); this.emit('todoUpdate', this.todos);
} }
if (this._loopUpdatePending) { if (this._loopDeb.isPending) {
this._loopUpdatePending = false; this._loopDeb.cancel();
if (this._loopUpdateTimer) {
clearTimeout(this._loopUpdateTimer);
this._loopUpdateTimer = null;
}
this.emit('loopUpdate', this.loopState); this.emit('loopUpdate', this.loopState);
} }
} }
@@ -2373,7 +2327,8 @@ export class RalphTracker extends EventEmitter {
* Clean up all resources and release memory. * Clean up all resources and release memory.
*/ */
destroy(): void { destroy(): void {
this.clearDebounceTimers(); this._todoDeb.dispose();
this._loopDeb.dispose();
this.fixPlanWatcher.destroy(); this.fixPlanWatcher.destroy();
this.stallDetector.destroy(); this.stallDetector.destroy();
this.statusParser.destroy(); this.statusParser.destroy();
+94 -157
View File
@@ -41,7 +41,7 @@ import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js'; import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
import type { TeamWatcher } from './team-watcher.js'; import type { TeamWatcher } from './team-watcher.js';
import { BufferAccumulator } from './utils/buffer-accumulator.js'; import { BufferAccumulator } from './utils/buffer-accumulator.js';
import { ANSI_ESCAPE_PATTERN_SIMPLE, assertNever } from './utils/index.js'; import { ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js';
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js'; import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
import { import {
isCompletionMessage, isCompletionMessage,
@@ -643,27 +643,15 @@ export class RespawnController extends EventEmitter {
/** Current state machine state */ /** Current state machine state */
private _state: RespawnState = 'stopped'; private _state: RespawnState = 'stopped';
/** Timer for step delays */ /** Centralized timer lifecycle manager — disposed and recreated on clearTimers() */
private stepTimer: NodeJS.Timeout | null = null; private cleanup = new CleanupManager();
/** Timer for completion confirmation (Layer 2) */ /** Maps timer names to CleanupManager registration IDs (for individual cancel) */
private completionConfirmTimer: NodeJS.Timeout | null = null; private timerIds = new Map<string, string>();
/** Timer for no-output fallback (Layer 5) */
private noOutputTimer: NodeJS.Timeout | null = null;
/** Timer for periodic detection status updates */
private detectionUpdateTimer: NodeJS.Timeout | null = null;
/** Cached key fields from last emitted detection status (for dedup) */ /** Cached key fields from last emitted detection status (for dedup) */
private lastEmittedDetectionKey: string = ''; private lastEmittedDetectionKey: string = '';
/** Timer for auto-accepting plan mode prompts */
private autoAcceptTimer: NodeJS.Timeout | null = null;
/** Timer for pre-filter silence detection (triggers AI check) */
private preFilterTimer: NodeJS.Timeout | null = null;
/** Whether any terminal output has been received since start/last-auto-accept */ /** Whether any terminal output has been received since start/last-auto-accept */
private hasReceivedOutput: boolean = false; private hasReceivedOutput: boolean = false;
@@ -684,9 +672,6 @@ export class RespawnController extends EventEmitter {
/** Timestamp when idle_prompt was received */ /** Timestamp when idle_prompt was received */
private idlePromptTime: number | null = null; private idlePromptTime: number | null = null;
/** Timer for short confirmation after hook signal (handles race conditions) */
private hookConfirmTimer: NodeJS.Timeout | null = null;
/** Confirmation delay after hook signal before confirming idle (ms) */ /** Confirmation delay after hook signal before confirming idle (ms) */
private static readonly HOOK_CONFIRM_DELAY_MS = 3000; private static readonly HOOK_CONFIRM_DELAY_MS = 3000;
@@ -720,12 +705,6 @@ export class RespawnController extends EventEmitter {
/** Unique ID for current AI check request (to detect stale results) */ /** Unique ID for current AI check request (to detect stale results) */
private _currentAiCheckId: string | null = null; private _currentAiCheckId: string | null = null;
/** Timer for /clear step fallback (sends /init if no prompt detected) */
private clearFallbackTimer: NodeJS.Timeout | null = null;
/** Timer for step completion confirmation (waits for silence after completion) */
private stepConfirmTimer: NodeJS.Timeout | null = null;
/** Fallback timeout for /clear step (ms) - sends /init without waiting for prompt */ /** Fallback timeout for /clear step (ms) - sends /init without waiting for prompt */
private static readonly CLEAR_FALLBACK_TIMEOUT_MS = 10000; private static readonly CLEAR_FALLBACK_TIMEOUT_MS = 10000;
@@ -743,9 +722,6 @@ export class RespawnController extends EventEmitter {
/** Timestamp when the current state was entered */ /** Timestamp when the current state was entered */
private stateEnteredAt: number = 0; private stateEnteredAt: number = 0;
/** Timer for stuck-state detection */
private stuckStateTimer: NodeJS.Timeout | null = null;
/** Whether a stuck-state warning has been emitted for current state */ /** Whether a stuck-state warning has been emitted for current state */
private stuckStateWarned: boolean = false; private stuckStateWarned: boolean = false;
@@ -1116,7 +1092,7 @@ export class RespawnController extends EventEmitter {
this.stopDetectionUpdates(); this.stopDetectionUpdates();
if (this._state === 'stopped') return; if (this._state === 'stopped') return;
this.lastEmittedDetectionKey = ''; this.lastEmittedDetectionKey = '';
this.detectionUpdateTimer = setInterval(() => { const id = this.cleanup.setInterval(() => {
try { try {
if (this._state !== 'stopped') { if (this._state !== 'stopped') {
const status = this.getDetectionStatus(); const status = this.getDetectionStatus();
@@ -1131,17 +1107,15 @@ export class RespawnController extends EventEmitter {
} catch (err) { } catch (err) {
console.error(`[RespawnController] Error in detectionUpdateTimer:`, err); console.error(`[RespawnController] Error in detectionUpdateTimer:`, err);
} }
}, 2000); }, 2000, { description: 'detection-update' });
this.timerIds.set('detection-update', id);
} }
/** /**
* Stop periodic detection status updates. * Stop periodic detection status updates.
*/ */
private stopDetectionUpdates(): void { private stopDetectionUpdates(): void {
if (this.detectionUpdateTimer) { this.cancelTrackedTimer('detection-update');
clearInterval(this.detectionUpdateTimer);
this.detectionUpdateTimer = null;
}
} }
/** /**
@@ -1266,7 +1240,6 @@ export class RespawnController extends EventEmitter {
this.aiChecker.removeAllListeners(); this.aiChecker.removeAllListeners();
this.planChecker.removeAllListeners(); this.planChecker.removeAllListeners();
this.clearTimers(); this.clearTimers();
this.stopDetectionUpdates();
this.recentActions.length = 0; this.recentActions.length = 0;
this.setState('stopped'); this.setState('stopped');
if (this.terminalHandler) { if (this.terminalHandler) {
@@ -1431,8 +1404,8 @@ export class RespawnController extends EventEmitter {
this.lastWorkingPatternTime = now; this.lastWorkingPatternTime = now;
// Cancel hook confirmation timer if running // Cancel hook confirmation timer if running
this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimer, 'working patterns detected'); this.cancelTrackedTimer('hook-confirm','working patterns detected');
this.hookConfirmTimer = null;
// Cancel any pending completion confirmation // Cancel any pending completion confirmation
this.cancelCompletionConfirm(); this.cancelCompletionConfirm();
@@ -1564,8 +1537,8 @@ export class RespawnController extends EventEmitter {
*/ */
private checkClearComplete(): void { private checkClearComplete(): void {
// Clear the fallback timer since we got prompt detection // Clear the fallback timer since we got prompt detection
this.cancelTrackedTimer('clear-fallback', this.clearFallbackTimer, 'prompt detected'); this.cancelTrackedTimer('clear-fallback','prompt detected');
this.clearFallbackTimer = null;
this.logAction('step', '/clear completed'); this.logAction('step', '/clear completed');
this.emit('stepCompleted', 'clear'); this.emit('stepCompleted', 'clear');
@@ -1613,11 +1586,11 @@ export class RespawnController extends EventEmitter {
this.logAction('step', 'Monitoring if /init triggered work...'); this.logAction('step', 'Monitoring if /init triggered work...');
// Give Claude a moment to start working before checking for idle // Give Claude a moment to start working before checking for idle
this.stepTimer = this.startTrackedTimer( this.startTrackedTimer(
'init-monitor', 'init-monitor',
3000, 3000,
() => { () => {
this.stepTimer = null;
// If still in monitoring state and no work detected, consider it idle // If still in monitoring state and no work detected, consider it idle
if (this._state === 'monitoring_init' && !this.workingDetected) { if (this._state === 'monitoring_init' && !this.workingDetected) {
this.checkMonitoringInitIdle(); this.checkMonitoringInitIdle();
@@ -1633,10 +1606,7 @@ export class RespawnController extends EventEmitter {
* @fires stepCompleted - With step 'init' * @fires stepCompleted - With step 'init'
*/ */
private checkMonitoringInitIdle(): void { private checkMonitoringInitIdle(): void {
if (this.stepTimer) { this.cancelTrackedTimer('init-monitor');
clearTimeout(this.stepTimer);
this.stepTimer = null;
}
this.log('/init did not trigger work, sending kickstart prompt'); this.log('/init did not trigger work, sending kickstart prompt');
this.emit('stepCompleted', 'init'); this.emit('stepCompleted', 'init');
this.sendKickstart(); this.sendKickstart();
@@ -1651,11 +1621,11 @@ export class RespawnController extends EventEmitter {
this.terminalBuffer.clear(); this.terminalBuffer.clear();
this.clearWorkingPatternWindow(); this.clearWorkingPatternWindow();
this.stepTimer = this.startTrackedTimer( this.startTrackedTimer(
'step-delay', 'step-delay',
this.config.interStepDelayMs, this.config.interStepDelayMs,
async () => { async () => {
this.stepTimer = null;
if (this._state === 'stopped') return; if (this._state === 'stopped') return;
const prompt = this.config.kickstartPrompt!; const prompt = this.config.kickstartPrompt!;
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`); this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
@@ -1685,48 +1655,10 @@ export class RespawnController extends EventEmitter {
/** Clear all timers (step, completion confirm, no-output, pre-filter, step confirm, auto-accept, hook confirm, and clear fallback) */ /** Clear all timers (step, completion confirm, no-output, pre-filter, step confirm, auto-accept, hook confirm, and clear fallback) */
private clearTimers(): void { private clearTimers(): void {
// Clear tracked timers map first to avoid stale entries during individual cleanup
this.activeTimers.clear(); this.activeTimers.clear();
if (this.stepTimer) { this.timerIds.clear();
clearTimeout(this.stepTimer); this.cleanup.dispose();
this.stepTimer = null; this.cleanup = new CleanupManager();
}
if (this.clearFallbackTimer) {
clearTimeout(this.clearFallbackTimer);
this.clearFallbackTimer = null;
}
if (this.completionConfirmTimer) {
clearTimeout(this.completionConfirmTimer);
this.completionConfirmTimer = null;
}
if (this.stepConfirmTimer) {
clearTimeout(this.stepConfirmTimer);
this.stepConfirmTimer = null;
}
if (this.autoAcceptTimer) {
clearTimeout(this.autoAcceptTimer);
this.autoAcceptTimer = null;
}
if (this.preFilterTimer) {
clearTimeout(this.preFilterTimer);
this.preFilterTimer = null;
}
if (this.noOutputTimer) {
clearTimeout(this.noOutputTimer);
this.noOutputTimer = null;
}
if (this.hookConfirmTimer) {
clearTimeout(this.hookConfirmTimer);
this.hookConfirmTimer = null;
}
if (this.stuckStateTimer) {
clearInterval(this.stuckStateTimer);
this.stuckStateTimer = null;
}
if (this.detectionUpdateTimer) {
clearInterval(this.detectionUpdateTimer);
this.detectionUpdateTimer = null;
}
} }
// ========== Stuck-State Detection Methods ========== // ========== Stuck-State Detection Methods ==========
@@ -1740,21 +1672,19 @@ export class RespawnController extends EventEmitter {
if (this._state === 'stopped') return; if (this._state === 'stopped') return;
// Clear existing timer // Clear existing timer
if (this.stuckStateTimer) { this.cancelTrackedTimer('stuck-state');
clearInterval(this.stuckStateTimer);
this.stuckStateTimer = null;
}
// Check interval for stuck state // Check interval for stuck state
const checkIntervalMs = Math.min(this.config.stuckStateWarningMs, 60000); // Check every minute max const checkIntervalMs = Math.min(this.config.stuckStateWarningMs, 60000); // Check every minute max
this.stuckStateTimer = setInterval(() => { const id = this.cleanup.setInterval(() => {
try { try {
this.checkStuckState(); this.checkStuckState();
} catch (err) { } catch (err) {
console.error(`[RespawnController] Error in stuckStateTimer:`, err); console.error(`[RespawnController] Error in stuckStateTimer:`, err);
} }
}, checkIntervalMs); }, checkIntervalMs, { description: 'stuck-state' });
this.timerIds.set('stuck-state', id);
} }
/** /**
@@ -1895,7 +1825,10 @@ export class RespawnController extends EventEmitter {
* Start a tracked timer with UI countdown support. * Start a tracked timer with UI countdown support.
* Emits timerStarted event and tracks the timer for UI display. * Emits timerStarted event and tracks the timer for UI display.
*/ */
private startTrackedTimer(name: string, durationMs: number, callback: () => void, reason?: string): NodeJS.Timeout { private startTrackedTimer(name: string, durationMs: number, callback: () => void, reason?: string): void {
// Cancel any existing timer with this name
this.cancelTrackedTimer(name);
const now = Date.now(); const now = Date.now();
const endsAt = now + durationMs; const endsAt = now + durationMs;
@@ -1903,19 +1836,23 @@ export class RespawnController extends EventEmitter {
this.emit('timerStarted', { name, durationMs, endsAt, reason }); this.emit('timerStarted', { name, durationMs, endsAt, reason });
this.logAction('timer', `Started ${name}: ${Math.round(durationMs / 1000)}s${reason ? ` (${reason})` : ''}`); this.logAction('timer', `Started ${name}: ${Math.round(durationMs / 1000)}s${reason ? ` (${reason})` : ''}`);
return setTimeout(() => { const id = this.cleanup.setTimeout(() => {
this.timerIds.delete(name);
this.activeTimers.delete(name); this.activeTimers.delete(name);
this.emit('timerCompleted', name); this.emit('timerCompleted', name);
callback(); callback();
}, durationMs); }, durationMs, { description: name });
this.timerIds.set(name, id);
} }
/** /**
* Cancel a tracked timer and emit cancellation event. * Cancel a tracked timer and emit cancellation event.
*/ */
private cancelTrackedTimer(name: string, timerRef: NodeJS.Timeout | null, reason?: string): void { private cancelTrackedTimer(name: string, reason?: string): void {
if (timerRef) { const id = this.timerIds.get(name);
clearTimeout(timerRef); if (id) {
this.cleanup.unregister(id);
this.timerIds.delete(name);
if (this.activeTimers.has(name)) { if (this.activeTimers.has(name)) {
this.activeTimers.delete(name); this.activeTimers.delete(name);
this.emit('timerCancelled', name, reason); this.emit('timerCancelled', name, reason);
@@ -1988,14 +1925,14 @@ export class RespawnController extends EventEmitter {
* (used when AI check is disabled or has too many errors). * (used when AI check is disabled or has too many errors).
*/ */
private startNoOutputTimer(): void { private startNoOutputTimer(): void {
this.cancelTrackedTimer('no-output-fallback', this.noOutputTimer, 'restarting'); this.cancelTrackedTimer('no-output-fallback','restarting');
this.noOutputTimer = null;
this.noOutputTimer = this.startTrackedTimer(
this.startTrackedTimer(
'no-output-fallback', 'no-output-fallback',
this.config.noOutputTimeoutMs, this.config.noOutputTimeoutMs,
() => { () => {
this.noOutputTimer = null;
if (this._state === 'watching' || this._state === 'confirming_idle') { if (this._state === 'watching' || this._state === 'confirming_idle') {
const msSinceOutput = Date.now() - this.lastOutputTime; const msSinceOutput = Date.now() - this.lastOutputTime;
this.logAction('detection', `No-output fallback: ${Math.round(msSinceOutput / 1000)}s silence`); this.logAction('detection', `No-output fallback: ${Math.round(msSinceOutput / 1000)}s silence`);
@@ -2028,17 +1965,17 @@ export class RespawnController extends EventEmitter {
* This provides an additional path to AI check even without a completion message. * This provides an additional path to AI check even without a completion message.
*/ */
private startPreFilterTimer(): void { private startPreFilterTimer(): void {
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'restarting'); this.cancelTrackedTimer('pre-filter','restarting');
this.preFilterTimer = null;
// Only set up pre-filter when AI check is enabled // Only set up pre-filter when AI check is enabled
if (!this.config.aiIdleCheckEnabled) return; if (!this.config.aiIdleCheckEnabled) return;
this.preFilterTimer = this.startTrackedTimer( this.startTrackedTimer(
'pre-filter', 'pre-filter',
this.config.completionConfirmMs, this.config.completionConfirmMs,
() => { () => {
this.preFilterTimer = null;
if (this._state === 'watching') { if (this._state === 'watching') {
const now = Date.now(); const now = Date.now();
const msSinceOutput = now - this.lastOutputTime; const msSinceOutput = now - this.lastOutputTime;
@@ -2143,18 +2080,18 @@ export class RespawnController extends EventEmitter {
if (result.verdict === 'IDLE') { if (result.verdict === 'IDLE') {
// Cancel any pending confirmation timers - AI has spoken // Cancel any pending confirmation timers - AI has spoken
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: IDLE'); this.cancelTrackedTimer('completion-confirm','AI verdict: IDLE');
this.completionConfirmTimer = null;
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE'); this.cancelTrackedTimer('pre-filter','AI verdict: IDLE');
this.preFilterTimer = null;
this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`); this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`);
this.emit('aiCheckCompleted', result); this.emit('aiCheckCompleted', result);
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`); this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
} else if (result.verdict === 'WORKING') { } else if (result.verdict === 'WORKING') {
// Cancel timers and go to cooldown // Cancel timers and go to cooldown
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: WORKING'); this.cancelTrackedTimer('completion-confirm','AI verdict: WORKING');
this.completionConfirmTimer = null;
this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`); this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`);
this.emit('aiCheckCompleted', result); this.emit('aiCheckCompleted', result);
@@ -2210,14 +2147,14 @@ export class RespawnController extends EventEmitter {
* and no elicitation dialog was detected. Only handles plan mode approvals. * and no elicitation dialog was detected. Only handles plan mode approvals.
*/ */
private startAutoAcceptTimer(): void { private startAutoAcceptTimer(): void {
this.cancelTrackedTimer('auto-accept', this.autoAcceptTimer, 'restarting'); this.cancelTrackedTimer('auto-accept','restarting');
this.autoAcceptTimer = null;
this.autoAcceptTimer = this.startTrackedTimer(
this.startTrackedTimer(
'auto-accept', 'auto-accept',
this.config.autoAcceptDelayMs, this.config.autoAcceptDelayMs,
() => { () => {
this.autoAcceptTimer = null;
this.tryAutoAccept(); this.tryAutoAccept();
}, },
'plan mode detection' 'plan mode detection'
@@ -2229,8 +2166,8 @@ export class RespawnController extends EventEmitter {
* Called when a completion message is detected (normal idle flow handles it). * Called when a completion message is detected (normal idle flow handles it).
*/ */
private cancelAutoAcceptTimer(): void { private cancelAutoAcceptTimer(): void {
this.cancelTrackedTimer('auto-accept', this.autoAcceptTimer, 'cancelled'); this.cancelTrackedTimer('auto-accept','cancelled');
this.autoAcceptTimer = null;
} }
/** /**
@@ -2391,8 +2328,8 @@ export class RespawnController extends EventEmitter {
} }
// Cancel completion confirmation - auto-accept takes precedence // Cancel completion confirmation - auto-accept takes precedence
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'auto-accept'); this.cancelTrackedTimer('completion-confirm','auto-accept');
this.completionConfirmTimer = null;
this.completionMessageTime = null; this.completionMessageTime = null;
// Ensure we're in watching state (not confirming_idle or ai_checking) // Ensure we're in watching state (not confirming_idle or ai_checking)
@@ -2446,12 +2383,12 @@ export class RespawnController extends EventEmitter {
} }
// Cancel completion confirm timer - hook takes precedence // Cancel completion confirm timer - hook takes precedence
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'Stop hook received'); this.cancelTrackedTimer('completion-confirm','Stop hook received');
this.completionConfirmTimer = null;
// Cancel pre-filter timer - hook takes precedence // Cancel pre-filter timer - hook takes precedence
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'Stop hook received'); this.cancelTrackedTimer('pre-filter','Stop hook received');
this.preFilterTimer = null;
// Start short confirmation timer to handle race conditions // Start short confirmation timer to handle race conditions
// (e.g., Stop hook arrives but Claude immediately starts new work) // (e.g., Stop hook arrives but Claude immediately starts new work)
@@ -2485,12 +2422,12 @@ export class RespawnController extends EventEmitter {
} }
// Cancel all other detection timers - this is definitive // Cancel all other detection timers - this is definitive
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'idle_prompt received'); this.cancelTrackedTimer('completion-confirm','idle_prompt received');
this.completionConfirmTimer = null;
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'idle_prompt received'); this.cancelTrackedTimer('pre-filter','idle_prompt received');
this.preFilterTimer = null;
this.cancelTrackedTimer('no-output-fallback', this.noOutputTimer, 'idle_prompt received'); this.cancelTrackedTimer('no-output-fallback','idle_prompt received');
this.noOutputTimer = null;
// idle_prompt is an even stronger signal than Stop hook (60s+ idle) // idle_prompt is an even stronger signal than Stop hook (60s+ idle)
// Skip confirmation and go directly to idle // Skip confirmation and go directly to idle
@@ -2504,14 +2441,14 @@ export class RespawnController extends EventEmitter {
* @param hookType - Which hook triggered this ('stop' or 'idle_prompt') * @param hookType - Which hook triggered this ('stop' or 'idle_prompt')
*/ */
private startHookConfirmTimer(hookType: 'stop' | 'idle_prompt'): void { private startHookConfirmTimer(hookType: 'stop' | 'idle_prompt'): void {
this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimer, 'restarting'); this.cancelTrackedTimer('hook-confirm','restarting');
this.hookConfirmTimer = null;
this.hookConfirmTimer = this.startTrackedTimer(
this.startTrackedTimer(
'hook-confirm', 'hook-confirm',
RespawnController.HOOK_CONFIRM_DELAY_MS, RespawnController.HOOK_CONFIRM_DELAY_MS,
() => { () => {
this.hookConfirmTimer = null;
// Verify we haven't received new output since the hook arrived // Verify we haven't received new output since the hook arrived
const hookTime = hookType === 'stop' ? this.stopHookTime : this.idlePromptTime; const hookTime = hookType === 'stop' ? this.stopHookTime : this.idlePromptTime;
@@ -2585,17 +2522,17 @@ export class RespawnController extends EventEmitter {
* After completion message, waits for output silence then triggers AI check. * After completion message, waits for output silence then triggers AI check.
*/ */
private startCompletionConfirmTimer(): void { private startCompletionConfirmTimer(): void {
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'restarting'); this.cancelTrackedTimer('completion-confirm','restarting');
this.completionConfirmTimer = null;
this.setState('confirming_idle'); this.setState('confirming_idle');
this.logAction('detection', 'Completion message found in output'); this.logAction('detection', 'Completion message found in output');
this.completionConfirmTimer = this.startTrackedTimer( this.startTrackedTimer(
'completion-confirm', 'completion-confirm',
this.config.completionConfirmMs, this.config.completionConfirmMs,
() => { () => {
this.completionConfirmTimer = null;
if (this._state === 'stopped') return; if (this._state === 'stopped') return;
const msSinceOutput = Date.now() - this.lastOutputTime; const msSinceOutput = Date.now() - this.lastOutputTime;
if (msSinceOutput >= this.config.completionConfirmMs) { if (msSinceOutput >= this.config.completionConfirmMs) {
@@ -2616,8 +2553,8 @@ export class RespawnController extends EventEmitter {
* Cancel completion confirmation if new activity detected. * Cancel completion confirmation if new activity detected.
*/ */
private cancelCompletionConfirm(): void { private cancelCompletionConfirm(): void {
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'activity detected'); this.cancelTrackedTimer('completion-confirm','activity detected');
this.completionConfirmTimer = null;
if (this._state === 'confirming_idle') { if (this._state === 'confirming_idle') {
this.setState('watching'); this.setState('watching');
this.completionMessageTime = null; this.completionMessageTime = null;
@@ -2630,14 +2567,14 @@ export class RespawnController extends EventEmitter {
* This ensures Claude has finished processing before we send the next command. * This ensures Claude has finished processing before we send the next command.
*/ */
private startStepConfirmTimer(step: 'update' | 'init' | 'kickstart'): void { private startStepConfirmTimer(step: 'update' | 'init' | 'kickstart'): void {
this.cancelTrackedTimer('step-confirm', this.stepConfirmTimer, 'restarting'); this.cancelTrackedTimer('step-confirm','restarting');
this.stepConfirmTimer = null;
this.stepConfirmTimer = this.startTrackedTimer(
this.startTrackedTimer(
'step-confirm', 'step-confirm',
this.config.completionConfirmMs, this.config.completionConfirmMs,
() => { () => {
this.stepConfirmTimer = null;
if (this._state === 'stopped') return; if (this._state === 'stopped') return;
const msSinceOutput = Date.now() - this.lastOutputTime; const msSinceOutput = Date.now() - this.lastOutputTime;
@@ -2670,8 +2607,8 @@ export class RespawnController extends EventEmitter {
* Cancel step confirmation if working patterns detected. * Cancel step confirmation if working patterns detected.
*/ */
private cancelStepConfirm(): void { private cancelStepConfirm(): void {
this.cancelTrackedTimer('step-confirm', this.stepConfirmTimer, 'working detected'); this.cancelTrackedTimer('step-confirm','working detected');
this.stepConfirmTimer = null;
} }
/** /**
@@ -2833,11 +2770,11 @@ export class RespawnController extends EventEmitter {
this.terminalBuffer.clear(); // Clear buffer for fresh detection this.terminalBuffer.clear(); // Clear buffer for fresh detection
this.clearWorkingPatternWindow(); // Clear rolling window this.clearWorkingPatternWindow(); // Clear rolling window
this.stepTimer = this.startTrackedTimer( this.startTrackedTimer(
'step-delay', 'step-delay',
this.config.interStepDelayMs, this.config.interStepDelayMs,
async () => { async () => {
this.stepTimer = null;
if (this._state === 'stopped') return; if (this._state === 'stopped') return;
// Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config // Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config
@@ -2874,11 +2811,11 @@ export class RespawnController extends EventEmitter {
this.terminalBuffer.clear(); this.terminalBuffer.clear();
this.clearWorkingPatternWindow(); this.clearWorkingPatternWindow();
this.stepTimer = this.startTrackedTimer( this.startTrackedTimer(
'step-delay', 'step-delay',
this.config.interStepDelayMs, this.config.interStepDelayMs,
async () => { async () => {
this.stepTimer = null;
if (this._state === 'stopped') return; if (this._state === 'stopped') return;
this.logAction('command', 'Sending: /clear'); this.logAction('command', 'Sending: /clear');
await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI
@@ -2887,11 +2824,11 @@ export class RespawnController extends EventEmitter {
this.promptDetected = false; this.promptDetected = false;
// Start fallback timer - if no prompt detected after 10s, proceed to /init anyway // Start fallback timer - if no prompt detected after 10s, proceed to /init anyway
this.clearFallbackTimer = this.startTrackedTimer( this.startTrackedTimer(
'clear-fallback', 'clear-fallback',
RespawnController.CLEAR_FALLBACK_TIMEOUT_MS, RespawnController.CLEAR_FALLBACK_TIMEOUT_MS,
() => { () => {
this.clearFallbackTimer = null;
if (this._state === 'waiting_clear') { if (this._state === 'waiting_clear') {
this.logAction('step', '/clear fallback: proceeding to /init'); this.logAction('step', '/clear fallback: proceeding to /init');
this.emit('stepCompleted', 'clear'); this.emit('stepCompleted', 'clear');
@@ -2918,11 +2855,11 @@ export class RespawnController extends EventEmitter {
this.terminalBuffer.clear(); this.terminalBuffer.clear();
this.clearWorkingPatternWindow(); this.clearWorkingPatternWindow();
this.stepTimer = this.startTrackedTimer( this.startTrackedTimer(
'step-delay', 'step-delay',
this.config.interStepDelayMs, this.config.interStepDelayMs,
async () => { async () => {
this.stepTimer = null;
if (this._state === 'stopped') return; if (this._state === 'stopped') return;
this.logAction('command', 'Sending: /init'); this.logAction('command', 'Sending: /init');
await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI