Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29d6c8cb14 | |||
| 01295eff99 | |||
| a41e5a0eba | |||
| 5ed85e86ab | |||
| 071379a109 | |||
| 0ddd740bd1 | |||
| 2e2b82365f | |||
| acb131b721 | |||
| df528f0650 | |||
| ff91a1af0f | |||
| 6d0222fa9c | |||
| fe08c644c4 |
@@ -40,6 +40,17 @@ 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
|
||||
|
||||
```
|
||||
|
||||
@@ -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 -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.1)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.2)
|
||||
<!-- BETA_VERSION_BADGE_END -->
|
||||
|
||||
<!-- BETA_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **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) |
|
||||
| **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) |
|
||||
<!-- BETA_DOWNLOADS_END -->
|
||||
|
||||
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
|
||||
|
||||
@@ -6,6 +6,7 @@ Shared imports, types, and constants used across agent modules.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -14,7 +15,82 @@ logger = logging.getLogger(__name__)
|
||||
AUTO_CONTINUE_DELAY_SECONDS = 3
|
||||
HUMAN_INTERVENTION_FILE = "PAUSE"
|
||||
|
||||
# Concurrency retry constants
|
||||
MAX_CONCURRENCY_RETRIES = 5
|
||||
INITIAL_RETRY_DELAY_SECONDS = 2
|
||||
MAX_RETRY_DELAY_SECONDS = 32
|
||||
# Retry configuration for 400 tool concurrency errors
|
||||
MAX_CONCURRENCY_RETRIES = 5 # Maximum number of retries for tool concurrency errors
|
||||
INITIAL_RETRY_DELAY_SECONDS = (
|
||||
2 # Initial retry delay (doubles each retry: 2s, 4s, 8s, 16s, 32s)
|
||||
)
|
||||
MAX_RETRY_DELAY_SECONDS = 32 # Cap retry delay at 32 seconds
|
||||
|
||||
# Pause file constants for intelligent error recovery
|
||||
# These files signal pause/resume between frontend and backend
|
||||
RATE_LIMIT_PAUSE_FILE = "RATE_LIMIT_PAUSE" # Created when rate limited
|
||||
AUTH_FAILURE_PAUSE_FILE = "AUTH_PAUSE" # Created when auth fails
|
||||
RESUME_FILE = "RESUME" # Created by frontend to signal resume
|
||||
|
||||
# Maximum time to wait for rate limit reset (2 hours)
|
||||
# If reset time is beyond this, task should fail rather than wait indefinitely
|
||||
MAX_RATE_LIMIT_WAIT_SECONDS = 7200
|
||||
|
||||
# Wait intervals for pause/resume checking
|
||||
RATE_LIMIT_CHECK_INTERVAL_SECONDS = (
|
||||
30 # Check for RESUME file every 30 seconds during rate limit wait
|
||||
)
|
||||
AUTH_RESUME_CHECK_INTERVAL_SECONDS = 10 # Check for re-authentication every 10 seconds
|
||||
AUTH_RESUME_MAX_WAIT_SECONDS = 86400 # Maximum wait for re-authentication (24 hours)
|
||||
|
||||
|
||||
def sanitize_error_message(error_message: str, max_length: int = 500) -> str:
|
||||
"""
|
||||
Sanitize error messages to remove potentially sensitive information.
|
||||
|
||||
Redacts:
|
||||
- API keys (sk-..., key-...)
|
||||
- Bearer tokens
|
||||
- Token/secret values
|
||||
|
||||
Args:
|
||||
error_message: The raw error message to sanitize
|
||||
max_length: Maximum length to truncate to (default 500)
|
||||
|
||||
Returns:
|
||||
Sanitized and truncated error message
|
||||
"""
|
||||
if not error_message:
|
||||
return ""
|
||||
|
||||
# Redact patterns that look like API keys or tokens
|
||||
# Pattern: sk-... (OpenAI/Anthropic keys like sk-ant-api03-...)
|
||||
sanitized = re.sub(
|
||||
r"\bsk-[a-zA-Z0-9._\-]{20,}\b", "[REDACTED_API_KEY]", error_message
|
||||
)
|
||||
|
||||
# Pattern: key-... (generic API keys)
|
||||
sanitized = re.sub(r"\bkey-[a-zA-Z0-9._\-]{20,}\b", "[REDACTED_API_KEY]", sanitized)
|
||||
|
||||
# Pattern: Bearer ... (bearer tokens)
|
||||
sanitized = re.sub(
|
||||
r"\bBearer\s+[a-zA-Z0-9._\-]{20,}\b", "Bearer [REDACTED_TOKEN]", sanitized
|
||||
)
|
||||
|
||||
# Pattern: token= or token: followed by long strings
|
||||
sanitized = re.sub(
|
||||
r"(token[=:]\s*)[a-zA-Z0-9._\-]{20,}\b",
|
||||
r"\1[REDACTED_TOKEN]",
|
||||
sanitized,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Pattern: secret= or secret: followed by strings
|
||||
sanitized = re.sub(
|
||||
r"(secret[=:]\s*)[a-zA-Z0-9._\-]{20,}\b",
|
||||
r"\1[REDACTED_SECRET]",
|
||||
sanitized,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Truncate to max length
|
||||
if len(sanitized) > max_length:
|
||||
sanitized = sanitized[:max_length] + "..."
|
||||
|
||||
return sanitized
|
||||
|
||||
@@ -6,8 +6,11 @@ Main autonomous agent loop that runs the coder agent to implement subtasks.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
@@ -57,11 +60,19 @@ from ui import (
|
||||
)
|
||||
|
||||
from .base import (
|
||||
AUTH_FAILURE_PAUSE_FILE,
|
||||
AUTH_RESUME_CHECK_INTERVAL_SECONDS,
|
||||
AUTH_RESUME_MAX_WAIT_SECONDS,
|
||||
AUTO_CONTINUE_DELAY_SECONDS,
|
||||
HUMAN_INTERVENTION_FILE,
|
||||
INITIAL_RETRY_DELAY_SECONDS,
|
||||
MAX_CONCURRENCY_RETRIES,
|
||||
MAX_RATE_LIMIT_WAIT_SECONDS,
|
||||
MAX_RETRY_DELAY_SECONDS,
|
||||
RATE_LIMIT_CHECK_INTERVAL_SECONDS,
|
||||
RATE_LIMIT_PAUSE_FILE,
|
||||
RESUME_FILE,
|
||||
sanitize_error_message,
|
||||
)
|
||||
from .memory_manager import debug_memory_system_status, get_graphiti_context
|
||||
from .session import post_session_processing, run_agent_session
|
||||
@@ -76,6 +87,219 @@ from .utils import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_and_clear_resume_file(
|
||||
resume_file: Path,
|
||||
pause_file: Path,
|
||||
fallback_resume_file: Path | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if resume file exists and clean up both resume and pause files.
|
||||
|
||||
Also checks a fallback location (main project spec dir) in case the frontend
|
||||
couldn't find the worktree and only wrote the RESUME file there.
|
||||
|
||||
Args:
|
||||
resume_file: Path to RESUME file
|
||||
pause_file: Path to pause file (RATE_LIMIT_PAUSE or AUTH_PAUSE)
|
||||
fallback_resume_file: Optional fallback RESUME file path (e.g. main project spec dir)
|
||||
|
||||
Returns:
|
||||
True if resume file existed (early resume), False otherwise
|
||||
"""
|
||||
found = resume_file.exists()
|
||||
|
||||
# Check fallback location if primary not found
|
||||
if not found and fallback_resume_file and fallback_resume_file.exists():
|
||||
found = True
|
||||
try:
|
||||
fallback_resume_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(f"Error cleaning up fallback resume file: {e}")
|
||||
|
||||
if found:
|
||||
try:
|
||||
resume_file.unlink(missing_ok=True)
|
||||
pause_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(
|
||||
f"Error cleaning up resume files: {e} (resume: {resume_file}, pause: {pause_file})"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def wait_for_rate_limit_reset(
|
||||
spec_dir: Path,
|
||||
wait_seconds: float,
|
||||
source_spec_dir: Path | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Wait for rate limit reset with periodic checks for resume/cancel.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory to check for RESUME file
|
||||
wait_seconds: Maximum time to wait in seconds
|
||||
source_spec_dir: Optional main project spec dir as fallback for RESUME file
|
||||
|
||||
Returns:
|
||||
True if resumed early, False if waited full duration
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
start_time = loop.time()
|
||||
resume_file = spec_dir / RESUME_FILE
|
||||
pause_file = spec_dir / RATE_LIMIT_PAUSE_FILE
|
||||
fallback_resume = (source_spec_dir / RESUME_FILE) if source_spec_dir else None
|
||||
|
||||
while True:
|
||||
# Check elapsed time using loop.time() to avoid drift
|
||||
elapsed = max(0, loop.time() - start_time) # Ensure non-negative
|
||||
if elapsed >= wait_seconds:
|
||||
break
|
||||
|
||||
# Check if user requested resume
|
||||
if _check_and_clear_resume_file(resume_file, pause_file, fallback_resume):
|
||||
return True
|
||||
|
||||
# Wait for next check interval or remaining time
|
||||
sleep_time = min(RATE_LIMIT_CHECK_INTERVAL_SECONDS, wait_seconds - elapsed)
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
# Clean up pause file after wait completes
|
||||
try:
|
||||
pause_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(f"Error cleaning up pause file {pause_file}: {e}")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def wait_for_auth_resume(
|
||||
spec_dir: Path,
|
||||
source_spec_dir: Path | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Wait for user re-authentication signal.
|
||||
|
||||
Blocks until:
|
||||
- RESUME file is created (user completed re-auth in UI)
|
||||
- AUTH_PAUSE file is deleted (alternative resume signal)
|
||||
- Maximum wait timeout is reached (24 hours)
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory to monitor for signal files
|
||||
source_spec_dir: Optional main project spec dir as fallback for RESUME file
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
start_time = loop.time()
|
||||
resume_file = spec_dir / RESUME_FILE
|
||||
pause_file = spec_dir / AUTH_FAILURE_PAUSE_FILE
|
||||
fallback_resume = (source_spec_dir / RESUME_FILE) if source_spec_dir else None
|
||||
|
||||
while True:
|
||||
# Check elapsed time using loop.time() to avoid drift
|
||||
elapsed = max(0, loop.time() - start_time) # Ensure non-negative
|
||||
if elapsed >= AUTH_RESUME_MAX_WAIT_SECONDS:
|
||||
break
|
||||
|
||||
# Check for resume signals
|
||||
if (
|
||||
_check_and_clear_resume_file(resume_file, pause_file, fallback_resume)
|
||||
or not pause_file.exists()
|
||||
):
|
||||
# If pause file was deleted externally, still clean up resume file if it exists
|
||||
if not pause_file.exists():
|
||||
try:
|
||||
resume_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(f"Error cleaning up resume file {resume_file}: {e}")
|
||||
return
|
||||
|
||||
await asyncio.sleep(AUTH_RESUME_CHECK_INTERVAL_SECONDS)
|
||||
|
||||
# Timeout reached - clean up and return
|
||||
print_status(
|
||||
"Authentication wait timeout reached (24 hours) - resuming with original credentials",
|
||||
"warning",
|
||||
)
|
||||
try:
|
||||
pause_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(f"Error cleaning up pause file {pause_file} after timeout: {e}")
|
||||
|
||||
|
||||
def parse_rate_limit_reset_time(error_info: dict | None) -> int | None:
|
||||
"""
|
||||
Parse rate limit reset time from error info.
|
||||
|
||||
Attempts to extract reset time from various formats in error messages.
|
||||
|
||||
TIMEZONE ASSUMPTIONS:
|
||||
- "in X minutes/hours" patterns are timezone-safe (relative time)
|
||||
- "at HH:MM" patterns assume LOCAL timezone, which is reasonable since:
|
||||
1. The user sees timestamps in their local timezone
|
||||
2. The wait calculation happens locally using datetime.now()
|
||||
3. If the API returns UTC "at" times, this would need adjustment
|
||||
(but Claude API typically returns relative times like "in X minutes")
|
||||
|
||||
Args:
|
||||
error_info: Error info dict with 'message' key
|
||||
|
||||
Returns:
|
||||
Unix timestamp of reset time, or None if not parseable
|
||||
"""
|
||||
if not error_info:
|
||||
return None
|
||||
|
||||
message = error_info.get("message", "")
|
||||
|
||||
# Try to find patterns like "resets at 3:00 PM" or "in 5 minutes"
|
||||
# Pattern: "in X minutes/hours" (timezone-safe - relative time)
|
||||
in_time_match = re.search(r"in\s+(\d+)\s*(minute|hour|min|hr)s?", message, re.I)
|
||||
if in_time_match:
|
||||
amount = int(in_time_match.group(1))
|
||||
unit = in_time_match.group(2).lower()
|
||||
if unit.startswith("hour") or unit.startswith("hr"):
|
||||
delta = timedelta(hours=amount)
|
||||
else:
|
||||
delta = timedelta(minutes=amount)
|
||||
return int((datetime.now() + delta).timestamp())
|
||||
|
||||
# Pattern: "at HH:MM" (12 or 24 hour)
|
||||
at_time_match = re.search(r"at\s+(\d{1,2}):(\d{2})(?:\s*(am|pm))?", message, re.I)
|
||||
if at_time_match:
|
||||
try:
|
||||
hour = int(at_time_match.group(1))
|
||||
minute = int(at_time_match.group(2))
|
||||
meridiem = at_time_match.group(3)
|
||||
|
||||
# Validate hour range when meridiem is present
|
||||
# Hours should be 1-12 for AM/PM format
|
||||
if meridiem and not (1 <= hour <= 12):
|
||||
return None
|
||||
|
||||
if meridiem:
|
||||
if meridiem.lower() == "pm" and hour < 12:
|
||||
hour += 12
|
||||
elif meridiem.lower() == "am" and hour == 12:
|
||||
hour = 0
|
||||
|
||||
# Validate hour and minute ranges
|
||||
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
||||
return None
|
||||
|
||||
now = datetime.now()
|
||||
reset_time = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if reset_time <= now:
|
||||
reset_time += timedelta(days=1)
|
||||
return int(reset_time.timestamp())
|
||||
except ValueError:
|
||||
# Invalid time values - return None to fall back to standard retry
|
||||
return None
|
||||
|
||||
# No pattern matched - return None to let caller decide retry behavior
|
||||
return None
|
||||
|
||||
|
||||
async def run_autonomous_agent(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
@@ -682,6 +906,135 @@ async def run_autonomous_agent(
|
||||
current_retry_delay * 2, MAX_RETRY_DELAY_SECONDS
|
||||
)
|
||||
|
||||
elif error_info and error_info.get("type") == "rate_limit":
|
||||
# Rate limit error - intelligent wait for reset
|
||||
_reset_concurrency_state()
|
||||
|
||||
reset_timestamp = parse_rate_limit_reset_time(error_info)
|
||||
if reset_timestamp:
|
||||
wait_seconds = reset_timestamp - datetime.now().timestamp()
|
||||
|
||||
# Handle negative wait_seconds (reset time in the past)
|
||||
if wait_seconds <= 0:
|
||||
print_status(
|
||||
"Rate limit reset time already passed - retrying immediately",
|
||||
"warning",
|
||||
)
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
await asyncio.sleep(2) # Brief delay before retry
|
||||
continue
|
||||
|
||||
if wait_seconds > MAX_RATE_LIMIT_WAIT_SECONDS:
|
||||
# Wait time too long - fail the task
|
||||
print_status("Rate limit wait time too long", "error")
|
||||
print(
|
||||
f"Reset time would require waiting {wait_seconds / 3600:.1f} hours"
|
||||
)
|
||||
print(
|
||||
f"Maximum wait is {MAX_RATE_LIMIT_WAIT_SECONDS / 3600:.1f} hours"
|
||||
)
|
||||
emit_phase(
|
||||
ExecutionPhase.FAILED,
|
||||
"Rate limit wait time exceeds maximum allowed",
|
||||
)
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
break
|
||||
|
||||
# Emit pause phase with reset time for frontend
|
||||
wait_minutes = wait_seconds / 60
|
||||
emit_phase(
|
||||
ExecutionPhase.RATE_LIMIT_PAUSED,
|
||||
f"Rate limit - resuming in {wait_minutes:.0f} minutes",
|
||||
reset_timestamp=reset_timestamp,
|
||||
)
|
||||
|
||||
# Create pause file for frontend detection
|
||||
# Sanitize error message to prevent exposing sensitive data
|
||||
raw_error = error_info.get("message", "Rate limit reached")
|
||||
sanitized_error = (
|
||||
sanitize_error_message(raw_error, max_length=500)
|
||||
or "Rate limit reached"
|
||||
)
|
||||
pause_data = {
|
||||
"paused_at": datetime.now().isoformat(),
|
||||
"reset_timestamp": reset_timestamp,
|
||||
"error": sanitized_error,
|
||||
}
|
||||
pause_file = spec_dir / RATE_LIMIT_PAUSE_FILE
|
||||
pause_file.write_text(json.dumps(pause_data), encoding="utf-8")
|
||||
|
||||
print_status(
|
||||
f"Rate limited - waiting {wait_minutes:.0f} minutes for reset",
|
||||
"warning",
|
||||
)
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
|
||||
# Wait with periodic checks for resume signal
|
||||
resumed_early = await wait_for_rate_limit_reset(
|
||||
spec_dir, wait_seconds, source_spec_dir
|
||||
)
|
||||
if resumed_early:
|
||||
print_status("Resumed early by user", "success")
|
||||
|
||||
# Resume execution
|
||||
emit_phase(ExecutionPhase.CODING, "Resuming after rate limit")
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
continue # Resume the loop
|
||||
else:
|
||||
# Couldn't parse reset time - fall back to standard retry
|
||||
print_status("Rate limit hit (unknown reset time)", "warning")
|
||||
print(muted("Will retry with a fresh session..."))
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS)
|
||||
_reset_concurrency_state()
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
continue
|
||||
|
||||
elif error_info and error_info.get("type") == "authentication":
|
||||
# Authentication error - pause for user re-authentication
|
||||
_reset_concurrency_state()
|
||||
|
||||
emit_phase(
|
||||
ExecutionPhase.AUTH_FAILURE_PAUSED,
|
||||
"Re-authentication required",
|
||||
)
|
||||
|
||||
# Create pause file for frontend detection
|
||||
# Sanitize error message to prevent exposing sensitive data
|
||||
raw_error = error_info.get("message", "Authentication failed")
|
||||
sanitized_error = (
|
||||
sanitize_error_message(raw_error, max_length=500)
|
||||
or "Authentication failed"
|
||||
)
|
||||
pause_data = {
|
||||
"paused_at": datetime.now().isoformat(),
|
||||
"error": sanitized_error,
|
||||
"requires_action": "re-authenticate",
|
||||
}
|
||||
pause_file = spec_dir / AUTH_FAILURE_PAUSE_FILE
|
||||
pause_file.write_text(json.dumps(pause_data), encoding="utf-8")
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(" AUTHENTICATION REQUIRED")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print("OAuth token is invalid or expired.")
|
||||
print("Please re-authenticate in the Auto Claude settings.")
|
||||
print()
|
||||
print("The task will automatically resume once you re-authenticate.")
|
||||
print()
|
||||
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
|
||||
# Wait for user to complete re-authentication
|
||||
await wait_for_auth_resume(spec_dir, source_spec_dir)
|
||||
|
||||
print_status("Authentication restored - resuming", "success")
|
||||
emit_phase(ExecutionPhase.CODING, "Resuming after re-authentication")
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
continue # Resume the loop
|
||||
|
||||
else:
|
||||
# Other errors - use standard retry logic
|
||||
print_status("Session encountered an error", "error")
|
||||
|
||||
@@ -7,6 +7,7 @@ memory updates, recovery tracking, and Linear integration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from claude_agent_sdk import ClaudeSDKClient
|
||||
@@ -34,6 +35,7 @@ from ui import (
|
||||
print_status,
|
||||
)
|
||||
|
||||
from .base import sanitize_error_message
|
||||
from .memory_manager import save_session_memory
|
||||
from .utils import (
|
||||
find_subtask_in_plan,
|
||||
@@ -68,6 +70,93 @@ def is_tool_concurrency_error(error: Exception) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def is_rate_limit_error(error: Exception) -> bool:
|
||||
"""
|
||||
Check if an error is a rate limit error (429 or similar).
|
||||
|
||||
Rate limit errors occur when the API usage quota is exceeded,
|
||||
either for session limits or weekly limits.
|
||||
|
||||
Args:
|
||||
error: The exception to check
|
||||
|
||||
Returns:
|
||||
True if this is a rate limit error, False otherwise
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
|
||||
# Check for HTTP 429 with word boundaries to avoid false positives
|
||||
if re.search(r"\b429\b", error_str):
|
||||
return True
|
||||
|
||||
# Check for other rate limit indicators
|
||||
return any(
|
||||
p in error_str
|
||||
for p in [
|
||||
"limit reached",
|
||||
"rate limit",
|
||||
"too many requests",
|
||||
"usage limit",
|
||||
"quota exceeded",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def is_authentication_error(error: Exception) -> bool:
|
||||
"""
|
||||
Check if an error is an authentication error (401, token expired, etc.).
|
||||
|
||||
Authentication errors occur when OAuth tokens are invalid, expired,
|
||||
or have been revoked (e.g., after token refresh on another process).
|
||||
|
||||
Validation approach:
|
||||
- HTTP 401 status code is checked with word boundaries to minimize false positives
|
||||
- Additional string patterns are validated against lowercase error messages
|
||||
- Patterns are designed to match known Claude API and OAuth error formats
|
||||
|
||||
Known false positive risks:
|
||||
- Generic error messages containing "unauthorized" or "access denied" may match
|
||||
even if not related to authentication (e.g., file permission errors)
|
||||
- Error messages containing these keywords in user-provided content could match
|
||||
- Mitigation: HTTP 401 check provides strong signal; string patterns are secondary
|
||||
|
||||
Real-world validation:
|
||||
- Pattern matching has been tested against actual Claude API error responses
|
||||
- False positive rate is acceptable given the recovery mechanism (prompt user to re-auth)
|
||||
- If false positive occurs, user can simply resume without re-authenticating
|
||||
|
||||
Args:
|
||||
error: The exception to check
|
||||
|
||||
Returns:
|
||||
True if this is an authentication error, False otherwise
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
|
||||
# Check for HTTP 401 with word boundaries to avoid false positives
|
||||
if re.search(r"\b401\b", error_str):
|
||||
return True
|
||||
|
||||
# Check for other authentication indicators
|
||||
# NOTE: "authentication failed" and "authentication error" are more specific patterns
|
||||
# to reduce false positives from generic "authentication" mentions
|
||||
return any(
|
||||
p in error_str
|
||||
for p in [
|
||||
"authentication failed",
|
||||
"authentication error",
|
||||
"unauthorized",
|
||||
"invalid token",
|
||||
"token expired",
|
||||
"authentication_error",
|
||||
"invalid_token",
|
||||
"token_expired",
|
||||
"not authenticated",
|
||||
"http 401",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def post_session_processing(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
@@ -568,7 +657,18 @@ async def run_agent_session(
|
||||
except Exception as e:
|
||||
# Detect specific error types for better retry handling
|
||||
is_concurrency = is_tool_concurrency_error(e)
|
||||
error_type = "tool_concurrency" if is_concurrency else "other"
|
||||
is_rate_limit = is_rate_limit_error(e)
|
||||
is_auth = is_authentication_error(e)
|
||||
|
||||
# Classify error type for appropriate handling
|
||||
if is_concurrency:
|
||||
error_type = "tool_concurrency"
|
||||
elif is_rate_limit:
|
||||
error_type = "rate_limit"
|
||||
elif is_auth:
|
||||
error_type = "authentication"
|
||||
else:
|
||||
error_type = "other"
|
||||
|
||||
debug_error(
|
||||
"session",
|
||||
@@ -579,20 +679,32 @@ async def run_agent_session(
|
||||
tool_count=tool_count,
|
||||
)
|
||||
|
||||
# Log concurrency errors prominently
|
||||
# Sanitize error message to remove potentially sensitive data
|
||||
# Must happen BEFORE printing to stdout, since stdout is captured by the frontend
|
||||
sanitized_error = sanitize_error_message(str(e))
|
||||
|
||||
# Log errors prominently based on type
|
||||
if is_concurrency:
|
||||
print("\n⚠️ Tool concurrency limit reached (400 error)")
|
||||
print(" Claude API limits concurrent tool use in a single request")
|
||||
print(f" Error: {str(e)[:200]}\n")
|
||||
print(f" Error: {sanitized_error[:200]}\n")
|
||||
elif is_rate_limit:
|
||||
print("\n⚠️ Rate limit reached")
|
||||
print(" API usage quota exceeded - waiting for reset")
|
||||
print(f" Error: {sanitized_error[:200]}\n")
|
||||
elif is_auth:
|
||||
print("\n⚠️ Authentication error")
|
||||
print(" OAuth token may be invalid or expired")
|
||||
print(f" Error: {sanitized_error[:200]}\n")
|
||||
else:
|
||||
print(f"Error during agent session: {e}")
|
||||
print(f"Error during agent session: {sanitized_error}")
|
||||
|
||||
if task_logger:
|
||||
task_logger.log_error(f"Session error: {e}", phase)
|
||||
task_logger.log_error(f"Session error: {sanitized_error}", phase)
|
||||
|
||||
error_info = {
|
||||
"type": error_type,
|
||||
"message": str(e),
|
||||
"message": sanitized_error,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
return "error", str(e), error_info
|
||||
return "error", sanitized_error, error_info
|
||||
|
||||
@@ -23,6 +23,9 @@ class ExecutionPhase(str, Enum):
|
||||
QA_FIXING = "qa_fixing"
|
||||
COMPLETE = "complete"
|
||||
FAILED = "failed"
|
||||
# Pause states for intelligent error recovery
|
||||
RATE_LIMIT_PAUSED = "rate_limit_paused"
|
||||
AUTH_FAILURE_PAUSED = "auth_failure_paused"
|
||||
|
||||
|
||||
def emit_phase(
|
||||
@@ -31,8 +34,19 @@ def emit_phase(
|
||||
*,
|
||||
progress: int | None = None,
|
||||
subtask: str | None = None,
|
||||
reset_timestamp: int | None = None,
|
||||
profile_id: str | None = None,
|
||||
) -> None:
|
||||
"""Emit structured phase event to stdout for frontend parsing."""
|
||||
"""Emit structured phase event to stdout for frontend parsing.
|
||||
|
||||
Args:
|
||||
phase: The execution phase (e.g., PLANNING, CODING, RATE_LIMIT_PAUSED)
|
||||
message: Optional message describing the phase state
|
||||
progress: Optional progress percentage (0-100)
|
||||
subtask: Optional subtask identifier
|
||||
reset_timestamp: Optional Unix timestamp for rate limit reset time
|
||||
profile_id: Optional profile ID that triggered the pause
|
||||
"""
|
||||
phase_value = phase.value if isinstance(phase, ExecutionPhase) else phase
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
@@ -48,6 +62,12 @@ def emit_phase(
|
||||
if subtask is not None:
|
||||
payload["subtask"] = subtask
|
||||
|
||||
if reset_timestamp is not None:
|
||||
payload["reset_timestamp"] = reset_timestamp
|
||||
|
||||
if profile_id is not None:
|
||||
payload["profile_id"] = profile_id
|
||||
|
||||
try:
|
||||
print(f"{PHASE_MARKER_PREFIX}{json.dumps(payload, default=str)}", flush=True)
|
||||
except (OSError, UnicodeEncodeError) as e:
|
||||
|
||||
@@ -12,12 +12,8 @@ The refactored code is now organized as:
|
||||
- graphiti/search.py - Semantic search logic
|
||||
- graphiti/schema.py - Graph schema definitions
|
||||
|
||||
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
|
||||
Import from this module:
|
||||
from integrations.graphiti.memory import GraphitiMemory, is_graphiti_enabled, 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 graphiti_memory import GraphitiMemory, GroupIdMode
|
||||
from integrations.graphiti.memory import GraphitiMemory, GroupIdMode
|
||||
|
||||
# Determine project directory from project_id or use current dir
|
||||
project_dir = Path.cwd()
|
||||
|
||||
@@ -17,7 +17,7 @@ from core.sentry import capture_exception
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from graphiti_memory import GraphitiMemory
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
|
||||
|
||||
def is_graphiti_memory_enabled() -> bool:
|
||||
@@ -60,7 +60,7 @@ async def get_graphiti_memory(
|
||||
return None
|
||||
|
||||
try:
|
||||
from graphiti_memory import GraphitiMemory, GroupIdMode
|
||||
from integrations.graphiti.memory import GraphitiMemory, GroupIdMode
|
||||
|
||||
if project_dir is None:
|
||||
project_dir = spec_dir.parent.parent
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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.
|
||||
|
||||
@@ -96,6 +96,8 @@
|
||||
"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, TerminalCreateOptions } from '../../shared/types';
|
||||
import type { ClaudeProfile, IPCResult, } 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, callb
|
||||
return childProcess as unknown as import('child_process').ChildProcess;
|
||||
});
|
||||
|
||||
const mockExec = vi.fn((cmd: unknown, options: unknown, callback: unknown) => {
|
||||
const mockExec = vi.fn((_cmd: unknown, _options: unknown, callback: unknown) => {
|
||||
// Return a minimal ChildProcess-like object
|
||||
const childProcess = {
|
||||
stdout: { on: vi.fn() },
|
||||
|
||||
@@ -61,15 +61,17 @@ import path from 'path';
|
||||
import { isValidConfigDir } from '../utils/config-path-validator';
|
||||
|
||||
describe('isValidConfigDir - Security Validation', () => {
|
||||
let originalHomedir: string;
|
||||
let consoleWarnSpy: any;
|
||||
let _originalHomedir: string;
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Store original homedir for restoration
|
||||
originalHomedir = os.homedir();
|
||||
_originalHomedir = os.homedir();
|
||||
|
||||
// Spy on console.warn to suppress warning output during tests
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {
|
||||
/* intentionally empty - suppress console output during tests */
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('PythonEnvManager', () => {
|
||||
const sitePackagesPath = 'C:\\test\\site-packages';
|
||||
|
||||
// Access private property for testing
|
||||
(manager as any).sitePackagesPath = sitePackagesPath;
|
||||
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
|
||||
|
||||
const env = manager.getPythonEnv();
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('PythonEnvManager', () => {
|
||||
const sitePackagesPath = 'C:\\test\\site-packages';
|
||||
|
||||
// Access private property for testing
|
||||
(manager as any).sitePackagesPath = sitePackagesPath;
|
||||
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
|
||||
|
||||
const env = manager.getPythonEnv();
|
||||
|
||||
@@ -125,7 +125,7 @@ describe('PythonEnvManager', () => {
|
||||
const sitePackagesPath = '/test/site-packages';
|
||||
|
||||
// Access private property for testing
|
||||
(manager as any).sitePackagesPath = sitePackagesPath;
|
||||
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
|
||||
|
||||
const env = manager.getPythonEnv();
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('PythonEnvManager', () => {
|
||||
const sitePackagesPath = 'C:\\test\\site-packages';
|
||||
|
||||
// Access private property for testing
|
||||
(manager as any).sitePackagesPath = sitePackagesPath;
|
||||
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
|
||||
|
||||
// Save and clear existing PATH, then set lowercase 'Path'
|
||||
// This simulates a Windows environment where the system has 'Path' instead of 'PATH'
|
||||
|
||||
@@ -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,24 +243,31 @@ describe('SETTINGS_CLAUDE_CODE_GET_ONBOARDING_STATUS handler', () => {
|
||||
// Get the mocked functions (vitest stores the implementation)
|
||||
const { existsSync, readFileSync } = await import('fs');
|
||||
|
||||
// Cast to vi.Mock for accessing mock methods
|
||||
type MockFn = ReturnType<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
|
||||
const originalExistsSync = (existsSync as any).getMockImplementation();
|
||||
const originalReadFileSync = (readFileSync as any).getMockImplementation();
|
||||
type ExistsSyncFn = (path: string) => boolean;
|
||||
type ReadFileSyncFn = (path: string) => string;
|
||||
const originalExistsSync: ExistsSyncFn = (existsSyncMock.getMockImplementation() as ExistsSyncFn | undefined) ?? (() => false);
|
||||
const originalReadFileSync: ReadFileSyncFn = (readFileSyncMock.getMockImplementation() as ReadFileSyncFn | undefined) ?? (() => '');
|
||||
|
||||
// Override existsSync to make file appear to exist
|
||||
(existsSync as any).mockImplementation((path: string) => {
|
||||
existsSyncMock.mockImplementation((path: string) => {
|
||||
if (path === claudeJsonPath) {
|
||||
return true; // File appears to exist
|
||||
}
|
||||
return originalExistsSync ? originalExistsSync(path) : false;
|
||||
return originalExistsSync(path);
|
||||
});
|
||||
|
||||
// Override readFileSync to throw error for our specific file
|
||||
(readFileSync as any).mockImplementation((path: string) => {
|
||||
readFileSyncMock.mockImplementation((path: string) => {
|
||||
if (path === claudeJsonPath) {
|
||||
throw new Error('EACCES: permission denied, open \'' + path + '\'');
|
||||
}
|
||||
return originalReadFileSync ? originalReadFileSync(path) : '';
|
||||
return originalReadFileSync(path);
|
||||
});
|
||||
|
||||
const result = await onboardingStatusHandler({}, null) as {
|
||||
@@ -272,8 +279,8 @@ describe('SETTINGS_CLAUDE_CODE_GET_ONBOARDING_STATUS handler', () => {
|
||||
expect(result.data?.hasCompletedOnboarding).toBe(false);
|
||||
|
||||
// Restore original mocks
|
||||
(existsSync as any).mockImplementation(originalExistsSync);
|
||||
(readFileSync as any).mockImplementation(originalReadFileSync);
|
||||
existsSyncMock.mockImplementation(originalExistsSync);
|
||||
readFileSyncMock.mockImplementation(originalReadFileSync);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,7 +178,9 @@ describe("safeSendToRenderer", () => {
|
||||
|
||||
describe("error handling - non-disposal errors", () => {
|
||||
it("catches other errors and returns false", () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
|
||||
/* intentionally empty - suppress console output during tests */
|
||||
});
|
||||
|
||||
mockSend.mockImplementation(() => {
|
||||
throw new Error("Some other IPC error");
|
||||
@@ -216,7 +218,9 @@ describe("safeSendToRenderer", () => {
|
||||
});
|
||||
|
||||
it("logs console.warn only once for multiple consecutive calls to same channel", () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||
/* intentionally empty - suppress console output during tests */
|
||||
});
|
||||
|
||||
mockWindow = {
|
||||
isDestroyed: vi.fn(() => true),
|
||||
@@ -242,7 +246,9 @@ describe("safeSendToRenderer", () => {
|
||||
});
|
||||
|
||||
it("logs console.warn separately for different channels", () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||
/* intentionally empty - suppress console output during tests */
|
||||
});
|
||||
|
||||
mockWindow = {
|
||||
isDestroyed: vi.fn(() => true),
|
||||
@@ -307,7 +313,9 @@ describe("safeSendToRenderer", () => {
|
||||
|
||||
describe("warning pruning logic - 100-entry hard cap", () => {
|
||||
it("enforces 100-entry cap by removing oldest entries when exceeded", async () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||
/* intentionally empty - suppress console output during tests */
|
||||
});
|
||||
|
||||
mockWindow = {
|
||||
isDestroyed: vi.fn(() => true),
|
||||
@@ -340,7 +348,9 @@ describe("safeSendToRenderer", () => {
|
||||
});
|
||||
|
||||
it("handles many unique channels without throwing errors", async () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||
/* intentionally empty - suppress console output during tests */
|
||||
});
|
||||
|
||||
mockWindow = {
|
||||
isDestroyed: vi.fn(() => true),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { parsePhaseEvent } from './phase-event-parser';
|
||||
import {
|
||||
wouldPhaseRegress,
|
||||
isTerminalPhase,
|
||||
isPausePhase,
|
||||
isValidExecutionPhase,
|
||||
type ExecutionPhase
|
||||
} from '../../shared/constants/phase-protocol';
|
||||
@@ -13,18 +14,48 @@ export class AgentEvents {
|
||||
log: string,
|
||||
currentPhase: ExecutionProgressData['phase'],
|
||||
isSpecRunner: boolean
|
||||
): { phase: ExecutionProgressData['phase']; message?: string; currentSubtask?: string } | null {
|
||||
): {
|
||||
phase: ExecutionProgressData['phase'];
|
||||
message?: string;
|
||||
currentSubtask?: string;
|
||||
resetTimestamp?: number;
|
||||
profileId?: string;
|
||||
} | null {
|
||||
const structuredEvent = parsePhaseEvent(log);
|
||||
if (structuredEvent) {
|
||||
return {
|
||||
phase: structuredEvent.phase as ExecutionProgressData['phase'],
|
||||
// structuredEvent.phase is validated as BackendPhase (via Zod schema),
|
||||
// which is a subset of ExecutionPhase, so this assertion is safe
|
||||
const result: {
|
||||
phase: ExecutionProgressData['phase'];
|
||||
message?: string;
|
||||
currentSubtask?: string;
|
||||
resetTimestamp?: number;
|
||||
profileId?: string;
|
||||
} = {
|
||||
phase: structuredEvent.phase as ExecutionPhase,
|
||||
message: structuredEvent.message,
|
||||
currentSubtask: structuredEvent.subtask
|
||||
};
|
||||
|
||||
// Include pause phase metadata if present
|
||||
if (structuredEvent.reset_timestamp !== undefined) {
|
||||
result.resetTimestamp = structuredEvent.reset_timestamp;
|
||||
}
|
||||
if (structuredEvent.profile_id !== undefined) {
|
||||
result.profileId = structuredEvent.profile_id;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Terminal states can't be changed by fallback matching
|
||||
if (isTerminalPhase(currentPhase as ExecutionPhase)) {
|
||||
if (isTerminalPhase(currentPhase)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pause phases should only be changed by structured events
|
||||
// Don't allow fallback text matching to transition out of pause phases
|
||||
if (isPausePhase(currentPhase)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { AgentEvents } from './agent-events';
|
||||
import { AgentProcessManager } from './agent-process';
|
||||
import { AgentQueueManager } from './agent-queue';
|
||||
import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager';
|
||||
import type { ClaudeProfileManager } from '../claude-profile-manager';
|
||||
import { getOperationRegistry } from '../claude-profile/operation-registry';
|
||||
import {
|
||||
SpecCreationMetadata,
|
||||
TaskExecutionOptions,
|
||||
@@ -33,6 +35,8 @@ export class AgentManager extends EventEmitter {
|
||||
baseBranch?: string;
|
||||
swapCount: number;
|
||||
projectId?: string;
|
||||
/** Generation counter to prevent stale cleanup after restart */
|
||||
generation: number;
|
||||
}> = new Map();
|
||||
|
||||
constructor() {
|
||||
@@ -57,21 +61,35 @@ export class AgentManager extends EventEmitter {
|
||||
// 1. Task completed successfully (code === 0), or
|
||||
// 2. Task failed and won't be restarted (handled by auto-swap logic)
|
||||
|
||||
// Capture generation at exit time to prevent race conditions with restarts
|
||||
const contextAtExit = this.taskExecutionContext.get(taskId);
|
||||
const generationAtExit = contextAtExit?.generation;
|
||||
|
||||
// Note: Auto-swap restart happens BEFORE this exit event is processed,
|
||||
// so we need a small delay to allow restart to preserve context
|
||||
setTimeout(() => {
|
||||
const context = this.taskExecutionContext.get(taskId);
|
||||
if (!context) return; // Already cleaned up or restarted
|
||||
|
||||
// Check if the context's generation matches - if not, a restart incremented it
|
||||
// and this cleanup is for a stale exit event that shouldn't affect the new task
|
||||
if (generationAtExit !== undefined && context.generation !== generationAtExit) {
|
||||
return; // Stale exit event - task was restarted, don't clean up new context
|
||||
}
|
||||
|
||||
// If task completed successfully, always clean up
|
||||
if (code === 0) {
|
||||
this.taskExecutionContext.delete(taskId);
|
||||
// Unregister from OperationRegistry
|
||||
getOperationRegistry().unregisterOperation(taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
// If task failed and hit max retries, clean up
|
||||
if (context.swapCount >= 2) {
|
||||
this.taskExecutionContext.delete(taskId);
|
||||
// Unregister from OperationRegistry
|
||||
getOperationRegistry().unregisterOperation(taskId);
|
||||
}
|
||||
// Otherwise keep context for potential restart
|
||||
}, 1000); // Delay to allow restart logic to run first
|
||||
@@ -85,6 +103,46 @@ export class AgentManager extends EventEmitter {
|
||||
this.processManager.configure(pythonPath, autoBuildSourcePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a task with the unified OperationRegistry for proactive swap support.
|
||||
* Extracted helper to avoid code duplication between spec creation and task execution.
|
||||
* @private
|
||||
*/
|
||||
private registerTaskWithOperationRegistry(
|
||||
taskId: string,
|
||||
operationType: 'spec-creation' | 'task-execution',
|
||||
metadata: Record<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
|
||||
*/
|
||||
@@ -99,7 +157,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;
|
||||
let profileManager: ClaudeProfileManager;
|
||||
try {
|
||||
profileManager = await initializeClaudeProfileManager();
|
||||
} catch (error) {
|
||||
@@ -177,6 +235,9 @@ export class AgentManager extends EventEmitter {
|
||||
// Store context for potential restart
|
||||
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch, projectId);
|
||||
|
||||
// Register with unified OperationRegistry for proactive swap support
|
||||
this.registerTaskWithOperationRegistry(taskId, 'spec-creation', { projectPath, taskDescription, specDir });
|
||||
|
||||
// Note: This is spec-creation but it chains to task-execution via run.py
|
||||
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId);
|
||||
}
|
||||
@@ -193,7 +254,7 @@ export class AgentManager extends EventEmitter {
|
||||
): Promise<void> {
|
||||
// Pre-flight auth check: Verify active profile has valid authentication
|
||||
// Ensure profile manager is initialized to prevent race condition
|
||||
let profileManager;
|
||||
let profileManager: ClaudeProfileManager;
|
||||
try {
|
||||
profileManager = await initializeClaudeProfileManager();
|
||||
} catch (error) {
|
||||
@@ -256,6 +317,9 @@ export class AgentManager extends EventEmitter {
|
||||
// Store context for potential restart
|
||||
this.storeTaskContext(taskId, projectPath, specId, options, false, undefined, undefined, undefined, undefined, projectId);
|
||||
|
||||
// Register with unified OperationRegistry for proactive swap support
|
||||
this.registerTaskWithOperationRegistry(taskId, 'task-execution', { projectPath, specId, options });
|
||||
|
||||
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId);
|
||||
}
|
||||
|
||||
@@ -397,6 +461,8 @@ export class AgentManager extends EventEmitter {
|
||||
// Preserve swapCount if context already exists (for restarts)
|
||||
const existingContext = this.taskExecutionContext.get(taskId);
|
||||
const swapCount = existingContext?.swapCount ?? 0;
|
||||
// Increment generation on each store (restarts) to invalidate pending cleanup callbacks
|
||||
const generation = (existingContext?.generation ?? 0) + 1;
|
||||
|
||||
this.taskExecutionContext.set(taskId, {
|
||||
projectPath,
|
||||
@@ -408,7 +474,8 @@ export class AgentManager extends EventEmitter {
|
||||
metadata,
|
||||
baseBranch,
|
||||
swapCount, // Preserve existing count instead of resetting
|
||||
projectId
|
||||
projectId,
|
||||
generation, // Incremented to prevent stale exit cleanup
|
||||
});
|
||||
}
|
||||
|
||||
@@ -464,10 +531,14 @@ export class AgentManager extends EventEmitter {
|
||||
console.log('[AgentManager] Restarting task now:', taskId);
|
||||
if (context.isSpecCreation) {
|
||||
console.log('[AgentManager] Restarting as spec creation');
|
||||
if (!context.taskDescription) {
|
||||
console.error('[AgentManager] Cannot restart spec creation: taskDescription is missing');
|
||||
return;
|
||||
}
|
||||
this.startSpecCreation(
|
||||
taskId,
|
||||
context.projectPath,
|
||||
context.taskDescription!,
|
||||
context.taskDescription,
|
||||
context.specDir,
|
||||
context.metadata,
|
||||
context.baseBranch,
|
||||
|
||||
@@ -13,7 +13,7 @@ function createMockProcess() {
|
||||
return {
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event: string, callback: any) => {
|
||||
on: vi.fn((event: string, callback: (code: number) => void) => {
|
||||
if (event === 'exit') {
|
||||
// Simulate immediate exit with code 0
|
||||
setTimeout(() => callback(0), 10);
|
||||
|
||||
@@ -281,7 +281,11 @@ export class AgentProcessManager {
|
||||
} : 'NONE');
|
||||
|
||||
if (!bestProfile) {
|
||||
console.log('[AgentProcess] No alternative profile available - falling back to manual modal');
|
||||
// Single account case: let backend handle with intelligent pause
|
||||
// Don't show manual modal - backend will pause intelligently and resume when ready
|
||||
console.log('[AgentProcess] No alternative profile - backend will handle with intelligent pause');
|
||||
// Return false to let handleProcessFailure emit sdk-rate-limit event
|
||||
// The frontend can then show appropriate UI (e.g., "Paused until X time")
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -306,19 +310,84 @@ export class AgentProcessManager {
|
||||
console.log('[AgentProcess] No rate limit detected - checking for auth failure');
|
||||
const authFailureDetection = detectAuthFailure(allOutput);
|
||||
|
||||
if (authFailureDetection.isAuthFailure) {
|
||||
console.log('[AgentProcess] Auth failure detected:', authFailureDetection);
|
||||
if (!authFailureDetection.isAuthFailure) {
|
||||
console.log('[AgentProcess] Process failed but no rate limit or auth failure detected');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[AgentProcess] Auth failure detected:', authFailureDetection);
|
||||
|
||||
// Try auto-swap if enabled
|
||||
const wasHandled = this.handleAuthFailureWithAutoSwap(taskId, authFailureDetection);
|
||||
|
||||
if (!wasHandled) {
|
||||
// Fall back to UI notification
|
||||
this.emitter.emit('auth-failure', taskId, {
|
||||
profileId: authFailureDetection.profileId,
|
||||
failureType: authFailureDetection.failureType,
|
||||
message: authFailureDetection.message,
|
||||
originalError: authFailureDetection.originalError
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('[AgentProcess] Process failed but no rate limit or auth failure detected');
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to auto-swap to another profile on authentication failure.
|
||||
* Only works when autoSwitchOnAuthFailure is enabled and an alternative
|
||||
* authenticated profile is available.
|
||||
*/
|
||||
private handleAuthFailureWithAutoSwap(
|
||||
taskId: string,
|
||||
authFailureDetection: ReturnType<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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,9 +24,24 @@ describe('AgentState - Queue Routing', () => {
|
||||
|
||||
it('should group tasks by profile', () => {
|
||||
// Add mock processes
|
||||
state.addProcess('task-1', { pid: 1001 } as any);
|
||||
state.addProcess('task-2', { pid: 1002 } as any);
|
||||
state.addProcess('task-3', { pid: 1003 } as any);
|
||||
state.addProcess('task-1', {
|
||||
taskId: 'task-1',
|
||||
process: { pid: 1001 } as unknown as import('child_process').ChildProcess,
|
||||
startedAt: new Date(),
|
||||
spawnId: 1
|
||||
});
|
||||
state.addProcess('task-2', {
|
||||
taskId: 'task-2',
|
||||
process: { pid: 1002 } as unknown as import('child_process').ChildProcess,
|
||||
startedAt: new Date(),
|
||||
spawnId: 2
|
||||
});
|
||||
state.addProcess('task-3', {
|
||||
taskId: 'task-3',
|
||||
process: { pid: 1003 } as unknown as import('child_process').ChildProcess,
|
||||
startedAt: new Date(),
|
||||
spawnId: 3
|
||||
});
|
||||
|
||||
// Assign profiles
|
||||
state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive');
|
||||
@@ -42,7 +57,12 @@ describe('AgentState - Queue Routing', () => {
|
||||
|
||||
it('should use default profile for unassigned tasks', () => {
|
||||
// Add process without profile assignment
|
||||
state.addProcess('task-1', { pid: 1001 } as any);
|
||||
state.addProcess('task-1', {
|
||||
taskId: 'task-1',
|
||||
process: { pid: 1001 } as unknown as import('child_process').ChildProcess,
|
||||
startedAt: new Date(),
|
||||
spawnId: 1
|
||||
});
|
||||
|
||||
const result = state.getRunningTasksByProfile();
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ 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,6 +9,7 @@ import { BasePhaseParser, type PhaseParseResult, type PhaseParserContext } from
|
||||
import {
|
||||
EXECUTION_PHASES,
|
||||
TERMINAL_PHASES,
|
||||
isPausePhase,
|
||||
type ExecutionPhase
|
||||
} from '../../../shared/constants/phase-protocol';
|
||||
import { parsePhaseEvent } from '../phase-event-parser';
|
||||
@@ -40,11 +41,21 @@ export class ExecutionPhaseParser extends BasePhaseParser<ExecutionPhase> {
|
||||
// 1. Try structured event first (authoritative source)
|
||||
const structuredEvent = parsePhaseEvent(log);
|
||||
if (structuredEvent) {
|
||||
return {
|
||||
const result: PhaseParseResult<ExecutionPhase> = {
|
||||
phase: structuredEvent.phase as ExecutionPhase,
|
||||
message: structuredEvent.message,
|
||||
currentSubtask: structuredEvent.subtask
|
||||
};
|
||||
|
||||
// Include pause phase metadata if present
|
||||
if (structuredEvent.reset_timestamp !== undefined) {
|
||||
result.resetTimestamp = structuredEvent.reset_timestamp;
|
||||
}
|
||||
if (structuredEvent.profile_id !== undefined) {
|
||||
result.profileId = structuredEvent.profile_id;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. Terminal states can't be changed by fallback matching
|
||||
@@ -52,7 +63,13 @@ export class ExecutionPhaseParser extends BasePhaseParser<ExecutionPhase> {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. Fall back to text pattern matching
|
||||
// 3. Pause phases should only be changed by structured events
|
||||
// Don't allow fallback text matching to transition out of pause phases
|
||||
if (isPausePhase(context.currentPhase)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. Fall back to text pattern matching
|
||||
return this.parseFallbackPatterns(log, context);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ export const PhaseEventSchema = z.object({
|
||||
phase: BackendPhaseSchema,
|
||||
message: z.string().default(''),
|
||||
progress: z.number().int().min(0).max(100).optional(),
|
||||
subtask: z.string().optional()
|
||||
subtask: z.string().optional(),
|
||||
// Pause phase metadata
|
||||
reset_timestamp: z.number().int().optional(), // Unix timestamp for rate limit reset
|
||||
profile_id: z.string().optional() // Profile that hit the limit
|
||||
});
|
||||
|
||||
export type PhaseEventPayload = z.infer<typeof PhaseEventSchema>;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ChildProcess } from 'child_process';
|
||||
import type { IdeationConfig } from '../../shared/types';
|
||||
import type { CompletablePhase } from '../../shared/constants/phase-protocol';
|
||||
import type { CompletablePhase, ExecutionPhase } from '../../shared/constants/phase-protocol';
|
||||
import type { TaskEventPayload } from './task-event-schema';
|
||||
|
||||
/**
|
||||
@@ -19,7 +18,7 @@ export interface AgentProcess {
|
||||
}
|
||||
|
||||
export interface ExecutionProgressData {
|
||||
phase: 'idle' | 'planning' | 'coding' | 'qa_review' | 'qa_fixing' | 'complete' | 'failed';
|
||||
phase: ExecutionPhase;
|
||||
phaseProgress: number;
|
||||
overallProgress: number;
|
||||
currentSubtask?: string;
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* - 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';
|
||||
@@ -24,6 +26,7 @@ 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';
|
||||
@@ -91,10 +94,13 @@ 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}`);
|
||||
console.warn(`[app-updater] Update channel set to: ${channel}, allowPrerelease: ${autoUpdater.allowPrerelease}`);
|
||||
}
|
||||
|
||||
// Enable more verbose logging in debug mode
|
||||
@@ -187,8 +193,7 @@ 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,
|
||||
stack: error.stack
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -293,13 +298,57 @@ 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(): void {
|
||||
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;
|
||||
}
|
||||
|
||||
console.warn('[app-updater] Quitting and installing update');
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -402,7 +451,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)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: Intentionally matching control chars for sanitization
|
||||
const safeVersion = String(version).replace(/[\x00-\x1f\x7f]/g, '').slice(0, 50);
|
||||
console.warn('[app-updater] Found latest stable release:', safeVersion);
|
||||
|
||||
@@ -483,11 +532,8 @@ export async function setUpdateChannelWithDowngradeCheck(
|
||||
channel: UpdateChannel,
|
||||
triggerDowngradeCheck = false
|
||||
): Promise<AppUpdateInfo | null> {
|
||||
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}`);
|
||||
// Use the shared channel-setting function to avoid code duplication
|
||||
setUpdateChannel(channel);
|
||||
|
||||
// If switching to stable and downgrade check requested, look for stable version
|
||||
if (channel === 'latest' && triggerDowngradeCheck) {
|
||||
@@ -509,8 +555,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
|
||||
autoUpdater.channel = 'latest';
|
||||
// Switch to stable channel (resets allowPrerelease and clears downloadedUpdateInfo)
|
||||
setUpdateChannel('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,7 +44,6 @@ 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;
|
||||
@@ -454,7 +453,7 @@ export class ChangelogService extends EventEmitter {
|
||||
}
|
||||
|
||||
const parts = currentVersion.split('.').map(Number);
|
||||
if (parts.length !== 3 || parts.some(isNaN)) {
|
||||
if (parts.length !== 3 || parts.some(Number.isNaN)) {
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
@@ -491,7 +490,7 @@ export class ChangelogService extends EventEmitter {
|
||||
* Suggest version using AI analysis of git commits
|
||||
*/
|
||||
async suggestVersionFromCommits(
|
||||
projectPath: string,
|
||||
_projectPath: string,
|
||||
commits: import('../../shared/types').GitCommit[],
|
||||
currentVersion?: string
|
||||
): Promise<{ version: string; reason: string }> {
|
||||
@@ -502,7 +501,7 @@ export class ChangelogService extends EventEmitter {
|
||||
}
|
||||
|
||||
const parts = currentVersion.split('.').map(Number);
|
||||
if (parts.length !== 3 || parts.some(isNaN)) {
|
||||
if (parts.length !== 3 || parts.some(Number.isNaN)) {
|
||||
return { version: '1.0.0', reason: 'Invalid current version, resetting to 1.0.0' };
|
||||
}
|
||||
|
||||
|
||||
@@ -198,7 +198,6 @@ except Exception as e:
|
||||
case 'minor':
|
||||
newVersion = `${major}.${minor + 1}.0`;
|
||||
break;
|
||||
case 'patch':
|
||||
default:
|
||||
newVersion = `${major}.${minor}.${patch + 1}`;
|
||||
break;
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
clearRateLimitEvents as clearRateLimitEventsImpl
|
||||
} from './claude-profile/rate-limit-manager';
|
||||
import {
|
||||
loadProfileStore,
|
||||
loadProfileStoreAsync,
|
||||
saveProfileStore,
|
||||
ProfileStoreData,
|
||||
@@ -196,31 +195,6 @@ export class ClaudeProfileManager {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load profiles from disk
|
||||
*/
|
||||
private load(): ProfileStoreData {
|
||||
const loadedData = loadProfileStore(this.storePath);
|
||||
if (loadedData) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[ClaudeProfileManager] Loaded profiles:', {
|
||||
count: loadedData.profiles.length,
|
||||
activeProfileId: loadedData.activeProfileId,
|
||||
profiles: loadedData.profiles.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
email: p.email,
|
||||
isDefault: p.isDefault
|
||||
}))
|
||||
});
|
||||
}
|
||||
return loadedData;
|
||||
}
|
||||
|
||||
// Return default with a single "Default" profile
|
||||
return this.createDefaultData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default profile data
|
||||
*
|
||||
|
||||
@@ -0,0 +1,673 @@
|
||||
/**
|
||||
* Unit tests for OperationRegistry
|
||||
*
|
||||
* Tests cover:
|
||||
* - Singleton pattern
|
||||
* - Operation registration/unregistration
|
||||
* - Profile-based querying
|
||||
* - Summary generation
|
||||
* - Operation restart functionality
|
||||
* - Event emissions
|
||||
* - Edge cases
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
getOperationRegistry,
|
||||
resetOperationRegistry,
|
||||
type OperationType,
|
||||
} from '../operation-registry';
|
||||
|
||||
describe('OperationRegistry', () => {
|
||||
beforeEach(() => {
|
||||
// Reset registry before each test
|
||||
resetOperationRegistry();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up after each test
|
||||
resetOperationRegistry();
|
||||
});
|
||||
|
||||
describe('Singleton Pattern', () => {
|
||||
it('should return the same instance on multiple calls', () => {
|
||||
const instance1 = getOperationRegistry();
|
||||
const instance2 = getOperationRegistry();
|
||||
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
|
||||
it('should create new instance after reset', () => {
|
||||
const instance1 = getOperationRegistry();
|
||||
resetOperationRegistry();
|
||||
const instance2 = getOperationRegistry();
|
||||
|
||||
expect(instance1).not.toBe(instance2);
|
||||
});
|
||||
|
||||
it('should clear all operations on reset', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'spec-creation',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart
|
||||
);
|
||||
|
||||
expect(registry.getOperationCount()).toBe(1);
|
||||
|
||||
resetOperationRegistry();
|
||||
const newRegistry = getOperationRegistry();
|
||||
|
||||
expect(newRegistry.getOperationCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerOperation', () => {
|
||||
it('should register a basic operation', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'spec-creation',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart
|
||||
);
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation).toBeDefined();
|
||||
expect(operation?.id).toBe('op1');
|
||||
expect(operation?.type).toBe('spec-creation');
|
||||
expect(operation?.profileId).toBe('profile1');
|
||||
expect(operation?.profileName).toBe('Profile 1');
|
||||
expect(operation?.restartFn).toBe(mockRestart);
|
||||
expect(operation?.startedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should register operation with optional stopFn', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
const mockStop = vi.fn();
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'pr-review',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart,
|
||||
{ stopFn: mockStop }
|
||||
);
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation?.stopFn).toBe(mockStop);
|
||||
});
|
||||
|
||||
it('should register operation with metadata', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
const metadata = { projectId: 'proj1', prNumber: 123 };
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'pr-review',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart,
|
||||
{ metadata }
|
||||
);
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation?.metadata).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('should emit operation-registered event', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
const eventListener = vi.fn();
|
||||
|
||||
registry.on('operation-registered', eventListener);
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'task-execution',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart
|
||||
);
|
||||
|
||||
expect(eventListener).toHaveBeenCalledTimes(1);
|
||||
const emittedOperation = eventListener.mock.calls[0][0];
|
||||
expect(emittedOperation.id).toBe('op1');
|
||||
expect(emittedOperation.type).toBe('task-execution');
|
||||
});
|
||||
|
||||
it('should increment operation count', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
expect(registry.getOperationCount()).toBe(0);
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
expect(registry.getOperationCount()).toBe(1);
|
||||
|
||||
registry.registerOperation('op2', 'roadmap', 'profile1', 'Profile 1', mockRestart);
|
||||
expect(registry.getOperationCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('should allow registering multiple operation types', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
const types: OperationType[] = [
|
||||
'spec-creation',
|
||||
'task-execution',
|
||||
'pr-review',
|
||||
'mr-review',
|
||||
'insights',
|
||||
'roadmap',
|
||||
'changelog',
|
||||
'ideation',
|
||||
'triage',
|
||||
'other',
|
||||
];
|
||||
|
||||
types.forEach((type, index) => {
|
||||
registry.registerOperation(
|
||||
`op${index}`,
|
||||
type,
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart
|
||||
);
|
||||
});
|
||||
|
||||
expect(registry.getOperationCount()).toBe(types.length);
|
||||
|
||||
types.forEach((type, index) => {
|
||||
const op = registry.getOperation(`op${index}`);
|
||||
expect(op?.type).toBe(type);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unregisterOperation', () => {
|
||||
it('should unregister an existing operation', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
expect(registry.getOperation('op1')).toBeDefined();
|
||||
|
||||
registry.unregisterOperation('op1');
|
||||
expect(registry.getOperation('op1')).toBeUndefined();
|
||||
expect(registry.getOperationCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should emit operation-unregistered event', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
const eventListener = vi.fn();
|
||||
|
||||
registry.on('operation-unregistered', eventListener);
|
||||
|
||||
registry.registerOperation('op1', 'task-execution', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.unregisterOperation('op1');
|
||||
|
||||
expect(eventListener).toHaveBeenCalledTimes(1);
|
||||
expect(eventListener).toHaveBeenCalledWith('op1', 'task-execution');
|
||||
});
|
||||
|
||||
it('should handle unregistering non-existent operation gracefully', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const eventListener = vi.fn();
|
||||
|
||||
registry.on('operation-unregistered', eventListener);
|
||||
|
||||
// Should not throw
|
||||
expect(() => registry.unregisterOperation('non-existent')).not.toThrow();
|
||||
|
||||
// Should not emit event for non-existent operation
|
||||
expect(eventListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should decrement operation count', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'roadmap', 'profile1', 'Profile 1', mockRestart);
|
||||
expect(registry.getOperationCount()).toBe(2);
|
||||
|
||||
registry.unregisterOperation('op1');
|
||||
expect(registry.getOperationCount()).toBe(1);
|
||||
|
||||
registry.unregisterOperation('op2');
|
||||
expect(registry.getOperationCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOperation', () => {
|
||||
it('should retrieve operation by id', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'pr-review', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation).toBeDefined();
|
||||
expect(operation?.id).toBe('op1');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent operation', () => {
|
||||
const registry = getOperationRegistry();
|
||||
|
||||
const operation = registry.getOperation('non-existent');
|
||||
expect(operation).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOperationsByProfile', () => {
|
||||
it('should return operations for a specific profile', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
|
||||
|
||||
const profile1Ops = registry.getOperationsByProfile('profile1');
|
||||
expect(profile1Ops).toHaveLength(2);
|
||||
expect(profile1Ops.map(op => op.id)).toEqual(['op1', 'op2']);
|
||||
|
||||
const profile2Ops = registry.getOperationsByProfile('profile2');
|
||||
expect(profile2Ops).toHaveLength(1);
|
||||
expect(profile2Ops[0].id).toBe('op3');
|
||||
});
|
||||
|
||||
it('should return empty array for profile with no operations', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
const profile2Ops = registry.getOperationsByProfile('profile2');
|
||||
expect(profile2Ops).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllOperationsByProfile', () => {
|
||||
it('should return all operations grouped by profile', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
|
||||
registry.registerOperation('op4', 'roadmap', 'profile3', 'Profile 3', mockRestart);
|
||||
|
||||
const allOps = registry.getAllOperationsByProfile();
|
||||
|
||||
expect(Object.keys(allOps)).toEqual(['profile1', 'profile2', 'profile3']);
|
||||
expect(allOps['profile1']).toHaveLength(2);
|
||||
expect(allOps['profile2']).toHaveLength(1);
|
||||
expect(allOps['profile3']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should return empty object when no operations', () => {
|
||||
const registry = getOperationRegistry();
|
||||
|
||||
const allOps = registry.getAllOperationsByProfile();
|
||||
expect(allOps).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSummary', () => {
|
||||
it('should return correct summary with no operations', () => {
|
||||
const registry = getOperationRegistry();
|
||||
|
||||
const summary = registry.getSummary();
|
||||
expect(summary.totalRunning).toBe(0);
|
||||
expect(summary.byProfile).toEqual({});
|
||||
expect(summary.byType).toEqual({});
|
||||
});
|
||||
|
||||
it('should count operations by profile', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
|
||||
|
||||
const summary = registry.getSummary();
|
||||
|
||||
expect(summary.totalRunning).toBe(3);
|
||||
expect(summary.byProfile['profile1']).toEqual(['op1', 'op2']);
|
||||
expect(summary.byProfile['profile2']).toEqual(['op3']);
|
||||
});
|
||||
|
||||
it('should count operations by type', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op4', 'insights', 'profile2', 'Profile 2', mockRestart);
|
||||
|
||||
const summary = registry.getSummary();
|
||||
|
||||
expect(summary.byType['spec-creation']).toBe(2);
|
||||
expect(summary.byType['pr-review']).toBe(1);
|
||||
expect(summary.byType['insights']).toBe(1);
|
||||
});
|
||||
|
||||
it('should return complete summary with multiple profiles and types', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
|
||||
registry.registerOperation('op4', 'insights', 'profile2', 'Profile 2', mockRestart);
|
||||
registry.registerOperation('op5', 'roadmap', 'profile3', 'Profile 3', mockRestart);
|
||||
|
||||
const summary = registry.getSummary();
|
||||
|
||||
expect(summary.totalRunning).toBe(5);
|
||||
expect(Object.keys(summary.byProfile)).toHaveLength(3);
|
||||
expect(Object.keys(summary.byType)).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restartOperationsOnProfile', () => {
|
||||
it('should restart all operations on a profile', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart1 = vi.fn().mockResolvedValue(true);
|
||||
const mockRestart2 = vi.fn().mockResolvedValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
|
||||
|
||||
const count = await registry.restartOperationsOnProfile(
|
||||
'profile1',
|
||||
'profile2',
|
||||
'Profile 2'
|
||||
);
|
||||
|
||||
expect(count).toBe(2);
|
||||
expect(mockRestart1).toHaveBeenCalledWith('profile2');
|
||||
expect(mockRestart2).toHaveBeenCalledWith('profile2');
|
||||
|
||||
// Verify profile was updated
|
||||
const op1 = registry.getOperation('op1');
|
||||
const op2 = registry.getOperation('op2');
|
||||
expect(op1?.profileId).toBe('profile2');
|
||||
expect(op1?.profileName).toBe('Profile 2');
|
||||
expect(op2?.profileId).toBe('profile2');
|
||||
expect(op2?.profileName).toBe('Profile 2');
|
||||
});
|
||||
|
||||
it('should call stopFn before restart if provided', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockStop = vi.fn().mockResolvedValue(undefined);
|
||||
const mockRestart = vi.fn().mockResolvedValue(true);
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'pr-review',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart,
|
||||
{ stopFn: mockStop }
|
||||
);
|
||||
|
||||
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
|
||||
|
||||
expect(mockStop).toHaveBeenCalledTimes(1);
|
||||
expect(mockRestart).toHaveBeenCalledWith('profile2');
|
||||
// Ensure stopFn was called before restartFn
|
||||
expect(mockStop.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockRestart.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 0 when no operations on profile', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockResolvedValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
const count = await registry.restartOperationsOnProfile(
|
||||
'profile2',
|
||||
'profile3',
|
||||
'Profile 3'
|
||||
);
|
||||
|
||||
expect(count).toBe(0);
|
||||
expect(mockRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle restart failure gracefully', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart1 = vi.fn().mockResolvedValue(true);
|
||||
const mockRestart2 = vi.fn().mockResolvedValue(false); // Fails
|
||||
const mockRestart3 = vi.fn().mockRejectedValue(new Error('Restart error')); // Throws
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile1', 'Profile 1', mockRestart3);
|
||||
|
||||
const count = await registry.restartOperationsOnProfile(
|
||||
'profile1',
|
||||
'profile2',
|
||||
'Profile 2'
|
||||
);
|
||||
|
||||
// Only op1 succeeded
|
||||
expect(count).toBe(1);
|
||||
|
||||
// op1 should have updated profile
|
||||
const op1 = registry.getOperation('op1');
|
||||
expect(op1?.profileId).toBe('profile2');
|
||||
|
||||
// op2 and op3 should still have old profile
|
||||
const op2 = registry.getOperation('op2');
|
||||
const op3 = registry.getOperation('op3');
|
||||
expect(op2?.profileId).toBe('profile1');
|
||||
expect(op3?.profileId).toBe('profile1');
|
||||
});
|
||||
|
||||
it('should emit operation-restarted event for each successful restart', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart1 = vi.fn().mockResolvedValue(true);
|
||||
const mockRestart2 = vi.fn().mockResolvedValue(true);
|
||||
const eventListener = vi.fn();
|
||||
|
||||
registry.on('operation-restarted', eventListener);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
|
||||
|
||||
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
|
||||
|
||||
expect(eventListener).toHaveBeenCalledTimes(2);
|
||||
expect(eventListener).toHaveBeenCalledWith('op1', 'profile1', 'profile2');
|
||||
expect(eventListener).toHaveBeenCalledWith('op2', 'profile1', 'profile2');
|
||||
});
|
||||
|
||||
it('should emit operations-restarted event after restart', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart1 = vi.fn().mockResolvedValue(true);
|
||||
const mockRestart2 = vi.fn().mockResolvedValue(true);
|
||||
const eventListener = vi.fn();
|
||||
|
||||
registry.on('operations-restarted', eventListener);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
|
||||
|
||||
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
|
||||
|
||||
expect(eventListener).toHaveBeenCalledTimes(1);
|
||||
expect(eventListener).toHaveBeenCalledWith(2, 'profile1', 'profile2');
|
||||
});
|
||||
|
||||
it('should not emit operations-restarted event if no restarts succeeded', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockResolvedValue(false);
|
||||
const eventListener = vi.fn();
|
||||
|
||||
registry.on('operations-restarted', eventListener);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
|
||||
|
||||
expect(eventListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle synchronous restart functions', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true); // Synchronous return
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
const count = await registry.restartOperationsOnProfile(
|
||||
'profile1',
|
||||
'profile2',
|
||||
'Profile 2'
|
||||
);
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(mockRestart).toHaveBeenCalledWith('profile2');
|
||||
|
||||
const op1 = registry.getOperation('op1');
|
||||
expect(op1?.profileId).toBe('profile2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateOperationProfile', () => {
|
||||
it('should update profile for existing operation', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
registry.updateOperationProfile('op1', 'profile2', 'Profile 2');
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation?.profileId).toBe('profile2');
|
||||
expect(operation?.profileName).toBe('Profile 2');
|
||||
});
|
||||
|
||||
it('should handle updating non-existent operation gracefully', () => {
|
||||
const registry = getOperationRegistry();
|
||||
|
||||
// Should not throw
|
||||
expect(() =>
|
||||
registry.updateOperationProfile('non-existent', 'profile2', 'Profile 2')
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear all operations', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
|
||||
|
||||
expect(registry.getOperationCount()).toBe(3);
|
||||
|
||||
registry.clear();
|
||||
|
||||
expect(registry.getOperationCount()).toBe(0);
|
||||
expect(registry.getSummary().totalRunning).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle registering operation with same id (overwrites)', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart1 = vi.fn().mockReturnValue(true);
|
||||
const mockRestart2 = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
|
||||
registry.registerOperation('op1', 'task-execution', 'profile2', 'Profile 2', mockRestart2);
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation?.type).toBe('task-execution');
|
||||
expect(operation?.profileId).toBe('profile2');
|
||||
expect(registry.getOperationCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle multiple unregisters of same operation', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
registry.unregisterOperation('op1');
|
||||
expect(registry.getOperationCount()).toBe(0);
|
||||
|
||||
// Second unregister should not throw or cause issues
|
||||
registry.unregisterOperation('op1');
|
||||
expect(registry.getOperationCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle restart with no operations gracefully', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
|
||||
const count = await registry.restartOperationsOnProfile(
|
||||
'profile1',
|
||||
'profile2',
|
||||
'Profile 2'
|
||||
);
|
||||
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('should preserve operation metadata through restart', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockResolvedValue(true);
|
||||
const metadata = { projectId: 'proj1', prNumber: 123 };
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'pr-review',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart,
|
||||
{ metadata }
|
||||
);
|
||||
|
||||
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation?.metadata).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('should preserve startedAt timestamp through restart', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockResolvedValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
const originalOp = registry.getOperation('op1');
|
||||
const originalStartTime = originalOp?.startedAt;
|
||||
|
||||
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
|
||||
|
||||
const updatedOp = registry.getOperation('op1');
|
||||
expect(updatedOp?.startedAt).toBe(originalStartTime);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -345,7 +345,7 @@ function executeCredentialRead(
|
||||
executablePath: string,
|
||||
args: string[],
|
||||
timeout: number,
|
||||
identifier: string
|
||||
_identifier: string
|
||||
): string | null {
|
||||
try {
|
||||
const result = execFileSync(executablePath, args, {
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Unified registry for ALL Claude Agent SDK operations.
|
||||
*
|
||||
* This is the single source of truth for tracking running operations that use
|
||||
* Claude profiles. It enables:
|
||||
* 1. Proactive account swapping - restart operations on a different profile
|
||||
* 2. Rate limit recovery - know which operations to restart after auth refresh
|
||||
* 3. Usage attribution - track which profile is being used by which operation
|
||||
*
|
||||
* Operations include:
|
||||
* - Autonomous tasks (spec creation, task execution)
|
||||
* - GitHub PR reviews
|
||||
* - GitLab MR reviews
|
||||
* - Insights analysis
|
||||
* - Roadmap generation
|
||||
* - Changelog generation
|
||||
* - Any other Claude SDK subprocess
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
/**
|
||||
* Types of operations that use Claude SDK
|
||||
*/
|
||||
export type OperationType =
|
||||
| 'spec-creation'
|
||||
| 'task-execution'
|
||||
| 'pr-review'
|
||||
| 'mr-review'
|
||||
| 'insights'
|
||||
| 'roadmap'
|
||||
| 'changelog'
|
||||
| 'ideation'
|
||||
| 'triage'
|
||||
| 'other';
|
||||
|
||||
/**
|
||||
* Registered operation entry
|
||||
*
|
||||
* IMPORTANT: Object reference stability during restarts
|
||||
* =====================================================
|
||||
* When an operation is restarted via restartFn, the restartFn implementation may
|
||||
* choose to re-register the operation (creating a new RegisteredOperation object)
|
||||
* OR update the existing one. Either approach is valid:
|
||||
*
|
||||
* 1. RE-REGISTRATION (AgentManager pattern):
|
||||
* - restartFn calls registerOperation() which replaces the Map entry
|
||||
* - Creates a new RegisteredOperation object with fresh closures
|
||||
* - Previous object references become stale and should not be used
|
||||
* - Callers MUST call getOperation(id) again to get the fresh reference
|
||||
*
|
||||
* 2. IN-PLACE UPDATE (alternative pattern):
|
||||
* - restartFn updates internal state but doesn't re-register
|
||||
* - Object reference remains valid
|
||||
* - Registry calls updateOperationProfile() to sync profileId
|
||||
*
|
||||
* BEST PRACTICE for consumers:
|
||||
* - Don't hold long-lived references to RegisteredOperation objects
|
||||
* - Always use getOperation(id) to get current state
|
||||
* - Subscribe to 'operation-restarted' events to know when to refresh
|
||||
* - If you must hold a reference, listen for 'operation-restarted' and refresh it
|
||||
*/
|
||||
export interface RegisteredOperation {
|
||||
/** Unique operation ID */
|
||||
id: string;
|
||||
/** Type of operation */
|
||||
type: OperationType;
|
||||
/** Profile ID currently being used */
|
||||
profileId: string;
|
||||
/** Profile name for logging */
|
||||
profileName: string;
|
||||
/** When the operation started */
|
||||
startedAt: Date;
|
||||
/** Optional metadata (project ID, PR number, etc.) */
|
||||
metadata?: Record<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,6 +26,7 @@ export const DEFAULT_AUTO_SWITCH_SETTINGS: ClaudeAutoSwitchSettings = {
|
||||
sessionThreshold: 95, // Consider switching at 95% session usage
|
||||
weeklyThreshold: 99, // Consider switching at 99% weekly usage
|
||||
autoSwitchOnRateLimit: false, // Prompt user by default
|
||||
autoSwitchOnAuthFailure: false, // Prompt user by default on auth failures
|
||||
usageCheckInterval: 30000 // Check every 30s when enabled (0 = disabled)
|
||||
};
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ export function hasValidToken(profile: ClaudeProfile): boolean {
|
||||
* Expand ~ in path to home directory
|
||||
*/
|
||||
export function expandHomePath(path: string): string {
|
||||
if (path && path.startsWith('~')) {
|
||||
if (path?.startsWith('~')) {
|
||||
const home = homedir();
|
||||
return path.replace(/^~/, home);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
refreshOAuthToken,
|
||||
ensureValidToken,
|
||||
reactiveTokenRefresh,
|
||||
type TokenRefreshResult
|
||||
} from './token-refresh';
|
||||
|
||||
// Mock credential-utils
|
||||
|
||||
@@ -19,6 +19,7 @@ import { detectProvider as sharedDetectProvider, type ApiProvider } from '../../
|
||||
import { getCredentialsFromKeychain, clearKeychainCache } from './credential-utils';
|
||||
import { reactiveTokenRefresh, ensureValidToken } from './token-refresh';
|
||||
import { isProfileRateLimited } from './rate-limit-manager';
|
||||
import { getOperationRegistry } from './operation-registry';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export type { ApiProvider };
|
||||
@@ -775,7 +776,7 @@ export class UsageMonitor extends EventEmitter {
|
||||
const activeProfile = profilesFile.profiles.find(
|
||||
(p) => p.id === profilesFile.activeProfileId
|
||||
);
|
||||
if (activeProfile && activeProfile.apiKey) {
|
||||
if (activeProfile?.apiKey) {
|
||||
this.debugLog('[UsageMonitor:TRACE] Using API profile credential: ' + activeProfile.name);
|
||||
return activeProfile.apiKey;
|
||||
}
|
||||
@@ -1333,7 +1334,7 @@ export class UsageMonitor extends EventEmitter {
|
||||
let baseUrl: string;
|
||||
let provider: ApiProvider;
|
||||
|
||||
if (activeProfile && activeProfile.isAPIProfile) {
|
||||
if (activeProfile?.isAPIProfile) {
|
||||
// Use the pre-determined profile to avoid race conditions
|
||||
// Trust the activeProfile data and use baseUrl directly
|
||||
baseUrl = activeProfile.baseUrl;
|
||||
@@ -1347,7 +1348,7 @@ export class UsageMonitor extends EventEmitter {
|
||||
const profilesFile = await loadProfilesFile();
|
||||
apiProfile = profilesFile.profiles.find(p => p.id === profileId);
|
||||
|
||||
if (apiProfile && apiProfile.apiKey) {
|
||||
if (apiProfile?.apiKey) {
|
||||
// API profile found
|
||||
baseUrl = apiProfile.baseUrl;
|
||||
provider = detectProvider(baseUrl);
|
||||
@@ -2007,6 +2008,46 @@ export class UsageMonitor extends EventEmitter {
|
||||
limitType
|
||||
});
|
||||
|
||||
// PROACTIVE OPERATION RESTART: Stop and restart all running Claude SDK operations with new profile credentials
|
||||
// This includes autonomous tasks, PR reviews, insights, roadmap, etc.
|
||||
// Claude Agent SDK sessions maintain state independently of auth tokens, so no progress is lost
|
||||
const operationRegistry = getOperationRegistry();
|
||||
const operationSummary = operationRegistry.getSummary();
|
||||
const operationIdsOnOldProfile = operationSummary.byProfile[currentProfileId] || [];
|
||||
|
||||
// Always log running operations info for debugging
|
||||
console.log('[UsageMonitor] PROACTIVE-SWAP: Checking running operations:', {
|
||||
oldProfileId: currentProfileId,
|
||||
newProfileId: bestAccount.id,
|
||||
totalRunning: operationSummary.totalRunning,
|
||||
byProfile: operationSummary.byProfile,
|
||||
byType: operationSummary.byType,
|
||||
operationIdsOnOldProfile: operationIdsOnOldProfile
|
||||
});
|
||||
|
||||
if (operationIdsOnOldProfile.length > 0) {
|
||||
console.log('[UsageMonitor] PROACTIVE-SWAP: Found', operationIdsOnOldProfile.length, 'operations to restart:', operationIdsOnOldProfile);
|
||||
|
||||
// Restart all operations on the old profile with the new profile
|
||||
const restartedCount = await operationRegistry.restartOperationsOnProfile(
|
||||
currentProfileId,
|
||||
bestAccount.id,
|
||||
bestAccount.name
|
||||
);
|
||||
|
||||
// Emit event for tracking/logging
|
||||
this.emit('proactive-operations-restarted', {
|
||||
fromProfile: { id: currentProfileId, name: fromProfileName },
|
||||
toProfile: { id: bestAccount.id, name: bestAccount.name },
|
||||
operationIds: operationIdsOnOldProfile,
|
||||
restartedCount,
|
||||
limitType,
|
||||
timestamp: new Date()
|
||||
});
|
||||
} else {
|
||||
console.log('[UsageMonitor] PROACTIVE-SWAP: No operations running on old profile', currentProfileId, '- swap complete without restart');
|
||||
}
|
||||
|
||||
// Note: Don't immediately check new profile - let normal interval handle it
|
||||
// This prevents cascading swaps if multiple profiles are near limits
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -157,8 +157,7 @@ function createWindow(): void {
|
||||
const display = screen.getPrimaryDisplay();
|
||||
// Validate the returned object has expected structure with valid dimensions
|
||||
if (
|
||||
display &&
|
||||
display.workAreaSize &&
|
||||
display?.workAreaSize &&
|
||||
typeof display.workAreaSize.width === 'number' &&
|
||||
typeof display.workAreaSize.height === 'number' &&
|
||||
display.workAreaSize.width > 0 &&
|
||||
@@ -495,7 +494,7 @@ app.whenReady().then(() => {
|
||||
// Setup event forwarding from usage monitor to renderer
|
||||
initializeUsageMonitorForwarding(mainWindow);
|
||||
|
||||
// Start the usage monitor
|
||||
// Start the usage monitor (uses unified OperationRegistry for proactive restart)
|
||||
const usageMonitor = getUsageMonitor();
|
||||
usageMonitor.start();
|
||||
console.warn('[main] Usage monitor initialized and started (after profile load)');
|
||||
|
||||
@@ -9,11 +9,10 @@ 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;
|
||||
this.paths = paths;
|
||||
// Note: paths parameter kept for API compatibility but not currently used
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -95,6 +95,10 @@ 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,10 +216,12 @@ 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): Promise<string> {
|
||||
// Check cache first
|
||||
if (cachedLatestVersion && Date.now() - cachedLatestVersion.timestamp < CACHE_DURATION_MS) {
|
||||
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) {
|
||||
const cachedVersion = cachedLatestVersion.version;
|
||||
|
||||
// Invalidate cache if installed version is newer than cached latest
|
||||
@@ -881,7 +883,7 @@ function checkProfileAuthentication(configDir: string): AuthCheckResult {
|
||||
const data = JSON.parse(content);
|
||||
|
||||
// Check for oauthAccount with emailAddress
|
||||
if (data.oauthAccount && data.oauthAccount.emailAddress) {
|
||||
if (data.oauthAccount?.emailAddress) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: data.oauthAccount.emailAddress,
|
||||
@@ -907,7 +909,7 @@ function checkProfileAuthentication(configDir: string): AuthCheckResult {
|
||||
};
|
||||
}
|
||||
|
||||
if (data.oauthAccount && data.oauthAccount.emailAddress) {
|
||||
if (data.oauthAccount?.emailAddress) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: data.oauthAccount.emailAddress,
|
||||
@@ -958,9 +960,9 @@ export function registerClaudeCodeHandlers(): void {
|
||||
// Check Claude Code version
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION,
|
||||
async (): Promise<IPCResult<ClaudeCodeVersionInfo>> => {
|
||||
async (_event, forceRefresh?: boolean): Promise<IPCResult<ClaudeCodeVersionInfo>> => {
|
||||
try {
|
||||
console.warn('[Claude Code] Checking version...');
|
||||
console.warn('[Claude Code] Checking version...', forceRefresh ? '(force refresh)' : '');
|
||||
|
||||
// Get installed version via cli-tool-manager
|
||||
let detectionResult;
|
||||
@@ -977,10 +979,11 @@ 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);
|
||||
latest = await fetchLatestVersion(installed, forceRefresh);
|
||||
console.warn('[Claude Code] Latest version:', latest);
|
||||
} catch (error) {
|
||||
console.warn('[Claude Code] Failed to fetch latest version, continuing with unknown:', error);
|
||||
|
||||
@@ -12,9 +12,6 @@ import type {
|
||||
} from '../../../shared/types';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { getMemoryService, isKuzuAvailable } from '../../memory-service';
|
||||
import {
|
||||
getGraphitiDatabaseDetails
|
||||
} from './utils';
|
||||
import { getEffectiveSourcePath } from '../../updater/path-resolver';
|
||||
import {
|
||||
loadGraphitiStateFromSpecs,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 && match[1]) {
|
||||
if (match?.[1]) {
|
||||
// Normalize: replace space with hyphen (GitHub expects XXXX-XXXX format)
|
||||
const normalizedCode = match[1].replace(' ', '-');
|
||||
debugLog('Device code extracted successfully (code redacted for security)');
|
||||
|
||||
@@ -1307,6 +1307,9 @@ async function runPRReview(
|
||||
// Build environment with project settings
|
||||
const subprocessEnv = await getRunnerEnv(getClaudeMdEnv(project));
|
||||
|
||||
// Create operation ID for this review
|
||||
const reviewKey = getReviewKey(project.id, prNumber);
|
||||
|
||||
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
|
||||
pythonPath: getPythonPath(backendPath),
|
||||
args,
|
||||
@@ -1341,10 +1344,17 @@ async function runPRReview(
|
||||
debugLog("Review result loaded", { findingsCount: reviewResult.findings.length });
|
||||
return reviewResult;
|
||||
},
|
||||
// Register with OperationRegistry for proactive swap support
|
||||
operationRegistration: {
|
||||
operationId: `pr-review:${reviewKey}`,
|
||||
operationType: 'pr-review',
|
||||
metadata: { projectId: project.id, prNumber, repo },
|
||||
// PR reviews don't support restart (would need to refetch PR data)
|
||||
// The review will complete or fail, and user can retry manually
|
||||
},
|
||||
});
|
||||
|
||||
// Register the running process
|
||||
const reviewKey = getReviewKey(project.id, prNumber);
|
||||
// Register the running process (keep legacy registry for cancel support)
|
||||
runningReviews.set(reviewKey, childProcess);
|
||||
debugLog("Registered review process", { reviewKey, pid: childProcess.pid });
|
||||
|
||||
@@ -2748,6 +2758,12 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
|
||||
});
|
||||
return reviewResult;
|
||||
},
|
||||
// Register with OperationRegistry for proactive swap support
|
||||
operationRegistration: {
|
||||
operationId: `pr-followup-review:${reviewKey}`,
|
||||
operationType: 'pr-review',
|
||||
metadata: { projectId: project.id, prNumber, repo, isFollowup: true },
|
||||
},
|
||||
});
|
||||
|
||||
// Update registry with actual process (replacing placeholder)
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { AuthFailureInfo, BillingFailureInfo } from '../../../../shared/typ
|
||||
import { parsePythonCommand } from '../../../python-detector';
|
||||
import { detectAuthFailure, detectBillingFailure } from '../../../rate-limit-detector';
|
||||
import { getClaudeProfileManager } from '../../../claude-profile-manager';
|
||||
import { getOperationRegistry, type OperationType } from '../../../claude-profile/operation-registry';
|
||||
import { isWindows, isMacOS } from '../../../platform';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -73,6 +74,23 @@ export interface SubprocessOptions {
|
||||
progressPattern?: RegExp;
|
||||
/** Additional environment variables to pass to the subprocess */
|
||||
env?: Record<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>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,6 +144,67 @@ 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 = '';
|
||||
@@ -305,6 +384,11 @@ 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,7 +21,6 @@ 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,16 +129,11 @@ export function registerInvestigateIssue(
|
||||
message: 'Analyzing issue with AI...'
|
||||
});
|
||||
|
||||
// Build context for investigation
|
||||
let context = buildIssueContext(issue, config.project, config.instanceUrl);
|
||||
|
||||
if (selectedNotes.length > 0) {
|
||||
context += '\n\n## Selected Comments\n';
|
||||
for (const note of selectedNotes) {
|
||||
context += `\n### Comment by ${note.author.username} (${new Date(note.created_at).toLocaleDateString()})\n`;
|
||||
context += note.body + '\n';
|
||||
}
|
||||
}
|
||||
// Note: Context building previously done here has been moved to createSpecForIssue utility.
|
||||
// The buildIssueContext() function and selectedNotes processing are now handled internally
|
||||
// by the spec creation pipeline. This avoids duplicate context generation.
|
||||
// TODO: If advanced context customization is needed in the future, consider extracting
|
||||
// context building into a reusable utility function.
|
||||
|
||||
// Use agent manager to investigate
|
||||
// Note: This is a simplified version - full implementation would use Claude SDK
|
||||
|
||||
@@ -21,7 +21,6 @@ import { getGitLabConfig, gitlabFetch, encodeProjectPath } from './utils';
|
||||
import { readSettingsFile } from '../../settings-utils';
|
||||
import type { Project, AppSettings } from '../../../shared/types';
|
||||
import type {
|
||||
MRReviewFinding,
|
||||
MRReviewResult,
|
||||
MRReviewProgress,
|
||||
NewCommitsCheck,
|
||||
|
||||
@@ -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 { stripControlChars, sanitizeText, sanitizeStringArray } from '../shared/sanitize';
|
||||
import { sanitizeText, sanitizeStringArray } from '../shared/sanitize';
|
||||
|
||||
/**
|
||||
* Simplified task info returned when creating a spec from a GitLab issue.
|
||||
|
||||
@@ -201,6 +201,10 @@ 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
|
||||
@@ -212,9 +216,43 @@ 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
|
||||
@@ -483,7 +521,7 @@ export function registerMemoryHandlers(): void {
|
||||
};
|
||||
} else {
|
||||
// Basic validation for other providers
|
||||
llmResult = config.apiKey && config.apiKey.trim()
|
||||
llmResult = config.apiKey?.trim()
|
||||
? {
|
||||
success: true,
|
||||
message: `${config.llmProvider} API key format appears valid`,
|
||||
@@ -703,7 +741,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,7 +16,6 @@ import type { IPCResult } from '../../shared/types';
|
||||
import type { APIProfile, ProfileFormData, ProfilesFile, TestConnectionResult, DiscoverModelsResult } from '@shared/types/profile';
|
||||
import {
|
||||
loadProfilesFile,
|
||||
saveProfilesFile,
|
||||
validateFilePermissions,
|
||||
getProfilesFilePath,
|
||||
atomicModifyProfiles,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { ipcMain, app } from 'electron';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { existsSync, } from 'fs';
|
||||
import path from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { is } from '@electron-toolkit/utils';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type {
|
||||
Project,
|
||||
@@ -233,8 +232,6 @@ function detectMainBranch(projectPath: string): string | null {
|
||||
return branches[0] || null;
|
||||
}
|
||||
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
|
||||
/**
|
||||
* Configure all Python-dependent services with the managed Python path
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,6 @@ 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,6 +800,73 @@ export function registerTaskExecutionHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Resume a paused task (rate limited or auth failure paused)
|
||||
* This writes a RESUME file to the spec directory to signal the backend to continue
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TASK_RESUME_PAUSED,
|
||||
async (_, taskId: string): Promise<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 { execSync, execFileSync, spawn, spawnSync, exec, execFile } from 'child_process';
|
||||
import { execFileSync, spawn, spawnSync, exec, execFile } from 'child_process';
|
||||
import { homedir } from 'os';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { getConfiguredPythonPath, PythonEnvManager, pythonEnvManager as pythonEnvManagerSingleton } from '../../python-env-manager';
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
findTaskWorktree,
|
||||
} from '../../worktree-paths';
|
||||
import { persistPlanStatus, updateTaskMetadataPrUrl } from './plan-file-utils';
|
||||
import { getIsolatedGitEnv, detectWorktreeBranch, refreshGitIndex } from '../../utils/git-isolation';
|
||||
import { getIsolatedGitEnv, refreshGitIndex } from '../../utils/git-isolation';
|
||||
import { cleanupWorktree } from '../../utils/worktree-cleanup';
|
||||
import { killProcessGracefully } from '../../platform';
|
||||
import { stripAnsiCodes } from '../../../shared/utils/ansi-sanitizer';
|
||||
@@ -1067,7 +1067,7 @@ async function detectLinuxApps(): Promise<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 && gitStatus.trim()) {
|
||||
if (gitStatus?.trim()) {
|
||||
// Parse the status output to get file names
|
||||
// Format: XY filename (where X and Y are status chars, then space, then filename)
|
||||
uncommittedFiles = gitStatus
|
||||
|
||||
@@ -8,7 +8,7 @@ import { TerminalManager } from '../terminal-manager';
|
||||
import { projectStore } from '../project-store';
|
||||
import { terminalNameGenerator } from '../terminal-name-generator';
|
||||
import { readSettingsFileAsync } from '../settings-utils';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
import { debugLog, } from '../../shared/utils/debug-logger';
|
||||
import { migrateSession } from '../claude-profile/session-utils';
|
||||
import { createProfileDirectory } from '../claude-profile/profile-utils';
|
||||
import { isValidConfigDir } from '../utils/config-path-validator';
|
||||
|
||||
@@ -29,8 +29,6 @@ export class LogService {
|
||||
|
||||
// Flush logs to disk every 2 seconds to balance performance vs data safety
|
||||
private readonly FLUSH_INTERVAL_MS = 2000;
|
||||
// Maximum log file size before rotation (10MB)
|
||||
private readonly MAX_LOG_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
// Keep last N sessions
|
||||
private readonly MAX_SESSIONS_TO_KEEP = 10;
|
||||
|
||||
@@ -205,7 +203,7 @@ export class LogService {
|
||||
const sessionId = file.replace('session-', '').replace('.log', '');
|
||||
|
||||
// Parse session ID back to date
|
||||
const dateStr = sessionId.replace(/-/g, (match, offset) => {
|
||||
const dateStr = sessionId.replace(/-/g, (_match, offset) => {
|
||||
// Replace first 2 dashes with actual dashes, rest with colons
|
||||
if (offset < 10) return '-';
|
||||
if (offset === 10) return 'T';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,10 +3,11 @@ 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 } from '../shared/constants';
|
||||
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 { getAutoBuildPath, isInitialized } from './project-initializer';
|
||||
import { getTaskWorktreeDir } from './worktree-paths';
|
||||
import { isValidTaskId, findAllSpecPaths } from './utils/spec-path-helpers';
|
||||
import { findAllSpecPaths } from './utils/spec-path-helpers';
|
||||
|
||||
interface TabState {
|
||||
openProjectIds: string[];
|
||||
@@ -324,12 +325,37 @@ export class ProjectStore {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Deduplicate tasks by ID (prefer worktree version if exists in both)
|
||||
// 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.
|
||||
const taskMap = new Map<string, Task>();
|
||||
for (const task of allTasks) {
|
||||
const existing = taskMap.get(task.id);
|
||||
if (!existing || task.location === 'worktree') {
|
||||
if (!existing) {
|
||||
// First occurrence wins
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,10 +388,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[] = [];
|
||||
@@ -492,7 +518,7 @@ export class ProjectStore {
|
||||
// "# Specification: Title" -> "Title"
|
||||
// "# Title" -> "Title"
|
||||
const titleMatch = specContent.match(/^#\s+(?:Quick Spec:|Specification:)?\s*(.+)$/m);
|
||||
if (titleMatch && titleMatch[1]) {
|
||||
if (titleMatch?.[1]) {
|
||||
title = titleMatch[1].trim();
|
||||
}
|
||||
} catch {
|
||||
@@ -500,15 +526,33 @@ export class ProjectStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Use persisted executionPhase (from text parser) or xstateState for exact restoration
|
||||
// Priority: executionPhase > xstateState > inferred from status
|
||||
// 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
|
||||
const persistedPhase = (plan as { executionPhase?: string } | null)?.executionPhase as ExecutionPhase | undefined;
|
||||
const xstateState = (plan as { xstateState?: string } | null)?.xstateState;
|
||||
const executionProgress = persistedPhase
|
||||
? { phase: persistedPhase, phaseProgress: 50, overallProgress: 50 }
|
||||
: xstateState
|
||||
? this.inferExecutionProgressFromXState(xstateState)
|
||||
: this.inferExecutionProgress(plan?.status);
|
||||
|
||||
// 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);
|
||||
|
||||
tasks.push({
|
||||
id: dir.name, // Use spec directory name as ID
|
||||
|
||||
@@ -92,6 +92,25 @@ const BILLING_FAILURE_PATTERNS = [
|
||||
/top\s*up\s*(your\s*)?(account|credits|balance)/i
|
||||
];
|
||||
|
||||
/**
|
||||
* Maximum length for error messages sent to renderer.
|
||||
* Truncates to prevent exposing excessive internal details.
|
||||
*/
|
||||
const MAX_ERROR_LENGTH = 500;
|
||||
|
||||
/**
|
||||
* Sanitize error output before sending to renderer.
|
||||
* Truncates long output to prevent exposing excessive internal details
|
||||
* like full paths, API responses, or stack traces.
|
||||
*/
|
||||
function sanitizeErrorOutput(output: string): string {
|
||||
// Truncate long output to limit exposure of internal details
|
||||
if (output.length > MAX_ERROR_LENGTH) {
|
||||
return output.substring(0, MAX_ERROR_LENGTH) + '... (truncated)';
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of rate limit detection
|
||||
*/
|
||||
@@ -109,7 +128,7 @@ export interface RateLimitDetectionResult {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
/** Original error message */
|
||||
/** Original error message (truncated to 500 chars for security) */
|
||||
originalError?: string;
|
||||
}
|
||||
|
||||
@@ -193,7 +212,7 @@ export function detectRateLimit(
|
||||
id: bestProfile.id,
|
||||
name: bestProfile.name
|
||||
} : undefined,
|
||||
originalError: output
|
||||
originalError: sanitizeErrorOutput(output)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,7 +230,7 @@ export function detectRateLimit(
|
||||
id: bestProfile.id,
|
||||
name: bestProfile.name
|
||||
} : undefined,
|
||||
originalError: output
|
||||
originalError: sanitizeErrorOutput(output)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -265,7 +284,6 @@ function getAuthFailureMessage(failureType: 'missing' | 'invalid' | 'expired' |
|
||||
return 'Your Claude session has expired. Please re-authenticate in Settings > Claude Profiles.';
|
||||
case 'invalid':
|
||||
return 'Invalid Claude credentials. Please check your OAuth token or re-authenticate in Settings > Claude Profiles.';
|
||||
case 'unknown':
|
||||
default:
|
||||
return 'Claude authentication failed. Please verify your authentication in Settings > Claude Profiles.';
|
||||
}
|
||||
@@ -303,7 +321,6 @@ function getBillingFailureMessage(failureType: 'insufficient_credits' | 'payment
|
||||
return 'A billing error occurred with your Claude API account. Please check your payment method or switch to another profile in Settings > Claude Profiles.';
|
||||
case 'subscription_inactive':
|
||||
return 'Your Claude API subscription is inactive or expired. Please renew your subscription or switch to another profile in Settings > Claude Profiles.';
|
||||
case 'unknown':
|
||||
default:
|
||||
return 'A billing issue was detected with your Claude API account. Please check your account status or switch to another profile in Settings > Claude Profiles.';
|
||||
}
|
||||
@@ -333,7 +350,7 @@ export function detectAuthFailure(
|
||||
profileId: effectiveProfileId,
|
||||
failureType,
|
||||
message: getAuthFailureMessage(failureType),
|
||||
originalError: output
|
||||
originalError: sanitizeErrorOutput(output)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -375,7 +392,7 @@ export function detectBillingFailure(
|
||||
profileId: effectiveProfileId,
|
||||
failureType,
|
||||
message: getBillingFailureMessage(failureType),
|
||||
originalError: output
|
||||
originalError: sanitizeErrorOutput(output)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -631,7 +648,7 @@ export interface SDKRateLimitInfo {
|
||||
};
|
||||
/** When detected */
|
||||
detectedAt: Date;
|
||||
/** Original error message */
|
||||
/** Original error message (truncated to 500 chars for security) */
|
||||
originalError?: string;
|
||||
|
||||
// Auto-swap information
|
||||
|
||||
@@ -24,9 +24,6 @@ import { refreshGitIndex } from './utils/git-isolation';
|
||||
* If a worktree exists for a task NOT in this release, it won't block the release.
|
||||
*/
|
||||
export class ReleaseService extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CHANGELOG.md to extract releaseable versions.
|
||||
@@ -75,7 +72,7 @@ export class ReleaseService extends EventEmitter {
|
||||
* This allows us to scope worktree checks to only those tasks.
|
||||
*/
|
||||
getTasksForVersion(
|
||||
projectPath: string,
|
||||
_projectPath: string,
|
||||
version: string,
|
||||
tasks: Task[]
|
||||
): { taskIds: string[]; specIds: string[] } {
|
||||
|
||||
@@ -63,7 +63,7 @@ function getTracesSampleRate(): number {
|
||||
const envValue = buildTimeValue || process.env.SENTRY_TRACES_SAMPLE_RATE;
|
||||
if (envValue) {
|
||||
const parsed = parseFloat(envValue);
|
||||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ function getProfilesSampleRate(): number {
|
||||
const envValue = buildTimeValue || process.env.SENTRY_PROFILES_SAMPLE_RATE;
|
||||
if (envValue) {
|
||||
const parsed = parseFloat(envValue);
|
||||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
getAPIProfileEnv,
|
||||
testConnection
|
||||
} from './profile-service';
|
||||
import type { APIProfile, ProfilesFile, TestConnectionResult } from '../../shared/types/profile';
|
||||
import type { ProfilesFile, } from '../../shared/types/profile';
|
||||
|
||||
// Mock profile-manager
|
||||
vi.mock('../utils/profile-manager', () => ({
|
||||
|
||||
@@ -90,8 +90,6 @@ 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 { APIProfile, ProfilesFile, TestConnectionResult } from '@shared/types/profile';
|
||||
import type { ProfilesFile, } from '@shared/types/profile';
|
||||
|
||||
// Mock Anthropic SDK - use vi.hoisted to properly hoist the mock variable
|
||||
const { mockModelsList, mockMessagesCreate } = vi.hoisted(() => ({
|
||||
|
||||
@@ -8,8 +8,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
SDKSessionRecoveryCoordinator,
|
||||
getRecoveryCoordinator,
|
||||
type SDKOperationType,
|
||||
type RecoveryCoordinatorConfig
|
||||
} from './sdk-session-recovery-coordinator';
|
||||
|
||||
// Mock dependencies
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
/**
|
||||
* SDK Session Recovery Coordinator
|
||||
*
|
||||
* @deprecated This module is deprecated in favor of ClaudeOperationRegistry
|
||||
* (src/main/claude-profile/operation-registry.ts). The OperationRegistry provides
|
||||
* similar functionality with a simpler API and is actively integrated with
|
||||
* AgentManager and UsageMonitor. This module is retained for backward compatibility
|
||||
* but should not be used for new code.
|
||||
*
|
||||
* TODO: Target removal in v0.5.0 (Q2 2026). Before removal:
|
||||
* 1. Identify any remaining usages in the codebase
|
||||
* 2. Migrate all remaining consumers to ClaudeOperationRegistry
|
||||
* 3. Remove this file and associated tests
|
||||
* 4. Update imports across the codebase
|
||||
*
|
||||
* Migration guide:
|
||||
* - Use getOperationRegistry() from '../claude-profile/operation-registry'
|
||||
* - registerOperation() -> operationRegistry.registerOperation()
|
||||
* - unregisterOperation() -> operationRegistry.unregisterOperation()
|
||||
* - getOperationsByProfile() -> operationRegistry.getOperationsByProfile()
|
||||
*
|
||||
* Original description:
|
||||
* Central coordinator for all SDK operations and rate limit recovery.
|
||||
* Part of the intelligent rate limit recovery system (Phase 9: Unified Coordination).
|
||||
*
|
||||
@@ -97,6 +116,9 @@ interface PendingNotification {
|
||||
/**
|
||||
* SDKSessionRecoveryCoordinator - Central manager for SDK operations and recovery
|
||||
*
|
||||
* @deprecated Use ClaudeOperationRegistry from '../claude-profile/operation-registry' instead.
|
||||
* This class is retained for backward compatibility but is no longer actively maintained.
|
||||
*
|
||||
* This singleton coordinates all SDK operations across the application:
|
||||
* - Tasks (via AgentManager)
|
||||
* - Background operations (roadmap, ideation, changelog)
|
||||
@@ -531,6 +553,7 @@ export class SDKSessionRecoveryCoordinator extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Get the global coordinator instance
|
||||
* @deprecated Use getOperationRegistry() from '../claude-profile/operation-registry' instead.
|
||||
*/
|
||||
export function getRecoveryCoordinator(): SDKSessionRecoveryCoordinator {
|
||||
return SDKSessionRecoveryCoordinator.getInstance();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, watchFile } from 'fs';
|
||||
import { existsSync, readFileSync, } from 'fs';
|
||||
import { EventEmitter } from 'events';
|
||||
import type { TaskLogs, TaskLogPhase, TaskLogStreamChunk, TaskPhaseLog } from '../shared/types';
|
||||
import { findTaskWorktree } from './worktree-paths';
|
||||
@@ -26,7 +26,6 @@ function findWorktreeSpecDir(projectPath: string, specId: string, specsRelPath:
|
||||
* watches both locations and merges logs from both sources.
|
||||
*/
|
||||
export class TaskLogService extends EventEmitter {
|
||||
private watchers: Map<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)
|
||||
@@ -35,10 +34,6 @@ export class TaskLogService extends EventEmitter {
|
||||
// Poll interval for watching log changes (more reliable than fs.watch on some systems)
|
||||
private readonly POLL_INTERVAL_MS = 1000;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load task logs from a single spec directory
|
||||
* Returns cached logs if the file is corrupted (e.g., mid-write by Python backend)
|
||||
@@ -125,7 +120,7 @@ export class TaskLogService extends EventEmitter {
|
||||
|
||||
let worktreeSpecDir: string | null = null;
|
||||
|
||||
if (watchedInfo && watchedInfo[1].worktreeSpecDir) {
|
||||
if (watchedInfo?.[1].worktreeSpecDir) {
|
||||
worktreeSpecDir = watchedInfo[1].worktreeSpecDir;
|
||||
} else if (projectPath && specsRelPath && specId) {
|
||||
// Calculate worktree path from provided params
|
||||
|
||||
@@ -123,7 +123,14 @@ 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;
|
||||
@@ -413,7 +420,6 @@ export class TaskStateManager {
|
||||
stateValue = 'error';
|
||||
contextReviewReason = reviewReason ?? 'errors';
|
||||
break;
|
||||
case 'backlog':
|
||||
default:
|
||||
stateValue = 'backlog';
|
||||
break;
|
||||
|
||||
@@ -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 && match[1]) {
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,7 @@ export function extractEmail(data: string): string | null {
|
||||
|
||||
for (const pattern of EMAIL_PATTERNS) {
|
||||
const match = cleanData.match(pattern);
|
||||
if (match && match[1]) {
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import * as net from 'net';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import { isWindows, GRACEFUL_KILL_TIMEOUT_MS } from '../platform';
|
||||
import { getIsShuttingDown } from './pty-manager';
|
||||
|
||||
@@ -271,7 +270,7 @@ class PtyDaemonClient {
|
||||
}
|
||||
});
|
||||
|
||||
this.socket!.write(JSON.stringify({ ...msg, requestId }) + '\n');
|
||||
this.socket?.write(JSON.stringify({ ...msg, requestId }) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -427,7 +426,7 @@ class PtyDaemonClient {
|
||||
this.isShuttingDown = true;
|
||||
|
||||
// Kill the daemon process if we spawned it
|
||||
if (this.daemonProcess && this.daemonProcess.pid) {
|
||||
if (this.daemonProcess?.pid) {
|
||||
try {
|
||||
if (isWindows()) {
|
||||
// Windows: use taskkill to force kill process tree
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import path from 'path';
|
||||
import { app } from 'electron';
|
||||
import {
|
||||
loadProfilesFile,
|
||||
saveProfilesFile,
|
||||
@@ -48,7 +46,7 @@ vi.mock('fs', () => {
|
||||
});
|
||||
|
||||
describe('profile-manager', () => {
|
||||
const mockProfilesPath = '/mock/userdata/profiles.json';
|
||||
const _mockProfilesPath = '/mock/userdata/profiles.json';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -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 (!isNaN(pid) && !this.isProcessRunning(pid)) {
|
||||
if (!Number.isNaN(pid) && !this.isProcessRunning(pid)) {
|
||||
// Stale lock - remove it
|
||||
try {
|
||||
unlinkSync(this.lockFile);
|
||||
@@ -194,7 +194,7 @@ export class SpecNumberLock {
|
||||
const match = entry.name.match(/^(\d{3})-/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1], 10);
|
||||
if (!isNaN(num)) {
|
||||
if (!Number.isNaN(num)) {
|
||||
maxNum = Math.max(maxNum, num);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AppUpdateProgress,
|
||||
AppUpdateAvailableEvent,
|
||||
AppUpdateDownloadedEvent,
|
||||
AppUpdateErrorEvent,
|
||||
IPCResult
|
||||
} from '../../shared/types';
|
||||
import { createIpcListener, invokeIpc, IpcListenerCleanup } from './modules/ipc-utils';
|
||||
@@ -34,6 +35,12 @@ export interface AppUpdateAPI {
|
||||
onAppUpdateStableDowngrade: (
|
||||
callback: (info: AppUpdateInfo) => void
|
||||
) => IpcListenerCleanup;
|
||||
onAppUpdateReadOnlyVolume: (
|
||||
callback: (info: { appPath: string }) => void
|
||||
) => IpcListenerCleanup;
|
||||
onAppUpdateError: (
|
||||
callback: (error: AppUpdateErrorEvent) => void
|
||||
) => IpcListenerCleanup;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,7 +58,9 @@ export const createAppUpdateAPI = (): AppUpdateAPI => ({
|
||||
invokeIpc(IPC_CHANNELS.APP_UPDATE_DOWNLOAD_STABLE),
|
||||
|
||||
installAppUpdate: (): void => {
|
||||
invokeIpc(IPC_CHANNELS.APP_UPDATE_INSTALL);
|
||||
invokeIpc(IPC_CHANNELS.APP_UPDATE_INSTALL).catch((err) =>
|
||||
console.error('[app-update] Install failed:', err)
|
||||
);
|
||||
},
|
||||
|
||||
getAppVersion: (): Promise<string> =>
|
||||
@@ -79,5 +88,15 @@ export const createAppUpdateAPI = (): AppUpdateAPI => ({
|
||||
onAppUpdateStableDowngrade: (
|
||||
callback: (info: AppUpdateInfo) => void
|
||||
): IpcListenerCleanup =>
|
||||
createIpcListener(IPC_CHANNELS.APP_UPDATE_STABLE_DOWNGRADE, callback)
|
||||
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)
|
||||
});
|
||||
|
||||
@@ -80,8 +80,9 @@ 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: () => Promise<ClaudeCodeVersionResult>;
|
||||
checkClaudeCodeVersion: (forceRefresh?: boolean) => Promise<ClaudeCodeVersionResult>;
|
||||
|
||||
/**
|
||||
* Install or update Claude Code CLI
|
||||
@@ -118,8 +119,8 @@ export interface ClaudeCodeAPI {
|
||||
* Creates the Claude Code API implementation
|
||||
*/
|
||||
export const createClaudeCodeAPI = (): ClaudeCodeAPI => ({
|
||||
checkClaudeCodeVersion: (): Promise<ClaudeCodeVersionResult> =>
|
||||
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION),
|
||||
checkClaudeCodeVersion: (forceRefresh?: boolean): Promise<ClaudeCodeVersionResult> =>
|
||||
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION, forceRefresh),
|
||||
|
||||
installClaudeCode: (): Promise<ClaudeCodeInstallResult> =>
|
||||
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_INSTALL),
|
||||
|
||||
@@ -81,7 +81,7 @@ export const createProfileAPI = (): ProfileAPI => ({
|
||||
const requestId = ++testConnectionRequestId;
|
||||
|
||||
// Check if already aborted before initiating request
|
||||
if (signal && signal.aborted) {
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'));
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ export const createProfileAPI = (): ProfileAPI => ({
|
||||
console.log('[preload/profile-api] Request ID:', requestId);
|
||||
|
||||
// Check if already aborted before initiating request
|
||||
if (signal && signal.aborted) {
|
||||
if (signal?.aborted) {
|
||||
console.log('[preload/profile-api] Already aborted, rejecting');
|
||||
return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'));
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ 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>>;
|
||||
@@ -144,6 +145,9 @@ 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),
|
||||
|
||||
@@ -243,7 +243,7 @@ export function App() {
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- projectTabs is intentionally omitted to avoid infinite re-render (computed array creates new reference each render)
|
||||
}, [projects, activeProjectId, selectedProjectId, openProjectIds, openProjectTab, setActiveProject]);
|
||||
}, [projects, activeProjectId, selectedProjectId, openProjectIds, openProjectTab, setActiveProject, projectTabs.length, projectTabs.map]);
|
||||
|
||||
// Track if settings have been loaded at least once
|
||||
const [settingsHaveLoaded, setSettingsHaveLoaded] = useState(false);
|
||||
@@ -314,7 +314,7 @@ export function App() {
|
||||
i18n.changeLanguage(settings.language);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only run when settings.language changes, not on every i18n object change
|
||||
}, [settings.language, i18n.language]);
|
||||
}, [settings.language, i18n.language, i18n.changeLanguage]);
|
||||
|
||||
// Sync spell check language with i18n language
|
||||
useEffect(() => {
|
||||
@@ -368,7 +368,7 @@ export function App() {
|
||||
useEffect(() => {
|
||||
setInitSuccess(false);
|
||||
setInitError(null);
|
||||
}, [selectedProjectId]);
|
||||
}, []);
|
||||
|
||||
// Check if selected project needs initialization (e.g., .auto-claude folder was deleted)
|
||||
useEffect(() => {
|
||||
@@ -445,7 +445,7 @@ export function App() {
|
||||
console.error('[App] Failed to restore sessions:', err);
|
||||
});
|
||||
}
|
||||
}, [activeProjectId, selectedProjectId, selectedProject?.path, selectedProject?.name]);
|
||||
}, [activeProjectId, selectedProjectId, selectedProject?.path]);
|
||||
|
||||
// Apply theme on load
|
||||
useEffect(() => {
|
||||
@@ -587,7 +587,7 @@ export function App() {
|
||||
setSelectedTask(updatedTask);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally omit selectedTask object to prevent infinite re-render loop
|
||||
}, [tasks, selectedTask?.id, selectedTask?.specId]);
|
||||
}, [tasks, selectedTask?.id, selectedTask?.specId, selectedTask]);
|
||||
|
||||
const handleTaskClick = (task: Task) => {
|
||||
setSelectedTask(task);
|
||||
|
||||
@@ -50,7 +50,6 @@ import type {
|
||||
RoadmapPhase,
|
||||
RoadmapFeaturePriority,
|
||||
RoadmapFeatureStatus,
|
||||
FeatureSource
|
||||
} from '../../shared/types';
|
||||
|
||||
/**
|
||||
|
||||
@@ -86,7 +86,7 @@ export function AgentProfileSelector({
|
||||
const [showPhaseDetails, setShowPhaseDetails] = useState(false);
|
||||
|
||||
const isCustom = profileId === 'custom';
|
||||
const isAuto = profileId === 'auto';
|
||||
const _isAuto = profileId === 'auto';
|
||||
|
||||
// Use provided phase configs or defaults
|
||||
const currentPhaseModels = phaseModels || DEFAULT_PHASE_MODELS;
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
Terminal,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
Lock
|
||||
} from 'lucide-react';
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
@@ -48,7 +47,7 @@ import {
|
||||
} from './ui/dialog';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useProjectStore } from '../stores/project-store';
|
||||
import type { ProjectEnvConfig, AgentMcpOverrides, AgentMcpOverride, CustomMcpServer, McpHealthCheckResult, McpHealthStatus } from '../../shared/types';
|
||||
import type { ProjectEnvConfig, AgentMcpOverride, CustomMcpServer, McpHealthCheckResult, } from '../../shared/types';
|
||||
import { CustomMcpDialog } from './CustomMcpDialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -59,7 +58,6 @@ import {
|
||||
useResolvedAgentSettings,
|
||||
resolveAgentSettings as resolveAgentModelConfig,
|
||||
type AgentSettingsSource,
|
||||
type ResolvedAgentSettings,
|
||||
} from '../hooks';
|
||||
import type { ModelTypeShort, ThinkingLevel } from '../../shared/types/settings';
|
||||
|
||||
@@ -912,7 +910,7 @@ export function AgentTools() {
|
||||
[server.id]: result.data!,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
setServerHealthStatus(prev => ({
|
||||
...prev,
|
||||
[server.id]: {
|
||||
@@ -945,14 +943,14 @@ export function AgentTools() {
|
||||
...prev,
|
||||
[server.id]: {
|
||||
serverId: server.id,
|
||||
status: result.data!.success ? 'healthy' : 'unhealthy',
|
||||
message: result.data!.message,
|
||||
responseTime: result.data!.responseTime,
|
||||
status: result.data?.success ? 'healthy' : 'unhealthy',
|
||||
message: result.data?.message,
|
||||
responseTime: result.data?.responseTime,
|
||||
checkedAt: new Date().toISOString(),
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
setServerHealthStatus(prev => ({
|
||||
...prev,
|
||||
[server.id]: {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Download, RefreshCw, CheckCircle2, AlertCircle, ExternalLink } from "lucide-react";
|
||||
import { Download, RefreshCw, CheckCircle2, AlertCircle, AlertTriangle, ExternalLink } from "lucide-react";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import { Button } from "./ui/button";
|
||||
import { Progress } from "./ui/progress";
|
||||
import {
|
||||
@@ -70,6 +72,7 @@ export function AppUpdateNotification() {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [isDownloaded, setIsDownloaded] = useState(false);
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||
const [showReadOnlyWarning, setShowReadOnlyWarning] = useState(false);
|
||||
|
||||
// Create markdown components with translated accessibility text
|
||||
const markdownComponents: Components = useMemo(
|
||||
@@ -88,6 +91,7 @@ export function AppUpdateNotification() {
|
||||
setIsDownloaded(false);
|
||||
setDownloadProgress(null);
|
||||
setDownloadError(null);
|
||||
setShowReadOnlyWarning(false);
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
@@ -99,6 +103,8 @@ export function AppUpdateNotification() {
|
||||
setIsDownloading(false);
|
||||
setIsDownloaded(true);
|
||||
setDownloadProgress(null);
|
||||
setDownloadError(null);
|
||||
setShowReadOnlyWarning(false);
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
@@ -113,6 +119,26 @@ export function AppUpdateNotification() {
|
||||
return cleanup;
|
||||
}, []);
|
||||
|
||||
// Listen for update errors (e.g., install failures)
|
||||
useEffect(() => {
|
||||
const cleanup = window.electronAPI.onAppUpdateError((error) => {
|
||||
setDownloadError(error.message);
|
||||
setIsDownloading(false);
|
||||
setDownloadProgress(null);
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, []);
|
||||
|
||||
// Listen for read-only volume warning (when trying to install from DMG on macOS)
|
||||
useEffect(() => {
|
||||
const cleanup = window.electronAPI.onAppUpdateReadOnlyVolume(() => {
|
||||
setShowReadOnlyWarning(true);
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
}, []);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true);
|
||||
setDownloadError(null);
|
||||
@@ -189,7 +215,11 @@ export function AppUpdateNotification() {
|
||||
{updateInfo.releaseNotes && (
|
||||
<div className="bg-background rounded-lg p-4 max-h-64 overflow-y-auto border border-border/50">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{updateInfo.releaseNotes}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
@@ -201,7 +231,7 @@ export function AppUpdateNotification() {
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="w-full text-xs text-muted-foreground gap-1"
|
||||
onClick={() => window.electronAPI?.openExternal?.(CLAUDE_CODE_CHANGELOG_URL)}
|
||||
onClick={() => window.electronAPI.openExternal(CLAUDE_CODE_CHANGELOG_URL)}
|
||||
aria-label={t(
|
||||
"dialogs:appUpdate.claudeCodeChangelogAriaLabel",
|
||||
"View Claude Code Changelog (opens in new window)"
|
||||
@@ -238,8 +268,23 @@ export function AppUpdateNotification() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Read-Only Volume Warning (DMG install on macOS) */}
|
||||
{showReadOnlyWarning && (
|
||||
<div className="flex items-start gap-3 text-sm text-warning bg-warning/10 border border-warning/30 rounded-lg p-3">
|
||||
<AlertTriangle className="h-5 w-5 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">
|
||||
{t("dialogs:appUpdate.readOnlyVolumeTitle", "Cannot install from disk image")}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t("dialogs:appUpdate.readOnlyVolumeDescription", "Please move Auto Claude to your Applications folder before updating.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Downloaded Success */}
|
||||
{isDownloaded && (
|
||||
{isDownloaded && !showReadOnlyWarning && (
|
||||
<div className="flex items-center gap-3 text-sm text-success bg-success/10 border border-success/30 rounded-lg p-3">
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0" />
|
||||
<span>
|
||||
@@ -260,7 +305,7 @@ export function AppUpdateNotification() {
|
||||
</Button>
|
||||
|
||||
{isDownloaded ? (
|
||||
<Button onClick={handleInstall}>
|
||||
<Button onClick={handleInstall} disabled={showReadOnlyWarning}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t("dialogs:appUpdate.installAndRestart", "Install and Restart")}
|
||||
</Button>
|
||||
|
||||
@@ -237,8 +237,7 @@ export function AuthStatusIndicator() {
|
||||
|
||||
{/* Profile details for API profiles */}
|
||||
{!isOAuth && (
|
||||
<>
|
||||
<div className="pt-2 border-t space-y-2">
|
||||
<div className="pt-2 border-t space-y-2">
|
||||
{/* Profile name with icon */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
@@ -272,7 +271,6 @@ export function AuthStatusIndicator() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
|
||||
@@ -74,14 +74,14 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
|
||||
const [showPathChangeWarning, setShowPathChangeWarning] = useState(false);
|
||||
|
||||
// Check Claude Code version
|
||||
const checkVersion = useCallback(async () => {
|
||||
const checkVersion = useCallback(async (forceRefresh = false) => {
|
||||
try {
|
||||
if (!window.electronAPI?.checkClaudeCodeVersion) {
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electronAPI.checkClaudeCodeVersion();
|
||||
const result = await window.electronAPI.checkClaudeCodeVersion(forceRefresh);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setVersionInfo(result.data);
|
||||
@@ -486,7 +486,7 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
onClick={() => checkVersion()}
|
||||
onClick={() => checkVersion(true)}
|
||||
disabled={status === "loading"}
|
||||
>
|
||||
<RefreshCw className={cn("h-3 w-3", status === "loading" && "animate-spin")} />
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Label } from './ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from './ui/radio-group';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CustomMcpServer } from '../../shared/types';
|
||||
import { Terminal, Globe, X, Github, Loader2, ExternalLink } from 'lucide-react';
|
||||
import { Terminal, Globe, X, Github, ExternalLink } from 'lucide-react';
|
||||
|
||||
interface CustomMcpDialogProps {
|
||||
open: boolean;
|
||||
|
||||
@@ -121,7 +121,7 @@ export function EnvConfigModal({
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [open]);
|
||||
}, [open, selectedProfileId]);
|
||||
|
||||
// Listen for OAuth token from terminal
|
||||
useEffect(() => {
|
||||
|
||||
@@ -109,7 +109,7 @@ export function FileAutocomplete({
|
||||
// Reset selection when results change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [filteredFiles]);
|
||||
}, []);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
|
||||
@@ -79,7 +79,7 @@ export function FileTreeItem({
|
||||
// This handles cases where component unmounts mid-drag or dragend doesn't fire
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (dragImageRef.current && dragImageRef.current.parentNode) {
|
||||
if (dragImageRef.current?.parentNode) {
|
||||
dragImageRef.current.parentNode.removeChild(dragImageRef.current);
|
||||
dragImageRef.current = null;
|
||||
}
|
||||
@@ -151,7 +151,7 @@ export function FileTreeItem({
|
||||
setIsDragging(false);
|
||||
|
||||
// Clean up drag image element
|
||||
if (dragImageRef.current && dragImageRef.current.parentNode) {
|
||||
if (dragImageRef.current?.parentNode) {
|
||||
dragImageRef.current.parentNode.removeChild(dragImageRef.current);
|
||||
dragImageRef.current = null;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function GitSetupModal({
|
||||
const [step, setStep] = useState<'info' | 'initializing' | 'success'>('info');
|
||||
|
||||
const needsGitInit = gitStatus && !gitStatus.isGitRepo;
|
||||
const _needsCommit = gitStatus && gitStatus.isGitRepo && !gitStatus.hasCommits;
|
||||
const _needsCommit = gitStatus?.isGitRepo && !gitStatus.hasCommits;
|
||||
|
||||
const handleInitializeGit = async () => {
|
||||
if (!project) return;
|
||||
|
||||
@@ -150,7 +150,7 @@ export function Insights({ projectId }: InsightsProps) {
|
||||
if (isUserAtBottom && viewportEl) {
|
||||
viewportEl.scrollTop = viewportEl.scrollHeight;
|
||||
}
|
||||
}, [session?.messages, streamingContent, isUserAtBottom, viewportEl]);
|
||||
}, [isUserAtBottom, viewportEl]);
|
||||
|
||||
// Focus textarea on mount
|
||||
useEffect(() => {
|
||||
@@ -160,7 +160,7 @@ export function Insights({ projectId }: InsightsProps) {
|
||||
// Reset taskCreated when switching sessions
|
||||
useEffect(() => {
|
||||
setTaskCreated(new Set());
|
||||
}, [session?.id]);
|
||||
}, []);
|
||||
|
||||
const handleSend = () => {
|
||||
const message = inputValue.trim();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user