From 71276c5d6ef159d7edc67969a5cf0de353e9c2c7 Mon Sep 17 00:00:00 2001 From: arkon Date: Sun, 1 Mar 2026 16:43:45 +0100 Subject: [PATCH] =?UTF-8?q?refactor:=20complete=20Phase=202=20=E2=80=94=20?= =?UTF-8?q?migrate=20ralph-tracker=20and=20respawn-controller=20to=20manag?= =?UTF-8?q?ed=20timers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/code-structure-findings.md | 8 +- src/ralph-tracker.ts | 75 ++-------- src/respawn-controller.ts | 251 ++++++++++++-------------------- 3 files changed, 113 insertions(+), 221 deletions(-) diff --git a/docs/code-structure-findings.md b/docs/code-structure-findings.md index ffdec5cf..426cabe1 100644 --- a/docs/code-structure-findings.md +++ b/docs/code-structure-findings.md @@ -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 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` -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`) -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. +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 — 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) -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 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 diff --git a/src/ralph-tracker.ts b/src/ralph-tracker.ts index 4b49f26c..0a0e8812 100644 --- a/src/ralph-tracker.ts +++ b/src/ralph-tracker.ts @@ -33,7 +33,7 @@ import { PlanTaskStatus, TddPhase, } 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_TODOS_PER_SESSION } from './config/map-limits.js'; import { RalphPlanTracker } from './ralph-plan-tracker.js'; @@ -454,17 +454,11 @@ export class RalphTracker extends EventEmitter { /** Timestamp of last cleanup check for throttling */ private _lastCleanupTime: number = 0; - /** Debounce timer for todoUpdate events */ - private _todoUpdateTimer: NodeJS.Timeout | null = null; + /** Debouncer for todoUpdate events */ + private _todoDeb = new Debouncer(EVENT_DEBOUNCE_MS); - /** Debounce timer for loopUpdate events */ - private _loopUpdateTimer: NodeJS.Timeout | null = null; - - /** Flag indicating pending todoUpdate emission */ - private _todoUpdatePending: boolean = false; - - /** Flag indicating pending loopUpdate emission */ - private _loopUpdatePending: boolean = false; + /** Debouncer for loopUpdate events */ + private _loopDeb = new Debouncer(EVENT_DEBOUNCE_MS); /** When true, prevents auto-enable on pattern detection */ private _autoEnableDisabled: boolean = true; @@ -996,74 +990,34 @@ export class RalphTracker extends EventEmitter { * Clear all debounce timers. */ private clearDebounceTimers(): void { - if (this._todoUpdateTimer) { - clearTimeout(this._todoUpdateTimer); - this._todoUpdateTimer = null; - } - if (this._loopUpdateTimer) { - clearTimeout(this._loopUpdateTimer); - this._loopUpdateTimer = null; - } - this._todoUpdatePending = false; - this._loopUpdatePending = false; + this._todoDeb.cancel(); + this._loopDeb.cancel(); } /** * Emit todoUpdate event with debouncing. */ private emitTodoUpdateDebounced(): void { - this._todoUpdatePending = true; - - 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); + this._todoDeb.schedule(() => this.emit('todoUpdate', this.todos)); } /** * Emit loopUpdate event with debouncing. */ private emitLoopUpdateDebounced(): void { - this._loopUpdatePending = true; - - 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); + this._loopDeb.schedule(() => this.emit('loopUpdate', this.loopState)); } /** * Flush all pending debounced events immediately. */ flushPendingEvents(): void { - if (this._todoUpdatePending) { - this._todoUpdatePending = false; - if (this._todoUpdateTimer) { - clearTimeout(this._todoUpdateTimer); - this._todoUpdateTimer = null; - } + if (this._todoDeb.isPending) { + this._todoDeb.cancel(); this.emit('todoUpdate', this.todos); } - if (this._loopUpdatePending) { - this._loopUpdatePending = false; - if (this._loopUpdateTimer) { - clearTimeout(this._loopUpdateTimer); - this._loopUpdateTimer = null; - } + if (this._loopDeb.isPending) { + this._loopDeb.cancel(); this.emit('loopUpdate', this.loopState); } } @@ -2373,7 +2327,8 @@ export class RalphTracker extends EventEmitter { * Clean up all resources and release memory. */ destroy(): void { - this.clearDebounceTimers(); + this._todoDeb.dispose(); + this._loopDeb.dispose(); this.fixPlanWatcher.destroy(); this.stallDetector.destroy(); this.statusParser.destroy(); diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index a42faee0..82d755ac 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -41,7 +41,7 @@ import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle- import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js'; import type { TeamWatcher } from './team-watcher.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 { isCompletionMessage, @@ -643,27 +643,15 @@ export class RespawnController extends EventEmitter { /** Current state machine state */ private _state: RespawnState = 'stopped'; - /** Timer for step delays */ - private stepTimer: NodeJS.Timeout | null = null; + /** Centralized timer lifecycle manager — disposed and recreated on clearTimers() */ + private cleanup = new CleanupManager(); - /** Timer for completion confirmation (Layer 2) */ - private completionConfirmTimer: NodeJS.Timeout | null = null; - - /** 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; + /** Maps timer names to CleanupManager registration IDs (for individual cancel) */ + private timerIds = new Map(); /** Cached key fields from last emitted detection status (for dedup) */ 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 */ private hasReceivedOutput: boolean = false; @@ -684,9 +672,6 @@ export class RespawnController extends EventEmitter { /** Timestamp when idle_prompt was received */ 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) */ 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) */ 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 */ private static readonly CLEAR_FALLBACK_TIMEOUT_MS = 10000; @@ -743,9 +722,6 @@ export class RespawnController extends EventEmitter { /** Timestamp when the current state was entered */ 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 */ private stuckStateWarned: boolean = false; @@ -1116,7 +1092,7 @@ export class RespawnController extends EventEmitter { this.stopDetectionUpdates(); if (this._state === 'stopped') return; this.lastEmittedDetectionKey = ''; - this.detectionUpdateTimer = setInterval(() => { + const id = this.cleanup.setInterval(() => { try { if (this._state !== 'stopped') { const status = this.getDetectionStatus(); @@ -1131,17 +1107,15 @@ export class RespawnController extends EventEmitter { } catch (err) { console.error(`[RespawnController] Error in detectionUpdateTimer:`, err); } - }, 2000); + }, 2000, { description: 'detection-update' }); + this.timerIds.set('detection-update', id); } /** * Stop periodic detection status updates. */ private stopDetectionUpdates(): void { - if (this.detectionUpdateTimer) { - clearInterval(this.detectionUpdateTimer); - this.detectionUpdateTimer = null; - } + this.cancelTrackedTimer('detection-update'); } /** @@ -1266,7 +1240,6 @@ export class RespawnController extends EventEmitter { this.aiChecker.removeAllListeners(); this.planChecker.removeAllListeners(); this.clearTimers(); - this.stopDetectionUpdates(); this.recentActions.length = 0; this.setState('stopped'); if (this.terminalHandler) { @@ -1431,8 +1404,8 @@ export class RespawnController extends EventEmitter { this.lastWorkingPatternTime = now; // Cancel hook confirmation timer if running - this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimer, 'working patterns detected'); - this.hookConfirmTimer = null; + this.cancelTrackedTimer('hook-confirm','working patterns detected'); + // Cancel any pending completion confirmation this.cancelCompletionConfirm(); @@ -1564,8 +1537,8 @@ export class RespawnController extends EventEmitter { */ private checkClearComplete(): void { // Clear the fallback timer since we got prompt detection - this.cancelTrackedTimer('clear-fallback', this.clearFallbackTimer, 'prompt detected'); - this.clearFallbackTimer = null; + this.cancelTrackedTimer('clear-fallback','prompt detected'); + this.logAction('step', '/clear completed'); this.emit('stepCompleted', 'clear'); @@ -1613,11 +1586,11 @@ export class RespawnController extends EventEmitter { this.logAction('step', 'Monitoring if /init triggered work...'); // Give Claude a moment to start working before checking for idle - this.stepTimer = this.startTrackedTimer( + this.startTrackedTimer( 'init-monitor', 3000, () => { - this.stepTimer = null; + // If still in monitoring state and no work detected, consider it idle if (this._state === 'monitoring_init' && !this.workingDetected) { this.checkMonitoringInitIdle(); @@ -1633,10 +1606,7 @@ export class RespawnController extends EventEmitter { * @fires stepCompleted - With step 'init' */ private checkMonitoringInitIdle(): void { - if (this.stepTimer) { - clearTimeout(this.stepTimer); - this.stepTimer = null; - } + this.cancelTrackedTimer('init-monitor'); this.log('/init did not trigger work, sending kickstart prompt'); this.emit('stepCompleted', 'init'); this.sendKickstart(); @@ -1651,11 +1621,11 @@ export class RespawnController extends EventEmitter { this.terminalBuffer.clear(); this.clearWorkingPatternWindow(); - this.stepTimer = this.startTrackedTimer( + this.startTrackedTimer( 'step-delay', this.config.interStepDelayMs, async () => { - this.stepTimer = null; + if (this._state === 'stopped') return; const prompt = this.config.kickstartPrompt!; 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) */ private clearTimers(): void { - // Clear tracked timers map first to avoid stale entries during individual cleanup this.activeTimers.clear(); - if (this.stepTimer) { - clearTimeout(this.stepTimer); - this.stepTimer = null; - } - 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; - } + this.timerIds.clear(); + this.cleanup.dispose(); + this.cleanup = new CleanupManager(); } // ========== Stuck-State Detection Methods ========== @@ -1740,21 +1672,19 @@ export class RespawnController extends EventEmitter { if (this._state === 'stopped') return; // Clear existing timer - if (this.stuckStateTimer) { - clearInterval(this.stuckStateTimer); - this.stuckStateTimer = null; - } + this.cancelTrackedTimer('stuck-state'); // Check interval for stuck state const checkIntervalMs = Math.min(this.config.stuckStateWarningMs, 60000); // Check every minute max - this.stuckStateTimer = setInterval(() => { + const id = this.cleanup.setInterval(() => { try { this.checkStuckState(); } catch (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. * 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 endsAt = now + durationMs; @@ -1903,19 +1836,23 @@ export class RespawnController extends EventEmitter { this.emit('timerStarted', { name, durationMs, endsAt, 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.emit('timerCompleted', name); callback(); - }, durationMs); + }, durationMs, { description: name }); + this.timerIds.set(name, id); } /** * Cancel a tracked timer and emit cancellation event. */ - private cancelTrackedTimer(name: string, timerRef: NodeJS.Timeout | null, reason?: string): void { - if (timerRef) { - clearTimeout(timerRef); + private cancelTrackedTimer(name: string, reason?: string): void { + const id = this.timerIds.get(name); + if (id) { + this.cleanup.unregister(id); + this.timerIds.delete(name); if (this.activeTimers.has(name)) { this.activeTimers.delete(name); 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). */ private startNoOutputTimer(): void { - this.cancelTrackedTimer('no-output-fallback', this.noOutputTimer, 'restarting'); - this.noOutputTimer = null; + this.cancelTrackedTimer('no-output-fallback','restarting'); - this.noOutputTimer = this.startTrackedTimer( + + this.startTrackedTimer( 'no-output-fallback', this.config.noOutputTimeoutMs, () => { - this.noOutputTimer = null; + if (this._state === 'watching' || this._state === 'confirming_idle') { const msSinceOutput = Date.now() - this.lastOutputTime; 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. */ private startPreFilterTimer(): void { - this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'restarting'); - this.preFilterTimer = null; + this.cancelTrackedTimer('pre-filter','restarting'); + // Only set up pre-filter when AI check is enabled if (!this.config.aiIdleCheckEnabled) return; - this.preFilterTimer = this.startTrackedTimer( + this.startTrackedTimer( 'pre-filter', this.config.completionConfirmMs, () => { - this.preFilterTimer = null; + if (this._state === 'watching') { const now = Date.now(); const msSinceOutput = now - this.lastOutputTime; @@ -2143,18 +2080,18 @@ export class RespawnController extends EventEmitter { if (result.verdict === 'IDLE') { // Cancel any pending confirmation timers - AI has spoken - this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: IDLE'); - this.completionConfirmTimer = null; - this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE'); - this.preFilterTimer = null; + this.cancelTrackedTimer('completion-confirm','AI verdict: IDLE'); + + this.cancelTrackedTimer('pre-filter','AI verdict: IDLE'); + this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`); this.emit('aiCheckCompleted', result); this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`); } else if (result.verdict === 'WORKING') { // Cancel timers and go to cooldown - this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: WORKING'); - this.completionConfirmTimer = null; + this.cancelTrackedTimer('completion-confirm','AI verdict: WORKING'); + this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`); this.emit('aiCheckCompleted', result); @@ -2210,14 +2147,14 @@ export class RespawnController extends EventEmitter { * and no elicitation dialog was detected. Only handles plan mode approvals. */ private startAutoAcceptTimer(): void { - this.cancelTrackedTimer('auto-accept', this.autoAcceptTimer, 'restarting'); - this.autoAcceptTimer = null; + this.cancelTrackedTimer('auto-accept','restarting'); - this.autoAcceptTimer = this.startTrackedTimer( + + this.startTrackedTimer( 'auto-accept', this.config.autoAcceptDelayMs, () => { - this.autoAcceptTimer = null; + this.tryAutoAccept(); }, 'plan mode detection' @@ -2229,8 +2166,8 @@ export class RespawnController extends EventEmitter { * Called when a completion message is detected (normal idle flow handles it). */ private cancelAutoAcceptTimer(): void { - this.cancelTrackedTimer('auto-accept', this.autoAcceptTimer, 'cancelled'); - this.autoAcceptTimer = null; + this.cancelTrackedTimer('auto-accept','cancelled'); + } /** @@ -2391,8 +2328,8 @@ export class RespawnController extends EventEmitter { } // Cancel completion confirmation - auto-accept takes precedence - this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'auto-accept'); - this.completionConfirmTimer = null; + this.cancelTrackedTimer('completion-confirm','auto-accept'); + this.completionMessageTime = null; // 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 - this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'Stop hook received'); - this.completionConfirmTimer = null; + this.cancelTrackedTimer('completion-confirm','Stop hook received'); + // Cancel pre-filter timer - hook takes precedence - this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'Stop hook received'); - this.preFilterTimer = null; + this.cancelTrackedTimer('pre-filter','Stop hook received'); + // Start short confirmation timer to handle race conditions // (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 - this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'idle_prompt received'); - this.completionConfirmTimer = null; - this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'idle_prompt received'); - this.preFilterTimer = null; - this.cancelTrackedTimer('no-output-fallback', this.noOutputTimer, 'idle_prompt received'); - this.noOutputTimer = null; + this.cancelTrackedTimer('completion-confirm','idle_prompt received'); + + this.cancelTrackedTimer('pre-filter','idle_prompt received'); + + this.cancelTrackedTimer('no-output-fallback','idle_prompt received'); + // idle_prompt is an even stronger signal than Stop hook (60s+ 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') */ private startHookConfirmTimer(hookType: 'stop' | 'idle_prompt'): void { - this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimer, 'restarting'); - this.hookConfirmTimer = null; + this.cancelTrackedTimer('hook-confirm','restarting'); - this.hookConfirmTimer = this.startTrackedTimer( + + this.startTrackedTimer( 'hook-confirm', RespawnController.HOOK_CONFIRM_DELAY_MS, () => { - this.hookConfirmTimer = null; + // Verify we haven't received new output since the hook arrived 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. */ private startCompletionConfirmTimer(): void { - this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'restarting'); - this.completionConfirmTimer = null; + this.cancelTrackedTimer('completion-confirm','restarting'); + this.setState('confirming_idle'); this.logAction('detection', 'Completion message found in output'); - this.completionConfirmTimer = this.startTrackedTimer( + this.startTrackedTimer( 'completion-confirm', this.config.completionConfirmMs, () => { - this.completionConfirmTimer = null; + if (this._state === 'stopped') return; const msSinceOutput = Date.now() - this.lastOutputTime; if (msSinceOutput >= this.config.completionConfirmMs) { @@ -2616,8 +2553,8 @@ export class RespawnController extends EventEmitter { * Cancel completion confirmation if new activity detected. */ private cancelCompletionConfirm(): void { - this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'activity detected'); - this.completionConfirmTimer = null; + this.cancelTrackedTimer('completion-confirm','activity detected'); + if (this._state === 'confirming_idle') { this.setState('watching'); this.completionMessageTime = null; @@ -2630,14 +2567,14 @@ export class RespawnController extends EventEmitter { * This ensures Claude has finished processing before we send the next command. */ private startStepConfirmTimer(step: 'update' | 'init' | 'kickstart'): void { - this.cancelTrackedTimer('step-confirm', this.stepConfirmTimer, 'restarting'); - this.stepConfirmTimer = null; + this.cancelTrackedTimer('step-confirm','restarting'); - this.stepConfirmTimer = this.startTrackedTimer( + + this.startTrackedTimer( 'step-confirm', this.config.completionConfirmMs, () => { - this.stepConfirmTimer = null; + if (this._state === 'stopped') return; const msSinceOutput = Date.now() - this.lastOutputTime; @@ -2670,8 +2607,8 @@ export class RespawnController extends EventEmitter { * Cancel step confirmation if working patterns detected. */ private cancelStepConfirm(): void { - this.cancelTrackedTimer('step-confirm', this.stepConfirmTimer, 'working detected'); - this.stepConfirmTimer = null; + this.cancelTrackedTimer('step-confirm','working detected'); + } /** @@ -2833,11 +2770,11 @@ export class RespawnController extends EventEmitter { this.terminalBuffer.clear(); // Clear buffer for fresh detection this.clearWorkingPatternWindow(); // Clear rolling window - this.stepTimer = this.startTrackedTimer( + this.startTrackedTimer( 'step-delay', this.config.interStepDelayMs, async () => { - this.stepTimer = null; + if (this._state === 'stopped') return; // Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config @@ -2874,11 +2811,11 @@ export class RespawnController extends EventEmitter { this.terminalBuffer.clear(); this.clearWorkingPatternWindow(); - this.stepTimer = this.startTrackedTimer( + this.startTrackedTimer( 'step-delay', this.config.interStepDelayMs, async () => { - this.stepTimer = null; + if (this._state === 'stopped') return; this.logAction('command', 'Sending: /clear'); 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; // Start fallback timer - if no prompt detected after 10s, proceed to /init anyway - this.clearFallbackTimer = this.startTrackedTimer( + this.startTrackedTimer( 'clear-fallback', RespawnController.CLEAR_FALLBACK_TIMEOUT_MS, () => { - this.clearFallbackTimer = null; + if (this._state === 'waiting_clear') { this.logAction('step', '/clear fallback: proceeding to /init'); this.emit('stepCompleted', 'clear'); @@ -2918,11 +2855,11 @@ export class RespawnController extends EventEmitter { this.terminalBuffer.clear(); this.clearWorkingPatternWindow(); - this.stepTimer = this.startTrackedTimer( + this.startTrackedTimer( 'step-delay', this.config.interStepDelayMs, async () => { - this.stepTimer = null; + if (this._state === 'stopped') return; this.logAction('command', 'Sending: /init'); await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI