diff --git a/apps/backend/agents/base.py b/apps/backend/agents/base.py index 3bb91421..a831573c 100644 --- a/apps/backend/agents/base.py +++ b/apps/backend/agents/base.py @@ -6,6 +6,7 @@ Shared imports, types, and constants used across agent modules. """ import logging +import re # Configure logging logger = logging.getLogger(__name__) @@ -14,7 +15,82 @@ logger = logging.getLogger(__name__) AUTO_CONTINUE_DELAY_SECONDS = 3 HUMAN_INTERVENTION_FILE = "PAUSE" -# Concurrency retry constants -MAX_CONCURRENCY_RETRIES = 5 -INITIAL_RETRY_DELAY_SECONDS = 2 -MAX_RETRY_DELAY_SECONDS = 32 +# Retry configuration for 400 tool concurrency errors +MAX_CONCURRENCY_RETRIES = 5 # Maximum number of retries for tool concurrency errors +INITIAL_RETRY_DELAY_SECONDS = ( + 2 # Initial retry delay (doubles each retry: 2s, 4s, 8s, 16s, 32s) +) +MAX_RETRY_DELAY_SECONDS = 32 # Cap retry delay at 32 seconds + +# Pause file constants for intelligent error recovery +# These files signal pause/resume between frontend and backend +RATE_LIMIT_PAUSE_FILE = "RATE_LIMIT_PAUSE" # Created when rate limited +AUTH_FAILURE_PAUSE_FILE = "AUTH_PAUSE" # Created when auth fails +RESUME_FILE = "RESUME" # Created by frontend to signal resume + +# Maximum time to wait for rate limit reset (2 hours) +# If reset time is beyond this, task should fail rather than wait indefinitely +MAX_RATE_LIMIT_WAIT_SECONDS = 7200 + +# Wait intervals for pause/resume checking +RATE_LIMIT_CHECK_INTERVAL_SECONDS = ( + 30 # Check for RESUME file every 30 seconds during rate limit wait +) +AUTH_RESUME_CHECK_INTERVAL_SECONDS = 10 # Check for re-authentication every 10 seconds +AUTH_RESUME_MAX_WAIT_SECONDS = 86400 # Maximum wait for re-authentication (24 hours) + + +def sanitize_error_message(error_message: str, max_length: int = 500) -> str: + """ + Sanitize error messages to remove potentially sensitive information. + + Redacts: + - API keys (sk-..., key-...) + - Bearer tokens + - Token/secret values + + Args: + error_message: The raw error message to sanitize + max_length: Maximum length to truncate to (default 500) + + Returns: + Sanitized and truncated error message + """ + if not error_message: + return "" + + # Redact patterns that look like API keys or tokens + # Pattern: sk-... (OpenAI/Anthropic keys like sk-ant-api03-...) + sanitized = re.sub( + r"\bsk-[a-zA-Z0-9._\-]{20,}\b", "[REDACTED_API_KEY]", error_message + ) + + # Pattern: key-... (generic API keys) + sanitized = re.sub(r"\bkey-[a-zA-Z0-9._\-]{20,}\b", "[REDACTED_API_KEY]", sanitized) + + # Pattern: Bearer ... (bearer tokens) + sanitized = re.sub( + r"\bBearer\s+[a-zA-Z0-9._\-]{20,}\b", "Bearer [REDACTED_TOKEN]", sanitized + ) + + # Pattern: token= or token: followed by long strings + sanitized = re.sub( + r"(token[=:]\s*)[a-zA-Z0-9._\-]{20,}\b", + r"\1[REDACTED_TOKEN]", + sanitized, + flags=re.IGNORECASE, + ) + + # Pattern: secret= or secret: followed by strings + sanitized = re.sub( + r"(secret[=:]\s*)[a-zA-Z0-9._\-]{20,}\b", + r"\1[REDACTED_SECRET]", + sanitized, + flags=re.IGNORECASE, + ) + + # Truncate to max length + if len(sanitized) > max_length: + sanitized = sanitized[:max_length] + "..." + + return sanitized diff --git a/apps/backend/agents/coder.py b/apps/backend/agents/coder.py index d92461ed..caebe711 100644 --- a/apps/backend/agents/coder.py +++ b/apps/backend/agents/coder.py @@ -6,8 +6,11 @@ Main autonomous agent loop that runs the coder agent to implement subtasks. """ import asyncio +import json import logging import os +import re +from datetime import datetime, timedelta from pathlib import Path from core.client import create_client @@ -57,11 +60,19 @@ from ui import ( ) from .base import ( + AUTH_FAILURE_PAUSE_FILE, + AUTH_RESUME_CHECK_INTERVAL_SECONDS, + AUTH_RESUME_MAX_WAIT_SECONDS, AUTO_CONTINUE_DELAY_SECONDS, HUMAN_INTERVENTION_FILE, INITIAL_RETRY_DELAY_SECONDS, MAX_CONCURRENCY_RETRIES, + MAX_RATE_LIMIT_WAIT_SECONDS, MAX_RETRY_DELAY_SECONDS, + RATE_LIMIT_CHECK_INTERVAL_SECONDS, + RATE_LIMIT_PAUSE_FILE, + RESUME_FILE, + sanitize_error_message, ) from .memory_manager import debug_memory_system_status, get_graphiti_context from .session import post_session_processing, run_agent_session @@ -76,6 +87,219 @@ from .utils import ( logger = logging.getLogger(__name__) +def _check_and_clear_resume_file( + resume_file: Path, + pause_file: Path, + fallback_resume_file: Path | None = None, +) -> bool: + """ + Check if resume file exists and clean up both resume and pause files. + + Also checks a fallback location (main project spec dir) in case the frontend + couldn't find the worktree and only wrote the RESUME file there. + + Args: + resume_file: Path to RESUME file + pause_file: Path to pause file (RATE_LIMIT_PAUSE or AUTH_PAUSE) + fallback_resume_file: Optional fallback RESUME file path (e.g. main project spec dir) + + Returns: + True if resume file existed (early resume), False otherwise + """ + found = resume_file.exists() + + # Check fallback location if primary not found + if not found and fallback_resume_file and fallback_resume_file.exists(): + found = True + try: + fallback_resume_file.unlink(missing_ok=True) + except OSError as e: + logger.debug(f"Error cleaning up fallback resume file: {e}") + + if found: + try: + resume_file.unlink(missing_ok=True) + pause_file.unlink(missing_ok=True) + except OSError as e: + logger.debug( + f"Error cleaning up resume files: {e} (resume: {resume_file}, pause: {pause_file})" + ) + return True + return False + + +async def wait_for_rate_limit_reset( + spec_dir: Path, + wait_seconds: float, + source_spec_dir: Path | None = None, +) -> bool: + """ + Wait for rate limit reset with periodic checks for resume/cancel. + + Args: + spec_dir: Spec directory to check for RESUME file + wait_seconds: Maximum time to wait in seconds + source_spec_dir: Optional main project spec dir as fallback for RESUME file + + Returns: + True if resumed early, False if waited full duration + """ + loop = asyncio.get_running_loop() + start_time = loop.time() + resume_file = spec_dir / RESUME_FILE + pause_file = spec_dir / RATE_LIMIT_PAUSE_FILE + fallback_resume = (source_spec_dir / RESUME_FILE) if source_spec_dir else None + + while True: + # Check elapsed time using loop.time() to avoid drift + elapsed = max(0, loop.time() - start_time) # Ensure non-negative + if elapsed >= wait_seconds: + break + + # Check if user requested resume + if _check_and_clear_resume_file(resume_file, pause_file, fallback_resume): + return True + + # Wait for next check interval or remaining time + sleep_time = min(RATE_LIMIT_CHECK_INTERVAL_SECONDS, wait_seconds - elapsed) + await asyncio.sleep(sleep_time) + + # Clean up pause file after wait completes + try: + pause_file.unlink(missing_ok=True) + except OSError as e: + logger.debug(f"Error cleaning up pause file {pause_file}: {e}") + + return False + + +async def wait_for_auth_resume( + spec_dir: Path, + source_spec_dir: Path | None = None, +) -> None: + """ + Wait for user re-authentication signal. + + Blocks until: + - RESUME file is created (user completed re-auth in UI) + - AUTH_PAUSE file is deleted (alternative resume signal) + - Maximum wait timeout is reached (24 hours) + + Args: + spec_dir: Spec directory to monitor for signal files + source_spec_dir: Optional main project spec dir as fallback for RESUME file + """ + loop = asyncio.get_running_loop() + start_time = loop.time() + resume_file = spec_dir / RESUME_FILE + pause_file = spec_dir / AUTH_FAILURE_PAUSE_FILE + fallback_resume = (source_spec_dir / RESUME_FILE) if source_spec_dir else None + + while True: + # Check elapsed time using loop.time() to avoid drift + elapsed = max(0, loop.time() - start_time) # Ensure non-negative + if elapsed >= AUTH_RESUME_MAX_WAIT_SECONDS: + break + + # Check for resume signals + if ( + _check_and_clear_resume_file(resume_file, pause_file, fallback_resume) + or not pause_file.exists() + ): + # If pause file was deleted externally, still clean up resume file if it exists + if not pause_file.exists(): + try: + resume_file.unlink(missing_ok=True) + except OSError as e: + logger.debug(f"Error cleaning up resume file {resume_file}: {e}") + return + + await asyncio.sleep(AUTH_RESUME_CHECK_INTERVAL_SECONDS) + + # Timeout reached - clean up and return + print_status( + "Authentication wait timeout reached (24 hours) - resuming with original credentials", + "warning", + ) + try: + pause_file.unlink(missing_ok=True) + except OSError as e: + logger.debug(f"Error cleaning up pause file {pause_file} after timeout: {e}") + + +def parse_rate_limit_reset_time(error_info: dict | None) -> int | None: + """ + Parse rate limit reset time from error info. + + Attempts to extract reset time from various formats in error messages. + + TIMEZONE ASSUMPTIONS: + - "in X minutes/hours" patterns are timezone-safe (relative time) + - "at HH:MM" patterns assume LOCAL timezone, which is reasonable since: + 1. The user sees timestamps in their local timezone + 2. The wait calculation happens locally using datetime.now() + 3. If the API returns UTC "at" times, this would need adjustment + (but Claude API typically returns relative times like "in X minutes") + + Args: + error_info: Error info dict with 'message' key + + Returns: + Unix timestamp of reset time, or None if not parseable + """ + if not error_info: + return None + + message = error_info.get("message", "") + + # Try to find patterns like "resets at 3:00 PM" or "in 5 minutes" + # Pattern: "in X minutes/hours" (timezone-safe - relative time) + in_time_match = re.search(r"in\s+(\d+)\s*(minute|hour|min|hr)s?", message, re.I) + if in_time_match: + amount = int(in_time_match.group(1)) + unit = in_time_match.group(2).lower() + if unit.startswith("hour") or unit.startswith("hr"): + delta = timedelta(hours=amount) + else: + delta = timedelta(minutes=amount) + return int((datetime.now() + delta).timestamp()) + + # Pattern: "at HH:MM" (12 or 24 hour) + at_time_match = re.search(r"at\s+(\d{1,2}):(\d{2})(?:\s*(am|pm))?", message, re.I) + if at_time_match: + try: + hour = int(at_time_match.group(1)) + minute = int(at_time_match.group(2)) + meridiem = at_time_match.group(3) + + # Validate hour range when meridiem is present + # Hours should be 1-12 for AM/PM format + if meridiem and not (1 <= hour <= 12): + return None + + if meridiem: + if meridiem.lower() == "pm" and hour < 12: + hour += 12 + elif meridiem.lower() == "am" and hour == 12: + hour = 0 + + # Validate hour and minute ranges + if not (0 <= hour <= 23 and 0 <= minute <= 59): + return None + + now = datetime.now() + reset_time = now.replace(hour=hour, minute=minute, second=0, microsecond=0) + if reset_time <= now: + reset_time += timedelta(days=1) + return int(reset_time.timestamp()) + except ValueError: + # Invalid time values - return None to fall back to standard retry + return None + + # No pattern matched - return None to let caller decide retry behavior + return None + + async def run_autonomous_agent( project_dir: Path, spec_dir: Path, @@ -682,6 +906,135 @@ async def run_autonomous_agent( current_retry_delay * 2, MAX_RETRY_DELAY_SECONDS ) + elif error_info and error_info.get("type") == "rate_limit": + # Rate limit error - intelligent wait for reset + _reset_concurrency_state() + + reset_timestamp = parse_rate_limit_reset_time(error_info) + if reset_timestamp: + wait_seconds = reset_timestamp - datetime.now().timestamp() + + # Handle negative wait_seconds (reset time in the past) + if wait_seconds <= 0: + print_status( + "Rate limit reset time already passed - retrying immediately", + "warning", + ) + status_manager.update(state=BuildState.BUILDING) + await asyncio.sleep(2) # Brief delay before retry + continue + + if wait_seconds > MAX_RATE_LIMIT_WAIT_SECONDS: + # Wait time too long - fail the task + print_status("Rate limit wait time too long", "error") + print( + f"Reset time would require waiting {wait_seconds / 3600:.1f} hours" + ) + print( + f"Maximum wait is {MAX_RATE_LIMIT_WAIT_SECONDS / 3600:.1f} hours" + ) + emit_phase( + ExecutionPhase.FAILED, + "Rate limit wait time exceeds maximum allowed", + ) + status_manager.update(state=BuildState.ERROR) + break + + # Emit pause phase with reset time for frontend + wait_minutes = wait_seconds / 60 + emit_phase( + ExecutionPhase.RATE_LIMIT_PAUSED, + f"Rate limit - resuming in {wait_minutes:.0f} minutes", + reset_timestamp=reset_timestamp, + ) + + # Create pause file for frontend detection + # Sanitize error message to prevent exposing sensitive data + raw_error = error_info.get("message", "Rate limit reached") + sanitized_error = ( + sanitize_error_message(raw_error, max_length=500) + or "Rate limit reached" + ) + pause_data = { + "paused_at": datetime.now().isoformat(), + "reset_timestamp": reset_timestamp, + "error": sanitized_error, + } + pause_file = spec_dir / RATE_LIMIT_PAUSE_FILE + pause_file.write_text(json.dumps(pause_data), encoding="utf-8") + + print_status( + f"Rate limited - waiting {wait_minutes:.0f} minutes for reset", + "warning", + ) + status_manager.update(state=BuildState.PAUSED) + + # Wait with periodic checks for resume signal + resumed_early = await wait_for_rate_limit_reset( + spec_dir, wait_seconds, source_spec_dir + ) + if resumed_early: + print_status("Resumed early by user", "success") + + # Resume execution + emit_phase(ExecutionPhase.CODING, "Resuming after rate limit") + status_manager.update(state=BuildState.BUILDING) + continue # Resume the loop + else: + # Couldn't parse reset time - fall back to standard retry + print_status("Rate limit hit (unknown reset time)", "warning") + print(muted("Will retry with a fresh session...")) + status_manager.update(state=BuildState.ERROR) + await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS) + _reset_concurrency_state() + status_manager.update(state=BuildState.BUILDING) + continue + + elif error_info and error_info.get("type") == "authentication": + # Authentication error - pause for user re-authentication + _reset_concurrency_state() + + emit_phase( + ExecutionPhase.AUTH_FAILURE_PAUSED, + "Re-authentication required", + ) + + # Create pause file for frontend detection + # Sanitize error message to prevent exposing sensitive data + raw_error = error_info.get("message", "Authentication failed") + sanitized_error = ( + sanitize_error_message(raw_error, max_length=500) + or "Authentication failed" + ) + pause_data = { + "paused_at": datetime.now().isoformat(), + "error": sanitized_error, + "requires_action": "re-authenticate", + } + pause_file = spec_dir / AUTH_FAILURE_PAUSE_FILE + pause_file.write_text(json.dumps(pause_data), encoding="utf-8") + + print() + print("=" * 70) + print(" AUTHENTICATION REQUIRED") + print("=" * 70) + print() + print("OAuth token is invalid or expired.") + print("Please re-authenticate in the Auto Claude settings.") + print() + print("The task will automatically resume once you re-authenticate.") + print() + + status_manager.update(state=BuildState.PAUSED) + + # Wait for user to complete re-authentication + await wait_for_auth_resume(spec_dir, source_spec_dir) + + print_status("Authentication restored - resuming", "success") + emit_phase(ExecutionPhase.CODING, "Resuming after re-authentication") + status_manager.update(state=BuildState.BUILDING) + continue # Resume the loop + else: # Other errors - use standard retry logic print_status("Session encountered an error", "error") diff --git a/apps/backend/agents/session.py b/apps/backend/agents/session.py index ffbc5698..370fd423 100644 --- a/apps/backend/agents/session.py +++ b/apps/backend/agents/session.py @@ -7,6 +7,7 @@ memory updates, recovery tracking, and Linear integration. """ import logging +import re from pathlib import Path from claude_agent_sdk import ClaudeSDKClient @@ -34,6 +35,7 @@ from ui import ( print_status, ) +from .base import sanitize_error_message from .memory_manager import save_session_memory from .utils import ( find_subtask_in_plan, @@ -68,6 +70,93 @@ def is_tool_concurrency_error(error: Exception) -> bool: ) +def is_rate_limit_error(error: Exception) -> bool: + """ + Check if an error is a rate limit error (429 or similar). + + Rate limit errors occur when the API usage quota is exceeded, + either for session limits or weekly limits. + + Args: + error: The exception to check + + Returns: + True if this is a rate limit error, False otherwise + """ + error_str = str(error).lower() + + # Check for HTTP 429 with word boundaries to avoid false positives + if re.search(r"\b429\b", error_str): + return True + + # Check for other rate limit indicators + return any( + p in error_str + for p in [ + "limit reached", + "rate limit", + "too many requests", + "usage limit", + "quota exceeded", + ] + ) + + +def is_authentication_error(error: Exception) -> bool: + """ + Check if an error is an authentication error (401, token expired, etc.). + + Authentication errors occur when OAuth tokens are invalid, expired, + or have been revoked (e.g., after token refresh on another process). + + Validation approach: + - HTTP 401 status code is checked with word boundaries to minimize false positives + - Additional string patterns are validated against lowercase error messages + - Patterns are designed to match known Claude API and OAuth error formats + + Known false positive risks: + - Generic error messages containing "unauthorized" or "access denied" may match + even if not related to authentication (e.g., file permission errors) + - Error messages containing these keywords in user-provided content could match + - Mitigation: HTTP 401 check provides strong signal; string patterns are secondary + + Real-world validation: + - Pattern matching has been tested against actual Claude API error responses + - False positive rate is acceptable given the recovery mechanism (prompt user to re-auth) + - If false positive occurs, user can simply resume without re-authenticating + + Args: + error: The exception to check + + Returns: + True if this is an authentication error, False otherwise + """ + error_str = str(error).lower() + + # Check for HTTP 401 with word boundaries to avoid false positives + if re.search(r"\b401\b", error_str): + return True + + # Check for other authentication indicators + # NOTE: "authentication failed" and "authentication error" are more specific patterns + # to reduce false positives from generic "authentication" mentions + return any( + p in error_str + for p in [ + "authentication failed", + "authentication error", + "unauthorized", + "invalid token", + "token expired", + "authentication_error", + "invalid_token", + "token_expired", + "not authenticated", + "http 401", + ] + ) + + async def post_session_processing( spec_dir: Path, project_dir: Path, @@ -568,7 +657,18 @@ async def run_agent_session( except Exception as e: # Detect specific error types for better retry handling is_concurrency = is_tool_concurrency_error(e) - error_type = "tool_concurrency" if is_concurrency else "other" + is_rate_limit = is_rate_limit_error(e) + is_auth = is_authentication_error(e) + + # Classify error type for appropriate handling + if is_concurrency: + error_type = "tool_concurrency" + elif is_rate_limit: + error_type = "rate_limit" + elif is_auth: + error_type = "authentication" + else: + error_type = "other" debug_error( "session", @@ -579,20 +679,32 @@ async def run_agent_session( tool_count=tool_count, ) - # Log concurrency errors prominently + # Sanitize error message to remove potentially sensitive data + # Must happen BEFORE printing to stdout, since stdout is captured by the frontend + sanitized_error = sanitize_error_message(str(e)) + + # Log errors prominently based on type if is_concurrency: print("\n⚠️ Tool concurrency limit reached (400 error)") print(" Claude API limits concurrent tool use in a single request") - print(f" Error: {str(e)[:200]}\n") + print(f" Error: {sanitized_error[:200]}\n") + elif is_rate_limit: + print("\n⚠️ Rate limit reached") + print(" API usage quota exceeded - waiting for reset") + print(f" Error: {sanitized_error[:200]}\n") + elif is_auth: + print("\n⚠️ Authentication error") + print(" OAuth token may be invalid or expired") + print(f" Error: {sanitized_error[:200]}\n") else: - print(f"Error during agent session: {e}") + print(f"Error during agent session: {sanitized_error}") if task_logger: - task_logger.log_error(f"Session error: {e}", phase) + task_logger.log_error(f"Session error: {sanitized_error}", phase) error_info = { "type": error_type, - "message": str(e), + "message": sanitized_error, "exception_type": type(e).__name__, } - return "error", str(e), error_info + return "error", sanitized_error, error_info diff --git a/apps/backend/core/phase_event.py b/apps/backend/core/phase_event.py index acc03460..52f243ae 100644 --- a/apps/backend/core/phase_event.py +++ b/apps/backend/core/phase_event.py @@ -23,6 +23,9 @@ class ExecutionPhase(str, Enum): QA_FIXING = "qa_fixing" COMPLETE = "complete" FAILED = "failed" + # Pause states for intelligent error recovery + RATE_LIMIT_PAUSED = "rate_limit_paused" + AUTH_FAILURE_PAUSED = "auth_failure_paused" def emit_phase( @@ -31,8 +34,19 @@ def emit_phase( *, progress: int | None = None, subtask: str | None = None, + reset_timestamp: int | None = None, + profile_id: str | None = None, ) -> None: - """Emit structured phase event to stdout for frontend parsing.""" + """Emit structured phase event to stdout for frontend parsing. + + Args: + phase: The execution phase (e.g., PLANNING, CODING, RATE_LIMIT_PAUSED) + message: Optional message describing the phase state + progress: Optional progress percentage (0-100) + subtask: Optional subtask identifier + reset_timestamp: Optional Unix timestamp for rate limit reset time + profile_id: Optional profile ID that triggered the pause + """ phase_value = phase.value if isinstance(phase, ExecutionPhase) else phase payload: dict[str, Any] = { @@ -48,6 +62,12 @@ def emit_phase( if subtask is not None: payload["subtask"] = subtask + if reset_timestamp is not None: + payload["reset_timestamp"] = reset_timestamp + + if profile_id is not None: + payload["profile_id"] = profile_id + try: print(f"{PHASE_MARKER_PREFIX}{json.dumps(payload, default=str)}", flush=True) except (OSError, UnicodeEncodeError) as e: diff --git a/apps/frontend/src/__tests__/integration/claude-profile-ipc.test.ts b/apps/frontend/src/__tests__/integration/claude-profile-ipc.test.ts index 3a084e56..418b3a54 100644 --- a/apps/frontend/src/__tests__/integration/claude-profile-ipc.test.ts +++ b/apps/frontend/src/__tests__/integration/claude-profile-ipc.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mkdirSync, rmSync, existsSync, mkdtempSync } from 'fs'; import { tmpdir } from 'os'; import path from 'path'; -import type { ClaudeProfile, IPCResult, TerminalCreateOptions } from '../../shared/types'; +import type { ClaudeProfile, IPCResult, } from '../../shared/types'; // Test directories - use secure temp directory with random suffix let TEST_DIR: string; diff --git a/apps/frontend/src/main/__tests__/cli-tool-manager.test.ts b/apps/frontend/src/main/__tests__/cli-tool-manager.test.ts index edb6af16..6caf948a 100644 --- a/apps/frontend/src/main/__tests__/cli-tool-manager.test.ts +++ b/apps/frontend/src/main/__tests__/cli-tool-manager.test.ts @@ -69,7 +69,7 @@ vi.mock('child_process', () => { // so when tests call vi.mocked(execFileSync).mockReturnValue(), it affects execSync too const sharedSyncMock = vi.fn(); -const mockExecFile = vi.fn((cmd: unknown, args: unknown, options: unknown, callback: unknown) => { +const mockExecFile = vi.fn((_cmd: unknown, _args: unknown, _options: unknown, callback: unknown) => { // Return a minimal ChildProcess-like object const childProcess = { stdout: { on: vi.fn() }, @@ -86,7 +86,7 @@ const mockExecFile = vi.fn((cmd: unknown, args: unknown, options: unknown, callb return childProcess as unknown as import('child_process').ChildProcess; }); - const mockExec = vi.fn((cmd: unknown, options: unknown, callback: unknown) => { + const mockExec = vi.fn((_cmd: unknown, _options: unknown, callback: unknown) => { // Return a minimal ChildProcess-like object const childProcess = { stdout: { on: vi.fn() }, diff --git a/apps/frontend/src/main/__tests__/config-path-validator.test.ts b/apps/frontend/src/main/__tests__/config-path-validator.test.ts index 215e783a..82a4402b 100644 --- a/apps/frontend/src/main/__tests__/config-path-validator.test.ts +++ b/apps/frontend/src/main/__tests__/config-path-validator.test.ts @@ -61,15 +61,17 @@ import path from 'path'; import { isValidConfigDir } from '../utils/config-path-validator'; describe('isValidConfigDir - Security Validation', () => { - let originalHomedir: string; - let consoleWarnSpy: any; + let _originalHomedir: string; + let consoleWarnSpy: ReturnType; beforeEach(() => { // Store original homedir for restoration - originalHomedir = os.homedir(); + _originalHomedir = os.homedir(); // Spy on console.warn to suppress warning output during tests - consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { + /* intentionally empty - suppress console output during tests */ + }); }); afterEach(() => { diff --git a/apps/frontend/src/main/__tests__/python-env-manager.test.ts b/apps/frontend/src/main/__tests__/python-env-manager.test.ts index 98ecca33..ce4cd826 100644 --- a/apps/frontend/src/main/__tests__/python-env-manager.test.ts +++ b/apps/frontend/src/main/__tests__/python-env-manager.test.ts @@ -93,7 +93,7 @@ describe('PythonEnvManager', () => { const sitePackagesPath = 'C:\\test\\site-packages'; // Access private property for testing - (manager as any).sitePackagesPath = sitePackagesPath; + (manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath; const env = manager.getPythonEnv(); @@ -106,7 +106,7 @@ describe('PythonEnvManager', () => { const sitePackagesPath = 'C:\\test\\site-packages'; // Access private property for testing - (manager as any).sitePackagesPath = sitePackagesPath; + (manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath; const env = manager.getPythonEnv(); @@ -125,7 +125,7 @@ describe('PythonEnvManager', () => { const sitePackagesPath = '/test/site-packages'; // Access private property for testing - (manager as any).sitePackagesPath = sitePackagesPath; + (manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath; const env = manager.getPythonEnv(); @@ -144,7 +144,7 @@ describe('PythonEnvManager', () => { const sitePackagesPath = 'C:\\test\\site-packages'; // Access private property for testing - (manager as any).sitePackagesPath = sitePackagesPath; + (manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath; // Save and clear existing PATH, then set lowercase 'Path' // This simulates a Windows environment where the system has 'Path' instead of 'PATH' diff --git a/apps/frontend/src/main/__tests__/rate-limit-auto-recovery.test.ts b/apps/frontend/src/main/__tests__/rate-limit-auto-recovery.test.ts index 6a6b23e3..6811c40e 100644 --- a/apps/frontend/src/main/__tests__/rate-limit-auto-recovery.test.ts +++ b/apps/frontend/src/main/__tests__/rate-limit-auto-recovery.test.ts @@ -507,7 +507,7 @@ describe('Rate Limit Edge Cases', () => { ]; for (const msg of falsePositives) { - const result = detectRateLimit(msg); + const _result = detectRateLimit(msg); // Note: Some may still match secondary indicators - that's intentional // The primary pattern should NOT match these const primaryPattern = /Limit reached\s*[·•]\s*resets/i; diff --git a/apps/frontend/src/main/__tests__/settings-onboarding.test.ts b/apps/frontend/src/main/__tests__/settings-onboarding.test.ts index c570cdda..e484d638 100644 --- a/apps/frontend/src/main/__tests__/settings-onboarding.test.ts +++ b/apps/frontend/src/main/__tests__/settings-onboarding.test.ts @@ -243,24 +243,31 @@ describe('SETTINGS_CLAUDE_CODE_GET_ONBOARDING_STATUS handler', () => { // Get the mocked functions (vitest stores the implementation) const { existsSync, readFileSync } = await import('fs'); + // Cast to vi.Mock for accessing mock methods + type MockFn = ReturnType; + const existsSyncMock = existsSync as unknown as MockFn; + const readFileSyncMock = readFileSync as unknown as MockFn; + // Save original mock implementations to restore after test - const originalExistsSync = (existsSync as any).getMockImplementation(); - const originalReadFileSync = (readFileSync as any).getMockImplementation(); + type ExistsSyncFn = (path: string) => boolean; + type ReadFileSyncFn = (path: string) => string; + const originalExistsSync: ExistsSyncFn = (existsSyncMock.getMockImplementation() as ExistsSyncFn | undefined) ?? (() => false); + const originalReadFileSync: ReadFileSyncFn = (readFileSyncMock.getMockImplementation() as ReadFileSyncFn | undefined) ?? (() => ''); // Override existsSync to make file appear to exist - (existsSync as any).mockImplementation((path: string) => { + existsSyncMock.mockImplementation((path: string) => { if (path === claudeJsonPath) { return true; // File appears to exist } - return originalExistsSync ? originalExistsSync(path) : false; + return originalExistsSync(path); }); // Override readFileSync to throw error for our specific file - (readFileSync as any).mockImplementation((path: string) => { + readFileSyncMock.mockImplementation((path: string) => { if (path === claudeJsonPath) { throw new Error('EACCES: permission denied, open \'' + path + '\''); } - return originalReadFileSync ? originalReadFileSync(path) : ''; + return originalReadFileSync(path); }); const result = await onboardingStatusHandler({}, null) as { @@ -272,8 +279,8 @@ describe('SETTINGS_CLAUDE_CODE_GET_ONBOARDING_STATUS handler', () => { expect(result.data?.hasCompletedOnboarding).toBe(false); // Restore original mocks - (existsSync as any).mockImplementation(originalExistsSync); - (readFileSync as any).mockImplementation(originalReadFileSync); + existsSyncMock.mockImplementation(originalExistsSync); + readFileSyncMock.mockImplementation(originalReadFileSync); }); }); }); diff --git a/apps/frontend/src/main/__tests__/utils.test.ts b/apps/frontend/src/main/__tests__/utils.test.ts index 1ab513eb..c2091939 100644 --- a/apps/frontend/src/main/__tests__/utils.test.ts +++ b/apps/frontend/src/main/__tests__/utils.test.ts @@ -178,7 +178,9 @@ describe("safeSendToRenderer", () => { describe("error handling - non-disposal errors", () => { it("catches other errors and returns false", () => { - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => { + /* intentionally empty - suppress console output during tests */ + }); mockSend.mockImplementation(() => { throw new Error("Some other IPC error"); @@ -216,7 +218,9 @@ describe("safeSendToRenderer", () => { }); it("logs console.warn only once for multiple consecutive calls to same channel", () => { - const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => { + /* intentionally empty - suppress console output during tests */ + }); mockWindow = { isDestroyed: vi.fn(() => true), @@ -242,7 +246,9 @@ describe("safeSendToRenderer", () => { }); it("logs console.warn separately for different channels", () => { - const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => { + /* intentionally empty - suppress console output during tests */ + }); mockWindow = { isDestroyed: vi.fn(() => true), @@ -307,7 +313,9 @@ describe("safeSendToRenderer", () => { describe("warning pruning logic - 100-entry hard cap", () => { it("enforces 100-entry cap by removing oldest entries when exceeded", async () => { - const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => { + /* intentionally empty - suppress console output during tests */ + }); mockWindow = { isDestroyed: vi.fn(() => true), @@ -340,7 +348,9 @@ describe("safeSendToRenderer", () => { }); it("handles many unique channels without throwing errors", async () => { - const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => { + /* intentionally empty - suppress console output during tests */ + }); mockWindow = { isDestroyed: vi.fn(() => true), diff --git a/apps/frontend/src/main/agent/agent-events.ts b/apps/frontend/src/main/agent/agent-events.ts index 8febfcdb..cff8005a 100644 --- a/apps/frontend/src/main/agent/agent-events.ts +++ b/apps/frontend/src/main/agent/agent-events.ts @@ -3,6 +3,7 @@ import { parsePhaseEvent } from './phase-event-parser'; import { wouldPhaseRegress, isTerminalPhase, + isPausePhase, isValidExecutionPhase, type ExecutionPhase } from '../../shared/constants/phase-protocol'; @@ -13,18 +14,48 @@ export class AgentEvents { log: string, currentPhase: ExecutionProgressData['phase'], isSpecRunner: boolean - ): { phase: ExecutionProgressData['phase']; message?: string; currentSubtask?: string } | null { + ): { + phase: ExecutionProgressData['phase']; + message?: string; + currentSubtask?: string; + resetTimestamp?: number; + profileId?: string; + } | null { const structuredEvent = parsePhaseEvent(log); if (structuredEvent) { - return { - phase: structuredEvent.phase as ExecutionProgressData['phase'], + // structuredEvent.phase is validated as BackendPhase (via Zod schema), + // which is a subset of ExecutionPhase, so this assertion is safe + const result: { + phase: ExecutionProgressData['phase']; + message?: string; + currentSubtask?: string; + resetTimestamp?: number; + profileId?: string; + } = { + phase: structuredEvent.phase as ExecutionPhase, message: structuredEvent.message, currentSubtask: structuredEvent.subtask }; + + // Include pause phase metadata if present + if (structuredEvent.reset_timestamp !== undefined) { + result.resetTimestamp = structuredEvent.reset_timestamp; + } + if (structuredEvent.profile_id !== undefined) { + result.profileId = structuredEvent.profile_id; + } + + return result; } // Terminal states can't be changed by fallback matching - if (isTerminalPhase(currentPhase as ExecutionPhase)) { + if (isTerminalPhase(currentPhase)) { + return null; + } + + // Pause phases should only be changed by structured events + // Don't allow fallback text matching to transition out of pause phases + if (isPausePhase(currentPhase)) { return null; } diff --git a/apps/frontend/src/main/agent/agent-manager.ts b/apps/frontend/src/main/agent/agent-manager.ts index 7f40ea56..304bd4bb 100644 --- a/apps/frontend/src/main/agent/agent-manager.ts +++ b/apps/frontend/src/main/agent/agent-manager.ts @@ -6,6 +6,8 @@ import { AgentEvents } from './agent-events'; import { AgentProcessManager } from './agent-process'; import { AgentQueueManager } from './agent-queue'; import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager'; +import type { ClaudeProfileManager } from '../claude-profile-manager'; +import { getOperationRegistry } from '../claude-profile/operation-registry'; import { SpecCreationMetadata, TaskExecutionOptions, @@ -33,6 +35,8 @@ export class AgentManager extends EventEmitter { baseBranch?: string; swapCount: number; projectId?: string; + /** Generation counter to prevent stale cleanup after restart */ + generation: number; }> = new Map(); constructor() { @@ -57,21 +61,35 @@ export class AgentManager extends EventEmitter { // 1. Task completed successfully (code === 0), or // 2. Task failed and won't be restarted (handled by auto-swap logic) + // Capture generation at exit time to prevent race conditions with restarts + const contextAtExit = this.taskExecutionContext.get(taskId); + const generationAtExit = contextAtExit?.generation; + // Note: Auto-swap restart happens BEFORE this exit event is processed, // so we need a small delay to allow restart to preserve context setTimeout(() => { const context = this.taskExecutionContext.get(taskId); if (!context) return; // Already cleaned up or restarted + // Check if the context's generation matches - if not, a restart incremented it + // and this cleanup is for a stale exit event that shouldn't affect the new task + if (generationAtExit !== undefined && context.generation !== generationAtExit) { + return; // Stale exit event - task was restarted, don't clean up new context + } + // If task completed successfully, always clean up if (code === 0) { this.taskExecutionContext.delete(taskId); + // Unregister from OperationRegistry + getOperationRegistry().unregisterOperation(taskId); return; } // If task failed and hit max retries, clean up if (context.swapCount >= 2) { this.taskExecutionContext.delete(taskId); + // Unregister from OperationRegistry + getOperationRegistry().unregisterOperation(taskId); } // Otherwise keep context for potential restart }, 1000); // Delay to allow restart logic to run first @@ -85,6 +103,46 @@ export class AgentManager extends EventEmitter { this.processManager.configure(pythonPath, autoBuildSourcePath); } + /** + * Register a task with the unified OperationRegistry for proactive swap support. + * Extracted helper to avoid code duplication between spec creation and task execution. + * @private + */ + private registerTaskWithOperationRegistry( + taskId: string, + operationType: 'spec-creation' | 'task-execution', + metadata: Record + ): void { + const profileManager = getClaudeProfileManager(); + const activeProfile = profileManager.getActiveProfile(); + if (!activeProfile) { + return; + } + + // Keep internal state tracking for backward compatibility + this.assignProfileToTask(taskId, activeProfile.id, activeProfile.name, 'proactive'); + + // Register with unified registry for proactive swap + // Note: We don't provide a stopFn because restartTask() already handles stopping + // the task internally via killTask() before restarting. Providing a separate + // stopFn would cause a redundant double-kill during profile swaps. + const operationRegistry = getOperationRegistry(); + operationRegistry.registerOperation( + taskId, + operationType, + activeProfile.id, + activeProfile.name, + (newProfileId: string) => this.restartTask(taskId, newProfileId), + { metadata } + ); + console.log('[AgentManager] Task registered with OperationRegistry:', { + taskId, + profileId: activeProfile.id, + profileName: activeProfile.name, + type: operationType + }); + } + /** * Start spec creation process */ @@ -99,7 +157,7 @@ export class AgentManager extends EventEmitter { ): Promise { // Pre-flight auth check: Verify active profile has valid authentication // Ensure profile manager is initialized to prevent race condition - let profileManager; + let profileManager: ClaudeProfileManager; try { profileManager = await initializeClaudeProfileManager(); } catch (error) { @@ -177,6 +235,9 @@ export class AgentManager extends EventEmitter { // Store context for potential restart this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch, projectId); + // Register with unified OperationRegistry for proactive swap support + this.registerTaskWithOperationRegistry(taskId, 'spec-creation', { projectPath, taskDescription, specDir }); + // Note: This is spec-creation but it chains to task-execution via run.py await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId); } @@ -193,7 +254,7 @@ export class AgentManager extends EventEmitter { ): Promise { // Pre-flight auth check: Verify active profile has valid authentication // Ensure profile manager is initialized to prevent race condition - let profileManager; + let profileManager: ClaudeProfileManager; try { profileManager = await initializeClaudeProfileManager(); } catch (error) { @@ -256,6 +317,9 @@ export class AgentManager extends EventEmitter { // Store context for potential restart this.storeTaskContext(taskId, projectPath, specId, options, false, undefined, undefined, undefined, undefined, projectId); + // Register with unified OperationRegistry for proactive swap support + this.registerTaskWithOperationRegistry(taskId, 'task-execution', { projectPath, specId, options }); + await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId); } @@ -397,6 +461,8 @@ export class AgentManager extends EventEmitter { // Preserve swapCount if context already exists (for restarts) const existingContext = this.taskExecutionContext.get(taskId); const swapCount = existingContext?.swapCount ?? 0; + // Increment generation on each store (restarts) to invalidate pending cleanup callbacks + const generation = (existingContext?.generation ?? 0) + 1; this.taskExecutionContext.set(taskId, { projectPath, @@ -408,7 +474,8 @@ export class AgentManager extends EventEmitter { metadata, baseBranch, swapCount, // Preserve existing count instead of resetting - projectId + projectId, + generation, // Incremented to prevent stale exit cleanup }); } @@ -464,10 +531,14 @@ export class AgentManager extends EventEmitter { console.log('[AgentManager] Restarting task now:', taskId); if (context.isSpecCreation) { console.log('[AgentManager] Restarting as spec creation'); + if (!context.taskDescription) { + console.error('[AgentManager] Cannot restart spec creation: taskDescription is missing'); + return; + } this.startSpecCreation( taskId, context.projectPath, - context.taskDescription!, + context.taskDescription, context.specDir, context.metadata, context.baseBranch, diff --git a/apps/frontend/src/main/agent/agent-process.test.ts b/apps/frontend/src/main/agent/agent-process.test.ts index 6df08b91..e092d34e 100644 --- a/apps/frontend/src/main/agent/agent-process.test.ts +++ b/apps/frontend/src/main/agent/agent-process.test.ts @@ -13,7 +13,7 @@ function createMockProcess() { return { stdout: { on: vi.fn() }, stderr: { on: vi.fn() }, - on: vi.fn((event: string, callback: any) => { + on: vi.fn((event: string, callback: (code: number) => void) => { if (event === 'exit') { // Simulate immediate exit with code 0 setTimeout(() => callback(0), 10); diff --git a/apps/frontend/src/main/agent/agent-process.ts b/apps/frontend/src/main/agent/agent-process.ts index a4fe2cdc..bfc258b9 100644 --- a/apps/frontend/src/main/agent/agent-process.ts +++ b/apps/frontend/src/main/agent/agent-process.ts @@ -281,7 +281,11 @@ export class AgentProcessManager { } : 'NONE'); if (!bestProfile) { - console.log('[AgentProcess] No alternative profile available - falling back to manual modal'); + // Single account case: let backend handle with intelligent pause + // Don't show manual modal - backend will pause intelligently and resume when ready + console.log('[AgentProcess] No alternative profile - backend will handle with intelligent pause'); + // Return false to let handleProcessFailure emit sdk-rate-limit event + // The frontend can then show appropriate UI (e.g., "Paused until X time") return false; } @@ -306,19 +310,84 @@ export class AgentProcessManager { console.log('[AgentProcess] No rate limit detected - checking for auth failure'); const authFailureDetection = detectAuthFailure(allOutput); - if (authFailureDetection.isAuthFailure) { - console.log('[AgentProcess] Auth failure detected:', authFailureDetection); + if (!authFailureDetection.isAuthFailure) { + console.log('[AgentProcess] Process failed but no rate limit or auth failure detected'); + return false; + } + + console.log('[AgentProcess] Auth failure detected:', authFailureDetection); + + // Try auto-swap if enabled + const wasHandled = this.handleAuthFailureWithAutoSwap(taskId, authFailureDetection); + + if (!wasHandled) { + // Fall back to UI notification this.emitter.emit('auth-failure', taskId, { profileId: authFailureDetection.profileId, failureType: authFailureDetection.failureType, message: authFailureDetection.message, originalError: authFailureDetection.originalError }); - return true; } - console.log('[AgentProcess] Process failed but no rate limit or auth failure detected'); - return false; + return true; + } + + /** + * Attempt to auto-swap to another profile on authentication failure. + * Only works when autoSwitchOnAuthFailure is enabled and an alternative + * authenticated profile is available. + */ + private handleAuthFailureWithAutoSwap( + taskId: string, + authFailureDetection: ReturnType + ): boolean { + const profileManager = getClaudeProfileManager(); + const autoSwitchSettings = profileManager.getAutoSwitchSettings(); + + console.log('[AgentProcess] Auth failure auto-switch settings:', { + enabled: autoSwitchSettings.enabled, + autoSwitchOnAuthFailure: autoSwitchSettings.autoSwitchOnAuthFailure + }); + + // Check if auto-switch on auth failure is enabled + if (!autoSwitchSettings.enabled || !autoSwitchSettings.autoSwitchOnAuthFailure) { + console.log('[AgentProcess] Auth failure auto-switch disabled - falling back to UI'); + return false; + } + + const currentProfileId = authFailureDetection.profileId; + const bestProfile = profileManager.getBestAvailableProfile(currentProfileId); + + console.log('[AgentProcess] Best available profile for auth failure swap:', bestProfile ? { + id: bestProfile.id, + name: bestProfile.name, + isAuthenticated: bestProfile.isAuthenticated + } : 'NONE'); + + // Verify the best profile is actually authenticated + if (!bestProfile || !bestProfile.isAuthenticated) { + console.log('[AgentProcess] No authenticated alternative profile - falling back to UI'); + return false; + } + + console.log('[AgentProcess] AUTH-FAILURE AUTO-SWAP:', currentProfileId, '->', bestProfile.id); + profileManager.setActiveProfile(bestProfile.id); + + // Emit auth-failure event with swap metadata for UI notification + this.emitter.emit('auth-failure', taskId, { + profileId: authFailureDetection.profileId, + failureType: authFailureDetection.failureType, + message: authFailureDetection.message, + originalError: authFailureDetection.originalError, + wasAutoSwapped: true, + swappedToProfile: { id: bestProfile.id, name: bestProfile.name } + }); + + // Reuse existing restart event + console.log('[AgentProcess] Emitting auto-swap-restart-task event for auth failure:', taskId); + this.emitter.emit('auto-swap-restart-task', taskId, bestProfile.id); + return true; } /** diff --git a/apps/frontend/src/main/agent/agent-state.test.ts b/apps/frontend/src/main/agent/agent-state.test.ts index 03a9fa10..7ea0f387 100644 --- a/apps/frontend/src/main/agent/agent-state.test.ts +++ b/apps/frontend/src/main/agent/agent-state.test.ts @@ -24,9 +24,24 @@ describe('AgentState - Queue Routing', () => { it('should group tasks by profile', () => { // Add mock processes - state.addProcess('task-1', { pid: 1001 } as any); - state.addProcess('task-2', { pid: 1002 } as any); - state.addProcess('task-3', { pid: 1003 } as any); + state.addProcess('task-1', { + taskId: 'task-1', + process: { pid: 1001 } as unknown as import('child_process').ChildProcess, + startedAt: new Date(), + spawnId: 1 + }); + state.addProcess('task-2', { + taskId: 'task-2', + process: { pid: 1002 } as unknown as import('child_process').ChildProcess, + startedAt: new Date(), + spawnId: 2 + }); + state.addProcess('task-3', { + taskId: 'task-3', + process: { pid: 1003 } as unknown as import('child_process').ChildProcess, + startedAt: new Date(), + spawnId: 3 + }); // Assign profiles state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive'); @@ -42,7 +57,12 @@ describe('AgentState - Queue Routing', () => { it('should use default profile for unassigned tasks', () => { // Add process without profile assignment - state.addProcess('task-1', { pid: 1001 } as any); + state.addProcess('task-1', { + taskId: 'task-1', + process: { pid: 1001 } as unknown as import('child_process').ChildProcess, + startedAt: new Date(), + spawnId: 1 + }); const result = state.getRunningTasksByProfile(); diff --git a/apps/frontend/src/main/agent/parsers/base-phase-parser.ts b/apps/frontend/src/main/agent/parsers/base-phase-parser.ts index fd02090d..b0a2c2e8 100644 --- a/apps/frontend/src/main/agent/parsers/base-phase-parser.ts +++ b/apps/frontend/src/main/agent/parsers/base-phase-parser.ts @@ -14,6 +14,9 @@ export interface PhaseParseResult { message?: string; currentSubtask?: string; progress?: number; + // Pause phase metadata + resetTimestamp?: number; // Unix timestamp for rate limit reset + profileId?: string; // Profile that hit the limit } /** diff --git a/apps/frontend/src/main/agent/parsers/execution-phase-parser.ts b/apps/frontend/src/main/agent/parsers/execution-phase-parser.ts index 4537cc9a..301143e5 100644 --- a/apps/frontend/src/main/agent/parsers/execution-phase-parser.ts +++ b/apps/frontend/src/main/agent/parsers/execution-phase-parser.ts @@ -9,6 +9,7 @@ import { BasePhaseParser, type PhaseParseResult, type PhaseParserContext } from import { EXECUTION_PHASES, TERMINAL_PHASES, + isPausePhase, type ExecutionPhase } from '../../../shared/constants/phase-protocol'; import { parsePhaseEvent } from '../phase-event-parser'; @@ -40,11 +41,21 @@ export class ExecutionPhaseParser extends BasePhaseParser { // 1. Try structured event first (authoritative source) const structuredEvent = parsePhaseEvent(log); if (structuredEvent) { - return { + const result: PhaseParseResult = { phase: structuredEvent.phase as ExecutionPhase, message: structuredEvent.message, currentSubtask: structuredEvent.subtask }; + + // Include pause phase metadata if present + if (structuredEvent.reset_timestamp !== undefined) { + result.resetTimestamp = structuredEvent.reset_timestamp; + } + if (structuredEvent.profile_id !== undefined) { + result.profileId = structuredEvent.profile_id; + } + + return result; } // 2. Terminal states can't be changed by fallback matching @@ -52,7 +63,13 @@ export class ExecutionPhaseParser extends BasePhaseParser { return null; } - // 3. Fall back to text pattern matching + // 3. Pause phases should only be changed by structured events + // Don't allow fallback text matching to transition out of pause phases + if (isPausePhase(context.currentPhase)) { + return null; + } + + // 4. Fall back to text pattern matching return this.parseFallbackPatterns(log, context); } diff --git a/apps/frontend/src/main/agent/phase-event-schema.ts b/apps/frontend/src/main/agent/phase-event-schema.ts index 508a55d9..36ac7d90 100644 --- a/apps/frontend/src/main/agent/phase-event-schema.ts +++ b/apps/frontend/src/main/agent/phase-event-schema.ts @@ -7,7 +7,10 @@ export const PhaseEventSchema = z.object({ phase: BackendPhaseSchema, message: z.string().default(''), progress: z.number().int().min(0).max(100).optional(), - subtask: z.string().optional() + subtask: z.string().optional(), + // Pause phase metadata + reset_timestamp: z.number().int().optional(), // Unix timestamp for rate limit reset + profile_id: z.string().optional() // Profile that hit the limit }); export type PhaseEventPayload = z.infer; diff --git a/apps/frontend/src/main/agent/types.ts b/apps/frontend/src/main/agent/types.ts index 17cded63..259a16b5 100644 --- a/apps/frontend/src/main/agent/types.ts +++ b/apps/frontend/src/main/agent/types.ts @@ -1,6 +1,5 @@ import { ChildProcess } from 'child_process'; -import type { IdeationConfig } from '../../shared/types'; -import type { CompletablePhase } from '../../shared/constants/phase-protocol'; +import type { CompletablePhase, ExecutionPhase } from '../../shared/constants/phase-protocol'; import type { TaskEventPayload } from './task-event-schema'; /** @@ -19,7 +18,7 @@ export interface AgentProcess { } export interface ExecutionProgressData { - phase: 'idle' | 'planning' | 'coding' | 'qa_review' | 'qa_fixing' | 'complete' | 'failed'; + phase: ExecutionPhase; phaseProgress: number; overallProgress: number; currentSubtask?: string; diff --git a/apps/frontend/src/main/app-updater.ts b/apps/frontend/src/main/app-updater.ts index 7884f9f1..e9f7d63b 100644 --- a/apps/frontend/src/main/app-updater.ts +++ b/apps/frontend/src/main/app-updater.ts @@ -402,7 +402,7 @@ async function fetchLatestStableRelease(): Promise { const version = latestStable.tag_name.replace(/^v/, ''); // Sanitize version string for logging (remove control characters and limit length) - // eslint-disable-next-line no-control-regex + // biome-ignore lint/suspicious/noControlCharactersInRegex: Intentionally matching control chars for sanitization const safeVersion = String(version).replace(/[\x00-\x1f\x7f]/g, '').slice(0, 50); console.warn('[app-updater] Found latest stable release:', safeVersion); diff --git a/apps/frontend/src/main/changelog/changelog-service.ts b/apps/frontend/src/main/changelog/changelog-service.ts index 69d07a7b..ab5112db 100644 --- a/apps/frontend/src/main/changelog/changelog-service.ts +++ b/apps/frontend/src/main/changelog/changelog-service.ts @@ -44,7 +44,6 @@ export class ChangelogService extends EventEmitter { private _pythonPath: string | null = null; private claudePath: string; private autoBuildSourcePath: string = ''; - private cachedEnv: Record | null = null; private debugEnabled: boolean | null = null; private generator: ChangelogGenerator | null = null; private versionSuggester: VersionSuggester | null = null; @@ -454,7 +453,7 @@ export class ChangelogService extends EventEmitter { } const parts = currentVersion.split('.').map(Number); - if (parts.length !== 3 || parts.some(isNaN)) { + if (parts.length !== 3 || parts.some(Number.isNaN)) { return '1.0.0'; } @@ -491,7 +490,7 @@ export class ChangelogService extends EventEmitter { * Suggest version using AI analysis of git commits */ async suggestVersionFromCommits( - projectPath: string, + _projectPath: string, commits: import('../../shared/types').GitCommit[], currentVersion?: string ): Promise<{ version: string; reason: string }> { @@ -502,7 +501,7 @@ export class ChangelogService extends EventEmitter { } const parts = currentVersion.split('.').map(Number); - if (parts.length !== 3 || parts.some(isNaN)) { + if (parts.length !== 3 || parts.some(Number.isNaN)) { return { version: '1.0.0', reason: 'Invalid current version, resetting to 1.0.0' }; } diff --git a/apps/frontend/src/main/changelog/version-suggester.ts b/apps/frontend/src/main/changelog/version-suggester.ts index 7facdbcc..77c74243 100644 --- a/apps/frontend/src/main/changelog/version-suggester.ts +++ b/apps/frontend/src/main/changelog/version-suggester.ts @@ -198,7 +198,6 @@ except Exception as e: case 'minor': newVersion = `${major}.${minor + 1}.0`; break; - case 'patch': default: newVersion = `${major}.${minor}.${patch + 1}`; break; diff --git a/apps/frontend/src/main/claude-profile-manager.ts b/apps/frontend/src/main/claude-profile-manager.ts index 642180db..7dc83bac 100644 --- a/apps/frontend/src/main/claude-profile-manager.ts +++ b/apps/frontend/src/main/claude-profile-manager.ts @@ -32,7 +32,6 @@ import { clearRateLimitEvents as clearRateLimitEventsImpl } from './claude-profile/rate-limit-manager'; import { - loadProfileStore, loadProfileStoreAsync, saveProfileStore, ProfileStoreData, @@ -196,31 +195,6 @@ export class ClaudeProfileManager { return this.initialized; } - /** - * Load profiles from disk - */ - private load(): ProfileStoreData { - const loadedData = loadProfileStore(this.storePath); - if (loadedData) { - if (process.env.DEBUG === 'true') { - console.warn('[ClaudeProfileManager] Loaded profiles:', { - count: loadedData.profiles.length, - activeProfileId: loadedData.activeProfileId, - profiles: loadedData.profiles.map(p => ({ - id: p.id, - name: p.name, - email: p.email, - isDefault: p.isDefault - })) - }); - } - return loadedData; - } - - // Return default with a single "Default" profile - return this.createDefaultData(); - } - /** * Create default profile data * diff --git a/apps/frontend/src/main/claude-profile/__tests__/operation-registry.test.ts b/apps/frontend/src/main/claude-profile/__tests__/operation-registry.test.ts new file mode 100644 index 00000000..8b7406f3 --- /dev/null +++ b/apps/frontend/src/main/claude-profile/__tests__/operation-registry.test.ts @@ -0,0 +1,673 @@ +/** + * Unit tests for OperationRegistry + * + * Tests cover: + * - Singleton pattern + * - Operation registration/unregistration + * - Profile-based querying + * - Summary generation + * - Operation restart functionality + * - Event emissions + * - Edge cases + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + getOperationRegistry, + resetOperationRegistry, + type OperationType, +} from '../operation-registry'; + +describe('OperationRegistry', () => { + beforeEach(() => { + // Reset registry before each test + resetOperationRegistry(); + }); + + afterEach(() => { + // Clean up after each test + resetOperationRegistry(); + }); + + describe('Singleton Pattern', () => { + it('should return the same instance on multiple calls', () => { + const instance1 = getOperationRegistry(); + const instance2 = getOperationRegistry(); + + expect(instance1).toBe(instance2); + }); + + it('should create new instance after reset', () => { + const instance1 = getOperationRegistry(); + resetOperationRegistry(); + const instance2 = getOperationRegistry(); + + expect(instance1).not.toBe(instance2); + }); + + it('should clear all operations on reset', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + + registry.registerOperation( + 'op1', + 'spec-creation', + 'profile1', + 'Profile 1', + mockRestart + ); + + expect(registry.getOperationCount()).toBe(1); + + resetOperationRegistry(); + const newRegistry = getOperationRegistry(); + + expect(newRegistry.getOperationCount()).toBe(0); + }); + }); + + describe('registerOperation', () => { + it('should register a basic operation', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + + registry.registerOperation( + 'op1', + 'spec-creation', + 'profile1', + 'Profile 1', + mockRestart + ); + + const operation = registry.getOperation('op1'); + expect(operation).toBeDefined(); + expect(operation?.id).toBe('op1'); + expect(operation?.type).toBe('spec-creation'); + expect(operation?.profileId).toBe('profile1'); + expect(operation?.profileName).toBe('Profile 1'); + expect(operation?.restartFn).toBe(mockRestart); + expect(operation?.startedAt).toBeInstanceOf(Date); + }); + + it('should register operation with optional stopFn', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + const mockStop = vi.fn(); + + registry.registerOperation( + 'op1', + 'pr-review', + 'profile1', + 'Profile 1', + mockRestart, + { stopFn: mockStop } + ); + + const operation = registry.getOperation('op1'); + expect(operation?.stopFn).toBe(mockStop); + }); + + it('should register operation with metadata', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + const metadata = { projectId: 'proj1', prNumber: 123 }; + + registry.registerOperation( + 'op1', + 'pr-review', + 'profile1', + 'Profile 1', + mockRestart, + { metadata } + ); + + const operation = registry.getOperation('op1'); + expect(operation?.metadata).toEqual(metadata); + }); + + it('should emit operation-registered event', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + const eventListener = vi.fn(); + + registry.on('operation-registered', eventListener); + + registry.registerOperation( + 'op1', + 'task-execution', + 'profile1', + 'Profile 1', + mockRestart + ); + + expect(eventListener).toHaveBeenCalledTimes(1); + const emittedOperation = eventListener.mock.calls[0][0]; + expect(emittedOperation.id).toBe('op1'); + expect(emittedOperation.type).toBe('task-execution'); + }); + + it('should increment operation count', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + + expect(registry.getOperationCount()).toBe(0); + + registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart); + expect(registry.getOperationCount()).toBe(1); + + registry.registerOperation('op2', 'roadmap', 'profile1', 'Profile 1', mockRestart); + expect(registry.getOperationCount()).toBe(2); + }); + + it('should allow registering multiple operation types', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + + const types: OperationType[] = [ + 'spec-creation', + 'task-execution', + 'pr-review', + 'mr-review', + 'insights', + 'roadmap', + 'changelog', + 'ideation', + 'triage', + 'other', + ]; + + types.forEach((type, index) => { + registry.registerOperation( + `op${index}`, + type, + 'profile1', + 'Profile 1', + mockRestart + ); + }); + + expect(registry.getOperationCount()).toBe(types.length); + + types.forEach((type, index) => { + const op = registry.getOperation(`op${index}`); + expect(op?.type).toBe(type); + }); + }); + }); + + describe('unregisterOperation', () => { + it('should unregister an existing operation', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + + registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart); + expect(registry.getOperation('op1')).toBeDefined(); + + registry.unregisterOperation('op1'); + expect(registry.getOperation('op1')).toBeUndefined(); + expect(registry.getOperationCount()).toBe(0); + }); + + it('should emit operation-unregistered event', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + const eventListener = vi.fn(); + + registry.on('operation-unregistered', eventListener); + + registry.registerOperation('op1', 'task-execution', 'profile1', 'Profile 1', mockRestart); + registry.unregisterOperation('op1'); + + expect(eventListener).toHaveBeenCalledTimes(1); + expect(eventListener).toHaveBeenCalledWith('op1', 'task-execution'); + }); + + it('should handle unregistering non-existent operation gracefully', () => { + const registry = getOperationRegistry(); + const eventListener = vi.fn(); + + registry.on('operation-unregistered', eventListener); + + // Should not throw + expect(() => registry.unregisterOperation('non-existent')).not.toThrow(); + + // Should not emit event for non-existent operation + expect(eventListener).not.toHaveBeenCalled(); + }); + + it('should decrement operation count', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + + registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart); + registry.registerOperation('op2', 'roadmap', 'profile1', 'Profile 1', mockRestart); + expect(registry.getOperationCount()).toBe(2); + + registry.unregisterOperation('op1'); + expect(registry.getOperationCount()).toBe(1); + + registry.unregisterOperation('op2'); + expect(registry.getOperationCount()).toBe(0); + }); + }); + + describe('getOperation', () => { + it('should retrieve operation by id', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + + registry.registerOperation('op1', 'pr-review', 'profile1', 'Profile 1', mockRestart); + + const operation = registry.getOperation('op1'); + expect(operation).toBeDefined(); + expect(operation?.id).toBe('op1'); + }); + + it('should return undefined for non-existent operation', () => { + const registry = getOperationRegistry(); + + const operation = registry.getOperation('non-existent'); + expect(operation).toBeUndefined(); + }); + }); + + describe('getOperationsByProfile', () => { + it('should return operations for a specific profile', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + + registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart); + registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart); + registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart); + + const profile1Ops = registry.getOperationsByProfile('profile1'); + expect(profile1Ops).toHaveLength(2); + expect(profile1Ops.map(op => op.id)).toEqual(['op1', 'op2']); + + const profile2Ops = registry.getOperationsByProfile('profile2'); + expect(profile2Ops).toHaveLength(1); + expect(profile2Ops[0].id).toBe('op3'); + }); + + it('should return empty array for profile with no operations', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + + registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart); + + const profile2Ops = registry.getOperationsByProfile('profile2'); + expect(profile2Ops).toEqual([]); + }); + }); + + describe('getAllOperationsByProfile', () => { + it('should return all operations grouped by profile', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + + registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart); + registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart); + registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart); + registry.registerOperation('op4', 'roadmap', 'profile3', 'Profile 3', mockRestart); + + const allOps = registry.getAllOperationsByProfile(); + + expect(Object.keys(allOps)).toEqual(['profile1', 'profile2', 'profile3']); + expect(allOps['profile1']).toHaveLength(2); + expect(allOps['profile2']).toHaveLength(1); + expect(allOps['profile3']).toHaveLength(1); + }); + + it('should return empty object when no operations', () => { + const registry = getOperationRegistry(); + + const allOps = registry.getAllOperationsByProfile(); + expect(allOps).toEqual({}); + }); + }); + + describe('getSummary', () => { + it('should return correct summary with no operations', () => { + const registry = getOperationRegistry(); + + const summary = registry.getSummary(); + expect(summary.totalRunning).toBe(0); + expect(summary.byProfile).toEqual({}); + expect(summary.byType).toEqual({}); + }); + + it('should count operations by profile', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + + registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart); + registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart); + registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart); + + const summary = registry.getSummary(); + + expect(summary.totalRunning).toBe(3); + expect(summary.byProfile['profile1']).toEqual(['op1', 'op2']); + expect(summary.byProfile['profile2']).toEqual(['op3']); + }); + + it('should count operations by type', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + + registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart); + registry.registerOperation('op2', 'spec-creation', 'profile1', 'Profile 1', mockRestart); + registry.registerOperation('op3', 'pr-review', 'profile1', 'Profile 1', mockRestart); + registry.registerOperation('op4', 'insights', 'profile2', 'Profile 2', mockRestart); + + const summary = registry.getSummary(); + + expect(summary.byType['spec-creation']).toBe(2); + expect(summary.byType['pr-review']).toBe(1); + expect(summary.byType['insights']).toBe(1); + }); + + it('should return complete summary with multiple profiles and types', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + + registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart); + registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart); + registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart); + registry.registerOperation('op4', 'insights', 'profile2', 'Profile 2', mockRestart); + registry.registerOperation('op5', 'roadmap', 'profile3', 'Profile 3', mockRestart); + + const summary = registry.getSummary(); + + expect(summary.totalRunning).toBe(5); + expect(Object.keys(summary.byProfile)).toHaveLength(3); + expect(Object.keys(summary.byType)).toHaveLength(5); + }); + }); + + describe('restartOperationsOnProfile', () => { + it('should restart all operations on a profile', async () => { + const registry = getOperationRegistry(); + const mockRestart1 = vi.fn().mockResolvedValue(true); + const mockRestart2 = vi.fn().mockResolvedValue(true); + + registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1); + registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2); + + const count = await registry.restartOperationsOnProfile( + 'profile1', + 'profile2', + 'Profile 2' + ); + + expect(count).toBe(2); + expect(mockRestart1).toHaveBeenCalledWith('profile2'); + expect(mockRestart2).toHaveBeenCalledWith('profile2'); + + // Verify profile was updated + const op1 = registry.getOperation('op1'); + const op2 = registry.getOperation('op2'); + expect(op1?.profileId).toBe('profile2'); + expect(op1?.profileName).toBe('Profile 2'); + expect(op2?.profileId).toBe('profile2'); + expect(op2?.profileName).toBe('Profile 2'); + }); + + it('should call stopFn before restart if provided', async () => { + const registry = getOperationRegistry(); + const mockStop = vi.fn().mockResolvedValue(undefined); + const mockRestart = vi.fn().mockResolvedValue(true); + + registry.registerOperation( + 'op1', + 'pr-review', + 'profile1', + 'Profile 1', + mockRestart, + { stopFn: mockStop } + ); + + await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2'); + + expect(mockStop).toHaveBeenCalledTimes(1); + expect(mockRestart).toHaveBeenCalledWith('profile2'); + // Ensure stopFn was called before restartFn + expect(mockStop.mock.invocationCallOrder[0]).toBeLessThan( + mockRestart.mock.invocationCallOrder[0] + ); + }); + + it('should return 0 when no operations on profile', async () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockResolvedValue(true); + + registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart); + + const count = await registry.restartOperationsOnProfile( + 'profile2', + 'profile3', + 'Profile 3' + ); + + expect(count).toBe(0); + expect(mockRestart).not.toHaveBeenCalled(); + }); + + it('should handle restart failure gracefully', async () => { + const registry = getOperationRegistry(); + const mockRestart1 = vi.fn().mockResolvedValue(true); + const mockRestart2 = vi.fn().mockResolvedValue(false); // Fails + const mockRestart3 = vi.fn().mockRejectedValue(new Error('Restart error')); // Throws + + registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1); + registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2); + registry.registerOperation('op3', 'pr-review', 'profile1', 'Profile 1', mockRestart3); + + const count = await registry.restartOperationsOnProfile( + 'profile1', + 'profile2', + 'Profile 2' + ); + + // Only op1 succeeded + expect(count).toBe(1); + + // op1 should have updated profile + const op1 = registry.getOperation('op1'); + expect(op1?.profileId).toBe('profile2'); + + // op2 and op3 should still have old profile + const op2 = registry.getOperation('op2'); + const op3 = registry.getOperation('op3'); + expect(op2?.profileId).toBe('profile1'); + expect(op3?.profileId).toBe('profile1'); + }); + + it('should emit operation-restarted event for each successful restart', async () => { + const registry = getOperationRegistry(); + const mockRestart1 = vi.fn().mockResolvedValue(true); + const mockRestart2 = vi.fn().mockResolvedValue(true); + const eventListener = vi.fn(); + + registry.on('operation-restarted', eventListener); + + registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1); + registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2); + + await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2'); + + expect(eventListener).toHaveBeenCalledTimes(2); + expect(eventListener).toHaveBeenCalledWith('op1', 'profile1', 'profile2'); + expect(eventListener).toHaveBeenCalledWith('op2', 'profile1', 'profile2'); + }); + + it('should emit operations-restarted event after restart', async () => { + const registry = getOperationRegistry(); + const mockRestart1 = vi.fn().mockResolvedValue(true); + const mockRestart2 = vi.fn().mockResolvedValue(true); + const eventListener = vi.fn(); + + registry.on('operations-restarted', eventListener); + + registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1); + registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2); + + await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2'); + + expect(eventListener).toHaveBeenCalledTimes(1); + expect(eventListener).toHaveBeenCalledWith(2, 'profile1', 'profile2'); + }); + + it('should not emit operations-restarted event if no restarts succeeded', async () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockResolvedValue(false); + const eventListener = vi.fn(); + + registry.on('operations-restarted', eventListener); + + registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart); + + await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2'); + + expect(eventListener).not.toHaveBeenCalled(); + }); + + it('should handle synchronous restart functions', async () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); // Synchronous return + + registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart); + + const count = await registry.restartOperationsOnProfile( + 'profile1', + 'profile2', + 'Profile 2' + ); + + expect(count).toBe(1); + expect(mockRestart).toHaveBeenCalledWith('profile2'); + + const op1 = registry.getOperation('op1'); + expect(op1?.profileId).toBe('profile2'); + }); + }); + + describe('updateOperationProfile', () => { + it('should update profile for existing operation', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + + registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart); + + registry.updateOperationProfile('op1', 'profile2', 'Profile 2'); + + const operation = registry.getOperation('op1'); + expect(operation?.profileId).toBe('profile2'); + expect(operation?.profileName).toBe('Profile 2'); + }); + + it('should handle updating non-existent operation gracefully', () => { + const registry = getOperationRegistry(); + + // Should not throw + expect(() => + registry.updateOperationProfile('non-existent', 'profile2', 'Profile 2') + ).not.toThrow(); + }); + }); + + describe('clear', () => { + it('should clear all operations', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + + registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart); + registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart); + registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart); + + expect(registry.getOperationCount()).toBe(3); + + registry.clear(); + + expect(registry.getOperationCount()).toBe(0); + expect(registry.getSummary().totalRunning).toBe(0); + }); + }); + + describe('Edge Cases', () => { + it('should handle registering operation with same id (overwrites)', () => { + const registry = getOperationRegistry(); + const mockRestart1 = vi.fn().mockReturnValue(true); + const mockRestart2 = vi.fn().mockReturnValue(true); + + registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1); + registry.registerOperation('op1', 'task-execution', 'profile2', 'Profile 2', mockRestart2); + + const operation = registry.getOperation('op1'); + expect(operation?.type).toBe('task-execution'); + expect(operation?.profileId).toBe('profile2'); + expect(registry.getOperationCount()).toBe(1); + }); + + it('should handle multiple unregisters of same operation', () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockReturnValue(true); + + registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart); + + registry.unregisterOperation('op1'); + expect(registry.getOperationCount()).toBe(0); + + // Second unregister should not throw or cause issues + registry.unregisterOperation('op1'); + expect(registry.getOperationCount()).toBe(0); + }); + + it('should handle restart with no operations gracefully', async () => { + const registry = getOperationRegistry(); + + const count = await registry.restartOperationsOnProfile( + 'profile1', + 'profile2', + 'Profile 2' + ); + + expect(count).toBe(0); + }); + + it('should preserve operation metadata through restart', async () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockResolvedValue(true); + const metadata = { projectId: 'proj1', prNumber: 123 }; + + registry.registerOperation( + 'op1', + 'pr-review', + 'profile1', + 'Profile 1', + mockRestart, + { metadata } + ); + + await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2'); + + const operation = registry.getOperation('op1'); + expect(operation?.metadata).toEqual(metadata); + }); + + it('should preserve startedAt timestamp through restart', async () => { + const registry = getOperationRegistry(); + const mockRestart = vi.fn().mockResolvedValue(true); + + registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart); + + const originalOp = registry.getOperation('op1'); + const originalStartTime = originalOp?.startedAt; + + await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2'); + + const updatedOp = registry.getOperation('op1'); + expect(updatedOp?.startedAt).toBe(originalStartTime); + }); + }); +}); diff --git a/apps/frontend/src/main/claude-profile/credential-utils.ts b/apps/frontend/src/main/claude-profile/credential-utils.ts index 029988f4..3c523d33 100644 --- a/apps/frontend/src/main/claude-profile/credential-utils.ts +++ b/apps/frontend/src/main/claude-profile/credential-utils.ts @@ -345,7 +345,7 @@ function executeCredentialRead( executablePath: string, args: string[], timeout: number, - identifier: string + _identifier: string ): string | null { try { const result = execFileSync(executablePath, args, { diff --git a/apps/frontend/src/main/claude-profile/operation-registry.ts b/apps/frontend/src/main/claude-profile/operation-registry.ts new file mode 100644 index 00000000..ee390a57 --- /dev/null +++ b/apps/frontend/src/main/claude-profile/operation-registry.ts @@ -0,0 +1,497 @@ +/** + * Unified registry for ALL Claude Agent SDK operations. + * + * This is the single source of truth for tracking running operations that use + * Claude profiles. It enables: + * 1. Proactive account swapping - restart operations on a different profile + * 2. Rate limit recovery - know which operations to restart after auth refresh + * 3. Usage attribution - track which profile is being used by which operation + * + * Operations include: + * - Autonomous tasks (spec creation, task execution) + * - GitHub PR reviews + * - GitLab MR reviews + * - Insights analysis + * - Roadmap generation + * - Changelog generation + * - Any other Claude SDK subprocess + */ + +import { EventEmitter } from 'events'; + +/** + * Types of operations that use Claude SDK + */ +export type OperationType = + | 'spec-creation' + | 'task-execution' + | 'pr-review' + | 'mr-review' + | 'insights' + | 'roadmap' + | 'changelog' + | 'ideation' + | 'triage' + | 'other'; + +/** + * Registered operation entry + * + * IMPORTANT: Object reference stability during restarts + * ===================================================== + * When an operation is restarted via restartFn, the restartFn implementation may + * choose to re-register the operation (creating a new RegisteredOperation object) + * OR update the existing one. Either approach is valid: + * + * 1. RE-REGISTRATION (AgentManager pattern): + * - restartFn calls registerOperation() which replaces the Map entry + * - Creates a new RegisteredOperation object with fresh closures + * - Previous object references become stale and should not be used + * - Callers MUST call getOperation(id) again to get the fresh reference + * + * 2. IN-PLACE UPDATE (alternative pattern): + * - restartFn updates internal state but doesn't re-register + * - Object reference remains valid + * - Registry calls updateOperationProfile() to sync profileId + * + * BEST PRACTICE for consumers: + * - Don't hold long-lived references to RegisteredOperation objects + * - Always use getOperation(id) to get current state + * - Subscribe to 'operation-restarted' events to know when to refresh + * - If you must hold a reference, listen for 'operation-restarted' and refresh it + */ +export interface RegisteredOperation { + /** Unique operation ID */ + id: string; + /** Type of operation */ + type: OperationType; + /** Profile ID currently being used */ + profileId: string; + /** Profile name for logging */ + profileName: string; + /** When the operation started */ + startedAt: Date; + /** Optional metadata (project ID, PR number, etc.) */ + metadata?: Record; + /** + * Function to restart this operation with a new profile. + * Returns true if restart was initiated successfully. + * The registry will update the profileId after successful restart. + * + * IMPORTANT: This function may re-register the operation (creating a new object) + * or update in-place. Callers should use getOperation(id) after restart to get + * the current reference. + */ + restartFn: (newProfileId: string) => boolean | Promise; + /** + * Optional function to stop the operation. + * Called before restart if provided. + */ + stopFn?: () => void | Promise; +} + +/** + * Events emitted by the operation registry + * + * NOTE: This interface is defined for documentation purposes only. It describes the event types + * that ClaudeOperationRegistry can emit, but is not currently enforced at the type system level. + * EventEmitter uses runtime event names, so type-safe event binding would require additional + * type assertion infrastructure. This interface serves as documentation for consumers of the + * operation registry to know which events are available and their callback signatures. + */ +export interface OperationRegistryEvents { + 'operation-registered': (operation: RegisteredOperation) => void; + 'operation-unregistered': (operationId: string, type: OperationType) => void; + 'operation-restarted': (operationId: string, oldProfileId: string, newProfileId: string) => void; + 'operations-restarted': (count: number, oldProfileId: string, newProfileId: string) => void; + 'operation-profile-updated': (operationId: string, oldProfileId: string, newProfileId: string) => void; +} + +/** + * Singleton registry for Claude SDK operations + * + * CONSUMER GUIDELINES: Object Reference Stability + * ================================================ + * Operations may be restarted during profile swaps. When this happens: + * + * 1. The operation's restartFn is called with a new profileId + * 2. The restartFn may choose to: + * a) Re-register the operation (creates new RegisteredOperation object), OR + * b) Update internal state without re-registering (keeps same object) + * + * 3. Either pattern is valid, but has implications for consumers: + * - Pattern (a): Previous object references become stale + * - Pattern (b): Object references remain valid + * + * BEST PRACTICES for consumers: + * - Don't hold long-lived references to RegisteredOperation objects + * - Always use getOperation(id) to get current state when needed + * - Subscribe to 'operation-restarted' events to know when state may have changed + * - Use hasOperation(id) to verify an operation is still registered + * + * EXAMPLE: Safely working with operation references + * ```typescript + * const registry = getOperationRegistry(); + * + * // Initial fetch + * let operation = registry.getOperation('task-123'); + * + * // Listen for restarts + * registry.onOperationRestarted((operationId, oldProfileId, newProfileId) => { + * if (operationId === 'task-123') { + * // Refresh reference after restart + * operation = registry.getOperation('task-123'); + * console.log('Operation restarted with new profile:', newProfileId); + * } + * }); + * + * // When accessing operation state later, prefer fresh fetch: + * const currentOp = registry.getOperation('task-123'); + * if (currentOp) { + * console.log('Current profile:', currentOp.profileId); + * } + * ``` + */ +class ClaudeOperationRegistry extends EventEmitter { + private operations: Map = new Map(); + private debugMode: boolean; + + constructor() { + super(); + this.debugMode = process.env.DEBUG === 'true'; + } + + private debugLog(...args: unknown[]): void { + if (this.debugMode) { + console.log('[OperationRegistry]', ...args); + } + } + + /** + * Register a new operation + */ + registerOperation( + id: string, + type: OperationType, + profileId: string, + profileName: string, + restartFn: RegisteredOperation['restartFn'], + options?: { + stopFn?: RegisteredOperation['stopFn']; + metadata?: Record; + } + ): void { + const operation: RegisteredOperation = { + id, + type, + profileId, + profileName, + startedAt: new Date(), + restartFn, + stopFn: options?.stopFn, + metadata: options?.metadata, + }; + + this.operations.set(id, operation); + this.debugLog('Operation registered:', { + id, + type, + profileId, + profileName, + metadata: options?.metadata, + }); + + this.emit('operation-registered', operation); + } + + /** + * Unregister an operation (when it completes or is cancelled) + */ + unregisterOperation(id: string): void { + const operation = this.operations.get(id); + if (operation) { + this.operations.delete(id); + this.debugLog('Operation unregistered:', { id, type: operation.type }); + this.emit('operation-unregistered', id, operation.type); + } + } + + /** + * Get all operations running on a specific profile + */ + getOperationsByProfile(profileId: string): RegisteredOperation[] { + const result: RegisteredOperation[] = []; + for (const op of this.operations.values()) { + if (op.profileId === profileId) { + result.push(op); + } + } + return result; + } + + /** + * Get all running operations grouped by profile + */ + getAllOperationsByProfile(): Record { + const result: Record = {}; + for (const op of this.operations.values()) { + if (!result[op.profileId]) { + result[op.profileId] = []; + } + result[op.profileId].push(op); + } + return result; + } + + /** + * Get operation by ID + * + * IMPORTANT: Always call this method to get the current operation state. + * Don't hold long-lived references to RegisteredOperation objects, as they + * may become stale after a restart. Instead, call getOperation(id) whenever + * you need current state, or subscribe to 'operation-restarted' events. + */ + getOperation(id: string): RegisteredOperation | undefined { + return this.operations.get(id); + } + + /** + * Check if an operation exists and is currently registered. + * Use this to verify an operation reference is still valid. + * + * @param id - Operation ID to check + * @returns true if operation exists in registry, false otherwise + */ + hasOperation(id: string): boolean { + return this.operations.has(id); + } + + /** + * Get count of running operations + */ + getOperationCount(): number { + return this.operations.size; + } + + /** + * Get summary of running operations for logging + */ + getSummary(): { + totalRunning: number; + byProfile: Record; + byType: Record; + } { + const byProfile: Record = {}; + const byType: Record = {}; + + for (const op of this.operations.values()) { + // By profile + if (!byProfile[op.profileId]) { + byProfile[op.profileId] = []; + } + byProfile[op.profileId].push(op.id); + + // By type + byType[op.type] = (byType[op.type] || 0) + 1; + } + + return { + totalRunning: this.operations.size, + byProfile, + byType: byType as Record, + }; + } + + /** + * Restart all operations running on a specific profile with a new profile. + * This is called by UsageMonitor during proactive swaps. + * + * IMPORTANT: Object reference stability after restart + * ==================================================== + * When operations are restarted, their restartFn implementations may: + * 1. Re-register the operation (AgentManager pattern) - creates new object + * 2. Update in-place (alternative pattern) - keeps same object + * + * For consumers holding operation references: + * - Your reference may become stale if the operation re-registers + * - Always call getOperation(id) after this method to get fresh reference + * - Or subscribe to 'operation-restarted' events and refresh on each event + * + * This method emits: + * - 'operation-restarted' for each successful restart (use this to refresh refs) + * - 'operations-restarted' once with total count + * - 'operation-profile-updated' for each profile update + * + * @param oldProfileId - Profile ID to migrate away from + * @param newProfileId - Profile ID to migrate to + * @param newProfileName - Profile name for logging + * @returns Number of operations that were restarted + */ + async restartOperationsOnProfile( + oldProfileId: string, + newProfileId: string, + newProfileName: string + ): Promise { + const operations = this.getOperationsByProfile(oldProfileId); + + if (operations.length === 0) { + this.debugLog('No operations to restart on profile:', oldProfileId); + return 0; + } + + console.log('[OperationRegistry] Restarting', operations.length, 'operations:', { + from: oldProfileId, + to: newProfileId, + operations: operations.map(op => ({ id: op.id, type: op.type })), + }); + + let restartedCount = 0; + + for (const op of operations) { + try { + // Stop the operation first if a stop function is provided + if (op.stopFn) { + this.debugLog('Stopping operation before restart:', op.id); + await op.stopFn(); + } + + // Call the restart function + this.debugLog('Restarting operation:', op.id, 'with profile:', newProfileId); + const success = await op.restartFn(newProfileId); + + if (success) { + restartedCount++; + + // Update the profile for operations that weren't re-registered during restart. + // For AgentManager tasks, restartFn may create a NEW object in the Map, + // in which case this update is harmless (updates the new reference). + // For other operations, this ensures the profile is properly updated. + this.updateOperationProfile(op.id, newProfileId, newProfileName); + + // Re-fetch from Map to get the current object (restartFn may have + // re-registered the operation with a new object) + const currentOp = this.operations.get(op.id); + + console.log('[OperationRegistry] Operation restarted successfully:', { + id: op.id, + type: currentOp?.type ?? op.type, + newProfile: newProfileName, + }); + + this.emit('operation-restarted', op.id, oldProfileId, newProfileId); + } else { + console.warn('[OperationRegistry] Operation restart returned false:', op.id); + } + } catch (error) { + console.error('[OperationRegistry] Failed to restart operation:', op.id, error); + } + } + + if (restartedCount > 0) { + this.emit('operations-restarted', restartedCount, oldProfileId, newProfileId); + } + + console.log('[OperationRegistry] Restart complete:', { + total: operations.length, + succeeded: restartedCount, + failed: operations.length - restartedCount, + }); + + return restartedCount; + } + + /** + * Update the profile assignment for an operation (e.g., after restart) + */ + updateOperationProfile(id: string, newProfileId: string, newProfileName: string): void { + const operation = this.operations.get(id); + if (operation) { + const oldProfileId = operation.profileId; + operation.profileId = newProfileId; + operation.profileName = newProfileName; + this.debugLog('Operation profile updated:', { + id, + from: oldProfileId, + to: newProfileId, + }); + this.emit('operation-profile-updated', id, oldProfileId, newProfileId); + } + } + + /** + * Clear all registered operations (for testing or cleanup) + */ + clear(): void { + this.operations.clear(); + this.debugLog('All operations cleared'); + } + + /** + * Type-safe event subscription: operation-registered + * Subscribe to operation registration events + */ + onOperationRegistered(callback: (operation: RegisteredOperation) => void): () => void { + this.on('operation-registered', callback); + return () => this.off('operation-registered', callback); + } + + /** + * Type-safe event subscription: operation-unregistered + * Subscribe to operation unregistration events + */ + onOperationUnregistered(callback: (operationId: string, type: OperationType) => void): () => void { + this.on('operation-unregistered', callback); + return () => this.off('operation-unregistered', callback); + } + + /** + * Type-safe event subscription: operation-restarted + * Subscribe to individual operation restart events + */ + onOperationRestarted(callback: (operationId: string, oldProfileId: string, newProfileId: string) => void): () => void { + this.on('operation-restarted', callback); + return () => this.off('operation-restarted', callback); + } + + /** + * Type-safe event subscription: operations-restarted + * Subscribe to batch operation restart events + */ + onOperationsRestarted(callback: (count: number, oldProfileId: string, newProfileId: string) => void): () => void { + this.on('operations-restarted', callback); + return () => this.off('operations-restarted', callback); + } + + /** + * Type-safe event subscription: operation-profile-updated + * Subscribe to operation profile update events + */ + onOperationProfileUpdated(callback: (operationId: string, oldProfileId: string, newProfileId: string) => void): () => void { + this.on('operation-profile-updated', callback); + return () => this.off('operation-profile-updated', callback); + } +} + +// Singleton instance +let registryInstance: ClaudeOperationRegistry | null = null; + +/** + * Get the singleton ClaudeOperationRegistry instance + */ +export function getOperationRegistry(): ClaudeOperationRegistry { + if (!registryInstance) { + registryInstance = new ClaudeOperationRegistry(); + } + return registryInstance; +} + +/** + * Reset the registry (for testing) + */ +export function resetOperationRegistry(): void { + if (registryInstance) { + registryInstance.clear(); + registryInstance.removeAllListeners(); + } + registryInstance = null; +} diff --git a/apps/frontend/src/main/claude-profile/profile-storage.ts b/apps/frontend/src/main/claude-profile/profile-storage.ts index 0c7800e4..ed9d7988 100644 --- a/apps/frontend/src/main/claude-profile/profile-storage.ts +++ b/apps/frontend/src/main/claude-profile/profile-storage.ts @@ -26,6 +26,7 @@ export const DEFAULT_AUTO_SWITCH_SETTINGS: ClaudeAutoSwitchSettings = { sessionThreshold: 95, // Consider switching at 95% session usage weeklyThreshold: 99, // Consider switching at 99% weekly usage autoSwitchOnRateLimit: false, // Prompt user by default + autoSwitchOnAuthFailure: false, // Prompt user by default on auth failures usageCheckInterval: 30000 // Check every 30s when enabled (0 = disabled) }; diff --git a/apps/frontend/src/main/claude-profile/profile-utils.ts b/apps/frontend/src/main/claude-profile/profile-utils.ts index 793a61f9..de32b1d5 100644 --- a/apps/frontend/src/main/claude-profile/profile-utils.ts +++ b/apps/frontend/src/main/claude-profile/profile-utils.ts @@ -207,7 +207,7 @@ export function hasValidToken(profile: ClaudeProfile): boolean { * Expand ~ in path to home directory */ export function expandHomePath(path: string): string { - if (path && path.startsWith('~')) { + if (path?.startsWith('~')) { const home = homedir(); return path.replace(/^~/, home); } diff --git a/apps/frontend/src/main/claude-profile/token-refresh.test.ts b/apps/frontend/src/main/claude-profile/token-refresh.test.ts index b0ff2378..585ccf35 100644 --- a/apps/frontend/src/main/claude-profile/token-refresh.test.ts +++ b/apps/frontend/src/main/claude-profile/token-refresh.test.ts @@ -12,7 +12,6 @@ import { refreshOAuthToken, ensureValidToken, reactiveTokenRefresh, - type TokenRefreshResult } from './token-refresh'; // Mock credential-utils diff --git a/apps/frontend/src/main/claude-profile/usage-monitor.ts b/apps/frontend/src/main/claude-profile/usage-monitor.ts index 1a20bf5e..a89397b6 100644 --- a/apps/frontend/src/main/claude-profile/usage-monitor.ts +++ b/apps/frontend/src/main/claude-profile/usage-monitor.ts @@ -19,6 +19,7 @@ import { detectProvider as sharedDetectProvider, type ApiProvider } from '../../ import { getCredentialsFromKeychain, clearKeychainCache } from './credential-utils'; import { reactiveTokenRefresh, ensureValidToken } from './token-refresh'; import { isProfileRateLimited } from './rate-limit-manager'; +import { getOperationRegistry } from './operation-registry'; // Re-export for backward compatibility export type { ApiProvider }; @@ -775,7 +776,7 @@ export class UsageMonitor extends EventEmitter { const activeProfile = profilesFile.profiles.find( (p) => p.id === profilesFile.activeProfileId ); - if (activeProfile && activeProfile.apiKey) { + if (activeProfile?.apiKey) { this.debugLog('[UsageMonitor:TRACE] Using API profile credential: ' + activeProfile.name); return activeProfile.apiKey; } @@ -1333,7 +1334,7 @@ export class UsageMonitor extends EventEmitter { let baseUrl: string; let provider: ApiProvider; - if (activeProfile && activeProfile.isAPIProfile) { + if (activeProfile?.isAPIProfile) { // Use the pre-determined profile to avoid race conditions // Trust the activeProfile data and use baseUrl directly baseUrl = activeProfile.baseUrl; @@ -1347,7 +1348,7 @@ export class UsageMonitor extends EventEmitter { const profilesFile = await loadProfilesFile(); apiProfile = profilesFile.profiles.find(p => p.id === profileId); - if (apiProfile && apiProfile.apiKey) { + if (apiProfile?.apiKey) { // API profile found baseUrl = apiProfile.baseUrl; provider = detectProvider(baseUrl); @@ -2007,6 +2008,46 @@ export class UsageMonitor extends EventEmitter { limitType }); + // PROACTIVE OPERATION RESTART: Stop and restart all running Claude SDK operations with new profile credentials + // This includes autonomous tasks, PR reviews, insights, roadmap, etc. + // Claude Agent SDK sessions maintain state independently of auth tokens, so no progress is lost + const operationRegistry = getOperationRegistry(); + const operationSummary = operationRegistry.getSummary(); + const operationIdsOnOldProfile = operationSummary.byProfile[currentProfileId] || []; + + // Always log running operations info for debugging + console.log('[UsageMonitor] PROACTIVE-SWAP: Checking running operations:', { + oldProfileId: currentProfileId, + newProfileId: bestAccount.id, + totalRunning: operationSummary.totalRunning, + byProfile: operationSummary.byProfile, + byType: operationSummary.byType, + operationIdsOnOldProfile: operationIdsOnOldProfile + }); + + if (operationIdsOnOldProfile.length > 0) { + console.log('[UsageMonitor] PROACTIVE-SWAP: Found', operationIdsOnOldProfile.length, 'operations to restart:', operationIdsOnOldProfile); + + // Restart all operations on the old profile with the new profile + const restartedCount = await operationRegistry.restartOperationsOnProfile( + currentProfileId, + bestAccount.id, + bestAccount.name + ); + + // Emit event for tracking/logging + this.emit('proactive-operations-restarted', { + fromProfile: { id: currentProfileId, name: fromProfileName }, + toProfile: { id: bestAccount.id, name: bestAccount.name }, + operationIds: operationIdsOnOldProfile, + restartedCount, + limitType, + timestamp: new Date() + }); + } else { + console.log('[UsageMonitor] PROACTIVE-SWAP: No operations running on old profile', currentProfileId, '- swap complete without restart'); + } + // Note: Don't immediately check new profile - let normal interval handle it // This prevents cascading swaps if multiple profiles are near limits } diff --git a/apps/frontend/src/main/env-utils.ts b/apps/frontend/src/main/env-utils.ts index 8fbee2c0..c0a8dd5d 100644 --- a/apps/frontend/src/main/env-utils.ts +++ b/apps/frontend/src/main/env-utils.ts @@ -95,7 +95,7 @@ function getNpmGlobalPrefix(): string | null { const normalizedPath = path.normalize(binPath); return fs.existsSync(normalizedPath) ? normalizedPath : null; - } catch (error) { + } catch (_error) { // Fallback for Windows: try default npm global location when npm.cmd is not in PATH // This happens when the packaged app launches from GUI without full shell environment if (isWindows()) { diff --git a/apps/frontend/src/main/index.ts b/apps/frontend/src/main/index.ts index 8f68abf6..4e438940 100644 --- a/apps/frontend/src/main/index.ts +++ b/apps/frontend/src/main/index.ts @@ -157,8 +157,7 @@ function createWindow(): void { const display = screen.getPrimaryDisplay(); // Validate the returned object has expected structure with valid dimensions if ( - display && - display.workAreaSize && + display?.workAreaSize && typeof display.workAreaSize.width === 'number' && typeof display.workAreaSize.height === 'number' && display.workAreaSize.width > 0 && @@ -495,7 +494,7 @@ app.whenReady().then(() => { // Setup event forwarding from usage monitor to renderer initializeUsageMonitorForwarding(mainWindow); - // Start the usage monitor + // Start the usage monitor (uses unified OperationRegistry for proactive restart) const usageMonitor = getUsageMonitor(); usageMonitor.start(); console.warn('[main] Usage monitor initialized and started (after profile load)'); diff --git a/apps/frontend/src/main/insights/session-manager.ts b/apps/frontend/src/main/insights/session-manager.ts index 7293d527..73624a57 100644 --- a/apps/frontend/src/main/insights/session-manager.ts +++ b/apps/frontend/src/main/insights/session-manager.ts @@ -9,11 +9,10 @@ import { InsightsPaths } from './paths'; export class SessionManager { private sessions: Map = new Map(); private storage: SessionStorage; - private paths: InsightsPaths; - constructor(storage: SessionStorage, paths: InsightsPaths) { + constructor(storage: SessionStorage, _paths: InsightsPaths) { this.storage = storage; - this.paths = paths; + // Note: paths parameter kept for API compatibility but not currently used } /** diff --git a/apps/frontend/src/main/ipc-handlers/changelog-handlers.ts b/apps/frontend/src/main/ipc-handlers/changelog-handlers.ts index f5a1a99f..161e85c2 100644 --- a/apps/frontend/src/main/ipc-handlers/changelog-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/changelog-handlers.ts @@ -52,7 +52,7 @@ export function registerChangelogHandlers( } }); - changelogService.on('rate-limit', (projectId: string, rateLimitInfo: import('../../shared/types').SDKRateLimitInfo) => { + changelogService.on('rate-limit', (_projectId: string, rateLimitInfo: import('../../shared/types').SDKRateLimitInfo) => { const mainWindow = getMainWindow(); if (mainWindow) { mainWindow.webContents.send(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo); diff --git a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts index 14bf8909..7f08d489 100644 --- a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts @@ -881,7 +881,7 @@ function checkProfileAuthentication(configDir: string): AuthCheckResult { const data = JSON.parse(content); // Check for oauthAccount with emailAddress - if (data.oauthAccount && data.oauthAccount.emailAddress) { + if (data.oauthAccount?.emailAddress) { return { authenticated: true, email: data.oauthAccount.emailAddress, @@ -907,7 +907,7 @@ function checkProfileAuthentication(configDir: string): AuthCheckResult { }; } - if (data.oauthAccount && data.oauthAccount.emailAddress) { + if (data.oauthAccount?.emailAddress) { return { authenticated: true, email: data.oauthAccount.emailAddress, diff --git a/apps/frontend/src/main/ipc-handlers/context/project-context-handlers.ts b/apps/frontend/src/main/ipc-handlers/context/project-context-handlers.ts index bcf1acab..49134a6d 100644 --- a/apps/frontend/src/main/ipc-handlers/context/project-context-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/context/project-context-handlers.ts @@ -12,9 +12,6 @@ import type { } from '../../../shared/types'; import { projectStore } from '../../project-store'; import { getMemoryService, isKuzuAvailable } from '../../memory-service'; -import { - getGraphitiDatabaseDetails -} from './utils'; import { getEffectiveSourcePath } from '../../updater/path-resolver'; import { loadGraphitiStateFromSpecs, diff --git a/apps/frontend/src/main/ipc-handlers/env-handlers.ts b/apps/frontend/src/main/ipc-handlers/env-handlers.ts index ca2b6cad..021656a5 100644 --- a/apps/frontend/src/main/ipc-handlers/env-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/env-handlers.ts @@ -32,7 +32,7 @@ type ResolvedClaudeCliInvocation = | { command: string; env: Record } | { error: string }; -function resolveClaudeCliInvocation(): ResolvedClaudeCliInvocation { +function _resolveClaudeCliInvocation(): ResolvedClaudeCliInvocation { try { const invocation = getClaudeCliInvocation(); if (!invocation?.command) { diff --git a/apps/frontend/src/main/ipc-handlers/github/__tests__/oauth-handlers.spec.ts b/apps/frontend/src/main/ipc-handlers/github/__tests__/oauth-handlers.spec.ts index b0b5f073..085791d9 100644 --- a/apps/frontend/src/main/ipc-handlers/github/__tests__/oauth-handlers.spec.ts +++ b/apps/frontend/src/main/ipc-handlers/github/__tests__/oauth-handlers.spec.ts @@ -518,7 +518,7 @@ describe('GitHub OAuth Handlers', () => { mockFindExecutable.mockReturnValue('/usr/local/bin/gh'); // Mock execFileSync for version check - mockExecFileSync.mockImplementation((cmd: string, args?: string[]) => { + mockExecFileSync.mockImplementation((_cmd: string, args?: string[]) => { if (args && args[0] === '--version') { return 'gh version 2.65.0 (2024-01-15)\n'; } @@ -553,7 +553,7 @@ describe('GitHub OAuth Handlers', () => { describe('gh Auth Check Handler', () => { it('should return authenticated: true with username when logged in', async () => { - mockExecFileSync.mockImplementation((cmd: string, args?: string[]) => { + mockExecFileSync.mockImplementation((_cmd: string, args?: string[]) => { if (args && args[0] === 'auth' && args[1] === 'status') { return 'Logged in to github.com as testuser\n'; } diff --git a/apps/frontend/src/main/ipc-handlers/github/investigation-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/investigation-handlers.ts index 7ddae6e5..93596dc4 100644 --- a/apps/frontend/src/main/ipc-handlers/github/investigation-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/investigation-handlers.ts @@ -61,7 +61,7 @@ function sendComplete( * Investigate a GitHub issue and create a task */ export function registerInvestigateIssue( - agentManager: AgentManager, + _agentManager: AgentManager, getMainWindow: () => BrowserWindow | null ): void { ipcMain.on( diff --git a/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts index 3259583d..773feb15 100644 --- a/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/oauth-handlers.ts @@ -117,7 +117,7 @@ const GITHUB_DEVICE_URL = 'https://github.com/login/device'; */ function parseDeviceCode(output: string): string | null { const match = output.match(DEVICE_CODE_PATTERN); - if (match && match[1]) { + if (match?.[1]) { // Normalize: replace space with hyphen (GitHub expects XXXX-XXXX format) const normalizedCode = match[1].replace(' ', '-'); debugLog('Device code extracted successfully (code redacted for security)'); diff --git a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts index b974c717..99e49a71 100644 --- a/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/github/pr-handlers.ts @@ -1307,6 +1307,9 @@ async function runPRReview( // Build environment with project settings const subprocessEnv = await getRunnerEnv(getClaudeMdEnv(project)); + // Create operation ID for this review + const reviewKey = getReviewKey(project.id, prNumber); + const { process: childProcess, promise } = runPythonSubprocess({ pythonPath: getPythonPath(backendPath), args, @@ -1341,10 +1344,17 @@ async function runPRReview( debugLog("Review result loaded", { findingsCount: reviewResult.findings.length }); return reviewResult; }, + // Register with OperationRegistry for proactive swap support + operationRegistration: { + operationId: `pr-review:${reviewKey}`, + operationType: 'pr-review', + metadata: { projectId: project.id, prNumber, repo }, + // PR reviews don't support restart (would need to refetch PR data) + // The review will complete or fail, and user can retry manually + }, }); - // Register the running process - const reviewKey = getReviewKey(project.id, prNumber); + // Register the running process (keep legacy registry for cancel support) runningReviews.set(reviewKey, childProcess); debugLog("Registered review process", { reviewKey, pid: childProcess.pid }); @@ -2748,6 +2758,12 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v }); return reviewResult; }, + // Register with OperationRegistry for proactive swap support + operationRegistration: { + operationId: `pr-followup-review:${reviewKey}`, + operationType: 'pr-review', + metadata: { projectId: project.id, prNumber, repo, isFollowup: true }, + }, }); // Update registry with actual process (replacing placeholder) diff --git a/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts b/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts index 3bf2efe1..a352702a 100644 --- a/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts +++ b/apps/frontend/src/main/ipc-handlers/github/utils/subprocess-runner.ts @@ -20,6 +20,7 @@ import type { AuthFailureInfo, BillingFailureInfo } from '../../../../shared/typ import { parsePythonCommand } from '../../../python-detector'; import { detectAuthFailure, detectBillingFailure } from '../../../rate-limit-detector'; import { getClaudeProfileManager } from '../../../claude-profile-manager'; +import { getOperationRegistry, type OperationType } from '../../../claude-profile/operation-registry'; import { isWindows, isMacOS } from '../../../platform'; const execAsync = promisify(exec); @@ -73,6 +74,23 @@ export interface SubprocessOptions { progressPattern?: RegExp; /** Additional environment variables to pass to the subprocess */ env?: Record; + /** + * Operation registration for proactive swap support. + * If provided, the operation will be registered with the unified OperationRegistry. + */ + operationRegistration?: { + /** Unique operation ID */ + operationId: string; + /** Operation type for categorization */ + operationType: OperationType; + /** Optional metadata for the operation */ + metadata?: Record; + /** + * Function to restart the operation with a new profile. + * Should call the original function with refreshed environment. + */ + restartFn?: (newProfileId: string) => boolean | Promise; + }; } /** @@ -126,6 +144,67 @@ export function runPythonSubprocess( detached: !isWindows(), }); + // Register with OperationRegistry for proactive swap support + if (options.operationRegistration) { + const { operationId, operationType, metadata, restartFn } = options.operationRegistration; + const profileManager = getClaudeProfileManager(); + const activeProfile = profileManager.getActiveProfile(); + + if (activeProfile) { + const operationRegistry = getOperationRegistry(); + + // Create a stop function that kills the subprocess. + // Note: This sends SIGTERM and returns immediately without waiting for process exit. + // + // Timing dependency for restarts: + // - For subprocess-runner operations, restartFn returns false so no race condition + // (operations are non-resumable and won't be restarted, just stopped gracefully) + // - For AgentManager operations, there's a 500ms setTimeout delay in restartTask + // (see agent-manager.ts line 528) that mitigates the race between kill and restart + // + // RestartFn implementations should handle potential overlap between process termination + // and restart initialization if not using the setTimeout pattern. + const stopFn = async () => { + if (child.pid) { + try { + if (!isWindows()) { + process.kill(-child.pid, 'SIGTERM'); + } else { + execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], () => {}); + } + } catch { + child.kill('SIGTERM'); + } + } + }; + + // Register with OperationRegistry for tracking and proactive swap support. + // For operations that provide a restartFn, UsageMonitor can restart them with a new profile. + // For operations without restartFn (e.g., PR reviews which are non-resumable due to one-shot workflow), + // we register with a no-op restartFn that returns false. This allows the swap to stop the operation + // gracefully without attempting restart. The operation will be killed when the profile swaps, + // which is the correct behavior for non-resumable operations. + operationRegistry.registerOperation( + operationId, + operationType, + activeProfile.id, + activeProfile.name, + restartFn || (() => false), // Use provided restartFn or a no-op for non-resumable operations + { + stopFn, + metadata: { ...metadata, pythonPath: options.pythonPath, cwd: options.cwd } + } + ); + + console.log('[SubprocessRunner] Operation registered with OperationRegistry:', { + operationId, + operationType, + profileId: activeProfile.id, + profileName: activeProfile.name + }); + } + } + const promise = new Promise>((resolve) => { let stdout = ''; @@ -305,6 +384,11 @@ export function runPythonSubprocess( // Treat null exit code (killed with SIGKILL) as failure, not success const exitCode = code ?? -1; + // Unregister from OperationRegistry when process exits + if (options.operationRegistration) { + getOperationRegistry().unregisterOperation(options.operationRegistration.operationId); + } + // Debug logging only in development mode if (process.env.NODE_ENV === 'development') { console.log('[DEBUG] Process exited with code:', exitCode, '(raw:', code, ')'); diff --git a/apps/frontend/src/main/ipc-handlers/gitlab/autofix-handlers.ts b/apps/frontend/src/main/ipc-handlers/gitlab/autofix-handlers.ts index eb598b1b..6fafb85e 100644 --- a/apps/frontend/src/main/ipc-handlers/gitlab/autofix-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/gitlab/autofix-handlers.ts @@ -21,7 +21,6 @@ import type { GitLabAutoFixQueueItem, GitLabAutoFixProgress, GitLabIssueBatch, - GitLabBatchProgress, GitLabAnalyzePreviewResult, } from './types'; diff --git a/apps/frontend/src/main/ipc-handlers/gitlab/investigation-handlers.ts b/apps/frontend/src/main/ipc-handlers/gitlab/investigation-handlers.ts index f383f032..494a6b98 100644 --- a/apps/frontend/src/main/ipc-handlers/gitlab/investigation-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/gitlab/investigation-handlers.ts @@ -71,7 +71,7 @@ function sendError( * Register investigation handler */ export function registerInvestigateIssue( - agentManager: AgentManager, + _agentManager: AgentManager, getMainWindow: () => BrowserWindow | null ): void { ipcMain.on( @@ -129,16 +129,11 @@ export function registerInvestigateIssue( message: 'Analyzing issue with AI...' }); - // Build context for investigation - let context = buildIssueContext(issue, config.project, config.instanceUrl); - - if (selectedNotes.length > 0) { - context += '\n\n## Selected Comments\n'; - for (const note of selectedNotes) { - context += `\n### Comment by ${note.author.username} (${new Date(note.created_at).toLocaleDateString()})\n`; - context += note.body + '\n'; - } - } + // Note: Context building previously done here has been moved to createSpecForIssue utility. + // The buildIssueContext() function and selectedNotes processing are now handled internally + // by the spec creation pipeline. This avoids duplicate context generation. + // TODO: If advanced context customization is needed in the future, consider extracting + // context building into a reusable utility function. // Use agent manager to investigate // Note: This is a simplified version - full implementation would use Claude SDK diff --git a/apps/frontend/src/main/ipc-handlers/gitlab/mr-review-handlers.ts b/apps/frontend/src/main/ipc-handlers/gitlab/mr-review-handlers.ts index 61eec5d5..cd5f00f0 100644 --- a/apps/frontend/src/main/ipc-handlers/gitlab/mr-review-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/gitlab/mr-review-handlers.ts @@ -21,7 +21,6 @@ import { getGitLabConfig, gitlabFetch, encodeProjectPath } from './utils'; import { readSettingsFile } from '../../settings-utils'; import type { Project, AppSettings } from '../../../shared/types'; import type { - MRReviewFinding, MRReviewResult, MRReviewProgress, NewCommitsCheck, diff --git a/apps/frontend/src/main/ipc-handlers/gitlab/oauth-handlers.ts b/apps/frontend/src/main/ipc-handlers/gitlab/oauth-handlers.ts index 7665ad5e..70c830f9 100644 --- a/apps/frontend/src/main/ipc-handlers/gitlab/oauth-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/gitlab/oauth-handlers.ts @@ -248,13 +248,13 @@ export function registerStartGlabAuth(): void { env: getAugmentedEnv() }); - let output = ''; + let _output = ''; let errorOutput = ''; let browserOpened = false; glabProcess.stdout?.on('data', (data) => { const chunk = data.toString('utf-8'); - output += chunk; + _output += chunk; debugLog('glab stdout:', chunk); // Try to open browser if URL detected diff --git a/apps/frontend/src/main/ipc-handlers/gitlab/spec-utils.ts b/apps/frontend/src/main/ipc-handlers/gitlab/spec-utils.ts index 0e357a55..6bb42e43 100644 --- a/apps/frontend/src/main/ipc-handlers/gitlab/spec-utils.ts +++ b/apps/frontend/src/main/ipc-handlers/gitlab/spec-utils.ts @@ -8,7 +8,7 @@ import path from 'path'; import type { Project } from '../../../shared/types'; import type { GitLabAPIIssue, GitLabConfig } from './types'; import { labelMatchesWholeWord } from '../shared/label-utils'; -import { stripControlChars, sanitizeText, sanitizeStringArray } from '../shared/sanitize'; +import { sanitizeText, sanitizeStringArray } from '../shared/sanitize'; /** * Simplified task info returned when creating a spec from a GitLab issue. diff --git a/apps/frontend/src/main/ipc-handlers/memory-handlers.ts b/apps/frontend/src/main/ipc-handlers/memory-handlers.ts index 2ceb6a17..5d9b6bcd 100644 --- a/apps/frontend/src/main/ipc-handlers/memory-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/memory-handlers.ts @@ -483,7 +483,7 @@ export function registerMemoryHandlers(): void { }; } else { // Basic validation for other providers - llmResult = config.apiKey && config.apiKey.trim() + llmResult = config.apiKey?.trim() ? { success: true, message: `${config.llmProvider} API key format appears valid`, @@ -703,7 +703,7 @@ export function registerMemoryHandlers(): void { async ( event, modelName: string, - baseUrl?: string + _baseUrl?: string ): Promise> => { try { // Use configured Python path (venv if ready, otherwise bundled/system) diff --git a/apps/frontend/src/main/ipc-handlers/profile-handlers.ts b/apps/frontend/src/main/ipc-handlers/profile-handlers.ts index 6d4cfacb..9b522a65 100644 --- a/apps/frontend/src/main/ipc-handlers/profile-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/profile-handlers.ts @@ -16,7 +16,6 @@ import type { IPCResult } from '../../shared/types'; import type { APIProfile, ProfileFormData, ProfilesFile, TestConnectionResult, DiscoverModelsResult } from '@shared/types/profile'; import { loadProfilesFile, - saveProfilesFile, validateFilePermissions, getProfilesFilePath, atomicModifyProfiles, diff --git a/apps/frontend/src/main/ipc-handlers/project-handlers.ts b/apps/frontend/src/main/ipc-handlers/project-handlers.ts index 2dac34ad..2cba4bc4 100644 --- a/apps/frontend/src/main/ipc-handlers/project-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/project-handlers.ts @@ -1,8 +1,7 @@ import { ipcMain, app } from 'electron'; -import { existsSync, readFileSync } from 'fs'; +import { existsSync, } from 'fs'; import path from 'path'; import { execFileSync } from 'child_process'; -import { is } from '@electron-toolkit/utils'; import { IPC_CHANNELS } from '../../shared/constants'; import type { Project, @@ -233,8 +232,6 @@ function detectMainBranch(projectPath: string): string | null { return branches[0] || null; } -const settingsPath = path.join(app.getPath('userData'), 'settings.json'); - /** * Configure all Python-dependent services with the managed Python path */ diff --git a/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts b/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts index 0173d87c..8c611d44 100644 --- a/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/roadmap-handlers.ts @@ -11,7 +11,6 @@ import { import type { IPCResult, Roadmap, - RoadmapFeature, RoadmapFeatureStatus, RoadmapGenerationStatus, PersistedRoadmapProgress, diff --git a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts index e2189e3f..9abc76d6 100644 --- a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts @@ -511,7 +511,7 @@ export function registerSettingsHandlers( error: `Path is not a directory: ${resolvedPath}` }; } - } catch (statError) { + } catch (_statError) { return { success: false, error: `Cannot access path: ${resolvedPath}` diff --git a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts index d81c27f0..cee24431 100644 --- a/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/execution-handlers.ts @@ -800,6 +800,73 @@ export function registerTaskExecutionHandlers( } ); + /** + * Resume a paused task (rate limited or auth failure paused) + * This writes a RESUME file to the spec directory to signal the backend to continue + */ + ipcMain.handle( + IPC_CHANNELS.TASK_RESUME_PAUSED, + async (_, taskId: string): Promise => { + // Find task and project + const { task, project } = findTaskAndProject(taskId); + + if (!task || !project) { + return { success: false, error: 'Task not found' }; + } + + // Get the spec directory - use task.specsPath if available (handles worktree vs main) + const specsBaseDir = getSpecsDir(project.autoBuildPath); + const specDir = task.specsPath || path.join( + project.path, + specsBaseDir, + task.specId + ); + + // Write RESUME file to signal backend to continue + const resumeFilePath = path.join(specDir, 'RESUME'); + + try { + const resumeContent = JSON.stringify({ + resumed_at: new Date().toISOString(), + resumed_by: 'user' + }); + atomicWriteFileSync(resumeFilePath, resumeContent); + console.log(`[TASK_RESUME_PAUSED] Wrote RESUME file to: ${resumeFilePath}`); + + // Also write to worktree if it exists (backend may be running inside the worktree) + const worktreePath = findTaskWorktree(project.path, task.specId); + if (worktreePath) { + const worktreeResumeFilePath = path.join(worktreePath, specsBaseDir, task.specId, 'RESUME'); + try { + atomicWriteFileSync(worktreeResumeFilePath, resumeContent); + console.log(`[TASK_RESUME_PAUSED] Also wrote RESUME file to worktree: ${worktreeResumeFilePath}`); + } catch (worktreeError) { + // Non-fatal - main spec dir RESUME is sufficient + console.warn(`[TASK_RESUME_PAUSED] Could not write to worktree (non-fatal):`, worktreeError); + } + } else if ( + task.executionProgress?.phase === 'rate_limit_paused' || + task.executionProgress?.phase === 'auth_failure_paused' + ) { + // Warn if worktree not found for a paused task - the backend is likely + // running inside the worktree and may not see the RESUME file in the main spec dir + console.warn( + `[TASK_RESUME_PAUSED] Worktree not found for paused task ${task.specId}. ` + + `Backend may not detect the RESUME file if running inside a worktree.` + ); + } + + return { success: true }; + } catch (error) { + console.error('[TASK_RESUME_PAUSED] Failed to write RESUME file:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to signal resume' + }; + } + } + ); + /** * Recover a stuck task (status says in_progress but no process running) */ diff --git a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts index 2920fecc..356fff78 100644 --- a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts @@ -4,7 +4,7 @@ import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeDiffFile, Worktre import path from 'path'; import { minimatch } from 'minimatch'; import { existsSync, readdirSync, statSync, readFileSync, promises as fsPromises } from 'fs'; -import { execSync, execFileSync, spawn, spawnSync, exec, execFile } from 'child_process'; +import { execFileSync, spawn, spawnSync, exec, execFile } from 'child_process'; import { homedir } from 'os'; import { projectStore } from '../../project-store'; import { getConfiguredPythonPath, PythonEnvManager, pythonEnvManager as pythonEnvManagerSingleton } from '../../python-env-manager'; @@ -19,7 +19,7 @@ import { findTaskWorktree, } from '../../worktree-paths'; import { persistPlanStatus, updateTaskMetadataPrUrl } from './plan-file-utils'; -import { getIsolatedGitEnv, detectWorktreeBranch, refreshGitIndex } from '../../utils/git-isolation'; +import { getIsolatedGitEnv, refreshGitIndex } from '../../utils/git-isolation'; import { cleanupWorktree } from '../../utils/worktree-cleanup'; import { killProcessGracefully } from '../../platform'; import { stripAnsiCodes } from '../../../shared/utils/ansi-sanitizer'; @@ -1067,7 +1067,7 @@ async function detectLinuxApps(): Promise> { function isAppInstalled( appNames: string[], specificPaths: string[], - platform: string + _platform: string ): { installed: boolean; foundPath: string } { // First, check the cached app list (fast) for (const name of appNames) { @@ -2568,7 +2568,7 @@ export function registerWorktreeHandlers( encoding: 'utf-8' }); - if (gitStatus && gitStatus.trim()) { + if (gitStatus?.trim()) { // Parse the status output to get file names // Format: XY filename (where X and Y are status chars, then space, then filename) uncommittedFiles = gitStatus diff --git a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts index 226e7f42..0bfef379 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts @@ -8,7 +8,7 @@ import { TerminalManager } from '../terminal-manager'; import { projectStore } from '../project-store'; import { terminalNameGenerator } from '../terminal-name-generator'; import { readSettingsFileAsync } from '../settings-utils'; -import { debugLog, debugError } from '../../shared/utils/debug-logger'; +import { debugLog, } from '../../shared/utils/debug-logger'; import { migrateSession } from '../claude-profile/session-utils'; import { createProfileDirectory } from '../claude-profile/profile-utils'; import { isValidConfigDir } from '../utils/config-path-validator'; diff --git a/apps/frontend/src/main/log-service.ts b/apps/frontend/src/main/log-service.ts index 62a0eb4e..976a3474 100644 --- a/apps/frontend/src/main/log-service.ts +++ b/apps/frontend/src/main/log-service.ts @@ -29,8 +29,6 @@ export class LogService { // Flush logs to disk every 2 seconds to balance performance vs data safety private readonly FLUSH_INTERVAL_MS = 2000; - // Maximum log file size before rotation (10MB) - private readonly MAX_LOG_SIZE_BYTES = 10 * 1024 * 1024; // Keep last N sessions private readonly MAX_SESSIONS_TO_KEEP = 10; @@ -205,7 +203,7 @@ export class LogService { const sessionId = file.replace('session-', '').replace('.log', ''); // Parse session ID back to date - const dateStr = sessionId.replace(/-/g, (match, offset) => { + const dateStr = sessionId.replace(/-/g, (_match, offset) => { // Replace first 2 dashes with actual dashes, rest with colons if (offset < 10) return '-'; if (offset === 10) return 'T'; diff --git a/apps/frontend/src/main/platform/index.ts b/apps/frontend/src/main/platform/index.ts index aa52d03c..0ab6d3ac 100644 --- a/apps/frontend/src/main/platform/index.ts +++ b/apps/frontend/src/main/platform/index.ts @@ -243,7 +243,7 @@ function getWindowsShellConfig(preferredShell?: ShellType): ShellConfig { /** * Get Unix shell configuration */ -function getUnixShellConfig(preferredShell?: ShellType): ShellConfig { +function getUnixShellConfig(_preferredShell?: ShellType): ShellConfig { const shellPath = process.env.SHELL || '/bin/zsh'; return { diff --git a/apps/frontend/src/main/project-store.ts b/apps/frontend/src/main/project-store.ts index e8ca153c..d9b336db 100644 --- a/apps/frontend/src/main/project-store.ts +++ b/apps/frontend/src/main/project-store.ts @@ -6,7 +6,7 @@ import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, Implemen import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir, JSON_ERROR_PREFIX, JSON_ERROR_TITLE_SUFFIX, TASK_STATUS_PRIORITY } from '../shared/constants'; import { getAutoBuildPath, isInitialized } from './project-initializer'; import { getTaskWorktreeDir } from './worktree-paths'; -import { isValidTaskId, findAllSpecPaths } from './utils/spec-path-helpers'; +import { findAllSpecPaths } from './utils/spec-path-helpers'; interface TabState { openProjectIds: string[]; @@ -389,10 +389,10 @@ export class ProjectStore { */ private loadTasksFromSpecsDir( specsDir: string, - basePath: string, + _basePath: string, location: 'main' | 'worktree', projectId: string, - specsBaseDir: string + _specsBaseDir: string ): Task[] { const tasks: Task[] = []; let specDirs: Dirent[] = []; @@ -519,7 +519,7 @@ export class ProjectStore { // "# Specification: Title" -> "Title" // "# Title" -> "Title" const titleMatch = specContent.match(/^#\s+(?:Quick Spec:|Specification:)?\s*(.+)$/m); - if (titleMatch && titleMatch[1]) { + if (titleMatch?.[1]) { title = titleMatch[1].trim(); } } catch { diff --git a/apps/frontend/src/main/rate-limit-detector.ts b/apps/frontend/src/main/rate-limit-detector.ts index 5a4dba9b..43a74a78 100644 --- a/apps/frontend/src/main/rate-limit-detector.ts +++ b/apps/frontend/src/main/rate-limit-detector.ts @@ -92,6 +92,25 @@ const BILLING_FAILURE_PATTERNS = [ /top\s*up\s*(your\s*)?(account|credits|balance)/i ]; +/** + * Maximum length for error messages sent to renderer. + * Truncates to prevent exposing excessive internal details. + */ +const MAX_ERROR_LENGTH = 500; + +/** + * Sanitize error output before sending to renderer. + * Truncates long output to prevent exposing excessive internal details + * like full paths, API responses, or stack traces. + */ +function sanitizeErrorOutput(output: string): string { + // Truncate long output to limit exposure of internal details + if (output.length > MAX_ERROR_LENGTH) { + return output.substring(0, MAX_ERROR_LENGTH) + '... (truncated)'; + } + return output; +} + /** * Result of rate limit detection */ @@ -109,7 +128,7 @@ export interface RateLimitDetectionResult { id: string; name: string; }; - /** Original error message */ + /** Original error message (truncated to 500 chars for security) */ originalError?: string; } @@ -193,7 +212,7 @@ export function detectRateLimit( id: bestProfile.id, name: bestProfile.name } : undefined, - originalError: output + originalError: sanitizeErrorOutput(output) }; } @@ -211,7 +230,7 @@ export function detectRateLimit( id: bestProfile.id, name: bestProfile.name } : undefined, - originalError: output + originalError: sanitizeErrorOutput(output) }; } } @@ -265,7 +284,6 @@ function getAuthFailureMessage(failureType: 'missing' | 'invalid' | 'expired' | return 'Your Claude session has expired. Please re-authenticate in Settings > Claude Profiles.'; case 'invalid': return 'Invalid Claude credentials. Please check your OAuth token or re-authenticate in Settings > Claude Profiles.'; - case 'unknown': default: return 'Claude authentication failed. Please verify your authentication in Settings > Claude Profiles.'; } @@ -303,7 +321,6 @@ function getBillingFailureMessage(failureType: 'insufficient_credits' | 'payment return 'A billing error occurred with your Claude API account. Please check your payment method or switch to another profile in Settings > Claude Profiles.'; case 'subscription_inactive': return 'Your Claude API subscription is inactive or expired. Please renew your subscription or switch to another profile in Settings > Claude Profiles.'; - case 'unknown': default: return 'A billing issue was detected with your Claude API account. Please check your account status or switch to another profile in Settings > Claude Profiles.'; } @@ -333,7 +350,7 @@ export function detectAuthFailure( profileId: effectiveProfileId, failureType, message: getAuthFailureMessage(failureType), - originalError: output + originalError: sanitizeErrorOutput(output) }; } } @@ -375,7 +392,7 @@ export function detectBillingFailure( profileId: effectiveProfileId, failureType, message: getBillingFailureMessage(failureType), - originalError: output + originalError: sanitizeErrorOutput(output) }; } } @@ -631,7 +648,7 @@ export interface SDKRateLimitInfo { }; /** When detected */ detectedAt: Date; - /** Original error message */ + /** Original error message (truncated to 500 chars for security) */ originalError?: string; // Auto-swap information diff --git a/apps/frontend/src/main/release-service.ts b/apps/frontend/src/main/release-service.ts index be61f94b..e7203cb8 100644 --- a/apps/frontend/src/main/release-service.ts +++ b/apps/frontend/src/main/release-service.ts @@ -24,9 +24,6 @@ import { refreshGitIndex } from './utils/git-isolation'; * If a worktree exists for a task NOT in this release, it won't block the release. */ export class ReleaseService extends EventEmitter { - constructor() { - super(); - } /** * Parse CHANGELOG.md to extract releaseable versions. @@ -75,7 +72,7 @@ export class ReleaseService extends EventEmitter { * This allows us to scope worktree checks to only those tasks. */ getTasksForVersion( - projectPath: string, + _projectPath: string, version: string, tasks: Task[] ): { taskIds: string[]; specIds: string[] } { diff --git a/apps/frontend/src/main/sentry.ts b/apps/frontend/src/main/sentry.ts index c15f823e..672203ae 100644 --- a/apps/frontend/src/main/sentry.ts +++ b/apps/frontend/src/main/sentry.ts @@ -63,7 +63,7 @@ function getTracesSampleRate(): number { const envValue = buildTimeValue || process.env.SENTRY_TRACES_SAMPLE_RATE; if (envValue) { const parsed = parseFloat(envValue); - if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) { + if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) { return parsed; } } @@ -83,7 +83,7 @@ function getProfilesSampleRate(): number { const envValue = buildTimeValue || process.env.SENTRY_PROFILES_SAMPLE_RATE; if (envValue) { const parsed = parseFloat(envValue); - if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) { + if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) { return parsed; } } diff --git a/apps/frontend/src/main/services/profile-service.test.ts b/apps/frontend/src/main/services/profile-service.test.ts index 54ba085c..d3df10b9 100644 --- a/apps/frontend/src/main/services/profile-service.test.ts +++ b/apps/frontend/src/main/services/profile-service.test.ts @@ -14,7 +14,7 @@ import { getAPIProfileEnv, testConnection } from './profile-service'; -import type { APIProfile, ProfilesFile, TestConnectionResult } from '../../shared/types/profile'; +import type { ProfilesFile, } from '../../shared/types/profile'; // Mock profile-manager vi.mock('../utils/profile-manager', () => ({ diff --git a/apps/frontend/src/main/services/profile-service.ts b/apps/frontend/src/main/services/profile-service.ts index a58651ac..9bd3ae64 100644 --- a/apps/frontend/src/main/services/profile-service.ts +++ b/apps/frontend/src/main/services/profile-service.ts @@ -90,8 +90,6 @@ export async function deleteProfile(id: string): Promise { throw new Error('Profile not found'); } - const profile = file.profiles[profileIndex]; - // Active Profile Check: Cannot delete active profile (AC3) if (file.activeProfileId === id) { throw new Error('Cannot delete active profile. Please switch to another profile or OAuth first.'); diff --git a/apps/frontend/src/main/services/profile/profile-service.test.ts b/apps/frontend/src/main/services/profile/profile-service.test.ts index 797f5b2c..c5f9ad99 100644 --- a/apps/frontend/src/main/services/profile/profile-service.test.ts +++ b/apps/frontend/src/main/services/profile/profile-service.test.ts @@ -13,7 +13,7 @@ import { testConnection, discoverModels } from './profile-service'; -import type { APIProfile, ProfilesFile, TestConnectionResult } from '@shared/types/profile'; +import type { ProfilesFile, } from '@shared/types/profile'; // Mock Anthropic SDK - use vi.hoisted to properly hoist the mock variable const { mockModelsList, mockMessagesCreate } = vi.hoisted(() => ({ diff --git a/apps/frontend/src/main/services/sdk-session-recovery-coordinator.test.ts b/apps/frontend/src/main/services/sdk-session-recovery-coordinator.test.ts index 6c5f0ef2..30ecb519 100644 --- a/apps/frontend/src/main/services/sdk-session-recovery-coordinator.test.ts +++ b/apps/frontend/src/main/services/sdk-session-recovery-coordinator.test.ts @@ -8,8 +8,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { SDKSessionRecoveryCoordinator, getRecoveryCoordinator, - type SDKOperationType, - type RecoveryCoordinatorConfig } from './sdk-session-recovery-coordinator'; // Mock dependencies diff --git a/apps/frontend/src/main/services/sdk-session-recovery-coordinator.ts b/apps/frontend/src/main/services/sdk-session-recovery-coordinator.ts index 64b8c04d..2b43497e 100644 --- a/apps/frontend/src/main/services/sdk-session-recovery-coordinator.ts +++ b/apps/frontend/src/main/services/sdk-session-recovery-coordinator.ts @@ -1,6 +1,25 @@ /** * SDK Session Recovery Coordinator * + * @deprecated This module is deprecated in favor of ClaudeOperationRegistry + * (src/main/claude-profile/operation-registry.ts). The OperationRegistry provides + * similar functionality with a simpler API and is actively integrated with + * AgentManager and UsageMonitor. This module is retained for backward compatibility + * but should not be used for new code. + * + * TODO: Target removal in v0.5.0 (Q2 2026). Before removal: + * 1. Identify any remaining usages in the codebase + * 2. Migrate all remaining consumers to ClaudeOperationRegistry + * 3. Remove this file and associated tests + * 4. Update imports across the codebase + * + * Migration guide: + * - Use getOperationRegistry() from '../claude-profile/operation-registry' + * - registerOperation() -> operationRegistry.registerOperation() + * - unregisterOperation() -> operationRegistry.unregisterOperation() + * - getOperationsByProfile() -> operationRegistry.getOperationsByProfile() + * + * Original description: * Central coordinator for all SDK operations and rate limit recovery. * Part of the intelligent rate limit recovery system (Phase 9: Unified Coordination). * @@ -97,6 +116,9 @@ interface PendingNotification { /** * SDKSessionRecoveryCoordinator - Central manager for SDK operations and recovery * + * @deprecated Use ClaudeOperationRegistry from '../claude-profile/operation-registry' instead. + * This class is retained for backward compatibility but is no longer actively maintained. + * * This singleton coordinates all SDK operations across the application: * - Tasks (via AgentManager) * - Background operations (roadmap, ideation, changelog) @@ -531,6 +553,7 @@ export class SDKSessionRecoveryCoordinator extends EventEmitter { /** * Get the global coordinator instance + * @deprecated Use getOperationRegistry() from '../claude-profile/operation-registry' instead. */ export function getRecoveryCoordinator(): SDKSessionRecoveryCoordinator { return SDKSessionRecoveryCoordinator.getInstance(); diff --git a/apps/frontend/src/main/task-log-service.ts b/apps/frontend/src/main/task-log-service.ts index 77521438..3f1666a7 100644 --- a/apps/frontend/src/main/task-log-service.ts +++ b/apps/frontend/src/main/task-log-service.ts @@ -1,5 +1,5 @@ import path from 'path'; -import { existsSync, readFileSync, watchFile } from 'fs'; +import { existsSync, readFileSync, } from 'fs'; import { EventEmitter } from 'events'; import type { TaskLogs, TaskLogPhase, TaskLogStreamChunk, TaskPhaseLog } from '../shared/types'; import { findTaskWorktree } from './worktree-paths'; @@ -26,7 +26,6 @@ function findWorktreeSpecDir(projectPath: string, specId: string, specsRelPath: * watches both locations and merges logs from both sources. */ export class TaskLogService extends EventEmitter { - private watchers: Map; specDir: string }> = new Map(); private logCache: Map = new Map(); private pollIntervals: Map = new Map(); // Store paths being watched for each specId (main + worktree) @@ -35,10 +34,6 @@ export class TaskLogService extends EventEmitter { // Poll interval for watching log changes (more reliable than fs.watch on some systems) private readonly POLL_INTERVAL_MS = 1000; - constructor() { - super(); - } - /** * Load task logs from a single spec directory * Returns cached logs if the file is corrupted (e.g., mid-write by Python backend) @@ -125,7 +120,7 @@ export class TaskLogService extends EventEmitter { let worktreeSpecDir: string | null = null; - if (watchedInfo && watchedInfo[1].worktreeSpecDir) { + if (watchedInfo?.[1].worktreeSpecDir) { worktreeSpecDir = watchedInfo[1].worktreeSpecDir; } else if (projectPath && specsRelPath && specId) { // Calculate worktree path from provided params diff --git a/apps/frontend/src/main/task-state-manager.ts b/apps/frontend/src/main/task-state-manager.ts index df0ee0a8..e4a979f5 100644 --- a/apps/frontend/src/main/task-state-manager.ts +++ b/apps/frontend/src/main/task-state-manager.ts @@ -413,7 +413,6 @@ export class TaskStateManager { stateValue = 'error'; contextReviewReason = reviewReason ?? 'errors'; break; - case 'backlog': default: stateValue = 'backlog'; break; diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index 9478254d..ef4c92b9 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -1555,7 +1555,7 @@ async function waitForClaudeExit( export async function switchClaudeProfile( terminal: TerminalProcess, profileId: string, - getWindow: WindowGetter, + _getWindow: WindowGetter, invokeClaudeCallback: (terminalId: string, cwd: string | undefined, profileId: string, dangerouslySkipPermissions?: boolean) => Promise, clearRateLimitCallback: (terminalId: string) => void ): Promise<{ success: boolean; error?: string }> { diff --git a/apps/frontend/src/main/terminal/output-parser.ts b/apps/frontend/src/main/terminal/output-parser.ts index 1d83d4db..10b0049b 100644 --- a/apps/frontend/src/main/terminal/output-parser.ts +++ b/apps/frontend/src/main/terminal/output-parser.ts @@ -60,7 +60,7 @@ const LOGIN_SUCCESS_PATTERN = /(?:Login successful|Successfully logged in|Logged export function extractClaudeSessionId(data: string): string | null { for (const pattern of CLAUDE_SESSION_PATTERNS) { const match = data.match(pattern); - if (match && match[1]) { + if (match?.[1]) { return match[1]; } } @@ -159,7 +159,7 @@ export function extractEmail(data: string): string | null { for (const pattern of EMAIL_PATTERNS) { const match = cleanData.match(pattern); - if (match && match[1]) { + if (match?.[1]) { return match[1]; } } diff --git a/apps/frontend/src/main/terminal/pty-daemon-client.ts b/apps/frontend/src/main/terminal/pty-daemon-client.ts index 67942ba5..f9832171 100644 --- a/apps/frontend/src/main/terminal/pty-daemon-client.ts +++ b/apps/frontend/src/main/terminal/pty-daemon-client.ts @@ -9,7 +9,6 @@ import * as net from 'net'; import * as path from 'path'; import { fileURLToPath } from 'url'; import { spawn, ChildProcess } from 'child_process'; -import { app } from 'electron'; import { isWindows, GRACEFUL_KILL_TIMEOUT_MS } from '../platform'; import { getIsShuttingDown } from './pty-manager'; @@ -271,7 +270,7 @@ class PtyDaemonClient { } }); - this.socket!.write(JSON.stringify({ ...msg, requestId }) + '\n'); + this.socket?.write(JSON.stringify({ ...msg, requestId }) + '\n'); }); } @@ -427,7 +426,7 @@ class PtyDaemonClient { this.isShuttingDown = true; // Kill the daemon process if we spawned it - if (this.daemonProcess && this.daemonProcess.pid) { + if (this.daemonProcess?.pid) { try { if (isWindows()) { // Windows: use taskkill to force kill process tree diff --git a/apps/frontend/src/main/utils/profile-manager.test.ts b/apps/frontend/src/main/utils/profile-manager.test.ts index a0e3aef3..a47804d2 100644 --- a/apps/frontend/src/main/utils/profile-manager.test.ts +++ b/apps/frontend/src/main/utils/profile-manager.test.ts @@ -6,8 +6,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { promises as fsPromises } from 'fs'; -import path from 'path'; -import { app } from 'electron'; import { loadProfilesFile, saveProfilesFile, @@ -48,7 +46,7 @@ vi.mock('fs', () => { }); describe('profile-manager', () => { - const mockProfilesPath = '/mock/userdata/profiles.json'; + const _mockProfilesPath = '/mock/userdata/profiles.json'; beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/frontend/src/main/utils/profile-manager.ts b/apps/frontend/src/main/utils/profile-manager.ts index 2d6deb8c..2b7177ae 100644 --- a/apps/frontend/src/main/utils/profile-manager.ts +++ b/apps/frontend/src/main/utils/profile-manager.ts @@ -29,7 +29,7 @@ export async function loadProfilesFile(): Promise { const content = await fs.readFile(filePath, 'utf-8'); const data = JSON.parse(content) as ProfilesFile; return data; - } catch (error) { + } catch (_error) { // File doesn't exist or is corrupted - return default return { profiles: [], diff --git a/apps/frontend/src/main/utils/spec-number-lock.ts b/apps/frontend/src/main/utils/spec-number-lock.ts index b33fc455..3fd4c183 100644 --- a/apps/frontend/src/main/utils/spec-number-lock.ts +++ b/apps/frontend/src/main/utils/spec-number-lock.ts @@ -72,7 +72,7 @@ export class SpecNumberLock { const pidStr = readFileSync(this.lockFile, 'utf-8').trim(); const pid = parseInt(pidStr, 10); - if (!isNaN(pid) && !this.isProcessRunning(pid)) { + if (!Number.isNaN(pid) && !this.isProcessRunning(pid)) { // Stale lock - remove it try { unlinkSync(this.lockFile); @@ -194,7 +194,7 @@ export class SpecNumberLock { const match = entry.name.match(/^(\d{3})-/); if (match) { const num = parseInt(match[1], 10); - if (!isNaN(num)) { + if (!Number.isNaN(num)) { maxNum = Math.max(maxNum, num); } } diff --git a/apps/frontend/src/preload/api/profile-api.ts b/apps/frontend/src/preload/api/profile-api.ts index 69b27890..583ebbd2 100644 --- a/apps/frontend/src/preload/api/profile-api.ts +++ b/apps/frontend/src/preload/api/profile-api.ts @@ -81,7 +81,7 @@ export const createProfileAPI = (): ProfileAPI => ({ const requestId = ++testConnectionRequestId; // Check if already aborted before initiating request - if (signal && signal.aborted) { + if (signal?.aborted) { return Promise.reject(new DOMException('The operation was aborted.', 'AbortError')); } @@ -114,7 +114,7 @@ export const createProfileAPI = (): ProfileAPI => ({ console.log('[preload/profile-api] Request ID:', requestId); // Check if already aborted before initiating request - if (signal && signal.aborted) { + if (signal?.aborted) { console.log('[preload/profile-api] Already aborted, rejecting'); return Promise.reject(new DOMException('The operation was aborted.', 'AbortError')); } diff --git a/apps/frontend/src/preload/api/task-api.ts b/apps/frontend/src/preload/api/task-api.ts index ea69da2e..960ed433 100644 --- a/apps/frontend/src/preload/api/task-api.ts +++ b/apps/frontend/src/preload/api/task-api.ts @@ -51,6 +51,7 @@ export interface TaskAPI { options?: import('../../shared/types').TaskRecoveryOptions ) => Promise>; checkTaskRunning: (taskId: string) => Promise>; + resumePausedTask: (taskId: string) => Promise; // Image Operations loadImageThumbnail: (projectPath: string, specId: string, imagePath: string) => Promise>; @@ -144,6 +145,9 @@ export const createTaskAPI = (): TaskAPI => ({ checkTaskRunning: (taskId: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.TASK_CHECK_RUNNING, taskId), + resumePausedTask: (taskId: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.TASK_RESUME_PAUSED, taskId), + // Image Operations loadImageThumbnail: (projectPath: string, specId: string, imagePath: string): Promise> => ipcRenderer.invoke(IPC_CHANNELS.TASK_LOAD_IMAGE_THUMBNAIL, projectPath, specId, imagePath), diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index f0b22d88..e21922b1 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -243,7 +243,7 @@ export function App() { } } // eslint-disable-next-line react-hooks/exhaustive-deps -- projectTabs is intentionally omitted to avoid infinite re-render (computed array creates new reference each render) - }, [projects, activeProjectId, selectedProjectId, openProjectIds, openProjectTab, setActiveProject]); + }, [projects, activeProjectId, selectedProjectId, openProjectIds, openProjectTab, setActiveProject, projectTabs.length, projectTabs.map]); // Track if settings have been loaded at least once const [settingsHaveLoaded, setSettingsHaveLoaded] = useState(false); @@ -314,7 +314,7 @@ export function App() { i18n.changeLanguage(settings.language); } // eslint-disable-next-line react-hooks/exhaustive-deps -- Only run when settings.language changes, not on every i18n object change - }, [settings.language, i18n.language]); + }, [settings.language, i18n.language, i18n.changeLanguage]); // Sync spell check language with i18n language useEffect(() => { @@ -368,7 +368,7 @@ export function App() { useEffect(() => { setInitSuccess(false); setInitError(null); - }, [selectedProjectId]); + }, []); // Check if selected project needs initialization (e.g., .auto-claude folder was deleted) useEffect(() => { @@ -445,7 +445,7 @@ export function App() { console.error('[App] Failed to restore sessions:', err); }); } - }, [activeProjectId, selectedProjectId, selectedProject?.path, selectedProject?.name]); + }, [activeProjectId, selectedProjectId, selectedProject?.path]); // Apply theme on load useEffect(() => { @@ -587,7 +587,7 @@ export function App() { setSelectedTask(updatedTask); } // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally omit selectedTask object to prevent infinite re-render loop - }, [tasks, selectedTask?.id, selectedTask?.specId]); + }, [tasks, selectedTask?.id, selectedTask?.specId, selectedTask]); const handleTaskClick = (task: Task) => { setSelectedTask(task); diff --git a/apps/frontend/src/renderer/components/AddFeatureDialog.tsx b/apps/frontend/src/renderer/components/AddFeatureDialog.tsx index d139298b..9b451386 100644 --- a/apps/frontend/src/renderer/components/AddFeatureDialog.tsx +++ b/apps/frontend/src/renderer/components/AddFeatureDialog.tsx @@ -50,7 +50,6 @@ import type { RoadmapPhase, RoadmapFeaturePriority, RoadmapFeatureStatus, - FeatureSource } from '../../shared/types'; /** diff --git a/apps/frontend/src/renderer/components/AgentProfileSelector.tsx b/apps/frontend/src/renderer/components/AgentProfileSelector.tsx index fa74affa..2ded57c9 100644 --- a/apps/frontend/src/renderer/components/AgentProfileSelector.tsx +++ b/apps/frontend/src/renderer/components/AgentProfileSelector.tsx @@ -86,7 +86,7 @@ export function AgentProfileSelector({ const [showPhaseDetails, setShowPhaseDetails] = useState(false); const isCustom = profileId === 'custom'; - const isAuto = profileId === 'auto'; + const _isAuto = profileId === 'auto'; // Use provided phase configs or defaults const currentPhaseModels = phaseModels || DEFAULT_PHASE_MODELS; diff --git a/apps/frontend/src/renderer/components/AgentTools.tsx b/apps/frontend/src/renderer/components/AgentTools.tsx index 6797edbc..8510cfbf 100644 --- a/apps/frontend/src/renderer/components/AgentTools.tsx +++ b/apps/frontend/src/renderer/components/AgentTools.tsx @@ -32,7 +32,6 @@ import { Terminal, Loader2, RefreshCw, - AlertTriangle, Lock } from 'lucide-react'; import { useState, useMemo, useEffect, useCallback } from 'react'; @@ -48,7 +47,7 @@ import { } from './ui/dialog'; import { useSettingsStore } from '../stores/settings-store'; import { useProjectStore } from '../stores/project-store'; -import type { ProjectEnvConfig, AgentMcpOverrides, AgentMcpOverride, CustomMcpServer, McpHealthCheckResult, McpHealthStatus } from '../../shared/types'; +import type { ProjectEnvConfig, AgentMcpOverride, CustomMcpServer, McpHealthCheckResult, } from '../../shared/types'; import { CustomMcpDialog } from './CustomMcpDialog'; import { useTranslation } from 'react-i18next'; import { @@ -59,7 +58,6 @@ import { useResolvedAgentSettings, resolveAgentSettings as resolveAgentModelConfig, type AgentSettingsSource, - type ResolvedAgentSettings, } from '../hooks'; import type { ModelTypeShort, ThinkingLevel } from '../../shared/types/settings'; @@ -912,7 +910,7 @@ export function AgentTools() { [server.id]: result.data!, })); } - } catch (error) { + } catch (_error) { setServerHealthStatus(prev => ({ ...prev, [server.id]: { @@ -945,14 +943,14 @@ export function AgentTools() { ...prev, [server.id]: { serverId: server.id, - status: result.data!.success ? 'healthy' : 'unhealthy', - message: result.data!.message, - responseTime: result.data!.responseTime, + status: result.data?.success ? 'healthy' : 'unhealthy', + message: result.data?.message, + responseTime: result.data?.responseTime, checkedAt: new Date().toISOString(), } })); } - } catch (error) { + } catch (_error) { setServerHealthStatus(prev => ({ ...prev, [server.id]: { diff --git a/apps/frontend/src/renderer/components/AuthStatusIndicator.tsx b/apps/frontend/src/renderer/components/AuthStatusIndicator.tsx index 502d139c..621cd39a 100644 --- a/apps/frontend/src/renderer/components/AuthStatusIndicator.tsx +++ b/apps/frontend/src/renderer/components/AuthStatusIndicator.tsx @@ -237,8 +237,7 @@ export function AuthStatusIndicator() { {/* Profile details for API profiles */} {!isOAuth && ( - <> -
+
{/* Profile name with icon */}
@@ -272,7 +271,6 @@ export function AuthStatusIndicator() {
)}
- )}
diff --git a/apps/frontend/src/renderer/components/CustomMcpDialog.tsx b/apps/frontend/src/renderer/components/CustomMcpDialog.tsx index c9198898..290c6f53 100644 --- a/apps/frontend/src/renderer/components/CustomMcpDialog.tsx +++ b/apps/frontend/src/renderer/components/CustomMcpDialog.tsx @@ -20,7 +20,7 @@ import { Label } from './ui/label'; import { RadioGroup, RadioGroupItem } from './ui/radio-group'; import { useTranslation } from 'react-i18next'; import type { CustomMcpServer } from '../../shared/types'; -import { Terminal, Globe, X, Github, Loader2, ExternalLink } from 'lucide-react'; +import { Terminal, Globe, X, Github, ExternalLink } from 'lucide-react'; interface CustomMcpDialogProps { open: boolean; diff --git a/apps/frontend/src/renderer/components/EnvConfigModal.tsx b/apps/frontend/src/renderer/components/EnvConfigModal.tsx index f35138b8..e22112a9 100644 --- a/apps/frontend/src/renderer/components/EnvConfigModal.tsx +++ b/apps/frontend/src/renderer/components/EnvConfigModal.tsx @@ -121,7 +121,7 @@ export function EnvConfigModal({ }; loadData(); - }, [open]); + }, [open, selectedProfileId]); // Listen for OAuth token from terminal useEffect(() => { diff --git a/apps/frontend/src/renderer/components/FileAutocomplete.tsx b/apps/frontend/src/renderer/components/FileAutocomplete.tsx index f511e2dc..043b7298 100644 --- a/apps/frontend/src/renderer/components/FileAutocomplete.tsx +++ b/apps/frontend/src/renderer/components/FileAutocomplete.tsx @@ -109,7 +109,7 @@ export function FileAutocomplete({ // Reset selection when results change useEffect(() => { setSelectedIndex(0); - }, [filteredFiles]); + }, []); // Scroll selected item into view useEffect(() => { diff --git a/apps/frontend/src/renderer/components/FileTreeItem.tsx b/apps/frontend/src/renderer/components/FileTreeItem.tsx index d6273b5a..18a57050 100644 --- a/apps/frontend/src/renderer/components/FileTreeItem.tsx +++ b/apps/frontend/src/renderer/components/FileTreeItem.tsx @@ -79,7 +79,7 @@ export function FileTreeItem({ // This handles cases where component unmounts mid-drag or dragend doesn't fire useEffect(() => { return () => { - if (dragImageRef.current && dragImageRef.current.parentNode) { + if (dragImageRef.current?.parentNode) { dragImageRef.current.parentNode.removeChild(dragImageRef.current); dragImageRef.current = null; } @@ -151,7 +151,7 @@ export function FileTreeItem({ setIsDragging(false); // Clean up drag image element - if (dragImageRef.current && dragImageRef.current.parentNode) { + if (dragImageRef.current?.parentNode) { dragImageRef.current.parentNode.removeChild(dragImageRef.current); dragImageRef.current = null; } diff --git a/apps/frontend/src/renderer/components/GitSetupModal.tsx b/apps/frontend/src/renderer/components/GitSetupModal.tsx index 51df917f..ccbd1536 100644 --- a/apps/frontend/src/renderer/components/GitSetupModal.tsx +++ b/apps/frontend/src/renderer/components/GitSetupModal.tsx @@ -35,7 +35,7 @@ export function GitSetupModal({ const [step, setStep] = useState<'info' | 'initializing' | 'success'>('info'); const needsGitInit = gitStatus && !gitStatus.isGitRepo; - const _needsCommit = gitStatus && gitStatus.isGitRepo && !gitStatus.hasCommits; + const _needsCommit = gitStatus?.isGitRepo && !gitStatus.hasCommits; const handleInitializeGit = async () => { if (!project) return; diff --git a/apps/frontend/src/renderer/components/Insights.tsx b/apps/frontend/src/renderer/components/Insights.tsx index f380ec48..16adcb68 100644 --- a/apps/frontend/src/renderer/components/Insights.tsx +++ b/apps/frontend/src/renderer/components/Insights.tsx @@ -150,7 +150,7 @@ export function Insights({ projectId }: InsightsProps) { if (isUserAtBottom && viewportEl) { viewportEl.scrollTop = viewportEl.scrollHeight; } - }, [session?.messages, streamingContent, isUserAtBottom, viewportEl]); + }, [isUserAtBottom, viewportEl]); // Focus textarea on mount useEffect(() => { @@ -160,7 +160,7 @@ export function Insights({ projectId }: InsightsProps) { // Reset taskCreated when switching sessions useEffect(() => { setTaskCreated(new Set()); - }, [session?.id]); + }, []); const handleSend = () => { const message = inputValue.trim(); diff --git a/apps/frontend/src/renderer/components/PhaseProgressIndicator.tsx b/apps/frontend/src/renderer/components/PhaseProgressIndicator.tsx index 62206624..90d4791f 100644 --- a/apps/frontend/src/renderer/components/PhaseProgressIndicator.tsx +++ b/apps/frontend/src/renderer/components/PhaseProgressIndicator.tsx @@ -20,6 +20,8 @@ const PHASE_COLORS: Record = idle: { color: 'bg-muted-foreground', bgColor: 'bg-muted' }, planning: { color: 'bg-amber-500', bgColor: 'bg-amber-500/20' }, coding: { color: 'bg-info', bgColor: 'bg-info/20' }, + rate_limit_paused: { color: 'bg-orange-500', bgColor: 'bg-orange-500/20' }, + auth_failure_paused: { color: 'bg-red-500', bgColor: 'bg-red-500/20' }, qa_review: { color: 'bg-purple-500', bgColor: 'bg-purple-500/20' }, qa_fixing: { color: 'bg-orange-500', bgColor: 'bg-orange-500/20' }, complete: { color: 'bg-success', bgColor: 'bg-success/20' }, @@ -31,6 +33,8 @@ const PHASE_LABEL_KEYS: Record = { idle: 'execution.phases.idle', planning: 'execution.phases.planning', coding: 'execution.phases.coding', + rate_limit_paused: 'execution.phases.rate_limit_paused', + auth_failure_paused: 'execution.phases.auth_failure_paused', qa_review: 'execution.phases.reviewing', qa_fixing: 'execution.phases.fixing', complete: 'execution.phases.complete', diff --git a/apps/frontend/src/renderer/components/ProjectTabBar.tsx b/apps/frontend/src/renderer/components/ProjectTabBar.tsx index df41baf1..d76b34f4 100644 --- a/apps/frontend/src/renderer/components/ProjectTabBar.tsx +++ b/apps/frontend/src/renderer/components/ProjectTabBar.tsx @@ -48,7 +48,7 @@ export function ProjectTabBar({ // Cmd/Ctrl + 1-9: Switch to tab N if (e.key >= '1' && e.key <= '9') { e.preventDefault(); - const index = parseInt(e.key) - 1; + const index = parseInt(e.key, 10) - 1; if (index < projects.length) { onProjectSelect(projects[index].id); } diff --git a/apps/frontend/src/renderer/components/QueueSettingsModal.tsx b/apps/frontend/src/renderer/components/QueueSettingsModal.tsx index 18342101..810de311 100644 --- a/apps/frontend/src/renderer/components/QueueSettingsModal.tsx +++ b/apps/frontend/src/renderer/components/QueueSettingsModal.tsx @@ -93,7 +93,7 @@ export function QueueSettingsModal({ } const value = parseInt(inputValue, 10); - if (!isNaN(value)) { + if (!Number.isNaN(value)) { setMaxParallel(value); setError(null); } diff --git a/apps/frontend/src/renderer/components/RateLimitModal.tsx b/apps/frontend/src/renderer/components/RateLimitModal.tsx index 477a4177..30ed681e 100644 --- a/apps/frontend/src/renderer/components/RateLimitModal.tsx +++ b/apps/frontend/src/renderer/components/RateLimitModal.tsx @@ -49,6 +49,7 @@ export function RateLimitModal() { setSelectedProfileId(rateLimitInfo.suggestedProfileId); } } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isModalOpen, rateLimitInfo?.suggestedProfileId]); // Reset selection when modal closes diff --git a/apps/frontend/src/renderer/components/RoadmapKanbanView.tsx b/apps/frontend/src/renderer/components/RoadmapKanbanView.tsx index c679950a..8269722b 100644 --- a/apps/frontend/src/renderer/components/RoadmapKanbanView.tsx +++ b/apps/frontend/src/renderer/components/RoadmapKanbanView.tsx @@ -16,7 +16,6 @@ import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, - arrayMove } from '@dnd-kit/sortable'; import { Plus, Inbox, Eye, Calendar, Play, Check } from 'lucide-react'; import { ScrollArea } from './ui/scroll-area'; diff --git a/apps/frontend/src/renderer/components/SDKRateLimitModal.tsx b/apps/frontend/src/renderer/components/SDKRateLimitModal.tsx index e5cc0ca1..8c96205b 100644 --- a/apps/frontend/src/renderer/components/SDKRateLimitModal.tsx +++ b/apps/frontend/src/renderer/components/SDKRateLimitModal.tsx @@ -94,6 +94,7 @@ export function SDKRateLimitModal() { }); } } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSDKModalOpen, sdkRateLimitInfo, profiles]); // Reset selection when modal closes diff --git a/apps/frontend/src/renderer/components/Sidebar.tsx b/apps/frontend/src/renderer/components/Sidebar.tsx index 076c33d0..6151678c 100644 --- a/apps/frontend/src/renderer/components/Sidebar.tsx +++ b/apps/frontend/src/renderer/components/Sidebar.tsx @@ -57,7 +57,7 @@ import { GitSetupModal } from './GitSetupModal'; import { RateLimitIndicator } from './RateLimitIndicator'; import { ClaudeCodeStatusBadge } from './ClaudeCodeStatusBadge'; import { UpdateBanner } from './UpdateBanner'; -import type { Project, AutoBuildVersionInfo, GitStatus } from '../../shared/types'; +import type { Project, GitStatus } from '../../shared/types'; export type SidebarView = 'kanban' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'github-issues' | 'gitlab-issues' | 'github-prs' | 'gitlab-merge-requests' | 'changelog' | 'insights' | 'worktrees' | 'agent-tools'; diff --git a/apps/frontend/src/renderer/components/SortableTerminalWrapper.tsx b/apps/frontend/src/renderer/components/SortableTerminalWrapper.tsx index ea5873c2..7bc1ce21 100644 --- a/apps/frontend/src/renderer/components/SortableTerminalWrapper.tsx +++ b/apps/frontend/src/renderer/components/SortableTerminalWrapper.tsx @@ -1,4 +1,4 @@ -import React, { useRef, forwardRef, useImperativeHandle } from 'react'; +import { useRef, forwardRef, useImperativeHandle } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import type { Task } from '../../shared/types'; diff --git a/apps/frontend/src/renderer/components/TaskCard.tsx b/apps/frontend/src/renderer/components/TaskCard.tsx index 1caa7317..806f2413 100644 --- a/apps/frontend/src/renderer/components/TaskCard.tsx +++ b/apps/frontend/src/renderer/components/TaskCard.tsx @@ -403,8 +403,7 @@ export const TaskCard = memo(function TaskCard({ )} {/* Status badge - hide when execution phase badge is showing */} {!hasActiveExecution && ( - <> - {task.status === 'done' ? ( + task.status === 'done' ? ( {isStuck ? t('labels.needsRecovery') : isIncomplete ? t('labels.needsResume') : getStatusLabel(task.status)} - )} - + ) )} {/* Review reason badge - explains why task needs human review */} {reviewReasonInfo && !isStuck && !isIncomplete && ( diff --git a/apps/frontend/src/renderer/components/TerminalGrid.tsx b/apps/frontend/src/renderer/components/TerminalGrid.tsx index 146358d8..4f17af1f 100644 --- a/apps/frontend/src/renderer/components/TerminalGrid.tsx +++ b/apps/frontend/src/renderer/components/TerminalGrid.tsx @@ -84,7 +84,7 @@ export function TerminalGrid({ projectPath, onNewTaskClick, isActive = false }: // Reset expanded terminal when project changes useEffect(() => { setExpandedTerminalId(null); - }, [projectPath]); + }, []); // Fetch available session dates when project changes useEffect(() => { @@ -356,7 +356,7 @@ export function TerminalGrid({ projectPath, onNewTaskClick, isActive = false }: // Insert the file path into the terminal with a trailing space window.electronAPI.sendTerminalInput(terminalId, quotedPath + ' '); } - }, [reorderTerminals, terminals]); + }, [reorderTerminals, terminals, projectPath]); // Calculate grid layout based on number of terminals const gridLayout = useMemo(() => { diff --git a/apps/frontend/src/renderer/components/UpdateBanner.tsx b/apps/frontend/src/renderer/components/UpdateBanner.tsx index c7b2ad23..8323b464 100644 --- a/apps/frontend/src/renderer/components/UpdateBanner.tsx +++ b/apps/frontend/src/renderer/components/UpdateBanner.tsx @@ -52,7 +52,7 @@ export function UpdateBanner({ className }: UpdateBannerProps) { releaseDate: result.data.releaseDate, }); } - } catch (err) { + } catch (_err) { // Silent failure - update check is non-critical } }, []); @@ -165,7 +165,7 @@ export function UpdateBanner({ className }: UpdateBannerProps) { setDownloadError(result.error || t("navigation:updateBanner.downloadError")); setIsDownloading(false); } - } catch (error) { + } catch (_error) { setDownloadError(t("navigation:updateBanner.downloadError")); setIsDownloading(false); } diff --git a/apps/frontend/src/renderer/components/__tests__/AgentTools.test.tsx b/apps/frontend/src/renderer/components/__tests__/AgentTools.test.tsx index f93886ea..679b7a46 100644 --- a/apps/frontend/src/renderer/components/__tests__/AgentTools.test.tsx +++ b/apps/frontend/src/renderer/components/__tests__/AgentTools.test.tsx @@ -7,7 +7,7 @@ */ import { describe, it, expect, vi } from 'vitest'; import { DEFAULT_AGENT_PROFILES, DEFAULT_PHASE_MODELS, DEFAULT_FEATURE_MODELS, DEFAULT_FEATURE_THINKING } from '../../../shared/constants/models'; -import { resolveAgentSettings, type AgentSettingsSource } from '../../hooks'; +import { resolveAgentSettings, } from '../../hooks'; // Mock electronAPI global.window.electronAPI = { diff --git a/apps/frontend/src/renderer/components/changelog/ChangelogFilters.tsx b/apps/frontend/src/renderer/components/changelog/ChangelogFilters.tsx index f34ed93b..643197b6 100644 --- a/apps/frontend/src/renderer/components/changelog/ChangelogFilters.tsx +++ b/apps/frontend/src/renderer/components/changelog/ChangelogFilters.tsx @@ -218,7 +218,7 @@ export function ChangelogFilters({ min={1} max={500} value={gitHistoryCount} - onChange={(e) => onGitHistoryCountChange(parseInt(e.target.value) || 25)} + onChange={(e) => onGitHistoryCountChange(parseInt(e.target.value, 10) || 25)} />
)} diff --git a/apps/frontend/src/renderer/components/github-issues/hooks/useGitHubIssues.ts b/apps/frontend/src/renderer/components/github-issues/hooks/useGitHubIssues.ts index 7343ddd3..0037b58b 100644 --- a/apps/frontend/src/renderer/components/github-issues/hooks/useGitHubIssues.ts +++ b/apps/frontend/src/renderer/components/github-issues/hooks/useGitHubIssues.ts @@ -6,7 +6,6 @@ import { loadMoreGitHubIssues, loadAllGitHubIssues, checkGitHubConnection, - type IssueFilterState, } from "../../../stores/github"; import type { FilterState } from "../types"; @@ -36,7 +35,7 @@ export function useGitHubIssues(projectId: string | undefined) { // Reset search state when projectId changes to prevent incorrect fetchAll mode useEffect(() => { setIsSearchActive(false); - }, [projectId]); + }, []); // Always check connection when component mounts or projectId changes useEffect(() => { @@ -57,7 +56,7 @@ export function useGitHubIssues(projectId: string | undefined) { loadGitHubIssues(projectId, filterState, isSearchActive); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId, filterState, syncStatus?.connected]); + }, [projectId, filterState, syncStatus?.connected, isSearchActive]); const handleRefresh = useCallback(() => { if (projectId) { diff --git a/apps/frontend/src/renderer/components/github-prs/components/__tests__/PRDetail.test.tsx b/apps/frontend/src/renderer/components/github-prs/components/__tests__/PRDetail.test.tsx index bdb304a3..76f1e04c 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/__tests__/PRDetail.test.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/__tests__/PRDetail.test.tsx @@ -18,7 +18,7 @@ import type { NewCommitsCheck } from '@preload/api/modules/github-api'; /** * Factory function to create a mock PR data object */ -function createMockPR(overrides: Partial = {}): PRData { +function _createMockPR(overrides: Partial = {}): PRData { return { number: 123, title: 'Test PR', diff --git a/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts b/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts index 58c43d5c..20f3d7f2 100644 --- a/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts +++ b/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts @@ -95,7 +95,7 @@ export function useGitHubPRs( const fetchGenerationRef = useRef(0); // Get PR review state from the global store - const prReviews = usePRReviewStore((state) => state.prReviews); + const _prReviews = usePRReviewStore((state) => state.prReviews); const getPRReviewState = usePRReviewStore((state) => state.getPRReviewState); const getActivePRReviews = usePRReviewStore((state) => state.getActivePRReviews); const setNewCommitsCheckAction = usePRReviewStore((state) => state.setNewCommitsCheck); @@ -104,7 +104,7 @@ export function useGitHubPRs( const selectedPRReviewState = useMemo(() => { if (!projectId || selectedPRNumber === null) return null; return getPRReviewState(projectId, selectedPRNumber); - }, [projectId, selectedPRNumber, prReviews, getPRReviewState]); + }, [projectId, selectedPRNumber, getPRReviewState]); // Derive values from store state - all from the same source to ensure consistency const reviewResult = selectedPRReviewState?.result ?? null; @@ -117,7 +117,7 @@ export function useGitHubPRs( const activePRReviews = useMemo(() => { if (!projectId) return []; return getActivePRReviews(projectId).map((review) => review.prNumber); - }, [projectId, prReviews, getActivePRReviews]); + }, [projectId, getActivePRReviews]); // Helper to get review state for any PR const getReviewStateForPR = useCallback( @@ -135,7 +135,7 @@ export function useGitHubPRs( newCommitsCheck: state.newCommitsCheck, }; }, - [projectId, prReviews, getPRReviewState] + [projectId, getPRReviewState] ); // Use detailed PR data if available (includes files), otherwise fall back to list data @@ -251,7 +251,7 @@ export function useGitHubPRs( checkNewCommitsAbortRef.current.abort(); checkNewCommitsAbortRef.current = null; } - }, [projectId]); + }, []); // Cleanup abort controller on unmount to prevent memory leaks // and avoid state updates on unmounted components diff --git a/apps/frontend/src/renderer/components/github-prs/utils/formatDate.ts b/apps/frontend/src/renderer/components/github-prs/utils/formatDate.ts index ac21219f..d24511f2 100644 --- a/apps/frontend/src/renderer/components/github-prs/utils/formatDate.ts +++ b/apps/frontend/src/renderer/components/github-prs/utils/formatDate.ts @@ -6,7 +6,7 @@ */ export function formatDate(dateString: string, locale: string = 'en-US'): string { const date = new Date(dateString); - if (isNaN(date.getTime())) return ''; + if (Number.isNaN(date.getTime())) return ''; return date.toLocaleDateString(locale, { month: 'short', day: 'numeric', diff --git a/apps/frontend/src/renderer/components/gitlab-merge-requests/components/MRDetail.tsx b/apps/frontend/src/renderer/components/gitlab-merge-requests/components/MRDetail.tsx index db1fcce7..cbb3ca77 100644 --- a/apps/frontend/src/renderer/components/gitlab-merge-requests/components/MRDetail.tsx +++ b/apps/frontend/src/renderer/components/gitlab-merge-requests/components/MRDetail.tsx @@ -437,7 +437,7 @@ export function MRDetail({ Cancel )} - {reviewResult && reviewResult.success && selectedCount > 0 && !isReviewing && ( + {reviewResult?.success && selectedCount > 0 && !isReviewing && (