perf: implement phase 1-3 performance optimizations

Add implementation plans and code structure analysis for a 3-phase
performance optimization effort. Refactor core modules to reduce
timer overhead, consolidate regex usage, extract exec timeout config,
add debouncer utility, and streamline server/schema validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-28 17:39:44 +01:00
co-authored by Claude Opus 4.6
parent 562b14ab61
commit e0a2774d37
26 changed files with 4443 additions and 624 deletions
+35 -40
View File
@@ -15,6 +15,7 @@
import { EventEmitter } from 'node:events';
import { v4 as uuidv4 } from 'uuid';
import { ActiveBashTool } from './types.js';
import { CleanupManager, Debouncer } from './utils/index.js';
// ========== Configuration Constants ==========
@@ -145,15 +146,14 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
private _workingDir: string;
private _homeDir: string;
// Track auto-remove timers for cleanup
private _autoRemoveTimers: Set<ReturnType<typeof setTimeout>> = new Set();
// Centralized resource cleanup for auto-remove timers
private cleanup = new CleanupManager();
// Flag to prevent operations after destroy
private _destroyed: boolean = false;
// Debouncing
private _pendingUpdate: boolean = false;
private _updateTimer: ReturnType<typeof setTimeout> | null = null;
private _updateDeb = new Debouncer(EVENT_DEBOUNCE_MS);
constructor(config: BashToolParserConfig) {
super();
@@ -524,13 +524,15 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
this.scheduleUpdate();
// Remove completed tool after a short delay to allow UI to show completion
const timer = setTimeout(() => {
this._autoRemoveTimers.delete(timer);
if (this._destroyed) return;
this._activeTools.delete(tool.id);
this.scheduleUpdate();
}, 2000);
this._autoRemoveTimers.add(timer);
this.cleanup.setTimeout(
() => {
if (this._destroyed) return;
this._activeTools.delete(tool.id);
this.scheduleUpdate();
},
2000,
{ description: 'auto-remove completed tool' }
);
}
this._lastToolId = null;
return;
@@ -562,13 +564,15 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
this.scheduleUpdate();
// Auto-remove suggestions after 30 seconds
const timer = setTimeout(() => {
this._autoRemoveTimers.delete(timer);
if (this._destroyed) return;
this._activeTools.delete(tool.id);
this.scheduleUpdate();
}, 30000);
this._autoRemoveTimers.add(timer);
this.cleanup.setTimeout(
() => {
if (this._destroyed) return;
this._activeTools.delete(tool.id);
this.scheduleUpdate();
},
30000,
{ description: 'auto-remove suggestion tool' }
);
return;
}
@@ -599,13 +603,15 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
this.scheduleUpdate();
// Auto-remove after 60 seconds
const timer = setTimeout(() => {
this._autoRemoveTimers.delete(timer);
if (this._destroyed) return;
this._activeTools.delete(tool.id);
this.scheduleUpdate();
}, 60000);
this._autoRemoveTimers.add(timer);
this.cleanup.setTimeout(
() => {
if (this._destroyed) return;
this._activeTools.delete(tool.id);
this.scheduleUpdate();
},
60000,
{ description: 'auto-remove log file tool' }
);
}
}
@@ -675,13 +681,9 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
* Schedule a debounced update emission.
*/
private scheduleUpdate(): void {
if (this._pendingUpdate) return;
this._pendingUpdate = true;
this._updateTimer = setTimeout(() => {
this._pendingUpdate = false;
this._updateDeb.schedule(() => {
this.emitUpdate();
}, EVENT_DEBOUNCE_MS);
});
}
/**
@@ -696,15 +698,8 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
*/
destroy(): void {
this._destroyed = true;
if (this._updateTimer) {
clearTimeout(this._updateTimer);
this._updateTimer = null;
}
// Clear all auto-remove timers to prevent orphaned callbacks
for (const timer of this._autoRemoveTimers) {
clearTimeout(timer);
}
this._autoRemoveTimers.clear();
this._updateDeb.dispose();
this.cleanup.dispose();
this._activeTools.clear();
this.removeAllListeners();
}
+10
View File
@@ -0,0 +1,10 @@
/**
* @fileoverview Shared exec timeout constant.
*
* Used by CLI resolvers and tmux-manager for execSync/exec calls.
*
* @module config/exec-timeout
*/
/** Timeout for exec commands (5 seconds) */
export const EXEC_TIMEOUT_MS = 5000;
+17 -31
View File
@@ -13,6 +13,7 @@ import { watch, type FSWatcher } from 'chokidar';
import { basename, extname, relative } from 'node:path';
import { statSync } from 'node:fs';
import type { ImageDetectedEvent } from './types.js';
import { KeyedDebouncer } from './utils/index.js';
// ========== Types ==========
@@ -65,11 +66,11 @@ export class ImageWatcher extends EventEmitter {
/** Map of sessionId -> working directory path */
private sessionDirs = new Map<string, string>();
/** Debounce timers for rapid image creation (keyed by filePath) */
private debounceTimers = new Map<string, NodeJS.Timeout>();
/** Per-file debouncer for rapid image creation */
private fileDeb = new KeyedDebouncer(DEBOUNCE_DELAY_MS);
/** Track which session owns each debounce timer (for cleanup) */
private timerToSession = new Map<string, string>();
/** Track which session owns each debounced file (for cleanup) */
private fileToSession = new Map<string, string>();
/** Per-session burst tracking: sessionId -> { count, windowStart } */
private burstTrackers = new Map<string, { count: number; windowStart: number }>();
@@ -118,11 +119,8 @@ export class ImageWatcher extends EventEmitter {
this.sessionDirs.clear();
// Clear all debounce timers
for (const timer of this.debounceTimers.values()) {
clearTimeout(timer);
}
this.debounceTimers.clear();
this.timerToSession.clear();
this.fileDeb.dispose();
this.fileToSession.clear();
this.burstTrackers.clear();
}
@@ -212,20 +210,15 @@ export class ImageWatcher extends EventEmitter {
this.sessionDirs.delete(sessionId);
// Clear any pending debounce timers for this session
// Collect keys first to avoid iterator invalidation during deletion
const toDelete: string[] = [];
for (const [filePath, ownerId] of this.timerToSession) {
const toCancel: string[] = [];
for (const [filePath, ownerId] of this.fileToSession) {
if (ownerId === sessionId) {
toDelete.push(filePath);
toCancel.push(filePath);
}
}
for (const filePath of toDelete) {
const timer = this.debounceTimers.get(filePath);
if (timer) {
clearTimeout(timer);
this.debounceTimers.delete(filePath);
}
this.timerToSession.delete(filePath);
for (const filePath of toCancel) {
this.fileDeb.cancelKey(filePath);
this.fileToSession.delete(filePath);
}
this.burstTrackers.delete(sessionId);
}
@@ -269,22 +262,15 @@ export class ImageWatcher extends EventEmitter {
}
// Debounce rapid file creation (e.g., multiple screenshots quickly)
const existingTimer = this.debounceTimers.get(filePath);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(() => {
this.debounceTimers.delete(filePath);
this.timerToSession.delete(filePath);
this.fileDeb.schedule(filePath, () => {
this.fileToSession.delete(filePath);
this.emitImageDetected(sessionId, filePath);
// Increment burst count on actual emission (not on detection)
const b = this.burstTrackers.get(sessionId);
if (b) b.count++;
}, DEBOUNCE_DELAY_MS);
});
this.debounceTimers.set(filePath, timer);
this.timerToSession.set(filePath, sessionId);
this.fileToSession.set(filePath, sessionId);
}
/**
+4 -11
View File
@@ -11,6 +11,7 @@ import { join } from 'node:path';
import { homedir } from 'node:os';
import webpush from 'web-push';
import type { VapidKeys, PushSubscriptionRecord } from './types.js';
import { Debouncer } from './utils/index.js';
const DATA_DIR = join(homedir(), '.codeman');
const KEYS_FILE = join(DATA_DIR, 'push-keys.json');
@@ -20,7 +21,7 @@ const SAVE_DEBOUNCE_MS = 500;
export class PushSubscriptionStore {
private vapidKeys: VapidKeys | null = null;
private subscriptions: Map<string, PushSubscriptionRecord> = new Map();
private saveTimer: NodeJS.Timeout | null = null;
private saveDeb = new Debouncer(SAVE_DEBOUNCE_MS);
private _disposed = false;
constructor() {
@@ -149,10 +150,7 @@ export class PushSubscriptionStore {
/** Schedule a debounced save */
private scheduleSave(): void {
if (this._disposed) return;
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => {
this.flushSave();
}, SAVE_DEBOUNCE_MS);
this.saveDeb.schedule(() => this.flushSave());
}
/** Immediately persist subscriptions to disk */
@@ -169,11 +167,6 @@ export class PushSubscriptionStore {
dispose(): void {
if (this._disposed) return;
this._disposed = true;
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
// Final flush
this.flushSave();
this.saveDeb.flush(() => this.flushSave());
}
}
+45 -82
View File
@@ -34,7 +34,14 @@ import {
PlanTaskStatus,
TddPhase,
} from './types.js';
import { ANSI_ESCAPE_PATTERN_SIMPLE, fuzzyPhraseMatch, todoContentHash, stringSimilarity } from './utils/index.js';
import {
ANSI_ESCAPE_PATTERN_SIMPLE,
CleanupManager,
Debouncer,
fuzzyPhraseMatch,
todoContentHash,
stringSimilarity,
} from './utils/index.js';
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js';
import { MAX_TODOS_PER_SESSION } from './config/map-limits.js';
@@ -559,17 +566,14 @@ 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;
/** Centralized resource cleanup for timers/intervals */
private cleanup = new CleanupManager();
/** Debounce timer for loopUpdate events */
private _loopUpdateTimer: NodeJS.Timeout | null = null;
/** Debouncer for todoUpdate events */
private _todoUpdateDeb = new Debouncer(EVENT_DEBOUNCE_MS);
/** Flag indicating pending todoUpdate emission */
private _todoUpdatePending: boolean = false;
/** Flag indicating pending loopUpdate emission */
private _loopUpdatePending: boolean = false;
/** Debouncer for loopUpdate events */
private _loopUpdateDeb = new Debouncer(EVENT_DEBOUNCE_MS);
/** When true, prevents auto-enable on pattern detection */
private _autoEnableDisabled: boolean = true;
@@ -621,8 +625,8 @@ export class RalphTracker extends EventEmitter {
/** Error handler for FSWatcher (stored for cleanup to prevent memory leak) */
private _fixPlanWatcherErrorHandler: ((err: Error) => void) | null = null;
/** Debounce timer for file change events */
private _fixPlanReloadTimer: NodeJS.Timeout | null = null;
/** Debouncer for file change events */
private _fixPlanReloadDeb = new Debouncer(500);
/** Path to the @fix_plan.md file being watched */
private _fixPlanPath: string | null = null;
@@ -665,8 +669,8 @@ export class RalphTracker extends EventEmitter {
/** Last observed iteration count for stall detection */
private _lastObservedIteration: number = 0;
/** Timer for iteration stall detection */
private _iterationStallTimer: NodeJS.Timeout | null = null;
/** CleanupManager registration ID for iteration stall detection interval */
private _iterationStallTimerId: string | null = null;
/** Iteration stall warning threshold (ms) - default 10 minutes */
private _iterationStallWarningMs: number = 10 * 60 * 1000;
@@ -869,14 +873,9 @@ export class RalphTracker extends EventEmitter {
*/
private handleFixPlanChange(): void {
// Debounce rapid changes (e.g., multiple writes)
if (this._fixPlanReloadTimer) {
clearTimeout(this._fixPlanReloadTimer);
}
this._fixPlanReloadTimer = setTimeout(() => {
this._fixPlanReloadTimer = null;
this._fixPlanReloadDeb.schedule(() => {
this.loadFixPlanFromDisk();
}, 500); // 500ms debounce
});
}
/**
@@ -892,10 +891,7 @@ export class RalphTracker extends EventEmitter {
this._fixPlanWatcher.close();
this._fixPlanWatcher = null;
}
if (this._fixPlanReloadTimer) {
clearTimeout(this._fixPlanReloadTimer);
this._fixPlanReloadTimer = null;
}
this._fixPlanReloadDeb.cancel();
}
/**
@@ -1023,16 +1019,8 @@ export class RalphTracker extends EventEmitter {
* Called during reset/fullReset to prevent stale emissions.
*/
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._todoUpdateDeb.cancel();
this._loopUpdateDeb.cancel();
}
/**
@@ -1041,19 +1029,9 @@ export class RalphTracker extends EventEmitter {
* The event fires after EVENT_DEBOUNCE_MS of inactivity.
*/
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._todoUpdateDeb.schedule(() => {
this.emit('todoUpdate', this.todos);
});
}
/**
@@ -1062,19 +1040,9 @@ export class RalphTracker extends EventEmitter {
* The event fires after EVENT_DEBOUNCE_MS of inactivity.
*/
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._loopUpdateDeb.schedule(() => {
this.emit('loopUpdate', this.loopState);
});
}
/**
@@ -1082,21 +1050,11 @@ export class RalphTracker extends EventEmitter {
* Useful for testing or when immediate state sync is needed.
*/
flushPendingEvents(): void {
if (this._todoUpdatePending) {
this._todoUpdatePending = false;
if (this._todoUpdateTimer) {
clearTimeout(this._todoUpdateTimer);
this._todoUpdateTimer = null;
}
this.emit('todoUpdate', this.todos);
if (this._todoUpdateDeb.isPending) {
this._todoUpdateDeb.flush(() => this.emit('todoUpdate', this.todos));
}
if (this._loopUpdatePending) {
this._loopUpdatePending = false;
if (this._loopUpdateTimer) {
clearTimeout(this._loopUpdateTimer);
this._loopUpdateTimer = null;
}
this.emit('loopUpdate', this.loopState);
if (this._loopUpdateDeb.isPending) {
this._loopUpdateDeb.flush(() => this.emit('loopUpdate', this.loopState));
}
}
@@ -1116,18 +1074,22 @@ export class RalphTracker extends EventEmitter {
this._iterationStallWarned = false;
// Check every minute
this._iterationStallTimer = setInterval(() => {
this.checkIterationStall();
}, 60 * 1000);
this._iterationStallTimerId = this.cleanup.setInterval(
() => {
this.checkIterationStall();
},
60 * 1000,
{ description: 'iteration stall detection' }
);
}
/**
* Stop iteration stall detection timer.
*/
stopIterationStallDetection(): void {
if (this._iterationStallTimer) {
clearInterval(this._iterationStallTimer);
this._iterationStallTimer = null;
if (this._iterationStallTimerId) {
this.cleanup.unregister(this._iterationStallTimerId);
this._iterationStallTimerId = null;
}
}
@@ -3888,7 +3850,8 @@ export class RalphTracker extends EventEmitter {
destroy(): void {
this.clearDebounceTimers();
this.stopWatchingFixPlan();
this.stopIterationStallDetection();
this._fixPlanReloadDeb.dispose();
this.cleanup.dispose();
this._todos.clear();
this._taskNumberToContent.clear();
this._todoStartTimes.clear();
+153 -164
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, TOKEN_PATTERN, assertNever } from './utils/index.js';
import { ANSI_ESCAPE_PATTERN_SIMPLE, TOKEN_PATTERN, 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 type {
RespawnCycleMetrics,
@@ -641,26 +641,29 @@ export class RespawnController extends EventEmitter {
/** Current state machine state */
private _state: RespawnState = 'stopped';
/** Timer for step delays */
private stepTimer: NodeJS.Timeout | null = null;
/** Centralized resource cleanup manager for timers */
private cleanup = new CleanupManager();
/** Timer for completion confirmation (Layer 2) */
private completionConfirmTimer: NodeJS.Timeout | null = null;
/** Timer ID for step delays */
private stepTimerId: string | null = null;
/** Timer for no-output fallback (Layer 5) */
private noOutputTimer: NodeJS.Timeout | null = null;
/** Timer ID for completion confirmation (Layer 2) */
private completionConfirmTimerId: string | null = null;
/** Timer for periodic detection status updates */
private detectionUpdateTimer: NodeJS.Timeout | null = null;
/** Timer ID for no-output fallback (Layer 5) */
private noOutputTimerId: string | null = null;
/** Timer ID for periodic detection status updates */
private detectionUpdateTimerId: string | null = null;
/** 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 ID for auto-accepting plan mode prompts */
private autoAcceptTimerId: string | null = null;
/** Timer for pre-filter silence detection (triggers AI check) */
private preFilterTimer: NodeJS.Timeout | null = null;
/** Timer ID for pre-filter silence detection (triggers AI check) */
private preFilterTimerId: string | null = null;
/** Whether any terminal output has been received since start/last-auto-accept */
private hasReceivedOutput: boolean = false;
@@ -682,8 +685,8 @@ 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;
/** Timer ID for short confirmation after hook signal (handles race conditions) */
private hookConfirmTimerId: string | null = null;
/** Confirmation delay after hook signal before confirming idle (ms) */
private static readonly HOOK_CONFIRM_DELAY_MS = 3000;
@@ -718,11 +721,11 @@ 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 ID for /clear step fallback (sends /init if no prompt detected) */
private clearFallbackTimerId: string | null = null;
/** Timer for step completion confirmation (waits for silence after completion) */
private stepConfirmTimer: NodeJS.Timeout | null = null;
/** Timer ID for step completion confirmation (waits for silence after completion) */
private stepConfirmTimerId: string | null = null;
/** Fallback timeout for /clear step (ms) - sends /init without waiting for prompt */
private static readonly CLEAR_FALLBACK_TIMEOUT_MS = 10000;
@@ -741,8 +744,8 @@ 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;
/** Timer ID for stuck-state detection */
private stuckStateTimerId: string | null = null;
/** Whether a stuck-state warning has been emitted for current state */
private stuckStateWarned: boolean = false;
@@ -1200,31 +1203,35 @@ export class RespawnController extends EventEmitter {
this.stopDetectionUpdates();
if (this._state === 'stopped') return;
this.lastEmittedDetectionKey = '';
this.detectionUpdateTimer = setInterval(() => {
try {
if (this._state !== 'stopped') {
const status = this.getDetectionStatus();
// Only emit when status meaningfully changed (confidence, state text, or timer values)
// to avoid broadcasting identical data every 2s for stable/idle sessions.
const key = `${status.confidenceLevel}|${status.statusText}|${this._state}`;
if (key !== this.lastEmittedDetectionKey) {
this.lastEmittedDetectionKey = key;
this.emit('detectionUpdate', status);
this.detectionUpdateTimerId = this.cleanup.setInterval(
() => {
try {
if (this._state !== 'stopped') {
const status = this.getDetectionStatus();
// Only emit when status meaningfully changed (confidence, state text, or timer values)
// to avoid broadcasting identical data every 2s for stable/idle sessions.
const key = `${status.confidenceLevel}|${status.statusText}|${this._state}`;
if (key !== this.lastEmittedDetectionKey) {
this.lastEmittedDetectionKey = key;
this.emit('detectionUpdate', status);
}
}
} catch (err) {
console.error(`[RespawnController] Error in detectionUpdateTimer:`, err);
}
} catch (err) {
console.error(`[RespawnController] Error in detectionUpdateTimer:`, err);
}
}, 2000);
},
2000,
{ description: 'detection status updates' }
);
}
/**
* Stop periodic detection status updates.
*/
private stopDetectionUpdates(): void {
if (this.detectionUpdateTimer) {
clearInterval(this.detectionUpdateTimer);
this.detectionUpdateTimer = null;
if (this.detectionUpdateTimerId) {
this.cleanup.unregister(this.detectionUpdateTimerId);
this.detectionUpdateTimerId = null;
}
}
@@ -1515,8 +1522,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', this.hookConfirmTimerId, 'working patterns detected');
this.hookConfirmTimerId = null;
// Cancel any pending completion confirmation
this.cancelCompletionConfirm();
@@ -1650,8 +1657,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', this.clearFallbackTimerId, 'prompt detected');
this.clearFallbackTimerId = null;
this.logAction('step', '/clear completed');
this.emit('stepCompleted', 'clear');
@@ -1699,11 +1706,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.stepTimerId = this.startTrackedTimer(
'init-monitor',
3000,
() => {
this.stepTimer = null;
this.stepTimerId = null;
// If still in monitoring state and no work detected, consider it idle
if (this._state === 'monitoring_init' && !this.workingDetected) {
this.checkMonitoringInitIdle();
@@ -1719,9 +1726,9 @@ export class RespawnController extends EventEmitter {
* @fires stepCompleted - With step 'init'
*/
private checkMonitoringInitIdle(): void {
if (this.stepTimer) {
clearTimeout(this.stepTimer);
this.stepTimer = null;
if (this.stepTimerId) {
this.cleanup.unregister(this.stepTimerId);
this.stepTimerId = null;
}
this.log('/init did not trigger work, sending kickstart prompt');
this.emit('stepCompleted', 'init');
@@ -1737,11 +1744,11 @@ export class RespawnController extends EventEmitter {
this.terminalBuffer.clear();
this.clearWorkingPatternWindow();
this.stepTimer = this.startTrackedTimer(
this.stepTimerId = this.startTrackedTimer(
'step-delay',
this.config.interStepDelayMs,
async () => {
this.stepTimer = null;
this.stepTimerId = null;
if (this._state === 'stopped') return;
const prompt = this.config.kickstartPrompt!;
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
@@ -1773,46 +1780,20 @@ export class RespawnController extends EventEmitter {
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.cleanup.dispose();
// Reinitialize for reuse (controller can be stopped and restarted)
this.cleanup = new CleanupManager();
// Null out IDs
this.stepTimerId = null;
this.completionConfirmTimerId = null;
this.noOutputTimerId = null;
this.detectionUpdateTimerId = null;
this.autoAcceptTimerId = null;
this.preFilterTimerId = null;
this.hookConfirmTimerId = null;
this.clearFallbackTimerId = null;
this.stepConfirmTimerId = null;
this.stuckStateTimerId = null;
}
// ========== Stuck-State Detection Methods ==========
@@ -1826,21 +1807,25 @@ export class RespawnController extends EventEmitter {
if (this._state === 'stopped') return;
// Clear existing timer
if (this.stuckStateTimer) {
clearInterval(this.stuckStateTimer);
this.stuckStateTimer = null;
if (this.stuckStateTimerId) {
this.cleanup.unregister(this.stuckStateTimerId);
this.stuckStateTimerId = null;
}
// Check interval for stuck state
const checkIntervalMs = Math.min(this.config.stuckStateWarningMs, 60000); // Check every minute max
this.stuckStateTimer = setInterval(() => {
try {
this.checkStuckState();
} catch (err) {
console.error(`[RespawnController] Error in stuckStateTimer:`, err);
}
}, checkIntervalMs);
this.stuckStateTimerId = this.cleanup.setInterval(
() => {
try {
this.checkStuckState();
} catch (err) {
console.error(`[RespawnController] Error in stuckStateTimer:`, err);
}
},
checkIntervalMs,
{ description: 'stuck-state detection' }
);
}
/**
@@ -1981,7 +1966,7 @@ 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): string {
const now = Date.now();
const endsAt = now + durationMs;
@@ -1989,19 +1974,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(() => {
this.activeTimers.delete(name);
this.emit('timerCompleted', name);
callback();
}, durationMs);
return this.cleanup.setTimeout(
() => {
this.activeTimers.delete(name);
this.emit('timerCompleted', name);
callback();
},
durationMs,
{ description: name }
);
}
/**
* 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, timerId: string | null, reason?: string): void {
if (timerId) {
this.cleanup.unregister(timerId);
if (this.activeTimers.has(name)) {
this.activeTimers.delete(name);
this.emit('timerCancelled', name, reason);
@@ -2097,14 +2086,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', this.noOutputTimerId, 'restarting');
this.noOutputTimerId = null;
this.noOutputTimer = this.startTrackedTimer(
this.noOutputTimerId = this.startTrackedTimer(
'no-output-fallback',
this.config.noOutputTimeoutMs,
() => {
this.noOutputTimer = null;
this.noOutputTimerId = 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`);
@@ -2137,17 +2126,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', this.preFilterTimerId, 'restarting');
this.preFilterTimerId = null;
// Only set up pre-filter when AI check is enabled
if (!this.config.aiIdleCheckEnabled) return;
this.preFilterTimer = this.startTrackedTimer(
this.preFilterTimerId = this.startTrackedTimer(
'pre-filter',
this.config.completionConfirmMs,
() => {
this.preFilterTimer = null;
this.preFilterTimerId = null;
if (this._state === 'watching') {
const now = Date.now();
const msSinceOutput = now - this.lastOutputTime;
@@ -2252,18 +2241,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', this.completionConfirmTimerId, 'AI verdict: IDLE');
this.completionConfirmTimerId = null;
this.cancelTrackedTimer('pre-filter', this.preFilterTimerId, 'AI verdict: IDLE');
this.preFilterTimerId = null;
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', this.completionConfirmTimerId, 'AI verdict: WORKING');
this.completionConfirmTimerId = null;
this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`);
this.emit('aiCheckCompleted', result);
@@ -2319,14 +2308,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', this.autoAcceptTimerId, 'restarting');
this.autoAcceptTimerId = null;
this.autoAcceptTimer = this.startTrackedTimer(
this.autoAcceptTimerId = this.startTrackedTimer(
'auto-accept',
this.config.autoAcceptDelayMs,
() => {
this.autoAcceptTimer = null;
this.autoAcceptTimerId = null;
this.tryAutoAccept();
},
'plan mode detection'
@@ -2338,8 +2327,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', this.autoAcceptTimerId, 'cancelled');
this.autoAcceptTimerId = null;
}
/**
@@ -2500,8 +2489,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', this.completionConfirmTimerId, 'auto-accept');
this.completionConfirmTimerId = null;
this.completionMessageTime = null;
// Ensure we're in watching state (not confirming_idle or ai_checking)
@@ -2555,12 +2544,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', this.completionConfirmTimerId, 'Stop hook received');
this.completionConfirmTimerId = null;
// Cancel pre-filter timer - hook takes precedence
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'Stop hook received');
this.preFilterTimer = null;
this.cancelTrackedTimer('pre-filter', this.preFilterTimerId, 'Stop hook received');
this.preFilterTimerId = null;
// Start short confirmation timer to handle race conditions
// (e.g., Stop hook arrives but Claude immediately starts new work)
@@ -2594,12 +2583,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', this.completionConfirmTimerId, 'idle_prompt received');
this.completionConfirmTimerId = null;
this.cancelTrackedTimer('pre-filter', this.preFilterTimerId, 'idle_prompt received');
this.preFilterTimerId = null;
this.cancelTrackedTimer('no-output-fallback', this.noOutputTimerId, 'idle_prompt received');
this.noOutputTimerId = null;
// idle_prompt is an even stronger signal than Stop hook (60s+ idle)
// Skip confirmation and go directly to idle
@@ -2613,14 +2602,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', this.hookConfirmTimerId, 'restarting');
this.hookConfirmTimerId = null;
this.hookConfirmTimer = this.startTrackedTimer(
this.hookConfirmTimerId = this.startTrackedTimer(
'hook-confirm',
RespawnController.HOOK_CONFIRM_DELAY_MS,
() => {
this.hookConfirmTimer = null;
this.hookConfirmTimerId = null;
// Verify we haven't received new output since the hook arrived
const hookTime = hookType === 'stop' ? this.stopHookTime : this.idlePromptTime;
@@ -2694,17 +2683,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', this.completionConfirmTimerId, 'restarting');
this.completionConfirmTimerId = null;
this.setState('confirming_idle');
this.logAction('detection', 'Completion message found in output');
this.completionConfirmTimer = this.startTrackedTimer(
this.completionConfirmTimerId = this.startTrackedTimer(
'completion-confirm',
this.config.completionConfirmMs,
() => {
this.completionConfirmTimer = null;
this.completionConfirmTimerId = null;
if (this._state === 'stopped') return;
const msSinceOutput = Date.now() - this.lastOutputTime;
if (msSinceOutput >= this.config.completionConfirmMs) {
@@ -2725,8 +2714,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', this.completionConfirmTimerId, 'activity detected');
this.completionConfirmTimerId = null;
if (this._state === 'confirming_idle') {
this.setState('watching');
this.completionMessageTime = null;
@@ -2739,14 +2728,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', this.stepConfirmTimerId, 'restarting');
this.stepConfirmTimerId = null;
this.stepConfirmTimer = this.startTrackedTimer(
this.stepConfirmTimerId = this.startTrackedTimer(
'step-confirm',
this.config.completionConfirmMs,
() => {
this.stepConfirmTimer = null;
this.stepConfirmTimerId = null;
if (this._state === 'stopped') return;
const msSinceOutput = Date.now() - this.lastOutputTime;
@@ -2779,8 +2768,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', this.stepConfirmTimerId, 'working detected');
this.stepConfirmTimerId = null;
}
/**
@@ -2942,11 +2931,11 @@ export class RespawnController extends EventEmitter {
this.terminalBuffer.clear(); // Clear buffer for fresh detection
this.clearWorkingPatternWindow(); // Clear rolling window
this.stepTimer = this.startTrackedTimer(
this.stepTimerId = this.startTrackedTimer(
'step-delay',
this.config.interStepDelayMs,
async () => {
this.stepTimer = null;
this.stepTimerId = null;
if (this._state === 'stopped') return;
// Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config
@@ -2983,11 +2972,11 @@ export class RespawnController extends EventEmitter {
this.terminalBuffer.clear();
this.clearWorkingPatternWindow();
this.stepTimer = this.startTrackedTimer(
this.stepTimerId = this.startTrackedTimer(
'step-delay',
this.config.interStepDelayMs,
async () => {
this.stepTimer = null;
this.stepTimerId = null;
if (this._state === 'stopped') return;
this.logAction('command', 'Sending: /clear');
await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI
@@ -2996,11 +2985,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.clearFallbackTimerId = this.startTrackedTimer(
'clear-fallback',
RespawnController.CLEAR_FALLBACK_TIMEOUT_MS,
() => {
this.clearFallbackTimer = null;
this.clearFallbackTimerId = null;
if (this._state === 'waiting_clear') {
this.logAction('step', '/clear fallback: proceeding to /init');
this.emit('stepCompleted', 'clear');
@@ -3027,11 +3016,11 @@ export class RespawnController extends EventEmitter {
this.terminalBuffer.clear();
this.clearWorkingPatternWindow();
this.stepTimer = this.startTrackedTimer(
this.stepTimerId = this.startTrackedTimer(
'step-delay',
this.config.interStepDelayMs,
async () => {
this.stepTimer = null;
this.stepTimerId = null;
if (this._state === 'stopped') return;
this.logAction('command', 'Sending: /init');
await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI
+12 -25
View File
@@ -28,7 +28,7 @@ import {
TokenStats,
TokenUsageEntry,
} from './types.js';
import { MAX_SESSION_TOKENS } from './utils/index.js';
import { Debouncer, MAX_SESSION_TOKENS } from './utils/index.js';
/** Debounce delay for batching state writes (ms) */
const SAVE_DEBOUNCE_MS = 500;
@@ -60,7 +60,7 @@ const MAX_CONSECUTIVE_FAILURES = 3;
export class StateStore {
private state: AppState;
private filePath: string;
private saveTimeout: NodeJS.Timeout | null = null;
private saveDeb = new Debouncer(SAVE_DEBOUNCE_MS);
private dirty: boolean = false;
private dirtySessions = new Set<string>();
private cachedSessionJsons = new Map<string, string>();
@@ -68,7 +68,7 @@ export class StateStore {
// Inner state storage (separate from main state to reduce write frequency)
private ralphStates: Map<string, RalphSessionState> = new Map();
private ralphStatePath: string;
private ralphStateSaveTimeout: NodeJS.Timeout | null = null;
private ralphStateSaveDeb = new Debouncer(SAVE_DEBOUNCE_MS);
private ralphStateDirty: boolean = false;
// Circuit breaker for save failures (prevents hammering disk on persistent errors)
@@ -150,14 +150,12 @@ export class StateStore {
*/
save(): void {
this.dirty = true;
if (this.saveTimeout) {
return; // Already scheduled
}
this.saveTimeout = setTimeout(() => {
if (this.saveDeb.isPending) return; // Already scheduled
this.saveDeb.schedule(() => {
this.saveNowAsync().catch((err) => {
console.error('[StateStore] Async save failed:', err);
});
}, SAVE_DEBOUNCE_MS);
});
}
/**
@@ -242,10 +240,7 @@ export class StateStore {
}
private async _doSaveAsync(): Promise<void> {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
}
this.saveDeb.cancel();
if (!this.dirty) {
return;
}
@@ -332,10 +327,7 @@ export class StateStore {
* Prefer saveNowAsync() for normal operation.
*/
saveNow(): void {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
}
this.saveDeb.cancel();
if (!this.dirty) {
return;
}
@@ -775,12 +767,10 @@ export class StateStore {
// Debounced save for inner states
private saveRalphStates(): void {
this.ralphStateDirty = true;
if (this.ralphStateSaveTimeout) {
return; // Already scheduled
}
this.ralphStateSaveTimeout = setTimeout(() => {
if (this.ralphStateSaveDeb.isPending) return; // Already scheduled
this.ralphStateSaveDeb.schedule(() => {
this.saveRalphStatesNow();
}, SAVE_DEBOUNCE_MS);
});
}
/**
@@ -788,10 +778,7 @@ export class StateStore {
* Writes to temp file first, then renames to prevent corruption on crash.
*/
private saveRalphStatesNow(): void {
if (this.ralphStateSaveTimeout) {
clearTimeout(this.ralphStateSaveTimeout);
this.ralphStateSaveTimeout = null;
}
this.ralphStateSaveDeb.cancel();
if (!this.ralphStateDirty) {
return;
}
+72 -105
View File
@@ -14,6 +14,7 @@ import { join, basename } from 'node:path';
import { execFile } from 'node:child_process';
import { readFile, readdir, stat as statAsync } from 'node:fs/promises';
import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS } from './config/map-limits.js';
import { CleanupManager, KeyedDebouncer } from './utils/index.js';
// ========== Types ==========
@@ -161,12 +162,11 @@ const FILE_CONTENT_DEBOUNCE_MS = 100; // Debounce delay for file content updates
export class SubagentWatcher extends EventEmitter {
private filePositions = new Map<string, number>();
private dirWatchers = new Map<string, FSWatcher>();
// Per-file debounce timers for directory watcher (replaces per-file FSWatchers)
private fileDebouncers = new Map<string, NodeJS.Timeout>();
// Per-file debouncer for directory watcher (replaces per-file FSWatchers)
private fileDeb = new KeyedDebouncer(FILE_CONTENT_DEBOUNCE_MS);
private agentInfo = new Map<string, SubagentInfo>();
private idleTimers = new Map<string, NodeJS.Timeout>();
private pollInterval: NodeJS.Timeout | null = null;
private livenessInterval: NodeJS.Timeout | null = null;
private idleDeb = new KeyedDebouncer(IDLE_TIMEOUT_MS);
private cleanup = new CleanupManager();
private _isRunning = false;
private knownSubagentDirs = new Set<string>();
// Map of agentId -> Map of toolUseId -> { toolName, timestamp } (for linking tool_result to tool_call)
@@ -228,12 +228,16 @@ export class SubagentWatcher extends EventEmitter {
// Periodic scan for new subagent directories
// Full directory traversal only every FULL_SCAN_EVERY_N_POLLS polls (~5s)
// FSWatchers handle known directories between full scans
this.pollInterval = setInterval(() => {
this._pollCount++;
if (this._pollCount % FULL_SCAN_EVERY_N_POLLS === 0) {
this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error));
}
}, POLL_INTERVAL_MS);
this.cleanup.setInterval(
() => {
this._pollCount++;
if (this._pollCount % FULL_SCAN_EVERY_N_POLLS === 0) {
this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error));
}
},
POLL_INTERVAL_MS,
{ description: 'subagent directory poll' }
);
// Periodic liveness check for active subagents
this.startLivenessChecker();
@@ -249,54 +253,56 @@ export class SubagentWatcher extends EventEmitter {
* 3. Full pgrep scan (expensive, ~500ms) — only for agents that fail tiers 1+2
*/
private startLivenessChecker(): void {
if (this.livenessInterval) return;
this.cleanup.setInterval(
async () => {
// Guard: prevent concurrent liveness checks (avoids duplicate completed events)
if (this._isCheckingLiveness) return;
this._isCheckingLiveness = true;
this.livenessInterval = setInterval(async () => {
// Guard: prevent concurrent liveness checks (avoids duplicate completed events)
if (this._isCheckingLiveness) return;
this._isCheckingLiveness = true;
try {
// Collect agents that need the expensive pgrep scan
const needsFullScan: SubagentInfo[] = [];
try {
// Collect agents that need the expensive pgrep scan
const needsFullScan: SubagentInfo[] = [];
for (const [_agentId, info] of this.agentInfo) {
if (info.status !== 'active' && info.status !== 'idle') continue;
// Tier 1: File mtime check (~0.3ms per agent)
if (await this.checkSubagentFileAlive(info)) continue;
// Tier 2: Cached PID check (~0.1ms per agent)
if (info.pid && (await this.checkPidAlive(info.pid))) continue;
// Tiers 1+2 failed — need expensive scan for this agent
needsFullScan.push(info);
}
// Tier 3: Full pgrep scan — only if any agents failed cheap checks
if (needsFullScan.length > 0) {
const pidMap = await this.getClaudePids();
for (const info of needsFullScan) {
// Re-check status in case another check completed this agent
for (const [_agentId, info] of this.agentInfo) {
if (info.status !== 'active' && info.status !== 'idle') continue;
const alive = this.checkSubagentAliveFromPidMap(info, pidMap);
if (!alive) {
info.pid = undefined;
info.status = 'completed';
this.pendingToolCalls.delete(info.agentId);
this.emit('subagent:completed', info);
// Tier 1: File mtime check (~0.3ms per agent)
if (await this.checkSubagentFileAlive(info)) continue;
// Tier 2: Cached PID check (~0.1ms per agent)
if (info.pid && (await this.checkPidAlive(info.pid))) continue;
// Tiers 1+2 failed — need expensive scan for this agent
needsFullScan.push(info);
}
// Tier 3: Full pgrep scan — only if any agents failed cheap checks
if (needsFullScan.length > 0) {
const pidMap = await this.getClaudePids();
for (const info of needsFullScan) {
// Re-check status in case another check completed this agent
if (info.status !== 'active' && info.status !== 'idle') continue;
const alive = this.checkSubagentAliveFromPidMap(info, pidMap);
if (!alive) {
info.pid = undefined;
info.status = 'completed';
this.pendingToolCalls.delete(info.agentId);
this.emit('subagent:completed', info);
}
}
}
}
// Periodically clean up stale completed agents (older than 24 hours)
this.cleanupStaleAgents();
} finally {
this._isCheckingLiveness = false;
}
}, LIVENESS_CHECK_MS);
// Periodically clean up stale completed agents (older than 24 hours)
this.cleanupStaleAgents();
} finally {
this._isCheckingLiveness = false;
}
},
LIVENESS_CHECK_MS,
{ description: 'subagent liveness check' }
);
}
/**
@@ -418,21 +424,12 @@ export class SubagentWatcher extends EventEmitter {
stop(): void {
this._isRunning = false;
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
if (this.livenessInterval) {
clearInterval(this.livenessInterval);
this.livenessInterval = null;
}
// Dispose poll and liveness intervals, then re-create for potential restart
this.cleanup.dispose();
this.cleanup = new CleanupManager();
// Clear file debouncers
for (const timer of this.fileDebouncers.values()) {
clearTimeout(timer);
}
this.fileDebouncers.clear();
this.fileDeb.dispose();
this.fileAgentContext.clear();
// Remove error handlers before closing watchers to prevent memory leak
@@ -447,10 +444,7 @@ export class SubagentWatcher extends EventEmitter {
}
this.dirWatchers.clear();
for (const timer of this.idleTimers.values()) {
clearTimeout(timer);
}
this.idleTimers.clear();
this.idleDeb.dispose();
// Clear all state for clean restart
this.filePositions.clear();
@@ -532,16 +526,8 @@ export class SubagentWatcher extends EventEmitter {
this.pendingToolCalls.delete(agentId);
this.filePositions.delete(info.filePath);
this.fileAgentContext.delete(info.filePath);
const debounceTimer = this.fileDebouncers.get(info.filePath);
if (debounceTimer) {
clearTimeout(debounceTimer);
this.fileDebouncers.delete(info.filePath);
}
const timer = this.idleTimers.get(agentId);
if (timer) {
clearTimeout(timer);
this.idleTimers.delete(agentId);
}
this.fileDeb.cancelKey(info.filePath);
this.idleDeb.cancelKey(agentId);
}
}
@@ -634,9 +620,9 @@ export class SubagentWatcher extends EventEmitter {
return {
agentCount: this.agentInfo.size,
fileDebouncerCount: this.fileDebouncers.size,
fileDebouncerCount: this.fileDeb.size,
dirWatcherCount: this.dirWatchers.size,
idleTimerCount: this.idleTimers.size,
idleTimerCount: this.idleDeb.size,
pendingToolCallsCount,
knownDirsCount: this.knownSubagentDirs.size,
filePositionsCount: this.filePositions.size,
@@ -1129,13 +1115,8 @@ export class SubagentWatcher extends EventEmitter {
if (!filename?.endsWith('.jsonl')) return;
const filePath = join(dir, filename);
// Clear existing debounce for this file
const existing = this.fileDebouncers.get(filePath);
if (existing) clearTimeout(existing);
// Debounce 100ms to batch rapid writes
const timer = setTimeout(() => {
this.fileDebouncers.delete(filePath);
this.fileDeb.schedule(filePath, () => {
if (!existsSync(filePath)) return;
if (this.fileAgentContext.has(filePath)) {
@@ -1145,9 +1126,7 @@ export class SubagentWatcher extends EventEmitter {
// New file — register it
this.registerAgentFile(filePath, projectHash, sessionId).catch(() => {});
}
}, FILE_CONTENT_DEBOUNCE_MS);
this.fileDebouncers.set(filePath, timer);
});
});
// Handle watcher errors to prevent unhandled exceptions
@@ -1602,25 +1581,13 @@ export class SubagentWatcher extends EventEmitter {
* Reset idle timer for an agent
*/
private resetIdleTimer(agentId: string): void {
const existing = this.idleTimers.get(agentId);
if (existing) {
clearTimeout(existing);
}
const timer = setTimeout(() => {
// Guard against race condition: agent may have been deleted before timer fires
this.idleDeb.schedule(agentId, () => {
const info = this.agentInfo.get(agentId);
if (!info) {
// Agent was deleted - clean up timer reference
this.idleTimers.delete(agentId);
return;
}
if (!info) return;
if (info.status === 'active') {
info.status = 'idle';
}
}, IDLE_TIMEOUT_MS);
this.idleTimers.set(agentId, timer);
});
}
/**
+1 -2
View File
@@ -59,8 +59,7 @@ import { resolveOpenCodeDir } from './utils/opencode-cli-resolver.js';
// Timing Constants
// ============================================================================
/** Timeout for exec commands (5 seconds) */
const EXEC_TIMEOUT_MS = 5000;
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
/** Delay after tmux session creation — enough for detached tmux to be queryable */
const TMUX_CREATION_WAIT_MS = 100;
+1 -3
View File
@@ -12,9 +12,7 @@ import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { delimiter, dirname, join } from 'node:path';
import { homedir } from 'node:os';
/** Timeout for exec commands (5 seconds) */
const EXEC_TIMEOUT_MS = 5000;
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
/** Common directories where the Claude CLI binary may be installed */
const CLAUDE_SEARCH_DIRS = [
+174
View File
@@ -0,0 +1,174 @@
/**
* @fileoverview Debounce utilities to replace manual timer management.
*
* Two variants:
* - `Debouncer` — single debounced operation (replaces timer + clearTimeout pattern)
* - `KeyedDebouncer` — per-key debouncing (replaces Map<string, Timeout> pattern)
*
* Both integrate with CleanupManager via dispose().
*
* @module utils/debouncer
*/
/**
* Single-operation debouncer.
*
* Replaces the common pattern of:
* ```
* private timer: NodeJS.Timeout | null = null;
* debounce(fn) { if (this.timer) clearTimeout(this.timer); this.timer = setTimeout(fn, delay); }
* cancel() { if (this.timer) { clearTimeout(this.timer); this.timer = null; } }
* ```
*
* @example
* ```typescript
* private saveDeb = new Debouncer(500);
*
* onChange() {
* this.saveDeb.schedule(() => this.save());
* }
*
* stop() {
* this.saveDeb.dispose();
* }
* ```
*/
export class Debouncer {
private timer: NodeJS.Timeout | null = null;
constructor(private readonly delayMs: number) {}
/**
* Schedule a debounced callback. Resets the timer on each call.
* If a previous call is pending, it is cancelled.
*/
schedule(fn: () => void): void {
this.cancel();
this.timer = setTimeout(() => {
this.timer = null;
fn();
}, this.delayMs);
}
/** Cancel any pending execution without invoking the callback. */
cancel(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
/** Whether a callback is currently pending. */
get isPending(): boolean {
return this.timer !== null;
}
/**
* Cancel pending callback and flush immediately.
* Useful for shutdown: cancel the timer but run the action now.
*
* @param fn - The flush function to run (typically the same function passed to schedule)
*/
flush(fn: () => void): void {
this.cancel();
fn();
}
/** Alias for cancel() — matches CleanupManager/Disposable convention. */
dispose(): void {
this.cancel();
}
}
/**
* Per-key debouncer for operations that need independent timers per resource.
*
* Replaces the common pattern of:
* ```
* private timers = new Map<string, NodeJS.Timeout>();
* debounce(key, fn) {
* const existing = this.timers.get(key);
* if (existing) clearTimeout(existing);
* this.timers.set(key, setTimeout(() => { this.timers.delete(key); fn(); }, delay));
* }
* ```
*
* @example
* ```typescript
* private fileDebouncers = new KeyedDebouncer(100);
*
* onFileChange(path: string) {
* this.fileDebouncers.schedule(path, () => this.processFile(path));
* }
*
* stop() {
* this.fileDebouncers.dispose();
* }
* ```
*/
export class KeyedDebouncer {
private timers = new Map<string, NodeJS.Timeout>();
constructor(private readonly delayMs: number) {}
/**
* Schedule a debounced callback for a specific key.
* Each key has its own independent timer.
*/
schedule(key: string, fn: () => void): void {
this.cancelKey(key);
this.timers.set(
key,
setTimeout(() => {
this.timers.delete(key);
fn();
}, this.delayMs)
);
}
/** Cancel a pending callback for a specific key. */
cancelKey(key: string): void {
const existing = this.timers.get(key);
if (existing) {
clearTimeout(existing);
this.timers.delete(key);
}
}
/** Whether a callback is pending for a specific key. */
has(key: string): boolean {
return this.timers.has(key);
}
/** Number of active timers. */
get size(): number {
return this.timers.size;
}
/** Get all currently active keys. */
keys(): IterableIterator<string> {
return this.timers.keys();
}
/** Cancel all pending callbacks. */
dispose(): void {
for (const timer of this.timers.values()) {
clearTimeout(timer);
}
this.timers.clear();
}
/**
* Cancel all pending callbacks and run a flush function for each active key.
* Useful for shutdown: cancel timers but run the action for each pending key.
*
* @param fn - Called once per active key with the key as argument
*/
flushAll(fn: (key: string) => void): void {
const activeKeys = Array.from(this.timers.keys());
this.dispose();
for (const key of activeKeys) {
fn(key);
}
}
}
+6 -1
View File
@@ -9,14 +9,19 @@
export { BufferAccumulator } from './buffer-accumulator.js';
export { LRUMap, type LRUMapOptions } from './lru-map.js';
export { CleanupManager, type TimerOptions } from './cleanup-manager.js';
export { Debouncer, KeyedDebouncer } from './debouncer.js';
export { StaleExpirationMap, type StaleExpirationMapOptions } from './stale-expiration-map.js';
export {
ANSI_ESCAPE_PATTERN_FULL,
ANSI_ESCAPE_PATTERN_SIMPLE,
TOKEN_PATTERN,
SPINNER_PATTERN,
createAnsiPatternFull,
createAnsiPatternSimple,
stripAnsi,
SAFE_PATH_PATTERN,
} from './regex-patterns.js';
export { MAX_SESSION_TOKENS } from './token-validation.js';
export { MAX_SESSION_TOKENS, validateTokenCounts, validateTokensAndCost } from './token-validation.js';
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
export { assertNever } from './type-safety.js';
export { wrapWithNice } from './nice-wrapper.js';
+1 -3
View File
@@ -11,9 +11,7 @@ import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
/** Timeout for exec commands (5 seconds) */
const EXEC_TIMEOUT_MS = 5000;
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
/** Common directories where the OpenCode CLI binary may be installed */
const OPENCODE_SEARCH_DIRS = [
+3 -19
View File
@@ -24,7 +24,7 @@
* levenshteinDistance('hello', 'helo') // 1 (one deletion)
* levenshteinDistance('COMPLETE', 'COMPLET') // 1 (one deletion)
*/
export function levenshteinDistance(a: string, b: string): number {
function levenshteinDistance(a: string, b: string): number {
// Ensure a is the shorter string for space efficiency
if (a.length > b.length) {
[a, b] = [b, a];
@@ -91,22 +91,6 @@ export function stringSimilarity(a: string, b: string): number {
return 1 - distance / maxLength;
}
/**
* Check if two strings are similar within a given threshold.
*
* @param a - First string
* @param b - Second string
* @param threshold - Minimum similarity ratio (default: 0.85 = 85% similar)
* @returns True if similarity >= threshold
*
* @example
* isSimilar('COMPLETE', 'COMPLET', 0.85) // true (87.5% similar)
* isSimilar('COMPLETE', 'DONE', 0.85) // false (0% similar)
*/
export function isSimilar(a: string, b: string, threshold = 0.85): boolean {
return stringSimilarity(a, b) >= threshold;
}
/**
* Check if two strings are similar with edit distance tolerance.
* More intuitive for short strings than percentage-based threshold.
@@ -120,7 +104,7 @@ export function isSimilar(a: string, b: string, threshold = 0.85): boolean {
* isSimilarByDistance('COMPLETE', 'COMPLET', 2) // true (distance 1)
* isSimilarByDistance('COMPLETE', 'COMP', 2) // false (distance 4)
*/
export function isSimilarByDistance(a: string, b: string, maxDistance = 2): boolean {
function isSimilarByDistance(a: string, b: string, maxDistance = 2): boolean {
return levenshteinDistance(a, b) <= maxDistance;
}
@@ -136,7 +120,7 @@ export function isSimilarByDistance(a: string, b: string, maxDistance = 2): bool
* normalizePhrase('TASK-DONE') // 'TASKDONE'
* normalizePhrase('Task Done') // 'TASKDONE'
*/
export function normalizePhrase(phrase: string): string {
function normalizePhrase(phrase: string): string {
return phrase
.toUpperCase()
.replace(/[\s_\-.]+/g, '') // Remove whitespace, underscores, hyphens, dots
+39
View File
@@ -506,3 +506,42 @@ export const RalphLoopStartSchema = z.object({
)
.optional(),
});
// ========== Inferred Types ==========
export type CreateSessionInput = z.infer<typeof CreateSessionSchema>;
export type RunPromptInput = z.infer<typeof RunPromptSchema>;
export type ResizeInput = z.infer<typeof ResizeSchema>;
export type CreateCaseInput = z.infer<typeof CreateCaseSchema>;
export type QuickStartInput = z.infer<typeof QuickStartSchema>;
export type HookEventInput = z.infer<typeof HookEventSchema>;
export type RespawnConfigInput = z.infer<typeof RespawnConfigSchema>;
export type ConfigUpdateInput = z.infer<typeof ConfigUpdateSchema>;
export type SettingsUpdateInput = z.infer<typeof SettingsUpdateSchema>;
export type SessionInputWithLimitInput = z.infer<typeof SessionInputWithLimitSchema>;
export type SessionNameInput = z.infer<typeof SessionNameSchema>;
export type SessionColorInput = z.infer<typeof SessionColorSchema>;
export type RalphConfigInput = z.infer<typeof RalphConfigSchema>;
export type FixPlanImportInput = z.infer<typeof FixPlanImportSchema>;
export type RalphPromptWriteInput = z.infer<typeof RalphPromptWriteSchema>;
export type AutoClearInput = z.infer<typeof AutoClearSchema>;
export type AutoCompactInput = z.infer<typeof AutoCompactSchema>;
export type ImageWatcherInput = z.infer<typeof ImageWatcherSchema>;
export type FlickerFilterInput = z.infer<typeof FlickerFilterSchema>;
export type QuickRunInput = z.infer<typeof QuickRunSchema>;
export type ScheduledRunInput = z.infer<typeof ScheduledRunSchema>;
export type LinkCaseInput = z.infer<typeof LinkCaseSchema>;
export type GeneratePlanInput = z.infer<typeof GeneratePlanSchema>;
export type GeneratePlanDetailedInput = z.infer<typeof GeneratePlanDetailedSchema>;
export type CancelPlanInput = z.infer<typeof CancelPlanSchema>;
export type PlanTaskUpdateInput = z.infer<typeof PlanTaskUpdateSchema>;
export type PlanTaskAddInput = z.infer<typeof PlanTaskAddSchema>;
export type CpuLimitInput = z.infer<typeof CpuLimitSchema>;
export type ModelConfigUpdateInput = z.infer<typeof ModelConfigUpdateSchema>;
export type SubagentWindowStatesInput = z.infer<typeof SubagentWindowStatesSchema>;
export type SubagentParentMapInput = z.infer<typeof SubagentParentMapSchema>;
export type InteractiveRespawnInput = z.infer<typeof InteractiveRespawnSchema>;
export type RespawnEnableInput = z.infer<typeof RespawnEnableSchema>;
export type PushSubscribeInput = z.infer<typeof PushSubscribeSchema>;
export type PushPreferencesUpdateInput = z.infer<typeof PushPreferencesUpdateSchema>;
export type RalphLoopStartInput = z.infer<typeof RalphLoopStartSchema>;
+53 -79
View File
@@ -123,7 +123,7 @@ import {
RalphLoopStartSchema,
isValidWorkingDir,
} from './schemas.js';
import { StaleExpirationMap } from '../utils/index.js';
import { CleanupManager, KeyedDebouncer, StaleExpirationMap } from '../utils/index.js';
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -420,16 +420,14 @@ export class WebServer extends EventEmitter {
ttlMs: 5 * 60 * 1000, // 5 minutes - auto-expire stale session timing data
refreshOnGet: false, // Don't refresh on reads, only on explicit sets
});
// Scheduled runs cleanup timer
private scheduledCleanupTimer: NodeJS.Timeout | null = null;
// Centralized cleanup for standalone timers (intervals + resettable timeouts)
private cleanup = new CleanupManager();
// SSE event batching
private taskUpdateBatches: Map<string, { sessionId: string; task: BackgroundTask }> = new Map();
private taskUpdateBatchTimer: NodeJS.Timeout | null = null;
private taskUpdateBatchTimerId: string | null = null;
// State update batching (reduce expensive toDetailedState() serialization)
private stateUpdatePending: Set<string> = new Set();
private stateUpdateTimer: NodeJS.Timeout | null = null;
// SSE client health check timer
private sseHealthCheckTimer: NodeJS.Timeout | null = null;
private stateUpdateTimerId: string | null = null;
// Flag to prevent new timers during shutdown
private _isStopping: boolean = false;
// Cached light state for SSE init (avoids rebuilding on every reconnect)
@@ -439,14 +437,13 @@ export class WebServer extends EventEmitter {
private cachedSessionsList: { data: unknown[]; timestamp: number } | null = null;
// Token recording for daily stats (track what's been recorded to avoid double-counting)
private lastRecordedTokens: Map<string, { input: number; output: number }> = new Map();
private tokenRecordingTimer: NodeJS.Timeout | null = null;
// Server startup time for respawn grace period calculation
private readonly serverStartTime: number = Date.now();
// Pending respawn start timers (for cleanup on shutdown)
private pendingRespawnStarts: Map<string, NodeJS.Timeout> = new Map();
// Active plan orchestrators (for cancellation via API)
private activePlanOrchestrators: Map<string, PlanOrchestrator> = new Map();
private persistDebounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
private persistDeb = new KeyedDebouncer(100);
// Grace period before starting restored respawn controllers (2 minutes)
private static readonly RESPAWN_RESTORE_GRACE_PERIOD_MS = 2 * 60 * 1000;
// Stored listener handlers for cleanup
@@ -4646,18 +4643,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
/** Debounced wrapper — coalesces rapid persistSessionState calls per session */
private persistSessionState(session: Session): void {
const existing = this.persistDebounceTimers.get(session.id);
if (existing) clearTimeout(existing);
this.persistDebounceTimers.set(
session.id,
setTimeout(() => {
this.persistDebounceTimers.delete(session.id);
// Session may have been removed during debounce
if (this.sessions.has(session.id)) {
this._persistSessionStateNow(session);
}
}, 100)
);
this.persistDeb.schedule(session.id, () => {
// Session may have been removed during debounce
if (this.sessions.has(session.id)) {
this._persistSessionStateNow(session);
}
});
}
/** Persists full session state including respawn config to state.json */
@@ -4845,11 +4836,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
// Clear pending persist-debounce timer (prevents stale closure holding session ref)
const pendingPersist = this.persistDebounceTimers.get(sessionId);
if (pendingPersist) {
clearTimeout(pendingPersist);
this.persistDebounceTimers.delete(sessionId);
}
this.persistDeb.cancelKey(sessionId);
// Clear batches, per-session timers, and pending state updates
this.terminalBatches.delete(sessionId);
@@ -5079,11 +5066,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
this.lastTerminalEventTime.delete(session.id);
// Clear pending persist-debounce timer
const pendingPersist = this.persistDebounceTimers.get(session.id);
if (pendingPersist) {
clearTimeout(pendingPersist);
this.persistDebounceTimers.delete(session.id);
}
this.persistDeb.cancelKey(session.id);
// Close any active file streams
fileStreamManager.closeSessionStreams(session.id);
@@ -6000,11 +5983,15 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
const key = `${sessionId}:${task.id}`;
this.taskUpdateBatches.set(key, { sessionId, task });
if (!this.taskUpdateBatchTimer) {
this.taskUpdateBatchTimer = setTimeout(() => {
this.flushTaskUpdateBatches();
this.taskUpdateBatchTimer = null;
}, TASK_UPDATE_BATCH_INTERVAL);
if (!this.taskUpdateBatchTimerId) {
this.taskUpdateBatchTimerId = this.cleanup.setTimeout(
() => {
this.taskUpdateBatchTimerId = null;
this.flushTaskUpdateBatches();
},
TASK_UPDATE_BATCH_INTERVAL,
{ description: 'task update batch flush' }
);
}
}
@@ -6031,11 +6018,15 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
this.stateUpdatePending.add(sessionId);
if (!this.stateUpdateTimer) {
this.stateUpdateTimer = setTimeout(() => {
this.flushStateUpdates();
this.stateUpdateTimer = null;
}, STATE_UPDATE_DEBOUNCE_INTERVAL);
if (!this.stateUpdateTimerId) {
this.stateUpdateTimerId = this.cleanup.setTimeout(
() => {
this.stateUpdateTimerId = null;
this.flushStateUpdates();
},
STATE_UPDATE_DEBOUNCE_INTERVAL,
{ description: 'state update debounce flush' }
);
}
}
@@ -6226,21 +6217,30 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
process.env.CODEMAN_API_URL = `${protocol}://localhost:${this.port}`;
// Start scheduled runs cleanup timer
this.scheduledCleanupTimer = setInterval(() => {
this.cleanupScheduledRuns();
}, SCHEDULED_CLEANUP_INTERVAL);
this.cleanup.setInterval(
() => {
this.cleanupScheduledRuns();
},
SCHEDULED_CLEANUP_INTERVAL,
{ description: 'scheduled runs cleanup' }
);
// Start SSE client health check timer (prevents memory leaks from dead connections)
this.sseHealthCheckTimer = setInterval(() => {
this.cleanupDeadSSEClients();
}, SSE_HEALTH_CHECK_INTERVAL);
this.cleanup.setInterval(
() => {
this.cleanupDeadSSEClients();
},
SSE_HEALTH_CHECK_INTERVAL,
{ description: 'SSE client health check' }
);
// Start token recording timer (every 5 minutes for long-running sessions)
this.tokenRecordingTimer = setInterval(
this.cleanup.setInterval(
() => {
this.recordPeriodicTokenUsage();
},
5 * 60 * 1000
5 * 60 * 1000,
{ description: 'periodic token recording' }
);
// Start subagent watcher for Claude Code background agent visibility (if enabled)
@@ -6531,11 +6531,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Set stopping flag to prevent new timer creation during shutdown
this._isStopping = true;
// Clear SSE health check timer
if (this.sseHealthCheckTimer) {
clearInterval(this.sseHealthCheckTimer);
this.sseHealthCheckTimer = null;
}
// Dispose all managed timers (intervals + resettable timeouts)
this.cleanup.dispose();
// Gracefully close all SSE connections before clearing
for (const client of this.sseClients) {
@@ -6558,43 +6555,20 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
this.terminalBatches.clear();
this.terminalBatchSizes.clear();
if (this.taskUpdateBatchTimer) {
clearTimeout(this.taskUpdateBatchTimer);
this.taskUpdateBatchTimer = null;
}
this.taskUpdateBatches.clear();
if (this.stateUpdateTimer) {
clearTimeout(this.stateUpdateTimer);
this.stateUpdateTimer = null;
}
this.stateUpdatePending.clear();
// Clear token recording timer
if (this.tokenRecordingTimer) {
clearInterval(this.tokenRecordingTimer);
this.tokenRecordingTimer = null;
}
this.lastRecordedTokens.clear();
// Clear scheduled cleanup timer
if (this.scheduledCleanupTimer) {
clearInterval(this.scheduledCleanupTimer);
this.scheduledCleanupTimer = null;
}
// Stop multiplexer and flush pending saves
this.mux.destroy();
// Flush any pending persist-debounce timers and persist dirty sessions
for (const [sessionId, timer] of this.persistDebounceTimers) {
clearTimeout(timer);
this.persistDeb.flushAll((sessionId) => {
const session = this.sessions.get(sessionId);
if (session) {
this._persistSessionStateNow(session);
}
}
this.persistDebounceTimers.clear();
});
// Clear cached state
this.cachedLightState = null;