Compare commits

..

6 Commits

Author SHA1 Message Date
Test User ba49fd064e feat(github): add support for excluding CI checks during PR reviews
Enhanced the GitHub integration to allow users to specify CI checks to be excluded from blocking PR approvals. Key changes include:
- Introduced a new environment variable `GITHUB_EXCLUDED_CI_CHECKS` to configure excluded checks.
- Updated the `GHClient` methods to accept an `excluded_checks` parameter for filtering CI checks.
- Added UI components in the settings to manage excluded checks, including a textarea for input.
- Updated relevant documentation and translations for the new feature.

These improvements enable more flexible handling of CI checks, particularly for those that are known to be flaky or stuck, improving the overall user experience during PR reviews.
2026-02-05 14:16:41 +01:00
Test User f5f521da2a fix(github): enhance CI status handling to include pending checks
Updated the GitHubOrchestrator to account for pending CI checks in the verdict determination process. The changes include:
- Added logic to check for pending CI statuses alongside failing and awaiting approvals.
- Updated the blocker detection to include messages for pending checks.
- Enhanced reasoning for verdict updates to reflect pending CI checks.
- Adjusted summary messages to inform users about pending CI statuses.

These improvements ensure that the merging process accurately reflects the current state of CI checks, preventing merges when checks are still running.
2026-02-05 10:39:36 +01:00
Test User 624c15d29b docs 2026-02-05 09:33:11 +01:00
Test User 23f712c094 fix(github): enable text fallback when structured output validation fails
When Claude cannot produce valid structured output matching the Pydantic
schema (error_max_structured_output_retries), the reviewers now proceed
to use text fallback parsing instead of raising an exception or
returning empty results.

Previously, when structured output validation failed:
- parallel_followup_reviewer.py raised RuntimeError, losing all findings
- parallel_orchestrator_reviewer.py returned empty findings list

Now, when structured output validation fails:
- Warning is logged indicating text fallback will be used
- structured_output is cleared to ensure fallback parsing runs
- The markdown fallback parser extracts findings from AI's text response

Also reduces verbose debug logging in frontend modules:
- Removed frequent TRACE/CACHE/buffer update logs
- Kept error logs and state-change notifications
- Debug output is now cleaner and more actionable

Files modified:
- parallel_followup_reviewer.py: Handle error_max_structured_output_retries
- parallel_orchestrator_reviewer.py: Same fix in 2 locations
- usage-monitor.ts: Removed verbose trace logs
- credential-utils.ts: Removed cache hit logs
- terminal-session-store.ts: Removed buffer update logs
- token-refresh.ts: Removed frequent validity check logs
- claude-profile-manager.ts: Removed getActiveProfile log

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 19:46:01 +01:00
Test User 2f4393b7f1 refactor(github): extract shared markdown parser and add normalization tests
Extract duplicated _parse_markdown_output() logic (~280 lines) from
parallel_orchestrator_reviewer.py and parallel_followup_reviewer.py into
a shared markdown_utils.py module. This improves maintainability by
having a single source of truth for fallback markdown parsing.

Add comprehensive unit tests (72 tests) for the normalize_category() and
normalize_verdict() helper functions that map AI-generated values to
valid Pydantic schema enums. Tests cover synonym mapping, case handling,
whitespace normalization, and edge cases.

Fixes for test infrastructure:
- Add mock for services.markdown_utils in test_integration_phase4.py
- Fix import ordering in test_pydantic_models.py for ruff compliance
- Create test package structure under tests/runners/github/services/

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 18:56:12 +01:00
Test User 5046d37d62 fix(github): add structured output fallback and enum normalization
Addresses structured output validation failures (error_max_structured_output_retries)
where AI outputs findings in markdown format after schema validation fails.

Changes:
- Add markdown fallback parser to extract findings when JSON fails
- Add Pydantic field validators to normalize enum values:
  - "duplication" -> "redundancy"
  - "NEEDS REVISION" -> "NEEDS_REVISION"
- Add enum cheat sheets to orchestrator prompts with exact valid values
- Improve error logging to capture validation failure details

Closes #1581

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 18:44:09 +01:00
212 changed files with 1887 additions and 3939 deletions
-11
View File
@@ -40,17 +40,6 @@ Auto Claude is a desktop application (+ CLI) where users describe a goal and AI
**PR target** — Always target the `develop` branch for PRs to AndyMik90/Auto-Claude, NOT `main`.
## Memory (claude-mem)
This project uses claude-mem for persistent memory across sessions. MCP search tools are available — use them proactively:
- **Before modifying code** — Search for past work on the same files/features: `search(query="<file or feature>")`. Check if there are known bugs, decisions, or patterns to follow.
- **When debugging** — Search for prior encounters with the same error or symptom: `search(query="<error message>", type="bugfix")`.
- **When making architectural decisions** — Check for past decisions: `search(query="<topic>", type="decision")`.
- **When resuming work** — Use `timeline(anchor=<recent_id>)` to understand where things left off.
Follow the 3-layer workflow: `search` (cheap index) → `timeline` (context) → `get_observations` (full details only for relevant IDs). Never fetch full details without filtering first.
## Project Structure
```
+7 -7
View File
@@ -35,18 +35,18 @@
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
<!-- BETA_VERSION_BADGE -->
[![Beta](https://img.shields.io/badge/beta-2.7.6--beta.2-orange?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.2)
[![Beta](https://img.shields.io/badge/beta-2.7.6--beta.1-orange?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.1)
<!-- BETA_VERSION_BADGE_END -->
<!-- BETA_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **Windows** | [Auto-Claude-2.7.6-beta.2-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.2-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.2-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-amd64.deb) |
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-x86_64.flatpak) |
| **Windows** | [Auto-Claude-2.7.6-beta.1-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.1-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.1-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.6-beta.1-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.1-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-linux-amd64.deb) |
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.1-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-linux-x86_64.flatpak) |
<!-- BETA_DOWNLOADS_END -->
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
+4 -80
View File
@@ -6,7 +6,6 @@ Shared imports, types, and constants used across agent modules.
"""
import logging
import re
# Configure logging
logger = logging.getLogger(__name__)
@@ -15,82 +14,7 @@ logger = logging.getLogger(__name__)
AUTO_CONTINUE_DELAY_SECONDS = 3
HUMAN_INTERVENTION_FILE = "PAUSE"
# 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
# Concurrency retry constants
MAX_CONCURRENCY_RETRIES = 5
INITIAL_RETRY_DELAY_SECONDS = 2
MAX_RETRY_DELAY_SECONDS = 32
-353
View File
@@ -6,11 +6,8 @@ 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
@@ -60,19 +57,11 @@ 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
@@ -87,219 +76,6 @@ 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,
@@ -906,135 +682,6 @@ 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")
+7 -119
View File
@@ -7,7 +7,6 @@ memory updates, recovery tracking, and Linear integration.
"""
import logging
import re
from pathlib import Path
from claude_agent_sdk import ClaudeSDKClient
@@ -35,7 +34,6 @@ 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,
@@ -70,93 +68,6 @@ 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,
@@ -657,18 +568,7 @@ async def run_agent_session(
except Exception as e:
# Detect specific error types for better retry handling
is_concurrency = is_tool_concurrency_error(e)
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"
error_type = "tool_concurrency" if is_concurrency else "other"
debug_error(
"session",
@@ -679,32 +579,20 @@ async def run_agent_session(
tool_count=tool_count,
)
# 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
# Log concurrency errors prominently
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: {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")
print(f" Error: {str(e)[:200]}\n")
else:
print(f"Error during agent session: {sanitized_error}")
print(f"Error during agent session: {e}")
if task_logger:
task_logger.log_error(f"Session error: {sanitized_error}", phase)
task_logger.log_error(f"Session error: {e}", phase)
error_info = {
"type": error_type,
"message": sanitized_error,
"message": str(e),
"exception_type": type(e).__name__,
}
return "error", sanitized_error, error_info
return "error", str(e), error_info
+1 -21
View File
@@ -23,9 +23,6 @@ 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(
@@ -34,19 +31,8 @@ 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.
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
"""
"""Emit structured phase event to stdout for frontend parsing."""
phase_value = phase.value if isinstance(phase, ExecutionPhase) else phase
payload: dict[str, Any] = {
@@ -62,12 +48,6 @@ 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:
+6 -2
View File
@@ -12,8 +12,12 @@ The refactored code is now organized as:
- graphiti/search.py - Semantic search logic
- graphiti/schema.py - Graph schema definitions
Import from this module:
from integrations.graphiti.memory import GraphitiMemory, is_graphiti_enabled, GroupIdMode
This facade ensures existing imports continue to work:
from graphiti_memory import GraphitiMemory, is_graphiti_enabled
New code should prefer importing from the graphiti package:
from graphiti import GraphitiMemory
from graphiti.schema import GroupIdMode
For detailed documentation on the memory system architecture and usage,
see graphiti/graphiti.py.
@@ -62,7 +62,7 @@ async def get_graph_hints(
try:
from pathlib import Path
from integrations.graphiti.memory import GraphitiMemory, GroupIdMode
from graphiti_memory import GraphitiMemory, GroupIdMode
# Determine project directory from project_id or use current dir
project_dir = Path.cwd()
+2 -2
View File
@@ -17,7 +17,7 @@ from core.sentry import capture_exception
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from integrations.graphiti.memory import GraphitiMemory
from graphiti_memory import GraphitiMemory
def is_graphiti_memory_enabled() -> bool:
@@ -60,7 +60,7 @@ async def get_graphiti_memory(
return None
try:
from integrations.graphiti.memory import GraphitiMemory, GroupIdMode
from graphiti_memory import GraphitiMemory, GroupIdMode
if project_dir is None:
project_dir = spec_dir.parent.parent
@@ -297,6 +297,41 @@ When multiple agents report on the same area:
- **Track consensus**: Note which findings have cross-agent validation
- **Evidence-based, not confidence-based**: Multiple agents agreeing doesn't skip validation - all findings still verified
## STRUCTURED OUTPUT REQUIREMENTS
**CRITICAL: Use EXACT values below. Schema validation will fail otherwise.**
### Valid category values (use EXACTLY one of these, lowercase):
- `security` - Authentication, authorization, injection, XSS, secrets
- `quality` - Code quality, error handling, maintainability
- `logic` - Logic errors, edge cases, race conditions
- `test` - Test coverage, test quality
- `docs` - Documentation issues
- `regression` - Regression from previous fix
- `incomplete_fix` - Partial fix that needs more work
### Valid severity values (use EXACTLY one of these, lowercase):
- `critical` - Security vulnerabilities, data loss risk
- `high` - Significant bugs, breaking changes
- `medium` - Quality issues, should fix
- `low` - Suggestions, minor improvements
### Valid verdict values (use EXACTLY one of these, UPPERCASE with underscores):
- `READY_TO_MERGE` - All issues resolved, can merge
- `MERGE_WITH_CHANGES` - Only LOW severity issues
- `NEEDS_REVISION` - HIGH or MEDIUM issues (NOT "NEEDS REVISION")
- `BLOCKED` - CRITICAL issues or CI failing
### Common mistakes to AVOID:
| Wrong | Correct |
|-------|---------|
| `"duplication"` | Use `"quality"` or `"redundancy"` if in scope |
| `"NEEDS REVISION"` | `"NEEDS_REVISION"` |
| `"needs revision"` | `"NEEDS_REVISION"` |
| `"Ready to Merge"` | `"READY_TO_MERGE"` |
| `"HIGH"` | `"high"` (lowercase for severity) |
| `"CRITICAL"` | `"critical"` (lowercase for severity) |
## Output Format
Provide your synthesis as a structured response matching the ParallelFollowupResponse schema:
@@ -626,6 +626,44 @@ The `agent_agreement` field in structured output tracks:
**Note:** Agent agreement data is logged for monitoring. The cross-validation results
are reflected in each finding's source_agents, cross_validated, and confidence fields.
## STRUCTURED OUTPUT REQUIREMENTS
**CRITICAL: Use EXACT values below. Schema validation will fail otherwise.**
### Valid category values (use EXACTLY one of these, lowercase):
- `security` - Authentication, authorization, injection, XSS, secrets
- `quality` - Code quality, error handling, maintainability
- `logic` - Logic errors, edge cases, race conditions
- `codebase_fit` - Architectural consistency, pattern adherence
- `test` - Test coverage, test quality
- `docs` - Documentation issues
- `redundancy` - Code duplication (NOT "duplication"!)
- `pattern` - Anti-patterns, design issues
- `performance` - Performance problems
### Valid severity values (use EXACTLY one of these, lowercase):
- `critical` - Security vulnerabilities, data loss risk
- `high` - Significant bugs, breaking changes
- `medium` - Quality issues, should fix
- `low` - Suggestions, minor improvements
### Valid verdict values (use EXACTLY one of these, UPPERCASE with underscores):
- `APPROVE` - No issues found
- `COMMENT` - Only LOW severity issues
- `NEEDS_REVISION` - HIGH or MEDIUM issues (NOT "NEEDS REVISION")
- `BLOCKED` - CRITICAL issues
### Common mistakes to AVOID:
| Wrong | Correct |
|-------|---------|
| `"duplication"` | `"redundancy"` |
| `"NEEDS REVISION"` | `"NEEDS_REVISION"` |
| `"needs revision"` | `"NEEDS_REVISION"` |
| `"Ready to Merge"` | `"APPROVE"` |
| `"READY_TO_MERGE"` | `"APPROVE"` (this schema uses APPROVE) |
| `"HIGH"` | `"high"` (lowercase for severity) |
| `"CRITICAL"` | `"critical"` (lowercase for severity) |
## Output Format
After synthesis and validation, output your final review in this JSON format:
+23 -4
View File
@@ -818,7 +818,9 @@ class GHClient:
return commits[-1].get("oid")
return None
async def get_pr_checks(self, pr_number: int) -> dict[str, Any]:
async def get_pr_checks(
self, pr_number: int, excluded_checks: list[str] | None = None
) -> dict[str, Any]:
"""
Get CI check runs status for a PR.
@@ -826,6 +828,7 @@ class GHClient:
Args:
pr_number: PR number
excluded_checks: List of check names to exclude from counts (for stuck/broken checks)
Returns:
Dict with:
@@ -834,7 +837,9 @@ class GHClient:
- failing: Number of failing checks
- pending: Number of pending checks
- failed_checks: List of failed check names
- excluded_checks: List of checks that were excluded from counting
"""
excluded_set = set(excluded_checks or [])
try:
# Note: gh pr checks --json only supports: bucket, completedAt, description,
# event, link, name, startedAt, state, workflow
@@ -849,11 +854,20 @@ class GHClient:
failing = 0
pending = 0
failed_checks = []
excluded_from_count = []
for check in checks:
state = check.get("state", "").upper()
name = check.get("name", "Unknown")
# Skip excluded checks (for stuck/broken CI checks)
if name in excluded_set:
excluded_from_count.append(name)
logger.info(
f"Excluding CI check '{name}' from counts (user-configured)"
)
continue
# gh pr checks 'state' directly contains: SUCCESS, FAILURE, PENDING, NEUTRAL, etc.
if state in ("SUCCESS", "NEUTRAL", "SKIPPED"):
passing += 1
@@ -870,6 +884,7 @@ class GHClient:
"failing": failing,
"pending": pending,
"failed_checks": failed_checks,
"excluded_checks": excluded_from_count,
}
except (GHCommandError, GHTimeoutError, json.JSONDecodeError) as e:
logger.warning(f"Failed to get PR checks for #{pr_number}: {e}")
@@ -879,6 +894,7 @@ class GHClient:
"failing": 0,
"pending": 0,
"failed_checks": [],
"excluded_checks": [],
"error": str(e),
}
@@ -973,7 +989,9 @@ class GHClient:
logger.warning(f"Failed to approve workflow run {run_id}: {e}")
return False
async def get_pr_checks_comprehensive(self, pr_number: int) -> dict[str, Any]:
async def get_pr_checks_comprehensive(
self, pr_number: int, excluded_checks: list[str] | None = None
) -> dict[str, Any]:
"""
Get comprehensive CI status including workflows awaiting approval.
@@ -983,12 +1001,13 @@ class GHClient:
Args:
pr_number: PR number
excluded_checks: List of check names to exclude from counts (for stuck/broken checks)
Returns:
Dict with all check information including awaiting_approval count
"""
# Get standard checks
checks = await self.get_pr_checks(pr_number)
# Get standard checks (with exclusions applied)
checks = await self.get_pr_checks(pr_number, excluded_checks=excluded_checks)
# Get workflows awaiting approval
awaiting = await self.get_workflows_awaiting_approval(pr_number)
+78 -8
View File
@@ -14,6 +14,7 @@ REFACTORED: Service layer architecture - orchestrator delegates to specialized s
from __future__ import annotations
import os
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
@@ -156,6 +157,17 @@ class GitHubOrchestrator:
# Initialize rate limiter singleton
self.rate_limiter = RateLimiter.get_instance()
# Load excluded CI checks from environment (for stuck/broken checks)
excluded_ci_env = os.environ.get("GITHUB_EXCLUDED_CI_CHECKS", "")
self.excluded_ci_checks: list[str] = [
check.strip() for check in excluded_ci_env.split(",") if check.strip()
]
if self.excluded_ci_checks:
safe_print(
f"[CI] Excluding checks from review: {', '.join(self.excluded_ci_checks)}",
flush=True,
)
# Initialize service layer
self.pr_review_engine = PRReviewEngine(
project_dir=self.project_dir,
@@ -431,7 +443,10 @@ class GitHubOrchestrator:
)
# Check CI status (comprehensive - includes workflows awaiting approval)
ci_status = await self.gh_client.get_pr_checks_comprehensive(pr_number)
# Excluded checks are filtered out based on user configuration
ci_status = await self.gh_client.get_pr_checks_comprehensive(
pr_number, excluded_checks=self.excluded_ci_checks
)
# Log CI status with awaiting approval info
awaiting = ci_status.get("awaiting_approval", 0)
@@ -704,7 +719,10 @@ class GitHubOrchestrator:
# ALWAYS fetch current CI status to detect CI recovery
# This must happen BEFORE the early return check to avoid stale CI verdicts
ci_status = await self.gh_client.get_pr_checks_comprehensive(pr_number)
# Excluded checks are filtered out based on user configuration
ci_status = await self.gh_client.get_pr_checks_comprehensive(
pr_number, excluded_checks=self.excluded_ci_checks
)
followup_context.ci_status = ci_status
if not has_commits and not has_file_changes:
@@ -714,11 +732,14 @@ class GitHubOrchestrator:
# If CI was failing before but now passes, we need to update the verdict
current_failing = ci_status.get("failing", 0)
current_awaiting = ci_status.get("awaiting_approval", 0)
current_pending = ci_status.get("pending", 0)
# Helper to detect CI-related blockers (includes workflows pending)
# Helper to detect CI-related blockers (includes workflows pending and CI pending)
def is_ci_blocker(b: str) -> bool:
return b.startswith("CI Failed:") or b.startswith(
"Workflows Pending:"
return (
b.startswith("CI Failed:")
or b.startswith("Workflows Pending:")
or b.startswith("CI Pending:")
)
previous_blockers = getattr(previous_review, "blockers", [])
@@ -728,11 +749,13 @@ class GitHubOrchestrator:
)
# Determine the appropriate verdict based on current CI status
# CI/Workflow status check (both block merging)
ci_or_workflow_blocking = current_failing > 0 or current_awaiting > 0
# CI/Workflow/Pending status check (all block merging)
ci_or_workflow_blocking = (
current_failing > 0 or current_awaiting > 0 or current_pending > 0
)
if ci_or_workflow_blocking:
# CI is still failing or workflows pending - keep blocked verdict
# CI is still failing, pending, or workflows pending - keep blocked verdict
updated_verdict = MergeVerdict.BLOCKED
if current_failing > 0:
updated_reasoning = (
@@ -749,6 +772,15 @@ class GitHubOrchestrator:
f"No new commits since last review. "
f"CI status: {current_failing} check(s) failing.{ci_note}"
)
elif current_pending > 0:
updated_reasoning = (
f"No code changes since last review. "
f"{current_pending} CI check(s) still pending."
)
no_change_summary = (
f"No new commits since last review. "
f"CI status: {current_pending} check(s) still running."
)
else:
updated_reasoning = (
f"No code changes since last review. "
@@ -837,6 +869,14 @@ class GitHubOrchestrator:
if blocker_msg not in blockers:
blockers.append(blocker_msg)
# Add pending CI checks to blockers
if current_pending > 0:
blocker_msg = (
f"CI Pending: {current_pending} check(s) still running"
)
if blocker_msg not in blockers:
blockers.append(blocker_msg)
# Map verdict to overall_status (consistent with rest of codebase)
if updated_verdict == MergeVerdict.BLOCKED:
overall_status = "request_changes"
@@ -959,6 +999,36 @@ class GitHubOrchestrator:
if ci_warning not in result.summary:
result.summary += ci_warning
# Also check for pending CI checks
pending_checks = followup_context.ci_status.get("pending", 0)
if pending_checks > 0:
safe_print(
f"[Followup] CI checks still pending: {pending_checks}",
flush=True,
)
# Override verdict if CI is pending
if result.verdict in (
MergeVerdict.READY_TO_MERGE,
MergeVerdict.MERGE_WITH_CHANGES,
):
result.verdict = MergeVerdict.BLOCKED
result.verdict_reasoning = (
f"Blocked: {pending_checks} CI check(s) still running. "
"Wait for CI to complete before merge."
)
result.overall_status = "request_changes"
# Add pending CI to blockers
blocker_msg = f"CI Pending: {pending_checks} check(s) still running"
if blocker_msg not in result.blockers:
result.blockers.append(blocker_msg)
# Update summary to reflect CI status
ci_pending_warning = (
f"\n\n**⏳ CI Status:** {pending_checks} check(s) still running. "
"Wait for CI to complete."
)
if ci_pending_warning not in result.summary:
result.summary += ci_pending_warning
# Save result
await result.save(self.github_dir)
@@ -0,0 +1,163 @@
"""
Markdown Parsing Utilities for PR Reviews
==========================================
Shared utilities for parsing markdown output from AI reviewers.
"""
from __future__ import annotations
import hashlib
import logging
import re
try:
from ..models import PRReviewFinding, ReviewCategory, ReviewSeverity
from .category_utils import map_category
except (ImportError, ValueError, SystemError):
from models import PRReviewFinding, ReviewCategory, ReviewSeverity
from services.category_utils import map_category
logger = logging.getLogger(__name__)
def parse_markdown_findings(
output: str, context_name: str = "Review"
) -> list[PRReviewFinding]:
"""Parse findings from markdown output when JSON extraction fails.
This handles cases where the AI outputs findings in markdown format
after structured output validation fails. Extracts findings from patterns like:
- **CRITICAL - Code Duplication**
- ### Critical Issues (Must Fix)
- 1. **HIGH - Race condition in auth.ts:45**
Args:
output: Markdown text output to parse
context_name: Name of the context (e.g., "ParallelOrchestrator", "ParallelFollowup")
Returns:
List of PRReviewFinding instances extracted from markdown
"""
findings: list[PRReviewFinding] = []
# Normalize line endings
output = output.replace("\r\n", "\n")
# Pattern: **SEVERITY - Title** or **SEVERITY: Title** or numbered lists
numbered_pattern = r"(?:\d+\.\s*)?\*\*(\w+)\s*[-:]\s*([^*]+)\*\*"
# Track positions to avoid duplicates
found_titles: set[str] = set()
# Find all severity-title matches
for match in re.finditer(numbered_pattern, output, re.IGNORECASE):
severity_str = match.group(1).strip().upper()
title = match.group(2).strip()
# Skip if already found this title (avoid duplicates)
title_key = title.lower()[:50]
if title_key in found_titles:
continue
found_titles.add(title_key)
# Map severity string to enum
severity_map = {
"CRITICAL": ReviewSeverity.CRITICAL,
"HIGH": ReviewSeverity.HIGH,
"MEDIUM": ReviewSeverity.MEDIUM,
"MED": ReviewSeverity.MEDIUM,
"LOW": ReviewSeverity.LOW,
"SUGGESTION": ReviewSeverity.LOW,
}
severity = severity_map.get(severity_str, ReviewSeverity.MEDIUM)
# Try to extract file and line from title or nearby text
file_line_match = re.search(
r"[`(]?([a-zA-Z0-9_./\-]+\.(?:ts|tsx|js|jsx|py|go|rs|java|rb|c|cpp|h|hpp|swift|kt|scala|php|vue|svelte)):(\d+)[`)]?",
title + output[match.end() : match.end() + 200],
)
file_path = "unknown"
line_num = 0
if file_line_match:
file_path = file_line_match.group(1)
line_num = int(file_line_match.group(2))
# Extract description - text following the title until next heading or finding
desc_start = match.end()
desc_end_patterns = [
r"\n\s*\*\*\w+\s*[-:]", # Next finding
r"\n\s*#{1,3}\s", # Next heading
r"\n\s*\d+\.\s*\*\*", # Next numbered item
r"\n\s*---", # Horizontal rule
]
desc_end = len(output)
for pattern in desc_end_patterns:
end_match = re.search(pattern, output[desc_start:])
if end_match:
desc_end = min(desc_end, desc_start + end_match.start())
description = output[desc_start:desc_end].strip()
description = re.sub(r"^\s*[-*]\s*", "", description, flags=re.MULTILINE)
description = description[:500] # Limit length
# Infer category from title/description
category_keywords = {
"security": [
"security",
"injection",
"xss",
"auth",
"credential",
"secret",
],
"redundancy": [
"duplication",
"redundant",
"duplicate",
"similar",
"copy",
],
"performance": ["performance", "slow", "optimize", "memory", "leak"],
"logic": ["logic", "race", "condition", "edge case", "bug", "error"],
"quality": ["quality", "maintainability", "readability", "complexity"],
"test": ["test", "coverage", "assertion"],
"docs": ["doc", "comment", "readme"],
"regression": ["regression", "broke", "revert"],
"incomplete_fix": ["incomplete", "partial", "still"],
}
category = ReviewCategory.QUALITY # Default
title_desc_lower = (title + " " + description).lower()
for cat, keywords in category_keywords.items():
if any(kw in title_desc_lower for kw in keywords):
category = map_category(cat)
break
# Generate finding ID
finding_id = hashlib.md5(
f"{file_path}:{line_num}:{title[:50]}".encode(),
usedforsecurity=False,
).hexdigest()[:12]
finding = PRReviewFinding(
id=finding_id,
file=file_path,
line=line_num,
title=title[:80],
description=description if description else title,
category=category,
severity=severity,
suggested_fix="",
evidence=None,
)
findings.append(finding)
if findings:
logger.info(
f"[{context_name}] Extracted {len(findings)} findings from markdown output"
)
return findings
@@ -46,6 +46,7 @@ try:
from .agent_utils import create_working_dir_injector
from .category_utils import map_category
from .io_utils import safe_print
from .markdown_utils import parse_markdown_findings
from .pr_worktree_manager import PRWorktreeManager
from .pydantic_models import ParallelFollowupResponse
from .sdk_utils import process_sdk_stream
@@ -66,6 +67,7 @@ except (ImportError, ValueError, SystemError):
from services.agent_utils import create_working_dir_injector
from services.category_utils import map_category
from services.io_utils import safe_print
from services.markdown_utils import parse_markdown_findings
from services.pr_worktree_manager import PRWorktreeManager
from services.pydantic_models import ParallelFollowupResponse
from services.sdk_utils import process_sdk_stream
@@ -565,13 +567,27 @@ The SDK will run invoked agents in parallel automatically.
)
# Check for stream processing errors
if stream_result.get("error"):
logger.error(
f"[ParallelFollowup] SDK stream failed: {stream_result['error']}"
)
raise RuntimeError(
f"SDK stream processing failed: {stream_result['error']}"
)
stream_error = stream_result.get("error")
if stream_error:
# Don't raise for structured_output_validation_failed - use text fallback instead
if stream_error == "structured_output_validation_failed":
logger.warning(
"[ParallelFollowup] Structured output validation failed after retries. "
"Will use text fallback to extract findings."
)
safe_print(
"[ParallelFollowup] Structured output failed - using text fallback",
flush=True,
)
# Clear structured_output to ensure fallback is used
stream_result["structured_output"] = None
else:
logger.error(
f"[ParallelFollowup] SDK stream failed: {stream_error}"
)
raise RuntimeError(
f"SDK stream processing failed: {stream_error}"
)
result_text = stream_result["result_text"]
structured_output = stream_result["structured_output"]
@@ -1047,12 +1063,35 @@ The SDK will run invoked agents in parallel automatically.
return self._create_empty_result()
def _parse_markdown_output(self, output: str) -> list[PRReviewFinding]:
"""Parse findings from markdown output when JSON extraction fails.
Delegates to shared markdown parsing utility.
Args:
output: Markdown text output to parse
Returns:
List of PRReviewFinding instances extracted from markdown
"""
return parse_markdown_findings(output, context_name="ParallelFollowup")
def _parse_text_output(self, text: str, context: FollowupReviewContext) -> dict:
"""Parse text output when structured output fails."""
"""Parse text output when structured output fails.
Attempts markdown parsing to extract findings when the AI outputs
findings in markdown format after structured output validation fails.
"""
logger.warning("[ParallelFollowup] Falling back to text parsing")
# Simple heuristic parsing
findings = []
# Try markdown parsing first to extract findings
findings = self._parse_markdown_output(text)
new_finding_ids = [f.id for f in findings]
if findings:
logger.info(
f"[ParallelFollowup] Extracted {len(findings)} findings via markdown fallback"
)
# Look for verdict keywords
text_lower = text.lower()
@@ -1063,13 +1102,18 @@ The SDK will run invoked agents in parallel automatically.
elif "needs revision" in text_lower or "request changes" in text_lower:
verdict = MergeVerdict.NEEDS_REVISION
else:
verdict = MergeVerdict.MERGE_WITH_CHANGES
# If we found findings, default to NEEDS_REVISION
verdict = (
MergeVerdict.NEEDS_REVISION
if findings
else MergeVerdict.MERGE_WITH_CHANGES
)
return {
"findings": findings,
"resolved_ids": [],
"unresolved_ids": [],
"new_finding_ids": [],
"new_finding_ids": new_finding_ids,
"verdict": verdict,
"verdict_reasoning": text[:500] if text else "Unable to parse response",
}
@@ -48,6 +48,7 @@ try:
from .agent_utils import create_working_dir_injector
from .category_utils import map_category
from .io_utils import safe_print
from .markdown_utils import parse_markdown_findings
from .pr_worktree_manager import PRWorktreeManager
from .pydantic_models import (
AgentAgreement,
@@ -73,6 +74,7 @@ except (ImportError, ValueError, SystemError):
from services.agent_utils import create_working_dir_injector
from services.category_utils import map_category
from services.io_utils import safe_print
from services.markdown_utils import parse_markdown_findings
from services.pr_worktree_manager import PRWorktreeManager
from services.pydantic_models import (
AgentAgreement,
@@ -524,14 +526,27 @@ Report findings with specific file paths, line numbers, and code evidence.
error = stream_result.get("error")
if error:
logger.error(
f"[Specialist:{config.name}] SDK stream failed: {error}"
)
safe_print(
f"[Specialist:{config.name}] Analysis failed: {error}",
flush=True,
)
return (config.name, [])
# Don't bail out for structured_output_validation_failed - use text fallback
if error == "structured_output_validation_failed":
logger.warning(
f"[Specialist:{config.name}] Structured output validation failed. "
"Using text fallback."
)
safe_print(
f"[Specialist:{config.name}] Structured output failed - using text fallback",
flush=True,
)
# Clear structured_output to ensure fallback is used
stream_result["structured_output"] = None
else:
logger.error(
f"[Specialist:{config.name}] SDK stream failed: {error}"
)
safe_print(
f"[Specialist:{config.name}] Analysis failed: {error}",
flush=True,
)
return (config.name, [])
# Parse structured output
structured_output = stream_result.get("structured_output")
@@ -1385,6 +1400,19 @@ The SDK will run invoked agents in parallel automatically.
return None
def _parse_markdown_output(self, output: str) -> list[PRReviewFinding]:
"""Parse findings from markdown output when JSON extraction fails.
Delegates to shared markdown parsing utility.
Args:
output: Markdown text output to parse
Returns:
List of PRReviewFinding instances extracted from markdown
"""
return parse_markdown_findings(output, context_name="ParallelOrchestrator")
def _create_finding_from_dict(self, f_data: dict[str, Any]) -> PRReviewFinding:
"""Create a PRReviewFinding from dictionary data.
@@ -1419,23 +1447,39 @@ The SDK will run invoked agents in parallel automatically.
)
def _parse_text_output(self, output: str) -> list[PRReviewFinding]:
"""Parse findings from text output (fallback)."""
findings = []
"""Parse findings from text output (fallback).
Attempts JSON extraction first, then falls back to markdown parsing
when structured output validation fails and AI outputs findings in
markdown format.
"""
findings: list[PRReviewFinding] = []
try:
# Extract JSON from text
# First, try to extract JSON from text
data = self._extract_json_from_text(output)
if not data:
if data:
# Get findings array from JSON
findings_data = data.get("findings", [])
# Convert each finding dict to PRReviewFinding
for f_data in findings_data:
finding = self._create_finding_from_dict(f_data)
findings.append(finding)
if findings:
return findings
# Fallback: Try markdown parsing when JSON extraction fails
# This handles the case where structured output validation failed
# and Claude output findings in markdown format instead
findings = self._parse_markdown_output(output)
if findings:
logger.info(
f"[ParallelOrchestrator] Extracted {len(findings)} findings via markdown fallback"
)
return findings
# Get findings array from JSON
findings_data = data.get("findings", [])
# Convert each finding dict to PRReviewFinding
for f_data in findings_data:
finding = self._create_finding_from_dict(f_data)
findings.append(finding)
except Exception as e:
logger.error(f"[ParallelOrchestrator] Text parsing failed: {e}")
@@ -1749,25 +1793,34 @@ For EACH finding above:
error = stream_result.get("error")
if error:
# Check for specific error types that warrant retry
error_str = str(error).lower()
is_retryable = (
"400" in error_str
or "concurrency" in error_str
or "circuit breaker" in error_str
or "tool_use" in error_str
)
if is_retryable and attempt < MAX_VALIDATION_RETRIES:
# Handle structured output validation failure - use text fallback
if error == "structured_output_validation_failed":
logger.warning(
f"[PRReview] Retryable validation error: {error}"
"[PRReview:FindingValidator] Structured output validation failed. "
"Will use text fallback to parse validation results."
)
# Clear structured_output to trigger text parsing below
stream_result["structured_output"] = None
else:
# Check for specific error types that warrant retry
error_str = str(error).lower()
is_retryable = (
"400" in error_str
or "concurrency" in error_str
or "circuit breaker" in error_str
or "tool_use" in error_str
)
last_error = Exception(error)
continue # Retry
logger.error(f"[PRReview] Validation failed: {error}")
# Fail-safe: return original findings
return findings
if is_retryable and attempt < MAX_VALIDATION_RETRIES:
logger.warning(
f"[PRReview] Retryable validation error: {error}"
)
last_error = Exception(error)
continue # Retry
logger.error(f"[PRReview] Validation failed: {error}")
# Fail-safe: return original findings
return findings
structured_output = stream_result.get("structured_output")
@@ -26,7 +26,87 @@ from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
# =============================================================================
# Enum Normalization Helpers
# =============================================================================
# These help the AI produce valid output by mapping common synonyms/typos to
# the exact values required by the schema. This reduces structured output
# validation failures where the AI uses natural language like "duplication"
# when the schema requires "redundancy".
def normalize_category(value: str) -> str:
"""Normalize category value to match schema requirements.
Maps common synonyms and typos to valid category values.
"""
if not isinstance(value, str):
return value
# Map synonyms to valid category values
synonyms = {
# Redundancy synonyms
"duplication": "redundancy",
"code duplication": "redundancy",
"duplicate": "redundancy",
"duplicated": "redundancy",
"copy": "redundancy",
"copied": "redundancy",
# Performance synonyms
"perf": "performance",
"slow": "performance",
"optimization": "performance",
# Quality synonyms
"code quality": "quality",
"maintainability": "quality",
# Logic synonyms
"correctness": "logic",
"bug": "logic",
# Test synonyms
"testing": "test",
"tests": "test",
# Docs synonyms
"documentation": "docs",
"doc": "docs",
# Security synonyms
"sec": "security",
"vulnerability": "security",
# Style (map to quality or pattern)
"style": "quality",
"formatting": "quality",
}
normalized = value.lower().strip()
return synonyms.get(normalized, normalized)
def normalize_verdict(value: str) -> str:
"""Normalize verdict value to match schema requirements.
Handles common format variations like spaces, hyphens, and case sensitivity.
"""
if not isinstance(value, str):
return value
# Normalize: uppercase, replace spaces/hyphens with underscores
normalized = value.upper().strip().replace(" ", "_").replace("-", "_")
# Map common variations
synonyms = {
"READY": "READY_TO_MERGE",
"MERGE": "READY_TO_MERGE",
"APPROVED": "APPROVE",
"NEEDS_CHANGES": "NEEDS_REVISION",
"REQUEST_CHANGES": "NEEDS_REVISION",
"CHANGES_REQUESTED": "NEEDS_REVISION",
"BLOCK": "BLOCKED",
"REJECT": "BLOCKED",
}
return synonyms.get(normalized, normalized)
# =============================================================================
# Verification Evidence (Required for All Findings)
@@ -112,11 +192,22 @@ class QualityFinding(BaseFinding):
category: Literal[
"redundancy", "quality", "test", "performance", "pattern", "docs"
] = Field(description="Issue category")
] = Field(
description=(
"Issue category. MUST be exactly one of: redundancy, quality, test, "
"performance, pattern, docs. Use 'redundancy' for code duplication "
"(NOT 'duplication')."
)
)
redundant_with: str | None = Field(
None, description="Reference to duplicate code (file:line) if redundant"
)
@field_validator("category", mode="before")
@classmethod
def _normalize_category(cls, v: str) -> str:
return normalize_category(v)
class DeepAnalysisFinding(BaseFinding):
"""A finding from deep analysis with verification info."""
@@ -128,11 +219,20 @@ class DeepAnalysisFinding(BaseFinding):
"pattern",
"performance",
"logic",
] = Field(description="Issue category")
] = Field(
description=(
"Issue category. Use 'redundancy' for code duplication (NOT 'duplication')."
)
)
verification_note: str | None = Field(
None, description="What evidence is missing or couldn't be verified"
)
@field_validator("category", mode="before")
@classmethod
def _normalize_category(cls, v: str) -> str:
return normalize_category(v)
class StructuralIssue(BaseModel):
"""A structural issue with the PR."""
@@ -194,7 +294,10 @@ class FollowupFinding(BaseModel):
description="Issue severity level"
)
category: Literal["security", "quality", "logic", "test", "docs"] = Field(
description="Issue category"
description=(
"Issue category. MUST be exactly one of: security, quality, logic, "
"test, docs."
)
)
title: str = Field(description="Brief issue title")
description: str = Field(description="Detailed explanation of the issue")
@@ -206,6 +309,11 @@ class FollowupFinding(BaseModel):
description="Evidence that this finding was verified against actual code"
)
@field_validator("category", mode="before")
@classmethod
def _normalize_category(cls, v: str) -> str:
return normalize_category(v)
class FollowupReviewResponse(BaseModel):
"""Complete response schema for follow-up PR review."""
@@ -222,9 +330,19 @@ class FollowupReviewResponse(BaseModel):
)
verdict: Literal[
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
] = Field(description="Overall merge verdict")
] = Field(
description=(
"Overall merge verdict. MUST be exactly one of: READY_TO_MERGE, "
"MERGE_WITH_CHANGES, NEEDS_REVISION, BLOCKED. Use underscores, not spaces."
)
)
verdict_reasoning: str = Field(description="Explanation for the verdict")
@field_validator("verdict", mode="before")
@classmethod
def _normalize_verdict(cls, v: str) -> str:
return normalize_verdict(v)
# =============================================================================
# Initial Review Responses (Multi-Pass)
@@ -289,9 +407,19 @@ class StructuralPassResult(BaseModel):
)
verdict: Literal[
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
] = Field(description="Structural verdict")
] = Field(
description=(
"Structural verdict. MUST be exactly one of: READY_TO_MERGE, "
"MERGE_WITH_CHANGES, NEEDS_REVISION, BLOCKED."
)
)
verdict_reasoning: str = Field(description="Explanation for the verdict")
@field_validator("verdict", mode="before")
@classmethod
def _normalize_verdict(cls, v: str) -> str:
return normalize_verdict(v)
class AICommentTriageResult(BaseModel):
"""Result from AI comment triage pass."""
@@ -366,7 +494,11 @@ class OrchestratorFinding(BaseModel):
"performance",
"logic",
"test",
] = Field(description="Issue category")
] = Field(
description=(
"Issue category. Use 'redundancy' for code duplication (NOT 'duplication')."
)
)
severity: Literal["critical", "high", "medium", "low"] = Field(
description="Issue severity level"
)
@@ -379,19 +511,34 @@ class OrchestratorFinding(BaseModel):
description="Evidence that this finding was verified against actual code"
)
@field_validator("category", mode="before")
@classmethod
def _normalize_category(cls, v: str) -> str:
return normalize_category(v)
class OrchestratorReviewResponse(BaseModel):
"""Complete response schema for orchestrator PR review."""
verdict: Literal[
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
] = Field(description="Overall merge verdict")
] = Field(
description=(
"Overall merge verdict. MUST be exactly one of: READY_TO_MERGE, "
"MERGE_WITH_CHANGES, NEEDS_REVISION, BLOCKED."
)
)
verdict_reasoning: str = Field(description="Explanation for the verdict")
findings: list[OrchestratorFinding] = Field(
default_factory=list, description="Issues found during review"
)
summary: str = Field(description="Brief summary of the review")
@field_validator("verdict", mode="before")
@classmethod
def _normalize_verdict(cls, v: str) -> str:
return normalize_verdict(v)
# =============================================================================
# Parallel Orchestrator Review Response (SDK Subagents)
@@ -446,7 +593,13 @@ class ParallelOrchestratorFinding(BaseModel):
"redundancy",
"pattern",
"performance",
] = Field(description="Issue category")
] = Field(
description=(
"Issue category. MUST be exactly one of: security, quality, logic, "
"codebase_fit, test, docs, redundancy, pattern, performance. "
"Use 'redundancy' for code duplication (NOT 'duplication')."
)
)
severity: Literal["critical", "high", "medium", "low"] = Field(
description="Issue severity level"
)
@@ -482,6 +635,11 @@ class ParallelOrchestratorFinding(BaseModel):
False, description="Whether multiple agents agreed on this finding"
)
@field_validator("category", mode="before")
@classmethod
def _normalize_category(cls, v: str) -> str:
return normalize_category(v)
class AgentAgreement(BaseModel):
"""Tracks agreement between agents on findings."""
@@ -545,7 +703,12 @@ class SpecialistFinding(BaseModel):
)
category: Literal[
"security", "quality", "logic", "performance", "pattern", "test", "docs"
] = Field(description="Issue category")
] = Field(
description=(
"Issue category. MUST be exactly one of: security, quality, logic, "
"performance, pattern, test, docs."
)
)
title: str = Field(description="Brief issue title (max 80 chars)")
description: str = Field(description="Detailed explanation of the issue")
file: str = Field(description="File path where issue was found")
@@ -561,6 +724,11 @@ class SpecialistFinding(BaseModel):
description="True if this is about affected code outside the PR (callers, dependencies)",
)
@field_validator("category", mode="before")
@classmethod
def _normalize_category(cls, v: str) -> str:
return normalize_category(v)
class SpecialistResponse(BaseModel):
"""Response schema for individual specialist agent (parallel SDK sessions).
@@ -612,10 +780,18 @@ class ParallelOrchestratorResponse(BaseModel):
description="Information about agent agreement on findings",
)
verdict: Literal["APPROVE", "COMMENT", "NEEDS_REVISION", "BLOCKED"] = Field(
description="Overall PR verdict"
description=(
"Overall PR verdict. MUST be exactly one of: APPROVE, COMMENT, "
"NEEDS_REVISION, BLOCKED. Use underscores, not spaces."
)
)
verdict_reasoning: str = Field(description="Explanation for the verdict")
@field_validator("verdict", mode="before")
@classmethod
def _normalize_verdict(cls, v: str) -> str:
return normalize_verdict(v)
# =============================================================================
# Parallel Follow-up Review Response (SDK Subagents for Follow-up)
@@ -655,7 +831,12 @@ class ParallelFollowupFinding(BaseModel):
"docs",
"regression",
"incomplete_fix",
] = Field(description="Issue category")
] = Field(
description=(
"Issue category. MUST be exactly one of: security, quality, logic, "
"test, docs, regression, incomplete_fix."
)
)
severity: Literal["critical", "high", "medium", "low"] = Field(
description="Issue severity level"
)
@@ -683,6 +864,11 @@ class ParallelFollowupFinding(BaseModel):
),
)
@field_validator("category", mode="before")
@classmethod
def _normalize_category(cls, v: str) -> str:
return normalize_category(v)
class CommentAnalysis(BaseModel):
"""Analysis of a contributor or AI comment."""
@@ -754,9 +940,19 @@ class ParallelFollowupResponse(BaseModel):
# Verdict
verdict: Literal[
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
] = Field(description="Overall merge verdict")
] = Field(
description=(
"Overall merge verdict. MUST be exactly one of: READY_TO_MERGE, "
"MERGE_WITH_CHANGES, NEEDS_REVISION, BLOCKED. Use underscores, not spaces."
)
)
verdict_reasoning: str = Field(description="Explanation for the verdict")
@field_validator("verdict", mode="before")
@classmethod
def _normalize_verdict(cls, v: str) -> str:
return normalize_verdict(v)
# =============================================================================
# Finding Validation Response (Re-investigation of unresolved findings)
@@ -460,12 +460,17 @@ async def process_sdk_stream(
)
if subtype == "error_max_structured_output_retries":
# SDK failed to produce valid structured output after retries
# Log ALL available fields for debugging - helps diagnose enum mismatches
result_detail = getattr(msg, "result", None)
is_error = getattr(msg, "is_error", False)
logger.warning(
f"[{context_name}] Claude could not produce valid structured output "
f"after maximum retries - schema validation failed"
f"after maximum retries - schema validation failed. "
f"is_error={is_error}, result_preview={str(result_detail)[:500] if result_detail else 'None'}"
)
safe_print(
f"[{context_name}] WARNING: Structured output validation failed after retries"
f"[{context_name}] WARNING: Structured output validation failed after retries. "
f"Result preview: {str(result_detail)[:300] if result_detail else 'None'}"
)
if not stream_error:
stream_error = "structured_output_validation_failed"
-4
View File
@@ -1,10 +1,6 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
import { config as dotenvConfig } from 'dotenv';
// Load .env file for build-time constants (Sentry DSN, etc.)
dotenvConfig({ path: resolve(__dirname, '.env') });
/**
* Sentry configuration embedded at build time.
-2
View File
@@ -96,8 +96,6 @@
"react-i18next": "^16.5.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.2.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"semver": "^7.7.3",
"tailwind-merge": "^3.4.0",
@@ -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, } from '../../shared/types';
import type { ClaudeProfile, IPCResult, TerminalCreateOptions } from '../../shared/types';
// Test directories - use secure temp directory with random suffix
let TEST_DIR: string;
@@ -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, ca
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() },
@@ -61,17 +61,15 @@ import path from 'path';
import { isValidConfigDir } from '../utils/config-path-validator';
describe('isValidConfigDir - Security Validation', () => {
let _originalHomedir: string;
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
let originalHomedir: string;
let consoleWarnSpy: any;
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(() => {
/* intentionally empty - suppress console output during tests */
});
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
@@ -93,7 +93,7 @@ describe('PythonEnvManager', () => {
const sitePackagesPath = 'C:\\test\\site-packages';
// Access private property for testing
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
(manager as any).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 unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
(manager as any).sitePackagesPath = sitePackagesPath;
const env = manager.getPythonEnv();
@@ -125,7 +125,7 @@ describe('PythonEnvManager', () => {
const sitePackagesPath = '/test/site-packages';
// Access private property for testing
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
(manager as any).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 unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
(manager as any).sitePackagesPath = sitePackagesPath;
// Save and clear existing PATH, then set lowercase 'Path'
// This simulates a Windows environment where the system has 'Path' instead of 'PATH'
@@ -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;
@@ -243,31 +243,24 @@ 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<typeof vi.fn>;
const existsSyncMock = existsSync as unknown as MockFn;
const readFileSyncMock = readFileSync as unknown as MockFn;
// Save original mock implementations to restore after test
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) ?? (() => '');
const originalExistsSync = (existsSync as any).getMockImplementation();
const originalReadFileSync = (readFileSync as any).getMockImplementation();
// Override existsSync to make file appear to exist
existsSyncMock.mockImplementation((path: string) => {
(existsSync as any).mockImplementation((path: string) => {
if (path === claudeJsonPath) {
return true; // File appears to exist
}
return originalExistsSync(path);
return originalExistsSync ? originalExistsSync(path) : false;
});
// Override readFileSync to throw error for our specific file
readFileSyncMock.mockImplementation((path: string) => {
(readFileSync as any).mockImplementation((path: string) => {
if (path === claudeJsonPath) {
throw new Error('EACCES: permission denied, open \'' + path + '\'');
}
return originalReadFileSync(path);
return originalReadFileSync ? originalReadFileSync(path) : '';
});
const result = await onboardingStatusHandler({}, null) as {
@@ -279,8 +272,8 @@ describe('SETTINGS_CLAUDE_CODE_GET_ONBOARDING_STATUS handler', () => {
expect(result.data?.hasCompletedOnboarding).toBe(false);
// Restore original mocks
existsSyncMock.mockImplementation(originalExistsSync);
readFileSyncMock.mockImplementation(originalReadFileSync);
(existsSync as any).mockImplementation(originalExistsSync);
(readFileSync as any).mockImplementation(originalReadFileSync);
});
});
});
+5 -15
View File
@@ -178,9 +178,7 @@ describe("safeSendToRenderer", () => {
describe("error handling - non-disposal errors", () => {
it("catches other errors and returns false", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
/* intentionally empty - suppress console output during tests */
});
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockSend.mockImplementation(() => {
throw new Error("Some other IPC error");
@@ -218,9 +216,7 @@ describe("safeSendToRenderer", () => {
});
it("logs console.warn only once for multiple consecutive calls to same channel", () => {
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
/* intentionally empty - suppress console output during tests */
});
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
mockWindow = {
isDestroyed: vi.fn(() => true),
@@ -246,9 +242,7 @@ describe("safeSendToRenderer", () => {
});
it("logs console.warn separately for different channels", () => {
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
/* intentionally empty - suppress console output during tests */
});
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
mockWindow = {
isDestroyed: vi.fn(() => true),
@@ -313,9 +307,7 @@ 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(() => {
/* intentionally empty - suppress console output during tests */
});
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
mockWindow = {
isDestroyed: vi.fn(() => true),
@@ -348,9 +340,7 @@ describe("safeSendToRenderer", () => {
});
it("handles many unique channels without throwing errors", async () => {
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
/* intentionally empty - suppress console output during tests */
});
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
mockWindow = {
isDestroyed: vi.fn(() => true),
+4 -35
View File
@@ -3,7 +3,6 @@ import { parsePhaseEvent } from './phase-event-parser';
import {
wouldPhaseRegress,
isTerminalPhase,
isPausePhase,
isValidExecutionPhase,
type ExecutionPhase
} from '../../shared/constants/phase-protocol';
@@ -14,48 +13,18 @@ export class AgentEvents {
log: string,
currentPhase: ExecutionProgressData['phase'],
isSpecRunner: boolean
): {
phase: ExecutionProgressData['phase'];
message?: string;
currentSubtask?: string;
resetTimestamp?: number;
profileId?: string;
} | null {
): { phase: ExecutionProgressData['phase']; message?: string; currentSubtask?: string } | null {
const structuredEvent = parsePhaseEvent(log);
if (structuredEvent) {
// 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,
return {
phase: structuredEvent.phase as ExecutionProgressData['phase'],
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)) {
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)) {
if (isTerminalPhase(currentPhase as ExecutionPhase)) {
return null;
}
+4 -75
View File
@@ -6,8 +6,6 @@ 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,
@@ -35,8 +33,6 @@ export class AgentManager extends EventEmitter {
baseBranch?: string;
swapCount: number;
projectId?: string;
/** Generation counter to prevent stale cleanup after restart */
generation: number;
}> = new Map();
constructor() {
@@ -61,35 +57,21 @@ 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
@@ -103,46 +85,6 @@ 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<string, unknown>
): 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
*/
@@ -157,7 +99,7 @@ export class AgentManager extends EventEmitter {
): Promise<void> {
// Pre-flight auth check: Verify active profile has valid authentication
// Ensure profile manager is initialized to prevent race condition
let profileManager: ClaudeProfileManager;
let profileManager;
try {
profileManager = await initializeClaudeProfileManager();
} catch (error) {
@@ -235,9 +177,6 @@ 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);
}
@@ -254,7 +193,7 @@ export class AgentManager extends EventEmitter {
): Promise<void> {
// Pre-flight auth check: Verify active profile has valid authentication
// Ensure profile manager is initialized to prevent race condition
let profileManager: ClaudeProfileManager;
let profileManager;
try {
profileManager = await initializeClaudeProfileManager();
} catch (error) {
@@ -317,9 +256,6 @@ 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);
}
@@ -461,8 +397,6 @@ 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,
@@ -474,8 +408,7 @@ export class AgentManager extends EventEmitter {
metadata,
baseBranch,
swapCount, // Preserve existing count instead of resetting
projectId,
generation, // Incremented to prevent stale exit cleanup
projectId
});
}
@@ -531,14 +464,10 @@ 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,
@@ -13,7 +13,7 @@ function createMockProcess() {
return {
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn((event: string, callback: (code: number) => void) => {
on: vi.fn((event: string, callback: any) => {
if (event === 'exit') {
// Simulate immediate exit with code 0
setTimeout(() => callback(0), 10);
+6 -75
View File
@@ -281,11 +281,7 @@ export class AgentProcessManager {
} : 'NONE');
if (!bestProfile) {
// 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")
console.log('[AgentProcess] No alternative profile available - falling back to manual modal');
return false;
}
@@ -310,84 +306,19 @@ export class AgentProcessManager {
console.log('[AgentProcess] No rate limit detected - checking for auth failure');
const authFailureDetection = detectAuthFailure(allOutput);
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
if (authFailureDetection.isAuthFailure) {
console.log('[AgentProcess] Auth failure detected:', authFailureDetection);
this.emitter.emit('auth-failure', taskId, {
profileId: authFailureDetection.profileId,
failureType: authFailureDetection.failureType,
message: authFailureDetection.message,
originalError: authFailureDetection.originalError
});
return true;
}
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<typeof detectAuthFailure>
): 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;
console.log('[AgentProcess] Process failed but no rate limit or auth failure detected');
return false;
}
/**
@@ -24,24 +24,9 @@ describe('AgentState - Queue Routing', () => {
it('should group tasks by profile', () => {
// Add mock processes
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
});
state.addProcess('task-1', { pid: 1001 } as any);
state.addProcess('task-2', { pid: 1002 } as any);
state.addProcess('task-3', { pid: 1003 } as any);
// Assign profiles
state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive');
@@ -57,12 +42,7 @@ describe('AgentState - Queue Routing', () => {
it('should use default profile for unassigned tasks', () => {
// Add process without profile assignment
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-1', { pid: 1001 } as any);
const result = state.getRunningTasksByProfile();
@@ -14,9 +14,6 @@ export interface PhaseParseResult<TPhase extends string = string> {
message?: string;
currentSubtask?: string;
progress?: number;
// Pause phase metadata
resetTimestamp?: number; // Unix timestamp for rate limit reset
profileId?: string; // Profile that hit the limit
}
/**
@@ -9,7 +9,6 @@ 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';
@@ -41,21 +40,11 @@ export class ExecutionPhaseParser extends BasePhaseParser<ExecutionPhase> {
// 1. Try structured event first (authoritative source)
const structuredEvent = parsePhaseEvent(log);
if (structuredEvent) {
const result: PhaseParseResult<ExecutionPhase> = {
return {
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
@@ -63,13 +52,7 @@ export class ExecutionPhaseParser extends BasePhaseParser<ExecutionPhase> {
return null;
}
// 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
// 3. Fall back to text pattern matching
return this.parseFallbackPatterns(log, context);
}
@@ -7,10 +7,7 @@ export const PhaseEventSchema = z.object({
phase: BackendPhaseSchema,
message: z.string().default(''),
progress: z.number().int().min(0).max(100).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
subtask: z.string().optional()
});
export type PhaseEventPayload = z.infer<typeof PhaseEventSchema>;
+3 -2
View File
@@ -1,5 +1,6 @@
import { ChildProcess } from 'child_process';
import type { CompletablePhase, ExecutionPhase } from '../../shared/constants/phase-protocol';
import type { IdeationConfig } from '../../shared/types';
import type { CompletablePhase } from '../../shared/constants/phase-protocol';
import type { TaskEventPayload } from './task-event-schema';
/**
@@ -18,7 +19,7 @@ export interface AgentProcess {
}
export interface ExecutionProgressData {
phase: ExecutionPhase;
phase: 'idle' | 'planning' | 'coding' | 'qa_review' | 'qa_fixing' | 'complete' | 'failed';
phaseProgress: number;
overallProgress: number;
currentSubtask?: string;
+12 -58
View File
@@ -17,8 +17,6 @@
* - APP_UPDATE_ERROR: Error during update process
*/
import { accessSync, constants as fsConstants } from 'fs';
import path from 'path';
import { autoUpdater } from 'electron-updater';
import type { UpdateInfo } from 'electron-updater';
import { app, net } from 'electron';
@@ -26,7 +24,6 @@ import type { BrowserWindow } from 'electron';
import { IPC_CHANNELS } from '../shared/constants';
import type { AppUpdateInfo } from '../shared/types';
import { compareVersions } from './updater/version-manager';
import { isMacOS } from './platform';
// GitHub repo info for API calls
const GITHUB_OWNER = 'AndyMik90';
@@ -94,13 +91,10 @@ function formatReleaseNotes(releaseNotes: UpdateInfo['releaseNotes']): string |
*/
export function setUpdateChannel(channel: UpdateChannel): void {
autoUpdater.channel = channel;
// Enable pre-release scanning when beta channel is selected
// This allows electron-updater to find beta releases on GitHub
autoUpdater.allowPrerelease = channel === 'beta';
// Clear any downloaded update info when channel changes to prevent showing
// an Install button for an update from a different channel
downloadedUpdateInfo = null;
console.warn(`[app-updater] Update channel set to: ${channel}, allowPrerelease: ${autoUpdater.allowPrerelease}`);
console.warn(`[app-updater] Update channel set to: ${channel}`);
}
// Enable more verbose logging in debug mode
@@ -193,7 +187,8 @@ export function initializeAppUpdater(window: BrowserWindow, betaUpdates = false)
console.error('[app-updater] Update error:', error);
if (mainWindow) {
mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_ERROR, {
message: error.message
message: error.message,
stack: error.stack
});
}
});
@@ -298,57 +293,13 @@ export async function downloadUpdate(): Promise<void> {
}
}
/**
* Check if the app is running from a read-only volume (e.g., DMG on macOS)
* Returns true if the app cannot be updated in place
*/
function isRunningFromReadOnlyVolume(): boolean {
if (!isMacOS()) {
return false;
}
const appPath = app.getAppPath();
// Check if the filesystem is read-only by testing write access.
// We don't use a /Volumes/ prefix check because writable external drives
// (USB, external SSDs) are also mounted under /Volumes/ on macOS.
try {
// Navigate from app.asar to the Contents/ directory (app.asar -> Resources -> Contents)
const contentsPath = path.resolve(appPath, '..', '..');
// Try to check if we can write to the app bundle's parent directory
accessSync(path.dirname(contentsPath), fsConstants.W_OK);
return false;
} catch (error: unknown) {
// Only treat as read-only if the filesystem itself is read-only (EROFS).
// Permission errors (EACCES) in managed/enterprise environments should not
// block updates — the updater may still have elevated access.
const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined;
return code === 'EROFS';
}
}
/**
* Quit and install update
* Called from IPC handler when user confirms installation
* Returns false if running from a read-only volume (update cannot proceed)
*/
export function quitAndInstall(): boolean {
// Check if running from read-only volume before attempting install
if (isRunningFromReadOnlyVolume()) {
console.warn('[app-updater] Cannot install: running from read-only volume');
if (mainWindow) {
mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_READONLY_VOLUME, {
appPath: app.getAppPath()
});
}
return false;
}
export function quitAndInstall(): void {
console.warn('[app-updater] Quitting and installing update');
autoUpdater.quitAndInstall(false, true);
return true;
}
/**
@@ -451,7 +402,7 @@ async function fetchLatestStableRelease(): Promise<AppUpdateInfo | null> {
const version = latestStable.tag_name.replace(/^v/, '');
// Sanitize version string for logging (remove control characters and limit length)
// biome-ignore lint/suspicious/noControlCharactersInRegex: Intentionally matching control chars for sanitization
// eslint-disable-next-line no-control-regex
const safeVersion = String(version).replace(/[\x00-\x1f\x7f]/g, '').slice(0, 50);
console.warn('[app-updater] Found latest stable release:', safeVersion);
@@ -532,8 +483,11 @@ export async function setUpdateChannelWithDowngradeCheck(
channel: UpdateChannel,
triggerDowngradeCheck = false
): Promise<AppUpdateInfo | null> {
// Use the shared channel-setting function to avoid code duplication
setUpdateChannel(channel);
autoUpdater.channel = channel;
// Clear any downloaded update info when channel changes to prevent showing
// an Install button for an update from a different channel
downloadedUpdateInfo = null;
console.warn(`[app-updater] Update channel set to: ${channel}`);
// If switching to stable and downgrade check requested, look for stable version
if (channel === 'latest' && triggerDowngradeCheck) {
@@ -555,8 +509,8 @@ export async function setUpdateChannelWithDowngradeCheck(
* Uses electron-updater with allowDowngrade enabled to download older stable versions
*/
export async function downloadStableVersion(): Promise<void> {
// Switch to stable channel (resets allowPrerelease and clears downloadedUpdateInfo)
setUpdateChannel('latest');
// Switch to stable channel
autoUpdater.channel = 'latest';
// Enable downgrade to allow downloading older versions (e.g., stable when on beta)
autoUpdater.allowDowngrade = true;
console.warn('[app-updater] Downloading stable version (allowDowngrade=true)...');
@@ -44,6 +44,7 @@ export class ChangelogService extends EventEmitter {
private _pythonPath: string | null = null;
private claudePath: string;
private autoBuildSourcePath: string = '';
private cachedEnv: Record<string, string> | null = null;
private debugEnabled: boolean | null = null;
private generator: ChangelogGenerator | null = null;
private versionSuggester: VersionSuggester | null = null;
@@ -453,7 +454,7 @@ export class ChangelogService extends EventEmitter {
}
const parts = currentVersion.split('.').map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) {
if (parts.length !== 3 || parts.some(isNaN)) {
return '1.0.0';
}
@@ -490,7 +491,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 }> {
@@ -501,7 +502,7 @@ export class ChangelogService extends EventEmitter {
}
const parts = currentVersion.split('.').map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) {
if (parts.length !== 3 || parts.some(isNaN)) {
return { version: '1.0.0', reason: 'Invalid current version, resetting to 1.0.0' };
}
@@ -198,6 +198,7 @@ except Exception as e:
case 'minor':
newVersion = `${major}.${minor + 1}.0`;
break;
case 'patch':
default:
newVersion = `${major}.${minor}.${patch + 1}`;
break;
@@ -32,6 +32,7 @@ import {
clearRateLimitEvents as clearRateLimitEventsImpl
} from './claude-profile/rate-limit-manager';
import {
loadProfileStore,
loadProfileStoreAsync,
saveProfileStore,
ProfileStoreData,
@@ -53,6 +54,24 @@ import {
getEmailFromConfigDir
} from './claude-profile/profile-utils';
/**
* Debug flag - only log verbose profile operations when DEBUG=true
*/
const IS_DEBUG = process.env.DEBUG === 'true';
/**
* Debug log helper - only logs when DEBUG=true
*/
function debugLog(message: string, data?: unknown): void {
if (IS_DEBUG) {
if (data !== undefined) {
console.warn(message, data);
} else {
console.warn(message);
}
}
}
/**
* Manages Claude Code profiles for multi-account support.
* Profiles are stored in the app's userData directory.
@@ -120,7 +139,7 @@ export class ClaudeProfileManager {
const configEmail = getEmailFromConfigDir(profile.configDir);
if (configEmail && profile.email !== configEmail) {
console.warn('[ClaudeProfileManager] Migrating corrupted email for profile:', {
debugLog('[ClaudeProfileManager] Migrating corrupted email for profile:', {
profileId: profile.id,
oldEmail: profile.email,
newEmail: configEmail
@@ -132,7 +151,7 @@ export class ClaudeProfileManager {
if (needsSave) {
this.save();
console.warn('[ClaudeProfileManager] Email migration complete');
debugLog('[ClaudeProfileManager] Email migration complete');
}
}
@@ -167,7 +186,7 @@ export class ClaudeProfileManager {
if (result.subscriptionTypeUpdated) {
needsSave = true;
console.warn('[ClaudeProfileManager] Populated subscriptionType for profile:', {
debugLog('[ClaudeProfileManager] Populated subscriptionType for profile:', {
profileId: profile.id,
subscriptionType: result.subscriptionType
});
@@ -175,7 +194,7 @@ export class ClaudeProfileManager {
if (result.rateLimitTierUpdated) {
needsSave = true;
console.warn('[ClaudeProfileManager] Populated rateLimitTier for profile:', {
debugLog('[ClaudeProfileManager] Populated rateLimitTier for profile:', {
profileId: profile.id,
rateLimitTier: result.rateLimitTier
});
@@ -184,7 +203,7 @@ export class ClaudeProfileManager {
if (needsSave) {
this.save();
console.warn('[ClaudeProfileManager] Subscription metadata population complete');
debugLog('[ClaudeProfileManager] Subscription metadata population complete');
}
}
@@ -195,6 +214,31 @@ 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') {
debugLog('[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
*
@@ -303,19 +347,12 @@ export class ClaudeProfileManager {
// Fallback to default
const defaultProfile = this.data.profiles.find(p => p.isDefault);
if (defaultProfile) {
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] getActiveProfile - using default:', {
id: defaultProfile.id,
name: defaultProfile.name,
email: defaultProfile.email
});
}
return defaultProfile;
}
// If somehow no default exists, return first profile
const fallback = this.data.profiles[0];
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] getActiveProfile - using fallback:', {
debugLog('[ClaudeProfileManager] getActiveProfile - using fallback:', {
id: fallback.id,
name: fallback.name,
email: fallback.email
@@ -325,7 +362,7 @@ export class ClaudeProfileManager {
}
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] getActiveProfile:', {
debugLog('[ClaudeProfileManager] getActiveProfile:', {
id: active.id,
name: active.name,
email: active.email
@@ -416,12 +453,12 @@ export class ClaudeProfileManager {
const previousProfileId = this.data.activeProfileId;
const profile = this.getProfile(profileId);
if (!profile) {
console.warn('[ClaudeProfileManager] setActiveProfile failed - profile not found:', { profileId });
debugLog('[ClaudeProfileManager] setActiveProfile failed - profile not found:', { profileId });
return false;
}
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] setActiveProfile:', {
debugLog('[ClaudeProfileManager] setActiveProfile:', {
from: previousProfileId,
to: profileId,
profileName: profile.name
@@ -536,10 +573,10 @@ export class ClaudeProfileManager {
env.CLAUDE_CONFIG_DIR = expandedConfigDir;
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir);
debugLog('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', { profileName: profile.name, configDir: expandedConfigDir });
}
} else {
console.warn('[ClaudeProfileManager] Profile has no configDir configured:', profile?.name);
debugLog('[ClaudeProfileManager] Profile has no configDir configured:', profile?.name);
}
return env;
@@ -759,7 +796,7 @@ export class ClaudeProfileManager {
);
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] getProfileEnv:', {
debugLog('[ClaudeProfileManager] getProfileEnv:', {
profileId,
profileName: profile.name,
isDefault: profile.isDefault,
@@ -780,7 +817,7 @@ export class ClaudeProfileManager {
if (credentials.token) {
env.CLAUDE_CODE_OAUTH_TOKEN = credentials.token;
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] Retrieved OAuth token from Keychain for profile:', profile.name);
debugLog('[ClaudeProfileManager] Retrieved OAuth token from Keychain for profile:', profile.name);
}
}
} catch (error) {
@@ -836,7 +873,7 @@ export class ClaudeProfileManager {
}
this.save();
console.warn('[ClaudeProfileManager] Cleared migrated profile:', profileId);
debugLog('[ClaudeProfileManager] Cleared migrated profile:', profileId);
}
/**
@@ -1,673 +0,0 @@
/**
* 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);
});
});
});
@@ -36,6 +36,25 @@ function getTokenFingerprint(token: string | null | undefined): string {
return token.slice(0, 8) + '...' + token.slice(-4);
}
/**
* Debug flag - only log verbose credential operations when DEBUG=true
*/
const IS_DEBUG = process.env.DEBUG === 'true';
/**
* Debug log helper - only logs when DEBUG=true
* Uses console.warn to ensure visibility in Electron's main process
*/
function debugLog(message: string, ...args: unknown[]): void {
if (IS_DEBUG) {
if (args.length > 0) {
console.warn(message, ...args);
} else {
console.warn(message);
}
}
}
/**
* Escape a string for safe interpolation into PowerShell double-quoted strings.
* Escapes all PowerShell special characters to prevent injection attacks.
@@ -193,7 +212,7 @@ export function getKeychainServiceName(configDir?: string): string {
// No configDir provided - this should not happen with isolated profiles
// Fall back to unhashed name for backwards compatibility during migration
if (!configDir) {
console.warn('[CredentialUtils] getKeychainServiceName called without configDir - using legacy fallback');
debugLog('[CredentialUtils] getKeychainServiceName called without configDir - using legacy fallback');
return 'Claude Code-credentials';
}
@@ -345,7 +364,7 @@ function executeCredentialRead(
executablePath: string,
args: string[],
timeout: number,
_identifier: string
identifier: string
): string | null {
try {
const result = execFileSync(executablePath, args, {
@@ -396,13 +415,13 @@ function parseCredentialJson<T extends PlatformCredentials>(
try {
data = JSON.parse(credentialsJson);
} catch {
console.warn(`[CredentialUtils] Failed to parse credential JSON for ${identifier}`);
debugLog(`[CredentialUtils] Failed to parse credential JSON for ${identifier}`);
return extractFn({}) as T;
}
// Validate JSON structure
if (!validateCredentialData(data)) {
console.warn(`[CredentialUtils] Invalid credential data structure for ${identifier}`);
debugLog(`[CredentialUtils] Invalid credential data structure for ${identifier}`);
return extractFn({}) as T;
}
@@ -439,7 +458,7 @@ function getCredentialsFromFile(
if ((now - cached.timestamp) < ttl) {
if (isDebug) {
const cacheAge = now - cached.timestamp;
console.warn(`[CredentialUtils:${logPrefix}:CACHE] Returning cached credentials:`, {
debugLog(`[CredentialUtils:${logPrefix}:CACHE] Returning cached credentials:`, {
credentialsPath,
hasToken: !!cached.credentials.token,
tokenFingerprint: getTokenFingerprint(cached.credentials.token),
@@ -453,7 +472,7 @@ function getCredentialsFromFile(
// Defense-in-depth: Validate credentials path is within expected boundaries
if (!isValidCredentialsPath(credentialsPath)) {
if (isDebug) {
console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials path rejected:`, { credentialsPath });
debugLog(`[CredentialUtils:${logPrefix}] Invalid credentials path rejected:`, { credentialsPath });
}
const invalidResult = { token: null, email: null, error: 'Invalid credentials path' };
credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now });
@@ -463,7 +482,7 @@ function getCredentialsFromFile(
// Check if credentials file exists
if (!existsSync(credentialsPath)) {
if (isDebug) {
console.warn(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath);
debugLog(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath);
}
const notFoundResult = { token: null, email: null };
credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now });
@@ -478,7 +497,7 @@ function getCredentialsFromFile(
try {
data = JSON.parse(content);
} catch {
console.warn(`[CredentialUtils:${logPrefix}] Failed to parse credentials JSON:`, credentialsPath);
debugLog(`[CredentialUtils:${logPrefix}] Failed to parse credentials JSON:`, credentialsPath);
const errorResult = { token: null, email: null };
credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now });
return errorResult;
@@ -486,7 +505,7 @@ function getCredentialsFromFile(
// Validate JSON structure
if (!validateCredentialData(data)) {
console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath);
debugLog(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath);
const invalidResult = { token: null, email: null };
credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now });
return invalidResult;
@@ -496,7 +515,7 @@ function getCredentialsFromFile(
// Validate token format if present
if (token && !isValidTokenFormat(token)) {
console.warn(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath);
debugLog(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath);
const result = { token: null, email };
credentialCache.set(cacheKey, { credentials: result, timestamp: now });
return result;
@@ -506,7 +525,7 @@ function getCredentialsFromFile(
credentialCache.set(cacheKey, { credentials, timestamp: now });
if (isDebug) {
console.warn(`[CredentialUtils:${logPrefix}] Retrieved credentials from file:`, credentialsPath, {
debugLog(`[CredentialUtils:${logPrefix}] Retrieved credentials from file:`, credentialsPath, {
hasToken: !!token,
hasEmail: !!email,
tokenFingerprint: getTokenFingerprint(token),
@@ -516,7 +535,7 @@ function getCredentialsFromFile(
return credentials;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`[CredentialUtils:${logPrefix}] Failed to read credentials file:`, credentialsPath, errorMessage);
debugLog(`[CredentialUtils:${logPrefix}] Failed to read credentials file:`, credentialsPath, errorMessage);
const errorResult = { token: null, email: null, error: `Failed to read credentials: ${errorMessage}` };
credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now });
return errorResult;
@@ -540,7 +559,7 @@ function getFullCredentialsFromFile(
// Defense-in-depth: Validate credentials path is within expected boundaries
if (!isValidCredentialsPath(credentialsPath)) {
if (isDebug) {
console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials path rejected:`, { credentialsPath });
debugLog(`[CredentialUtils:${logPrefix}] Invalid credentials path rejected:`, { credentialsPath });
}
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'Invalid credentials path' };
}
@@ -548,7 +567,7 @@ function getFullCredentialsFromFile(
// Check if credentials file exists
if (!existsSync(credentialsPath)) {
if (isDebug) {
console.warn(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath);
debugLog(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath);
}
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null };
}
@@ -561,13 +580,13 @@ function getFullCredentialsFromFile(
try {
data = JSON.parse(content);
} catch {
console.warn(`[CredentialUtils:${logPrefix}] Failed to parse credentials JSON:`, credentialsPath);
debugLog(`[CredentialUtils:${logPrefix}] Failed to parse credentials JSON:`, credentialsPath);
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null };
}
// Validate JSON structure
if (!validateCredentialData(data)) {
console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath);
debugLog(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath);
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null };
}
@@ -575,12 +594,12 @@ function getFullCredentialsFromFile(
// Validate token format if present
if (token && !isValidTokenFormat(token)) {
console.warn(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath);
debugLog(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath);
return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
}
if (isDebug) {
console.warn(`[CredentialUtils:${logPrefix}] Retrieved full credentials from file:`, credentialsPath, {
debugLog(`[CredentialUtils:${logPrefix}] Retrieved full credentials from file:`, credentialsPath, {
hasToken: !!token,
hasEmail: !!email,
hasRefreshToken: !!refreshToken,
@@ -593,7 +612,7 @@ function getFullCredentialsFromFile(
return { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`[CredentialUtils:${logPrefix}] Failed to read credentials file:`, credentialsPath, errorMessage);
debugLog(`[CredentialUtils:${logPrefix}] Failed to read credentials file:`, credentialsPath, errorMessage);
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Failed to read credentials: ${errorMessage}` };
}
}
@@ -616,15 +635,6 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals
if (!forceRefresh && cached) {
const ttl = cached.credentials.error ? ERROR_CACHE_TTL_MS : CACHE_TTL_MS;
if ((now - cached.timestamp) < ttl) {
if (isDebug) {
const cacheAge = now - cached.timestamp;
console.warn('[CredentialUtils:macOS:CACHE] Returning cached credentials:', {
serviceName,
hasToken: !!cached.credentials.token,
tokenFingerprint: getTokenFingerprint(cached.credentials.token),
cacheAge: Math.round(cacheAge / 1000) + 's'
});
}
return cached.credentials;
}
}
@@ -664,7 +674,7 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals
// Validate token format if present
if (token && !isValidTokenFormat(token)) {
console.warn('[CredentialUtils:macOS] Invalid token format for service:', serviceName);
debugLog('[CredentialUtils:macOS] Invalid token format for service:', serviceName);
const result = { token: null, email };
credentialCache.set(cacheKey, { credentials: result, timestamp: now });
return result;
@@ -674,7 +684,7 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals
credentialCache.set(cacheKey, { credentials, timestamp: now });
if (isDebug) {
console.warn('[CredentialUtils:macOS] Retrieved credentials from Keychain for service:', serviceName, {
debugLog('[CredentialUtils:macOS] Retrieved credentials from Keychain for service:', serviceName, {
hasToken: !!token,
hasEmail: !!email,
tokenFingerprint: getTokenFingerprint(token),
@@ -685,7 +695,7 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals
} catch (error) {
// Unexpected error (executeCredentialRead already handles "not found" cases)
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('[CredentialUtils:macOS] Keychain access failed for service:', serviceName, errorMessage);
debugLog('[CredentialUtils:macOS] Keychain access failed for service:', serviceName, errorMessage);
const errorResult = { token: null, email: null, error: `Keychain access failed: ${errorMessage}` };
// Use shorter TTL for errors
credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now });
@@ -756,7 +766,7 @@ function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh =
if ((now - cached.timestamp) < ttl) {
if (isDebug) {
const cacheAge = now - cached.timestamp;
console.warn('[CredentialUtils:Linux:SecretService:CACHE] Returning cached credentials:', {
debugLog('[CredentialUtils:Linux:SecretService:CACHE] Returning cached credentials:', {
attribute,
hasToken: !!cached.credentials.token,
tokenFingerprint: getTokenFingerprint(cached.credentials.token),
@@ -771,7 +781,7 @@ function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh =
const secretToolPath = findSecretToolPath();
if (!secretToolPath) {
if (isDebug) {
console.warn('[CredentialUtils:Linux:SecretService] secret-tool not found, falling back to file storage');
debugLog('[CredentialUtils:Linux:SecretService] secret-tool not found, falling back to file storage');
}
// Return a special result indicating Secret Service is unavailable
return { token: null, email: null, error: 'secret-tool not found' };
@@ -795,7 +805,7 @@ function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh =
// Validate token format if present
if (token && !isValidTokenFormat(token)) {
console.warn('[CredentialUtils:Linux:SecretService] Invalid token format for attribute:', attribute);
debugLog('[CredentialUtils:Linux:SecretService] Invalid token format for attribute:', attribute);
const result = { token: null, email };
credentialCache.set(cacheKey, { credentials: result, timestamp: now });
return result;
@@ -805,7 +815,7 @@ function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh =
credentialCache.set(cacheKey, { credentials, timestamp: now });
if (isDebug) {
console.warn('[CredentialUtils:Linux:SecretService] Retrieved credentials from Secret Service:', {
debugLog('[CredentialUtils:Linux:SecretService] Retrieved credentials from Secret Service:', {
attribute,
hasToken: !!token,
hasEmail: !!email,
@@ -817,7 +827,7 @@ function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh =
} catch (error) {
// Unexpected error (executeCredentialRead already handles "not found" cases)
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('[CredentialUtils:Linux:SecretService] Secret Service access failed:', errorMessage);
debugLog('[CredentialUtils:Linux:SecretService] Secret Service access failed:', errorMessage);
// Return error to trigger fallback to file storage
return { token: null, email: null, error: `Secret Service access failed: ${errorMessage}` };
}
@@ -840,7 +850,7 @@ function getCredentialsFromLinux(configDir?: string, forceRefresh = false): Plat
// If Secret Service had an error (not just "not found"), log it and try file fallback
if (secretServiceResult.error && !secretServiceResult.error.includes('not found')) {
if (isDebug) {
console.warn('[CredentialUtils:Linux] Secret Service unavailable, trying file fallback:', secretServiceResult.error);
debugLog('[CredentialUtils:Linux] Secret Service unavailable, trying file fallback:', secretServiceResult.error);
}
}
@@ -894,7 +904,7 @@ function getCredentialsFromWindowsCredentialManager(configDir?: string, forceRef
if ((now - cached.timestamp) < ttl) {
if (isDebug) {
const cacheAge = now - cached.timestamp;
console.warn('[CredentialUtils:Windows:CACHE] Returning cached credentials:', {
debugLog('[CredentialUtils:Windows:CACHE] Returning cached credentials:', {
targetName,
hasToken: !!cached.credentials.token,
tokenFingerprint: getTokenFingerprint(cached.credentials.token),
@@ -910,7 +920,7 @@ function getCredentialsFromWindowsCredentialManager(configDir?: string, forceRef
const invalidResult = { token: null, email: null, error: 'Invalid credential target name format' };
credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now });
if (isDebug) {
console.warn('[CredentialUtils:Windows] Invalid target name rejected:', { targetName });
debugLog('[CredentialUtils:Windows] Invalid target name rejected:', { targetName });
}
return invalidResult;
}
@@ -1017,7 +1027,7 @@ public static extern bool CredFree(IntPtr cred);
// Validate token format if present
if (token && !isValidTokenFormat(token)) {
console.warn('[CredentialUtils:Windows] Invalid token format for target:', targetName);
debugLog('[CredentialUtils:Windows] Invalid token format for target:', targetName);
const result = { token: null, email };
credentialCache.set(cacheKey, { credentials: result, timestamp: now });
return result;
@@ -1027,7 +1037,7 @@ public static extern bool CredFree(IntPtr cred);
credentialCache.set(cacheKey, { credentials, timestamp: now });
if (isDebug) {
console.warn('[CredentialUtils:Windows] Retrieved credentials from Credential Manager for target:', targetName, {
debugLog('[CredentialUtils:Windows] Retrieved credentials from Credential Manager for target:', targetName, {
hasToken: !!token,
hasEmail: !!email,
tokenFingerprint: getTokenFingerprint(token),
@@ -1037,7 +1047,7 @@ public static extern bool CredFree(IntPtr cred);
return credentials;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('[CredentialUtils:Windows] Credential Manager access failed for target:', targetName, errorMessage);
debugLog('[CredentialUtils:Windows] Credential Manager access failed for target:', targetName, errorMessage);
const errorResult = { token: null, email: null, error: `Credential Manager access failed: ${errorMessage}` };
credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now });
return errorResult;
@@ -1102,13 +1112,13 @@ function getCredentialsFromWindows(configDir?: string, forceRefresh = false): Pl
// If only one has a token, use that one
if (fileResult.token && !credManagerResult.token) {
if (isDebug) {
console.warn('[CredentialUtils:Windows] Using file credentials (Credential Manager empty)');
debugLog('[CredentialUtils:Windows] Using file credentials (Credential Manager empty)');
}
return fileResult;
}
if (credManagerResult.token && !fileResult.token) {
if (isDebug) {
console.warn('[CredentialUtils:Windows] Using Credential Manager credentials (file empty)');
debugLog('[CredentialUtils:Windows] Using Credential Manager credentials (file empty)');
}
return credManagerResult;
}
@@ -1120,7 +1130,7 @@ function getCredentialsFromWindows(configDir?: string, forceRefresh = false): Pl
// Both have tokens - prefer file since Claude CLI writes there after login
if (isDebug) {
console.warn('[CredentialUtils:Windows] Both sources have tokens, preferring file (Claude CLI primary storage)');
debugLog('[CredentialUtils:Windows] Both sources have tokens, preferring file (Claude CLI primary storage)');
}
return fileResult;
}
@@ -1242,12 +1252,12 @@ function getFullCredentialsFromMacOSKeychain(configDir?: string): FullOAuthCrede
// Validate token format if present
if (token && !isValidTokenFormat(token)) {
console.warn('[CredentialUtils:macOS:Full] Invalid token format for service:', serviceName);
debugLog('[CredentialUtils:macOS:Full] Invalid token format for service:', serviceName);
return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
}
if (isDebug) {
console.warn('[CredentialUtils:macOS:Full] Retrieved full credentials from Keychain for service:', serviceName, {
debugLog('[CredentialUtils:macOS:Full] Retrieved full credentials from Keychain for service:', serviceName, {
hasToken: !!token,
hasEmail: !!email,
hasRefreshToken: !!refreshToken,
@@ -1261,7 +1271,7 @@ function getFullCredentialsFromMacOSKeychain(configDir?: string): FullOAuthCrede
} catch (error) {
// Unexpected error (executeCredentialRead already handles "not found" cases)
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('[CredentialUtils:macOS:Full] Keychain access failed for service:', serviceName, errorMessage);
debugLog('[CredentialUtils:macOS:Full] Keychain access failed for service:', serviceName, errorMessage);
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Keychain access failed: ${errorMessage}` };
}
}
@@ -1277,7 +1287,7 @@ function getFullCredentialsFromLinuxSecretService(configDir?: string): FullOAuth
const secretToolPath = findSecretToolPath();
if (!secretToolPath) {
if (isDebug) {
console.warn('[CredentialUtils:Linux:SecretService:Full] secret-tool not found');
debugLog('[CredentialUtils:Linux:SecretService:Full] secret-tool not found');
}
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'secret-tool not found' };
}
@@ -1299,12 +1309,12 @@ function getFullCredentialsFromLinuxSecretService(configDir?: string): FullOAuth
);
if (token && !isValidTokenFormat(token)) {
console.warn('[CredentialUtils:Linux:SecretService:Full] Invalid token format for attribute:', attribute);
debugLog('[CredentialUtils:Linux:SecretService:Full] Invalid token format for attribute:', attribute);
return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
}
if (isDebug) {
console.warn('[CredentialUtils:Linux:SecretService:Full] Retrieved full credentials from Secret Service:', {
debugLog('[CredentialUtils:Linux:SecretService:Full] Retrieved full credentials from Secret Service:', {
attribute,
hasToken: !!token,
hasEmail: !!email,
@@ -1319,7 +1329,7 @@ function getFullCredentialsFromLinuxSecretService(configDir?: string): FullOAuth
} catch (error) {
// Unexpected error (executeCredentialRead already handles "not found" cases)
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('[CredentialUtils:Linux:SecretService:Full] Secret Service access failed:', errorMessage);
debugLog('[CredentialUtils:Linux:SecretService:Full] Secret Service access failed:', errorMessage);
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Secret Service access failed: ${errorMessage}` };
}
}
@@ -1339,7 +1349,7 @@ function getFullCredentialsFromLinux(configDir?: string): FullOAuthCredentials {
if (secretServiceResult.error && !secretServiceResult.error.includes('not found')) {
if (isDebug) {
console.warn('[CredentialUtils:Linux:Full] Secret Service unavailable, trying file fallback:', secretServiceResult.error);
debugLog('[CredentialUtils:Linux:Full] Secret Service unavailable, trying file fallback:', secretServiceResult.error);
}
}
@@ -1366,7 +1376,7 @@ function getFullCredentialsFromWindowsCredentialManager(configDir?: string): Ful
if (!isValidTargetName(targetName)) {
const invalidResult = { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'Invalid credential target name format' };
if (isDebug) {
console.warn('[CredentialUtils:Windows:Full] Invalid target name rejected:', { targetName });
debugLog('[CredentialUtils:Windows:Full] Invalid target name rejected:', { targetName });
}
return invalidResult;
}
@@ -1461,12 +1471,12 @@ public static extern bool CredFree(IntPtr cred);
// Validate token format if present
if (token && !isValidTokenFormat(token)) {
console.warn('[CredentialUtils:Windows:Full] Invalid token format for target:', targetName);
debugLog('[CredentialUtils:Windows:Full] Invalid token format for target:', targetName);
return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
}
if (isDebug) {
console.warn('[CredentialUtils:Windows:Full] Retrieved full credentials from Credential Manager for target:', targetName, {
debugLog('[CredentialUtils:Windows:Full] Retrieved full credentials from Credential Manager for target:', targetName, {
hasToken: !!token,
hasEmail: !!email,
hasRefreshToken: !!refreshToken,
@@ -1479,7 +1489,7 @@ public static extern bool CredFree(IntPtr cred);
return { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('[CredentialUtils:Windows:Full] Credential Manager access failed for target:', targetName, errorMessage);
debugLog('[CredentialUtils:Windows:Full] Credential Manager access failed for target:', targetName, errorMessage);
return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Credential Manager access failed: ${errorMessage}` };
}
}
@@ -1508,13 +1518,13 @@ function getFullCredentialsFromWindows(configDir?: string): FullOAuthCredentials
// If only one has a token, use that one
if (fileResult.token && !credManagerResult.token) {
if (isDebug) {
console.warn('[CredentialUtils:Windows:Full] Using file credentials (Credential Manager empty)');
debugLog('[CredentialUtils:Windows:Full] Using file credentials (Credential Manager empty)');
}
return fileResult;
}
if (credManagerResult.token && !fileResult.token) {
if (isDebug) {
console.warn('[CredentialUtils:Windows:Full] Using Credential Manager credentials (file empty)');
debugLog('[CredentialUtils:Windows:Full] Using Credential Manager credentials (file empty)');
}
return credManagerResult;
}
@@ -1529,7 +1539,7 @@ function getFullCredentialsFromWindows(configDir?: string): FullOAuthCredentials
// Using file as primary ensures consistency: the same token is returned whether
// calling getCredentialsFromKeychain() or getFullCredentialsFromKeychain().
if (isDebug) {
console.warn('[CredentialUtils:Windows:Full] Both sources have tokens, preferring file (Claude CLI primary storage)');
debugLog('[CredentialUtils:Windows:Full] Both sources have tokens, preferring file (Claude CLI primary storage)');
}
return fileResult;
}
@@ -1633,12 +1643,12 @@ function updateMacOSKeychainCredentials(
}
);
if (isDebug) {
console.warn('[CredentialUtils:macOS:Update] Deleted existing Keychain entry for service:', serviceName);
debugLog('[CredentialUtils:macOS:Update] Deleted existing Keychain entry for service:', serviceName);
}
} catch {
// Entry didn't exist - that's fine, we'll create it
if (isDebug) {
console.warn('[CredentialUtils:macOS:Update] No existing entry to delete for service:', serviceName);
debugLog('[CredentialUtils:macOS:Update] No existing entry to delete for service:', serviceName);
}
}
@@ -1656,7 +1666,7 @@ function updateMacOSKeychainCredentials(
);
if (isDebug) {
console.warn('[CredentialUtils:macOS:Update] Successfully updated Keychain credentials for service:', serviceName);
debugLog('[CredentialUtils:macOS:Update] Successfully updated Keychain credentials for service:', serviceName);
}
// Clear cached credentials to ensure fresh values are read
@@ -1689,7 +1699,7 @@ function updateLinuxSecretServiceCredentials(
const secretToolPath = findSecretToolPath();
if (!secretToolPath) {
if (isDebug) {
console.warn('[CredentialUtils:Linux:SecretService:Update] secret-tool not found');
debugLog('[CredentialUtils:Linux:SecretService:Update] secret-tool not found');
}
return { success: false, error: 'secret-tool not found' };
}
@@ -1730,7 +1740,7 @@ function updateLinuxSecretServiceCredentials(
);
if (isDebug) {
console.warn('[CredentialUtils:Linux:SecretService:Update] Successfully updated Secret Service credentials for attribute:', attribute);
debugLog('[CredentialUtils:Linux:SecretService:Update] Successfully updated Secret Service credentials for attribute:', attribute);
}
// Clear cached credentials to ensure fresh values are read
@@ -1766,7 +1776,7 @@ function updateLinuxCredentials(
return secretServiceResult;
}
if (isDebug) {
console.warn('[CredentialUtils:Linux:Update] Secret Service update failed, trying file fallback:', secretServiceResult.error);
debugLog('[CredentialUtils:Linux:Update] Secret Service update failed, trying file fallback:', secretServiceResult.error);
}
}
@@ -1827,7 +1837,7 @@ function updateLinuxFileCredentials(
writeFileSync(credentialsPath, credentialsJson, { mode: 0o600, encoding: 'utf-8' });
if (isDebug) {
console.warn('[CredentialUtils:Linux:Update] Successfully updated credentials file:', credentialsPath);
debugLog('[CredentialUtils:Linux:Update] Successfully updated credentials file:', credentialsPath);
}
// Clear cached credentials to ensure fresh values are read
@@ -1966,7 +1976,7 @@ function updateWindowsCredentialManagerCredentials(
}
if (isDebug) {
console.warn('[CredentialUtils:Windows:Update] Successfully updated Credential Manager for target:', targetName);
debugLog('[CredentialUtils:Windows:Update] Successfully updated Credential Manager for target:', targetName);
}
// Clear cached credentials to ensure fresh values are read
@@ -2009,13 +2019,13 @@ function restrictWindowsFilePermissions(filePath: string): void {
});
if (isDebug) {
console.warn('[CredentialUtils:Windows] Set restrictive permissions on:', filePath);
debugLog('[CredentialUtils:Windows] Set restrictive permissions on:', filePath);
}
} catch (error) {
// Non-fatal: log warning but don't fail the operation
// The file is still protected by the user's home directory permissions
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('[CredentialUtils:Windows] Could not set restrictive file permissions:', errorMessage);
debugLog('[CredentialUtils:Windows] Could not set restrictive file permissions:', errorMessage);
}
}
@@ -2105,7 +2115,7 @@ function updateWindowsFileCredentials(
}
if (isDebug) {
console.warn('[CredentialUtils:Windows:Update] Successfully updated credentials file:', credentialsPath);
debugLog('[CredentialUtils:Windows:Update] Successfully updated credentials file:', credentialsPath);
}
// Clear cached credentials to ensure fresh values are read
@@ -2162,7 +2172,7 @@ function updateWindowsCredentials(
// Credential Manager failed but file succeeded - this is acceptable
// Claude CLI will use the file, which has the latest tokens
if (isDebug) {
console.warn('[CredentialUtils:Windows:Update] Credential Manager update failed (file update succeeded):', credManagerResult.error);
debugLog('[CredentialUtils:Windows:Update] Credential Manager update failed (file update succeeded):', credManagerResult.error);
}
}
}
@@ -1,497 +0,0 @@
/**
* 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<string, unknown>;
/**
* 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<boolean>;
/**
* Optional function to stop the operation.
* Called before restart if provided.
*/
stopFn?: () => void | Promise<void>;
}
/**
* 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<string, RegisteredOperation> = 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<string, unknown>;
}
): 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<string, RegisteredOperation[]> {
const result: Record<string, RegisteredOperation[]> = {};
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<string, string[]>;
byType: Record<OperationType, number>;
} {
const byProfile: Record<string, string[]> = {};
const byType: Record<string, number> = {};
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<OperationType, number>,
};
}
/**
* 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<number> {
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;
}
@@ -26,7 +26,6 @@ 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)
};
@@ -207,7 +207,7 @@ export function hasValidToken(profile: ClaudeProfile): boolean {
* Expand ~ in path to home directory
*/
export function expandHomePath(path: string): string {
if (path?.startsWith('~')) {
if (path && path.startsWith('~')) {
const home = homedir();
return path.replace(/^~/, home);
}
@@ -12,6 +12,7 @@ import {
refreshOAuthToken,
ensureValidToken,
reactiveTokenRefresh,
type TokenRefreshResult
} from './token-refresh';
// Mock credential-utils
@@ -319,12 +319,6 @@ export async function ensureValidToken(
? configDir.replace(/^~/, homedir())
: configDir;
if (isDebug) {
console.warn('[TokenRefresh:ensureValidToken] Checking token validity', {
configDir: expandedConfigDir || 'default'
});
}
// Step 1: Read full credentials from keychain
const creds = getFullCredentialsFromKeychain(expandedConfigDir);
@@ -19,7 +19,6 @@ 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 };
@@ -91,60 +90,22 @@ const PROVIDER_USAGE_ENDPOINTS: readonly ProviderUsageEndpoint[] = [
export function getUsageEndpoint(provider: ApiProvider, baseUrl: string): string | null {
const isDebug = process.env.DEBUG === 'true';
if (isDebug) {
console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] Constructing usage endpoint:', {
provider,
baseUrl
});
}
const endpointConfig = PROVIDER_USAGE_ENDPOINTS.find(e => e.provider === provider);
if (!endpointConfig) {
if (isDebug) {
console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] Unknown provider - no endpoint configured:', {
provider,
availableProviders: PROVIDER_USAGE_ENDPOINTS.map(e => e.provider)
});
}
return null;
}
if (isDebug) {
console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] Found endpoint config for provider:', {
provider,
usagePath: endpointConfig.usagePath
});
}
try {
const url = new URL(baseUrl);
const originalPath = url.pathname;
// Replace the path with the usage endpoint path
url.pathname = endpointConfig.usagePath;
// Note: quota/limit endpoint doesn't require query parameters
// The model-usage and tool-usage endpoints would need time windows, but we're using quota/limit
const finalUrl = url.toString();
if (isDebug) {
console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] Successfully constructed endpoint:', {
provider,
originalPath,
newPath: endpointConfig.usagePath,
finalUrl
});
}
return finalUrl;
return url.toString();
} catch (error) {
console.error('[UsageMonitor] Invalid baseUrl for usage endpoint:', baseUrl);
if (isDebug) {
console.warn('[UsageMonitor:ENDPOINT_CONSTRUCTION] URL construction failed:', {
baseUrl,
error: error instanceof Error ? error.message : String(error)
});
}
return null;
}
}
@@ -164,18 +125,7 @@ export function getUsageEndpoint(provider: ApiProvider, baseUrl: string): string
*/
export function detectProvider(baseUrl: string): ApiProvider {
// Wrapper around shared detectProvider with debug logging for main process
const isDebug = process.env.DEBUG === 'true';
const provider = sharedDetectProvider(baseUrl);
if (isDebug) {
console.warn('[UsageMonitor:PROVIDER_DETECTION] Detected provider:', {
baseUrl,
provider
});
}
return provider;
return sharedDetectProvider(baseUrl);
}
/**
@@ -776,7 +726,7 @@ export class UsageMonitor extends EventEmitter {
const activeProfile = profilesFile.profiles.find(
(p) => p.id === profilesFile.activeProfileId
);
if (activeProfile?.apiKey) {
if (activeProfile && activeProfile.apiKey) {
this.debugLog('[UsageMonitor:TRACE] Using API profile credential: ' + activeProfile.name);
return activeProfile.apiKey;
}
@@ -845,18 +795,12 @@ export class UsageMonitor extends EventEmitter {
// Fallback: Try direct keychain read (e.g., if refresh token unavailable)
const keychainCreds = getCredentialsFromKeychain(activeProfile.configDir);
if (keychainCreds.token) {
this.debugLog('[UsageMonitor:TRACE] Using fallback OAuth token from Keychain for profile: ' + activeProfile.name, {
tokenFingerprint: getCredentialFingerprint(keychainCreds.token)
});
return keychainCreds.token;
}
// Keychain read also failed
if (keychainCreds.error) {
this.debugLog('[UsageMonitor] Keychain access failed:', keychainCreds.error);
} else {
this.debugLog('[UsageMonitor:TRACE] No token in Keychain for profile: ' + activeProfile.name +
' - user may need to re-authenticate with claude /login');
}
// Mark profile as needing re-authentication since credentials are missing
@@ -864,7 +808,6 @@ export class UsageMonitor extends EventEmitter {
}
// No credential available
this.debugLog('[UsageMonitor:TRACE] No credential available (no API or OAuth profile active)');
return undefined;
}
@@ -1334,7 +1277,7 @@ export class UsageMonitor extends EventEmitter {
let baseUrl: string;
let provider: ApiProvider;
if (activeProfile?.isAPIProfile) {
if (activeProfile && activeProfile.isAPIProfile) {
// Use the pre-determined profile to avoid race conditions
// Trust the activeProfile data and use baseUrl directly
baseUrl = activeProfile.baseUrl;
@@ -1348,7 +1291,7 @@ export class UsageMonitor extends EventEmitter {
const profilesFile = await loadProfilesFile();
apiProfile = profilesFile.profiles.find(p => p.id === profileId);
if (apiProfile?.apiKey) {
if (apiProfile && apiProfile.apiKey) {
// API profile found
baseUrl = apiProfile.baseUrl;
provider = detectProvider(baseUrl);
@@ -2008,46 +1951,6 @@ 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
}
+1 -1
View File
@@ -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()) {
+3 -2
View File
@@ -157,7 +157,8 @@ function createWindow(): void {
const display = screen.getPrimaryDisplay();
// Validate the returned object has expected structure with valid dimensions
if (
display?.workAreaSize &&
display &&
display.workAreaSize &&
typeof display.workAreaSize.width === 'number' &&
typeof display.workAreaSize.height === 'number' &&
display.workAreaSize.width > 0 &&
@@ -494,7 +495,7 @@ app.whenReady().then(() => {
// Setup event forwarding from usage monitor to renderer
initializeUsageMonitorForwarding(mainWindow);
// Start the usage monitor (uses unified OperationRegistry for proactive restart)
// Start the usage monitor
const usageMonitor = getUsageMonitor();
usageMonitor.start();
console.warn('[main] Usage monitor initialized and started (after profile load)');
@@ -9,10 +9,11 @@ import { InsightsPaths } from './paths';
export class SessionManager {
private sessions: Map<string, InsightsSession> = new Map();
private storage: SessionStorage;
private paths: InsightsPaths;
constructor(storage: SessionStorage, _paths: InsightsPaths) {
constructor(storage: SessionStorage, paths: InsightsPaths) {
this.storage = storage;
// Note: paths parameter kept for API compatibility but not currently used
this.paths = paths;
}
/**
@@ -95,10 +95,6 @@ export function registerAppUpdateHandlers(): void {
IPC_CHANNELS.APP_UPDATE_INSTALL,
async (): Promise<IPCResult> => {
try {
// quitAndInstall() returns false if blocked by read-only volume,
// but the user is notified via APP_UPDATE_READONLY_VOLUME event instead.
// The preload fires this as fire-and-forget, so the return value is
// only consumed by the .catch() handler for unexpected errors.
quitAndInstall();
return { success: true };
} catch (error) {
@@ -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);
@@ -216,12 +216,10 @@ async function scanClaudeInstallations(activePath: string | null): Promise<Claud
* @param currentInstalled - Optional currently installed version. If provided and newer than
* cached latest, cache will be invalidated and fresh data fetched.
* This handles the case where CLI was updated while app was running.
* @param forceRefresh - If true, bypasses the cache and fetches fresh data from npm.
* Use this when the user explicitly clicks a "Refresh" button.
*/
async function fetchLatestVersion(currentInstalled?: string | null, forceRefresh?: boolean): Promise<string> {
// Check cache first (unless force refresh is requested)
if (!forceRefresh && cachedLatestVersion && Date.now() - cachedLatestVersion.timestamp < CACHE_DURATION_MS) {
async function fetchLatestVersion(currentInstalled?: string | null): Promise<string> {
// Check cache first
if (cachedLatestVersion && Date.now() - cachedLatestVersion.timestamp < CACHE_DURATION_MS) {
const cachedVersion = cachedLatestVersion.version;
// Invalidate cache if installed version is newer than cached latest
@@ -883,7 +881,7 @@ function checkProfileAuthentication(configDir: string): AuthCheckResult {
const data = JSON.parse(content);
// Check for oauthAccount with emailAddress
if (data.oauthAccount?.emailAddress) {
if (data.oauthAccount && data.oauthAccount.emailAddress) {
return {
authenticated: true,
email: data.oauthAccount.emailAddress,
@@ -909,7 +907,7 @@ function checkProfileAuthentication(configDir: string): AuthCheckResult {
};
}
if (data.oauthAccount?.emailAddress) {
if (data.oauthAccount && data.oauthAccount.emailAddress) {
return {
authenticated: true,
email: data.oauthAccount.emailAddress,
@@ -960,9 +958,9 @@ export function registerClaudeCodeHandlers(): void {
// Check Claude Code version
ipcMain.handle(
IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION,
async (_event, forceRefresh?: boolean): Promise<IPCResult<ClaudeCodeVersionInfo>> => {
async (): Promise<IPCResult<ClaudeCodeVersionInfo>> => {
try {
console.warn('[Claude Code] Checking version...', forceRefresh ? '(force refresh)' : '');
console.warn('[Claude Code] Checking version...');
// Get installed version via cli-tool-manager
let detectionResult;
@@ -979,11 +977,10 @@ export function registerClaudeCodeHandlers(): void {
// Fetch latest version from npm
// Pass installed version to invalidate cache if installed > cached (handles CLI update while app running)
// Pass forceRefresh to bypass cache when user explicitly clicks Refresh
let latest: string;
try {
console.warn('[Claude Code] Fetching latest version from npm...');
latest = await fetchLatestVersion(installed, forceRefresh);
latest = await fetchLatestVersion(installed);
console.warn('[Claude Code] Latest version:', latest);
} catch (error) {
console.warn('[Claude Code] Failed to fetch latest version, continuing with unknown:', error);
@@ -12,6 +12,9 @@ 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,
@@ -32,7 +32,7 @@ type ResolvedClaudeCliInvocation =
| { command: string; env: Record<string, string> }
| { error: string };
function _resolveClaudeCliInvocation(): ResolvedClaudeCliInvocation {
function resolveClaudeCliInvocation(): ResolvedClaudeCliInvocation {
try {
const invocation = getClaudeCliInvocation();
if (!invocation?.command) {
@@ -118,6 +118,14 @@ export function registerEnvHandlers(
if (config.githubAutoSync !== undefined) {
existingVars['GITHUB_AUTO_SYNC'] = config.githubAutoSync ? 'true' : 'false';
}
// GitHub CI check exclusion (comma-separated list of check names to ignore)
if (config.githubExcludedCIChecks !== undefined) {
if (config.githubExcludedCIChecks.length > 0) {
existingVars['GITHUB_EXCLUDED_CI_CHECKS'] = config.githubExcludedCIChecks.join(',');
} else {
delete existingVars['GITHUB_EXCLUDED_CI_CHECKS'];
}
}
// GitLab Integration
if (config.gitlabEnabled !== undefined) {
existingVars[GITLAB_ENV_KEYS.ENABLED] = config.gitlabEnabled ? 'true' : 'false';
@@ -251,6 +259,9 @@ ${existingVars['LINEAR_REALTIME_SYNC'] !== undefined ? `LINEAR_REALTIME_SYNC=${e
${existingVars['GITHUB_TOKEN'] ? `GITHUB_TOKEN=${existingVars['GITHUB_TOKEN']}` : '# GITHUB_TOKEN='}
${existingVars['GITHUB_REPO'] ? `GITHUB_REPO=${existingVars['GITHUB_REPO']}` : '# GITHUB_REPO=owner/repo'}
${existingVars['GITHUB_AUTO_SYNC'] !== undefined ? `GITHUB_AUTO_SYNC=${existingVars['GITHUB_AUTO_SYNC']}` : '# GITHUB_AUTO_SYNC=false'}
# CI check names to exclude from blocking during PR reviews (comma-separated)
# Useful for stuck/broken CI checks that never complete
${existingVars['GITHUB_EXCLUDED_CI_CHECKS'] ? `GITHUB_EXCLUDED_CI_CHECKS=${existingVars['GITHUB_EXCLUDED_CI_CHECKS']}` : '# GITHUB_EXCLUDED_CI_CHECKS='}
# =============================================================================
# GITLAB INTEGRATION (OPTIONAL)
@@ -430,6 +441,13 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
if (vars['GITHUB_AUTO_SYNC']?.toLowerCase() === 'true') {
config.githubAutoSync = true;
}
// Parse excluded CI checks (comma-separated list)
if (vars['GITHUB_EXCLUDED_CI_CHECKS']) {
config.githubExcludedCIChecks = vars['GITHUB_EXCLUDED_CI_CHECKS']
.split(',')
.map(s => s.trim())
.filter(Boolean);
}
// GitLab config
if (vars[GITLAB_ENV_KEYS.TOKEN]) {
@@ -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';
}
@@ -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(
@@ -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?.[1]) {
if (match && 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)');
@@ -245,6 +245,29 @@ function getClaudeMdEnv(project: Project): Record<string, string> | undefined {
return project.settings?.useClaudeMd !== false ? { USE_CLAUDE_MD: "true" } : undefined;
}
/**
* Builds extra environment variables for PR review subprocess.
* Includes Claude.md setting and GitHub-specific config like excluded CI checks.
*/
function getPRReviewExtraEnv(
project: Project,
config: { excludedCIChecks?: string[] } | null
): Record<string, string> {
const env: Record<string, string> = {};
// Add Claude.md setting
if (project.settings?.useClaudeMd !== false) {
env.USE_CLAUDE_MD = "true";
}
// Add excluded CI checks (for stuck/broken CI like license/cla)
if (config?.excludedCIChecks && config.excludedCIChecks.length > 0) {
env.GITHUB_EXCLUDED_CI_CHECKS = config.excludedCIChecks.join(",");
}
return env;
}
/**
* PR review finding from AI analysis
*/
@@ -1304,11 +1327,8 @@ async function runPRReview(
const repo = config?.repo || project.name || "unknown";
const logCollector = new PRLogCollector(project, prNumber, repo, false);
// Build environment with project settings
const subprocessEnv = await getRunnerEnv(getClaudeMdEnv(project));
// Create operation ID for this review
const reviewKey = getReviewKey(project.id, prNumber);
// Build environment with project settings (including excluded CI checks)
const subprocessEnv = await getRunnerEnv(getPRReviewExtraEnv(project, config));
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
pythonPath: getPythonPath(backendPath),
@@ -1344,17 +1364,10 @@ 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 (keep legacy registry for cancel support)
// Register the running process
const reviewKey = getReviewKey(project.id, prNumber);
runningReviews.set(reviewKey, childProcess);
debugLog("Registered review process", { reviewKey, pid: childProcess.pid });
@@ -2719,8 +2732,8 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
const repo = config?.repo || project.name || "unknown";
const logCollector = new PRLogCollector(project, prNumber, repo, true);
// Build environment with project settings
const followupEnv = await getRunnerEnv(getClaudeMdEnv(project));
// Build environment with project settings (including excluded CI checks)
const followupEnv = await getRunnerEnv(getPRReviewExtraEnv(project, config));
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
pythonPath: getPythonPath(backendPath),
@@ -2758,12 +2771,6 @@ 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)
@@ -5,6 +5,7 @@
export interface GitHubConfig {
token: string;
repo: string;
excludedCIChecks?: string[];
}
export interface GitHubAPIIssue {
@@ -81,7 +81,14 @@ export function getGitHubConfig(project: Project): GitHubConfig | null {
}
if (!token || !repo) return null;
return { token, repo };
// Parse excluded CI checks (comma-separated list)
const excludedCIChecksRaw = vars['GITHUB_EXCLUDED_CI_CHECKS'];
const excludedCIChecks = excludedCIChecksRaw
? excludedCIChecksRaw.split(',').map((s) => s.trim()).filter(Boolean)
: undefined;
return { token, repo, excludedCIChecks };
} catch {
return null;
}
@@ -20,7 +20,6 @@ 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);
@@ -74,23 +73,6 @@ export interface SubprocessOptions {
progressPattern?: RegExp;
/** Additional environment variables to pass to the subprocess */
env?: Record<string, string>;
/**
* 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<string, unknown>;
/**
* Function to restart the operation with a new profile.
* Should call the original function with refreshed environment.
*/
restartFn?: (newProfileId: string) => boolean | Promise<boolean>;
};
}
/**
@@ -144,67 +126,6 @@ export function runPythonSubprocess<T = unknown>(
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<SubprocessResult<T>>((resolve) => {
let stdout = '';
@@ -384,11 +305,6 @@ export function runPythonSubprocess<T = unknown>(
// 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, ')');
@@ -21,6 +21,7 @@ import type {
GitLabAutoFixQueueItem,
GitLabAutoFixProgress,
GitLabIssueBatch,
GitLabBatchProgress,
GitLabAnalyzePreviewResult,
} from './types';
@@ -71,7 +71,7 @@ function sendError(
* Register investigation handler
*/
export function registerInvestigateIssue(
_agentManager: AgentManager,
agentManager: AgentManager,
getMainWindow: () => BrowserWindow | null
): void {
ipcMain.on(
@@ -129,11 +129,16 @@ export function registerInvestigateIssue(
message: 'Analyzing issue with AI...'
});
// 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.
// 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';
}
}
// Use agent manager to investigate
// Note: This is a simplified version - full implementation would use Claude SDK
@@ -21,6 +21,7 @@ import { getGitLabConfig, gitlabFetch, encodeProjectPath } from './utils';
import { readSettingsFile } from '../../settings-utils';
import type { Project, AppSettings } from '../../../shared/types';
import type {
MRReviewFinding,
MRReviewResult,
MRReviewProgress,
NewCommitsCheck,
@@ -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
@@ -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 { sanitizeText, sanitizeStringArray } from '../shared/sanitize';
import { stripControlChars, sanitizeText, sanitizeStringArray } from '../shared/sanitize';
/**
* Simplified task info returned when creating a spec from a GitLab issue.
@@ -201,10 +201,6 @@ function getOllamaInstallCommand(): string {
* Spawns a subprocess to run Ollama detection/management commands with a 10-second timeout.
* Used to check Ollama status, list models, and manage downloads.
*
* Includes deduplication: identical command+baseUrl requests within 2s return the cached
* result/promise instead of spawning a new subprocess. This prevents runaway subprocess
* spawning from React re-render loops.
*
* Supported commands:
* - 'check-status': Verify Ollama service is running
* - 'list-models': Get all available models
@@ -216,43 +212,9 @@ function getOllamaInstallCommand(): string {
* @param {string} [baseUrl] - Optional Ollama API base URL (defaults to http://localhost:11434)
* @returns {Promise<{success, data?, error?}>} Result object with success flag and data/error
*/
// Deduplication cache to prevent rapid-fire subprocess spawning (e.g., from React re-render loops)
const ollamaDetectorCache = new Map<string, { promise: Promise<{ success: boolean; data?: unknown; error?: string }>; timestamp: number }>();
const OLLAMA_CACHE_TTL_MS = 2000; // Cache results for 2 seconds
async function executeOllamaDetector(
command: string,
baseUrl?: string
): Promise<{ success: boolean; data?: unknown; error?: string }> {
// Deduplication: return cached promise for identical requests within TTL
const cacheKey = `${command}:${baseUrl || 'default'}`;
const cached = ollamaDetectorCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < OLLAMA_CACHE_TTL_MS) {
if (process.env.DEBUG) {
console.log('[OllamaDetector] Returning cached result for:', command);
}
return cached.promise;
}
const promise = executeOllamaDetectorImpl(command, baseUrl);
ollamaDetectorCache.set(cacheKey, { promise, timestamp: Date.now() });
// Clean up cache entry after TTL
promise.finally(() => {
setTimeout(() => {
const entry = ollamaDetectorCache.get(cacheKey);
if (entry && entry.promise === promise) {
ollamaDetectorCache.delete(cacheKey);
}
}, OLLAMA_CACHE_TTL_MS);
});
return promise;
}
async function executeOllamaDetectorImpl(
command: string,
baseUrl?: string
): Promise<{ success: boolean; data?: unknown; error?: string }> {
// Use configured Python path (venv if ready, otherwise bundled/system)
// Note: ollama_model_detector.py doesn't require dotenv, but using venv is safer
@@ -521,7 +483,7 @@ export function registerMemoryHandlers(): void {
};
} else {
// Basic validation for other providers
llmResult = config.apiKey?.trim()
llmResult = config.apiKey && config.apiKey.trim()
? {
success: true,
message: `${config.llmProvider} API key format appears valid`,
@@ -741,7 +703,7 @@ export function registerMemoryHandlers(): void {
async (
event,
modelName: string,
_baseUrl?: string
baseUrl?: string
): Promise<IPCResult<OllamaPullResult>> => {
try {
// Use configured Python path (venv if ready, otherwise bundled/system)
@@ -16,6 +16,7 @@ import type { IPCResult } from '../../shared/types';
import type { APIProfile, ProfileFormData, ProfilesFile, TestConnectionResult, DiscoverModelsResult } from '@shared/types/profile';
import {
loadProfilesFile,
saveProfilesFile,
validateFilePermissions,
getProfilesFilePath,
atomicModifyProfiles,
@@ -1,7 +1,8 @@
import { ipcMain, app } from 'electron';
import { existsSync, } from 'fs';
import { existsSync, readFileSync } 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,
@@ -232,6 +233,8 @@ 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
*/
@@ -11,6 +11,7 @@ import {
import type {
IPCResult,
Roadmap,
RoadmapFeature,
RoadmapFeatureStatus,
RoadmapGenerationStatus,
PersistedRoadmapProgress,
@@ -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}`
@@ -800,73 +800,6 @@ 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<IPCResult> => {
// 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)
*/
@@ -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 { execFileSync, spawn, spawnSync, exec, execFile } from 'child_process';
import { execSync, 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, refreshGitIndex } from '../../utils/git-isolation';
import { getIsolatedGitEnv, detectWorktreeBranch, 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<Set<string>> {
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?.trim()) {
if (gitStatus && 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
@@ -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, } from '../../shared/utils/debug-logger';
import { debugLog, debugError } 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';
+3 -1
View File
@@ -29,6 +29,8 @@ 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;
@@ -203,7 +205,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';
+1 -1
View File
@@ -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 {
+14 -58
View File
@@ -3,11 +3,10 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, Dirent
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, ImplementationPlan, ReviewReason, PlanSubtask, KanbanPreferences, ExecutionPhase } from '../shared/types';
import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir, JSON_ERROR_PREFIX, JSON_ERROR_TITLE_SUFFIX, TASK_STATUS_PRIORITY } from '../shared/constants';
import { XSTATE_SETTLED_STATES } from '../shared/state-machines';
import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir, JSON_ERROR_PREFIX, JSON_ERROR_TITLE_SUFFIX } from '../shared/constants';
import { getAutoBuildPath, isInitialized } from './project-initializer';
import { getTaskWorktreeDir } from './worktree-paths';
import { findAllSpecPaths } from './utils/spec-path-helpers';
import { isValidTaskId, findAllSpecPaths } from './utils/spec-path-helpers';
interface TabState {
openProjectIds: string[];
@@ -325,37 +324,12 @@ export class ProjectStore {
}
}
// 3. Deduplicate tasks by ID
// CRITICAL FIX: Don't blindly prefer worktree - it may be stale!
// If main project task is "done", it should win over worktree's "in_progress".
// Worktrees can linger after completion, containing outdated task data.
// 3. Deduplicate tasks by ID (prefer worktree version if exists in both)
const taskMap = new Map<string, Task>();
for (const task of allTasks) {
const existing = taskMap.get(task.id);
if (!existing) {
// First occurrence wins
if (!existing || task.location === 'worktree') {
taskMap.set(task.id, task);
} else {
// PREFER MAIN PROJECT over worktree - main has current user changes
// Only use status priority when both are from same location
const existingIsMain = existing.location === 'main';
const newIsMain = task.location === 'main';
if (existingIsMain && !newIsMain) {
} else if (!existingIsMain && newIsMain) {
// New is main, replace existing worktree
taskMap.set(task.id, task);
} else {
// Same location - use status priority to determine which is more complete
const existingPriority = TASK_STATUS_PRIORITY[existing.status] || 0;
const newPriority = TASK_STATUS_PRIORITY[task.status] || 0;
if (newPriority > existingPriority) {
// New version has higher priority (more complete status)
taskMap.set(task.id, task);
}
// Otherwise keep existing version
}
}
}
@@ -388,10 +362,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[] = [];
@@ -518,7 +492,7 @@ export class ProjectStore {
// "# Specification: Title" -> "Title"
// "# Title" -> "Title"
const titleMatch = specContent.match(/^#\s+(?:Quick Spec:|Specification:)?\s*(.+)$/m);
if (titleMatch?.[1]) {
if (titleMatch && titleMatch[1]) {
title = titleMatch[1].trim();
}
} catch {
@@ -526,33 +500,15 @@ export class ProjectStore {
}
}
// Use xstateState as the authoritative source for execution phase when available.
// Only fall back to persistedPhase for active running states where xstateState
// might not reflect the detailed phase yet (e.g., planning vs coding within in_progress).
// Priority: xstateState (when settled) > persistedPhase (when running) > inferred from status
// Use persisted executionPhase (from text parser) or xstateState for exact restoration
// Priority: executionPhase > xstateState > inferred from status
const persistedPhase = (plan as { executionPhase?: string } | null)?.executionPhase as ExecutionPhase | undefined;
const xstateState = (plan as { xstateState?: string } | null)?.xstateState;
// XState settled states take priority - these override any stale persisted phase
// because the task is no longer actively running
const isSettledState = xstateState && XSTATE_SETTLED_STATES.has(xstateState);
// Status-based override: if the task has reached a terminal status (human_review, done, pr_created)
// but the persisted executionPhase is stale (e.g. still "planning"), correct it.
// This happens when the XState machine didn't persist the final phase before being reset to backlog.
const STATUS_IMPLIES_COMPLETE: ReadonlySet<string> = new Set(['human_review', 'done', 'pr_created']);
const phaseIsStale = persistedPhase && persistedPhase !== 'complete' && persistedPhase !== 'failed'
&& STATUS_IMPLIES_COMPLETE.has(finalStatus);
const executionProgress = isSettledState
? this.inferExecutionProgressFromXState(xstateState) // Use XState for settled states
: phaseIsStale
? { phase: 'complete' as ExecutionPhase, phaseProgress: 100, overallProgress: 100 } // Fix stale phase
: persistedPhase
? { phase: persistedPhase, phaseProgress: 50, overallProgress: 50 } // Use persisted for running
: xstateState
? this.inferExecutionProgressFromXState(xstateState)
: this.inferExecutionProgress(plan?.status);
const executionProgress = persistedPhase
? { phase: persistedPhase, phaseProgress: 50, overallProgress: 50 }
: xstateState
? this.inferExecutionProgressFromXState(xstateState)
: this.inferExecutionProgress(plan?.status);
tasks.push({
id: dir.name, // Use spec directory name as ID
+8 -25
View File
@@ -92,25 +92,6 @@ 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
*/
@@ -128,7 +109,7 @@ export interface RateLimitDetectionResult {
id: string;
name: string;
};
/** Original error message (truncated to 500 chars for security) */
/** Original error message */
originalError?: string;
}
@@ -212,7 +193,7 @@ export function detectRateLimit(
id: bestProfile.id,
name: bestProfile.name
} : undefined,
originalError: sanitizeErrorOutput(output)
originalError: output
};
}
@@ -230,7 +211,7 @@ export function detectRateLimit(
id: bestProfile.id,
name: bestProfile.name
} : undefined,
originalError: sanitizeErrorOutput(output)
originalError: output
};
}
}
@@ -284,6 +265,7 @@ 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.';
}
@@ -321,6 +303,7 @@ 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.';
}
@@ -350,7 +333,7 @@ export function detectAuthFailure(
profileId: effectiveProfileId,
failureType,
message: getAuthFailureMessage(failureType),
originalError: sanitizeErrorOutput(output)
originalError: output
};
}
}
@@ -392,7 +375,7 @@ export function detectBillingFailure(
profileId: effectiveProfileId,
failureType,
message: getBillingFailureMessage(failureType),
originalError: sanitizeErrorOutput(output)
originalError: output
};
}
}
@@ -648,7 +631,7 @@ export interface SDKRateLimitInfo {
};
/** When detected */
detectedAt: Date;
/** Original error message (truncated to 500 chars for security) */
/** Original error message */
originalError?: string;
// Auto-swap information
+4 -1
View File
@@ -24,6 +24,9 @@ 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.
@@ -72,7 +75,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[] } {
+2 -2
View File
@@ -63,7 +63,7 @@ function getTracesSampleRate(): number {
const envValue = buildTimeValue || process.env.SENTRY_TRACES_SAMPLE_RATE;
if (envValue) {
const parsed = parseFloat(envValue);
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) {
if (!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 (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) {
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
return parsed;
}
}
@@ -14,7 +14,7 @@ import {
getAPIProfileEnv,
testConnection
} from './profile-service';
import type { ProfilesFile, } from '../../shared/types/profile';
import type { APIProfile, ProfilesFile, TestConnectionResult } from '../../shared/types/profile';
// Mock profile-manager
vi.mock('../utils/profile-manager', () => ({
@@ -90,6 +90,8 @@ export async function deleteProfile(id: string): Promise<void> {
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.');
@@ -13,7 +13,7 @@ import {
testConnection,
discoverModels
} from './profile-service';
import type { ProfilesFile, } from '@shared/types/profile';
import type { APIProfile, ProfilesFile, TestConnectionResult } from '@shared/types/profile';
// Mock Anthropic SDK - use vi.hoisted to properly hoist the mock variable
const { mockModelsList, mockMessagesCreate } = vi.hoisted(() => ({
@@ -8,6 +8,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
SDKSessionRecoveryCoordinator,
getRecoveryCoordinator,
type SDKOperationType,
type RecoveryCoordinatorConfig
} from './sdk-session-recovery-coordinator';
// Mock dependencies
@@ -1,25 +1,6 @@
/**
* 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).
*
@@ -116,9 +97,6 @@ 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)
@@ -553,7 +531,6 @@ 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();
+7 -2
View File
@@ -1,5 +1,5 @@
import path from 'path';
import { existsSync, readFileSync, } from 'fs';
import { existsSync, readFileSync, watchFile } from 'fs';
import { EventEmitter } from 'events';
import type { TaskLogs, TaskLogPhase, TaskLogStreamChunk, TaskPhaseLog } from '../shared/types';
import { findTaskWorktree } from './worktree-paths';
@@ -26,6 +26,7 @@ function findWorktreeSpecDir(projectPath: string, specId: string, specsRelPath:
* watches both locations and merges logs from both sources.
*/
export class TaskLogService extends EventEmitter {
private watchers: Map<string, { watcher: ReturnType<typeof watchFile>; specDir: string }> = new Map();
private logCache: Map<string, TaskLogs> = new Map();
private pollIntervals: Map<string, NodeJS.Timeout> = new Map();
// Store paths being watched for each specId (main + worktree)
@@ -34,6 +35,10 @@ 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)
@@ -120,7 +125,7 @@ export class TaskLogService extends EventEmitter {
let worktreeSpecDir: string | null = null;
if (watchedInfo?.[1].worktreeSpecDir) {
if (watchedInfo && watchedInfo[1].worktreeSpecDir) {
worktreeSpecDir = watchedInfo[1].worktreeSpecDir;
} else if (projectPath && specsRelPath && specId) {
// Calculate worktree path from provided params
+1 -7
View File
@@ -123,14 +123,7 @@ export class TaskStateManager {
} else if (!currentState && task.reviewReason === 'plan_review') {
// Fallback: No actor exists (e.g., after app restart), use task data
this.handleUiEvent(taskId, { type: 'PLAN_APPROVED' }, task, project);
} else if (currentState === 'backlog' || !currentState) {
// Fresh start from backlog or no actor - send PLANNING_STARTED
// USER_RESUMED only works from human_review/error states
this.handleUiEvent(taskId, { type: 'PLANNING_STARTED' }, task, project);
} else {
// Already in a running state (planning, coding, qa_*) - send USER_RESUMED
// Note: USER_RESUMED may be ignored if state doesn't handle it, but that's OK
// since the task is already running
this.handleUiEvent(taskId, { type: 'USER_RESUMED' }, task, project);
}
return true;
@@ -420,6 +413,7 @@ export class TaskStateManager {
stateValue = 'error';
contextReviewReason = reviewReason ?? 'errors';
break;
case 'backlog':
default:
stateValue = 'backlog';
break;
@@ -377,23 +377,12 @@ export class TerminalSessionStore {
todaySessions[projectPath] = [];
}
// Debug: Log incoming outputBuffer info
const incomingBufferLen = session.outputBuffer?.length ?? 0;
debugLog('[TerminalSessionStore] Updating session in memory:', session.id,
'incoming outputBuffer:', incomingBufferLen, 'bytes',
'isClaudeMode:', session.isClaudeMode);
// Update existing or add new
const existingIndex = todaySessions[projectPath].findIndex(s => s.id === session.id);
if (existingIndex >= 0) {
// Preserve displayOrder from existing session if not provided in incoming session
// This prevents periodic saves (which don't include displayOrder) from losing tab order
const existingSession = todaySessions[projectPath][existingIndex];
const existingBufferLen = existingSession.outputBuffer?.length ?? 0;
const truncatedLen = session.outputBuffer.slice(-MAX_OUTPUT_BUFFER).length;
debugLog('[TerminalSessionStore] Updating existing session:', session.id,
'existing outputBuffer:', existingBufferLen, 'bytes',
'new outputBuffer (after truncation):', truncatedLen, 'bytes');
todaySessions[projectPath][existingIndex] = {
...session,
@@ -404,10 +393,6 @@ export class TerminalSessionStore {
displayOrder: session.displayOrder ?? existingSession.displayOrder,
};
} else {
const truncatedLen = session.outputBuffer.slice(-MAX_OUTPUT_BUFFER).length;
debugLog('[TerminalSessionStore] Creating new session:', session.id,
'outputBuffer (after truncation):', truncatedLen, 'bytes');
todaySessions[projectPath].push({
...session,
outputBuffer: session.outputBuffer.slice(-MAX_OUTPUT_BUFFER),
@@ -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<void>,
clearRateLimitCallback: (terminalId: string) => void
): Promise<{ success: boolean; error?: string }> {
@@ -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?.[1]) {
if (match && 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?.[1]) {
if (match && match[1]) {
return match[1];
}
}
@@ -9,6 +9,7 @@ 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';
@@ -270,7 +271,7 @@ class PtyDaemonClient {
}
});
this.socket?.write(JSON.stringify({ ...msg, requestId }) + '\n');
this.socket!.write(JSON.stringify({ ...msg, requestId }) + '\n');
});
}
@@ -426,7 +427,7 @@ class PtyDaemonClient {
this.isShuttingDown = true;
// Kill the daemon process if we spawned it
if (this.daemonProcess?.pid) {
if (this.daemonProcess && this.daemonProcess.pid) {
try {
if (isWindows()) {
// Windows: use taskkill to force kill process tree
@@ -6,6 +6,8 @@
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,
@@ -46,7 +48,7 @@ vi.mock('fs', () => {
});
describe('profile-manager', () => {
const _mockProfilesPath = '/mock/userdata/profiles.json';
const mockProfilesPath = '/mock/userdata/profiles.json';
beforeEach(() => {
vi.clearAllMocks();
@@ -29,7 +29,7 @@ export async function loadProfilesFile(): Promise<ProfilesFile> {
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: [],
@@ -72,7 +72,7 @@ export class SpecNumberLock {
const pidStr = readFileSync(this.lockFile, 'utf-8').trim();
const pid = parseInt(pidStr, 10);
if (!Number.isNaN(pid) && !this.isProcessRunning(pid)) {
if (!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 (!Number.isNaN(num)) {
if (!isNaN(num)) {
maxNum = Math.max(maxNum, num);
}
}
@@ -4,7 +4,6 @@ import type {
AppUpdateProgress,
AppUpdateAvailableEvent,
AppUpdateDownloadedEvent,
AppUpdateErrorEvent,
IPCResult
} from '../../shared/types';
import { createIpcListener, invokeIpc, IpcListenerCleanup } from './modules/ipc-utils';
@@ -35,12 +34,6 @@ export interface AppUpdateAPI {
onAppUpdateStableDowngrade: (
callback: (info: AppUpdateInfo) => void
) => IpcListenerCleanup;
onAppUpdateReadOnlyVolume: (
callback: (info: { appPath: string }) => void
) => IpcListenerCleanup;
onAppUpdateError: (
callback: (error: AppUpdateErrorEvent) => void
) => IpcListenerCleanup;
}
/**
@@ -58,9 +51,7 @@ export const createAppUpdateAPI = (): AppUpdateAPI => ({
invokeIpc(IPC_CHANNELS.APP_UPDATE_DOWNLOAD_STABLE),
installAppUpdate: (): void => {
invokeIpc(IPC_CHANNELS.APP_UPDATE_INSTALL).catch((err) =>
console.error('[app-update] Install failed:', err)
);
invokeIpc(IPC_CHANNELS.APP_UPDATE_INSTALL);
},
getAppVersion: (): Promise<string> =>
@@ -88,15 +79,5 @@ export const createAppUpdateAPI = (): AppUpdateAPI => ({
onAppUpdateStableDowngrade: (
callback: (info: AppUpdateInfo) => void
): IpcListenerCleanup =>
createIpcListener(IPC_CHANNELS.APP_UPDATE_STABLE_DOWNGRADE, callback),
onAppUpdateReadOnlyVolume: (
callback: (info: { appPath: string }) => void
): IpcListenerCleanup =>
createIpcListener(IPC_CHANNELS.APP_UPDATE_READONLY_VOLUME, callback),
onAppUpdateError: (
callback: (error: AppUpdateErrorEvent) => void
): IpcListenerCleanup =>
createIpcListener(IPC_CHANNELS.APP_UPDATE_ERROR, callback)
createIpcListener(IPC_CHANNELS.APP_UPDATE_STABLE_DOWNGRADE, callback)
});
@@ -80,9 +80,8 @@ export interface ClaudeCodeAPI {
/**
* Check Claude Code CLI version status
* Returns installed version, latest version, and whether update is available
* @param forceRefresh - If true, bypasses the 24-hour cache and fetches fresh data from npm
*/
checkClaudeCodeVersion: (forceRefresh?: boolean) => Promise<ClaudeCodeVersionResult>;
checkClaudeCodeVersion: () => Promise<ClaudeCodeVersionResult>;
/**
* Install or update Claude Code CLI
@@ -119,8 +118,8 @@ export interface ClaudeCodeAPI {
* Creates the Claude Code API implementation
*/
export const createClaudeCodeAPI = (): ClaudeCodeAPI => ({
checkClaudeCodeVersion: (forceRefresh?: boolean): Promise<ClaudeCodeVersionResult> =>
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION, forceRefresh),
checkClaudeCodeVersion: (): Promise<ClaudeCodeVersionResult> =>
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION),
installClaudeCode: (): Promise<ClaudeCodeInstallResult> =>
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_INSTALL),
+2 -2
View File
@@ -81,7 +81,7 @@ export const createProfileAPI = (): ProfileAPI => ({
const requestId = ++testConnectionRequestId;
// Check if already aborted before initiating request
if (signal?.aborted) {
if (signal && 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?.aborted) {
if (signal && signal.aborted) {
console.log('[preload/profile-api] Already aborted, rejecting');
return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'));
}
@@ -51,7 +51,6 @@ export interface TaskAPI {
options?: import('../../shared/types').TaskRecoveryOptions
) => Promise<IPCResult<TaskRecoveryResult>>;
checkTaskRunning: (taskId: string) => Promise<IPCResult<boolean>>;
resumePausedTask: (taskId: string) => Promise<IPCResult>;
// Image Operations
loadImageThumbnail: (projectPath: string, specId: string, imagePath: string) => Promise<IPCResult<string>>;
@@ -145,9 +144,6 @@ export const createTaskAPI = (): TaskAPI => ({
checkTaskRunning: (taskId: string): Promise<IPCResult<boolean>> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_CHECK_RUNNING, taskId),
resumePausedTask: (taskId: string): Promise<IPCResult> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_RESUME_PAUSED, taskId),
// Image Operations
loadImageThumbnail: (projectPath: string, specId: string, imagePath: string): Promise<IPCResult<string>> =>
ipcRenderer.invoke(IPC_CHANNELS.TASK_LOAD_IMAGE_THUMBNAIL, projectPath, specId, imagePath),

Some files were not shown because too many files have changed in this diff Show More