|
| 1 | +"""Adaptive performance mode for CodingBuddy hooks (#1002). |
| 2 | +
|
| 3 | +Monitors hook execution times and auto-switches to lightweight mode |
| 4 | +when the threshold is exceeded. In lightweight mode, heavy analysis |
| 5 | +(conflict prediction, full project scan) is skipped while core |
| 6 | +functions (mode detection, basic validation) are preserved. |
| 7 | +""" |
| 8 | +import sys |
| 9 | +import time |
| 10 | +from typing import Any, Dict, List, Optional |
| 11 | + |
| 12 | + |
| 13 | +# Default threshold in milliseconds |
| 14 | +DEFAULT_TIMEOUT_MS = 10000 |
| 15 | + |
| 16 | +# Operations classified by weight |
| 17 | +HEAVY_OPERATIONS = frozenset({ |
| 18 | + "conflict_prediction", |
| 19 | + "full_project_scan", |
| 20 | + "agent_recommendations", |
| 21 | + "file_watcher", |
| 22 | +}) |
| 23 | + |
| 24 | +CORE_OPERATIONS = frozenset({ |
| 25 | + "mode_detection", |
| 26 | + "basic_validation", |
| 27 | + "stats_recording", |
| 28 | + "history_recording", |
| 29 | + "prompt_injection", |
| 30 | + "hook_install", |
| 31 | +}) |
| 32 | + |
| 33 | + |
| 34 | +class AdaptivePerformanceMonitor: |
| 35 | + """Monitors hook performance and controls lightweight mode transitions. |
| 36 | +
|
| 37 | + Tracks execution times per hook and switches to lightweight mode |
| 38 | + when recent execution times consistently exceed the configured |
| 39 | + threshold. The monitor uses a sliding window of recent timings |
| 40 | + to make decisions. |
| 41 | + """ |
| 42 | + |
| 43 | + _instance: Optional["AdaptivePerformanceMonitor"] = None |
| 44 | + |
| 45 | + def __init__(self, timeout_ms: int = DEFAULT_TIMEOUT_MS) -> None: |
| 46 | + self._timeout_ms = timeout_ms |
| 47 | + self._lightweight_mode = False |
| 48 | + self._timings: Dict[str, List[float]] = {} |
| 49 | + self._active_timers: Dict[str, float] = {} |
| 50 | + self._switch_count = 0 |
| 51 | + self._window_size = 5 |
| 52 | + |
| 53 | + @classmethod |
| 54 | + def get_instance( |
| 55 | + cls, config: Optional[Dict[str, Any]] = None |
| 56 | + ) -> "AdaptivePerformanceMonitor": |
| 57 | + """Get or create the singleton monitor instance. |
| 58 | +
|
| 59 | + Args: |
| 60 | + config: Optional config dict; reads hooks.timeoutMs if present. |
| 61 | +
|
| 62 | + Returns: |
| 63 | + The singleton AdaptivePerformanceMonitor. |
| 64 | + """ |
| 65 | + if cls._instance is None: |
| 66 | + timeout_ms = DEFAULT_TIMEOUT_MS |
| 67 | + if config: |
| 68 | + hooks_cfg = config.get("hooks", {}) |
| 69 | + timeout_ms = hooks_cfg.get("timeoutMs", DEFAULT_TIMEOUT_MS) |
| 70 | + cls._instance = cls(timeout_ms=timeout_ms) |
| 71 | + return cls._instance |
| 72 | + |
| 73 | + @classmethod |
| 74 | + def reset_instance(cls) -> None: |
| 75 | + """Reset the singleton (for testing).""" |
| 76 | + cls._instance = None |
| 77 | + |
| 78 | + @property |
| 79 | + def is_lightweight(self) -> bool: |
| 80 | + """Whether lightweight mode is currently active.""" |
| 81 | + return self._lightweight_mode |
| 82 | + |
| 83 | + @property |
| 84 | + def timeout_ms(self) -> int: |
| 85 | + """Current timeout threshold in milliseconds.""" |
| 86 | + return self._timeout_ms |
| 87 | + |
| 88 | + @property |
| 89 | + def switch_count(self) -> int: |
| 90 | + """Number of times mode has been switched.""" |
| 91 | + return self._switch_count |
| 92 | + |
| 93 | + def start_timing(self, hook_name: str) -> None: |
| 94 | + """Start timing a hook execution. |
| 95 | +
|
| 96 | + Args: |
| 97 | + hook_name: Identifier for the hook being timed. |
| 98 | + """ |
| 99 | + self._active_timers[hook_name] = time.monotonic() |
| 100 | + |
| 101 | + def stop_timing(self, hook_name: str) -> float: |
| 102 | + """Stop timing and record the duration. Evaluates mode switch. |
| 103 | +
|
| 104 | + Args: |
| 105 | + hook_name: Identifier for the hook to stop timing. |
| 106 | +
|
| 107 | + Returns: |
| 108 | + Elapsed time in milliseconds. |
| 109 | +
|
| 110 | + Raises: |
| 111 | + ValueError: If no active timer for the given hook. |
| 112 | + """ |
| 113 | + if hook_name not in self._active_timers: |
| 114 | + raise ValueError(f"No active timer for: {hook_name}") |
| 115 | + |
| 116 | + elapsed_ms = (time.monotonic() - self._active_timers.pop(hook_name)) * 1000 |
| 117 | + |
| 118 | + if hook_name not in self._timings: |
| 119 | + self._timings[hook_name] = [] |
| 120 | + self._timings[hook_name].append(elapsed_ms) |
| 121 | + |
| 122 | + # Keep only the sliding window |
| 123 | + if len(self._timings[hook_name]) > self._window_size: |
| 124 | + self._timings[hook_name] = self._timings[hook_name][-self._window_size:] |
| 125 | + |
| 126 | + self._evaluate_mode() |
| 127 | + return elapsed_ms |
| 128 | + |
| 129 | + def record_timing(self, hook_name: str, elapsed_ms: float) -> None: |
| 130 | + """Record a timing directly (when start/stop is not used). |
| 131 | +
|
| 132 | + Args: |
| 133 | + hook_name: Identifier for the hook. |
| 134 | + elapsed_ms: Elapsed time in milliseconds. |
| 135 | + """ |
| 136 | + if hook_name not in self._timings: |
| 137 | + self._timings[hook_name] = [] |
| 138 | + self._timings[hook_name].append(elapsed_ms) |
| 139 | + |
| 140 | + if len(self._timings[hook_name]) > self._window_size: |
| 141 | + self._timings[hook_name] = self._timings[hook_name][-self._window_size:] |
| 142 | + |
| 143 | + self._evaluate_mode() |
| 144 | + |
| 145 | + def should_skip(self, operation: str) -> bool: |
| 146 | + """Check if an operation should be skipped in current mode. |
| 147 | +
|
| 148 | + Args: |
| 149 | + operation: Operation name to check. |
| 150 | +
|
| 151 | + Returns: |
| 152 | + True if the operation should be skipped (lightweight mode |
| 153 | + active and operation is heavy). |
| 154 | + """ |
| 155 | + if not self._lightweight_mode: |
| 156 | + return False |
| 157 | + return operation in HEAVY_OPERATIONS |
| 158 | + |
| 159 | + def get_status(self) -> Dict[str, Any]: |
| 160 | + """Get current performance monitor status. |
| 161 | +
|
| 162 | + Returns: |
| 163 | + Dict with mode, threshold, timings summary, and switch count. |
| 164 | + """ |
| 165 | + timing_summary: Dict[str, Dict[str, float]] = {} |
| 166 | + for hook_name, timings in self._timings.items(): |
| 167 | + if timings: |
| 168 | + timing_summary[hook_name] = { |
| 169 | + "count": len(timings), |
| 170 | + "avg_ms": round(sum(timings) / len(timings), 2), |
| 171 | + "max_ms": round(max(timings), 2), |
| 172 | + } |
| 173 | + return { |
| 174 | + "lightweight_mode": self._lightweight_mode, |
| 175 | + "timeout_ms": self._timeout_ms, |
| 176 | + "switch_count": self._switch_count, |
| 177 | + "timings": timing_summary, |
| 178 | + } |
| 179 | + |
| 180 | + def _evaluate_mode(self) -> None: |
| 181 | + """Evaluate whether to switch modes based on recent timings. |
| 182 | +
|
| 183 | + Switches to lightweight mode if any hook's average recent |
| 184 | + execution time exceeds the threshold. Switches back to normal |
| 185 | + if all hooks are under 60% of the threshold. |
| 186 | + """ |
| 187 | + any_over_threshold = False |
| 188 | + all_under_recovery = True |
| 189 | + recovery_threshold = self._timeout_ms * 0.6 |
| 190 | + |
| 191 | + for timings in self._timings.values(): |
| 192 | + if not timings: |
| 193 | + continue |
| 194 | + avg = sum(timings) / len(timings) |
| 195 | + if avg >= self._timeout_ms: |
| 196 | + any_over_threshold = True |
| 197 | + if avg >= recovery_threshold: |
| 198 | + all_under_recovery = False |
| 199 | + |
| 200 | + if not self._lightweight_mode and any_over_threshold: |
| 201 | + self._lightweight_mode = True |
| 202 | + self._switch_count += 1 |
| 203 | + elif self._lightweight_mode and all_under_recovery and not any_over_threshold: |
| 204 | + self._lightweight_mode = False |
| 205 | + self._switch_count += 1 |
| 206 | + |
| 207 | + |
| 208 | +def get_monitor(config: Optional[Dict[str, Any]] = None) -> AdaptivePerformanceMonitor: |
| 209 | + """Convenience function to get the singleton monitor. |
| 210 | +
|
| 211 | + Args: |
| 212 | + config: Optional codingbuddy config dict. |
| 213 | +
|
| 214 | + Returns: |
| 215 | + The AdaptivePerformanceMonitor singleton. |
| 216 | + """ |
| 217 | + return AdaptivePerformanceMonitor.get_instance(config) |
| 218 | + |
| 219 | + |
| 220 | +def format_lightweight_notice(language: str = "en") -> str: |
| 221 | + """Format user-facing notice when switching to lightweight mode. |
| 222 | +
|
| 223 | + Args: |
| 224 | + language: Language code (en, ko, ja, zh, es). |
| 225 | +
|
| 226 | + Returns: |
| 227 | + Localized notice string. |
| 228 | + """ |
| 229 | + notices = { |
| 230 | + "en": ( |
| 231 | + "[CodingBuddy] Switched to lightweight mode — " |
| 232 | + "hooks were exceeding time threshold. " |
| 233 | + "Heavy analysis (conflict prediction, full project scan) " |
| 234 | + "will be skipped. Core functions remain active." |
| 235 | + ), |
| 236 | + "ko": ( |
| 237 | + "[CodingBuddy] 경량 모드로 전환됨 — " |
| 238 | + "훅 실행 시간이 임계값을 초과했습니다. " |
| 239 | + "무거운 분석(충돌 예측, 전체 프로젝트 스캔)을 건너뜁니다. " |
| 240 | + "핵심 기능은 유지됩니다." |
| 241 | + ), |
| 242 | + "ja": ( |
| 243 | + "[CodingBuddy] 軽量モードに切り替えました — " |
| 244 | + "フックの実行時間がしきい値を超えました。" |
| 245 | + "重い分析(コンフリクト予測、フルプロジェクトスキャン)は" |
| 246 | + "スキップされます。コア機能は維持されます。" |
| 247 | + ), |
| 248 | + "zh": ( |
| 249 | + "[CodingBuddy] 已切换到轻量模式 — " |
| 250 | + "钩子执行时间超过阈值。" |
| 251 | + "将跳过重度分析(冲突预测、完整项目扫描)。" |
| 252 | + "核心功能保持活跃。" |
| 253 | + ), |
| 254 | + "es": ( |
| 255 | + "[CodingBuddy] Cambiado a modo ligero — " |
| 256 | + "los hooks excedieron el umbral de tiempo. " |
| 257 | + "Se omitirá el análisis pesado (predicción de conflictos, " |
| 258 | + "escaneo completo del proyecto). Las funciones principales " |
| 259 | + "permanecen activas." |
| 260 | + ), |
| 261 | + } |
| 262 | + return notices.get(language, notices["en"]) |
| 263 | + |
| 264 | + |
| 265 | +def format_normal_notice(language: str = "en") -> str: |
| 266 | + """Format user-facing notice when returning to normal mode. |
| 267 | +
|
| 268 | + Args: |
| 269 | + language: Language code (en, ko, ja, zh, es). |
| 270 | +
|
| 271 | + Returns: |
| 272 | + Localized notice string. |
| 273 | + """ |
| 274 | + notices = { |
| 275 | + "en": ( |
| 276 | + "[CodingBuddy] Returned to normal mode — " |
| 277 | + "hook performance has recovered. " |
| 278 | + "Full analysis is now active." |
| 279 | + ), |
| 280 | + "ko": ( |
| 281 | + "[CodingBuddy] 일반 모드로 복귀 — " |
| 282 | + "훅 성능이 회복되었습니다. " |
| 283 | + "전체 분석이 다시 활성화됩니다." |
| 284 | + ), |
| 285 | + "ja": ( |
| 286 | + "[CodingBuddy] 通常モードに復帰 — " |
| 287 | + "フックのパフォーマンスが回復しました。" |
| 288 | + "完全な分析が再びアクティブです。" |
| 289 | + ), |
| 290 | + "zh": ( |
| 291 | + "[CodingBuddy] 已返回正常模式 — " |
| 292 | + "钩子性能已恢复。" |
| 293 | + "完整分析已重新激活。" |
| 294 | + ), |
| 295 | + "es": ( |
| 296 | + "[CodingBuddy] Vuelto al modo normal — " |
| 297 | + "el rendimiento de los hooks se ha recuperado. " |
| 298 | + "El análisis completo está activo nuevamente." |
| 299 | + ), |
| 300 | + } |
| 301 | + return notices.get(language, notices["en"]) |
0 commit comments