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
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
+15 -60
View File
@@ -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();
+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 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<string, string>();
/** 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