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`.
|
**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
|
## 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 releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
|
||||||
|
|
||||||
<!-- BETA_VERSION_BADGE -->
|
<!-- 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_VERSION_BADGE_END -->
|
||||||
|
|
||||||
<!-- BETA_DOWNLOADS -->
|
<!-- BETA_DOWNLOADS -->
|
||||||
| Platform | Download |
|
| 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) |
|
| **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.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 (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.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) |
|
| **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.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** | [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.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 (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.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) |
|
| **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 -->
|
<!-- BETA_DOWNLOADS_END -->
|
||||||
|
|
||||||
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
|
> 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 logging
|
||||||
|
import re
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -14,7 +15,82 @@ logger = logging.getLogger(__name__)
|
|||||||
AUTO_CONTINUE_DELAY_SECONDS = 3
|
AUTO_CONTINUE_DELAY_SECONDS = 3
|
||||||
HUMAN_INTERVENTION_FILE = "PAUSE"
|
HUMAN_INTERVENTION_FILE = "PAUSE"
|
||||||
|
|
||||||
# Concurrency retry constants
|
# Retry configuration for 400 tool concurrency errors
|
||||||
MAX_CONCURRENCY_RETRIES = 5
|
MAX_CONCURRENCY_RETRIES = 5 # Maximum number of retries for tool concurrency errors
|
||||||
INITIAL_RETRY_DELAY_SECONDS = 2
|
INITIAL_RETRY_DELAY_SECONDS = (
|
||||||
MAX_RETRY_DELAY_SECONDS = 32
|
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 asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from core.client import create_client
|
from core.client import create_client
|
||||||
@@ -57,11 +60,19 @@ from ui import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .base import (
|
from .base import (
|
||||||
|
AUTH_FAILURE_PAUSE_FILE,
|
||||||
|
AUTH_RESUME_CHECK_INTERVAL_SECONDS,
|
||||||
|
AUTH_RESUME_MAX_WAIT_SECONDS,
|
||||||
AUTO_CONTINUE_DELAY_SECONDS,
|
AUTO_CONTINUE_DELAY_SECONDS,
|
||||||
HUMAN_INTERVENTION_FILE,
|
HUMAN_INTERVENTION_FILE,
|
||||||
INITIAL_RETRY_DELAY_SECONDS,
|
INITIAL_RETRY_DELAY_SECONDS,
|
||||||
MAX_CONCURRENCY_RETRIES,
|
MAX_CONCURRENCY_RETRIES,
|
||||||
|
MAX_RATE_LIMIT_WAIT_SECONDS,
|
||||||
MAX_RETRY_DELAY_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 .memory_manager import debug_memory_system_status, get_graphiti_context
|
||||||
from .session import post_session_processing, run_agent_session
|
from .session import post_session_processing, run_agent_session
|
||||||
@@ -76,6 +87,219 @@ from .utils import (
|
|||||||
logger = logging.getLogger(__name__)
|
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(
|
async def run_autonomous_agent(
|
||||||
project_dir: Path,
|
project_dir: Path,
|
||||||
spec_dir: Path,
|
spec_dir: Path,
|
||||||
@@ -682,6 +906,135 @@ async def run_autonomous_agent(
|
|||||||
current_retry_delay * 2, MAX_RETRY_DELAY_SECONDS
|
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:
|
else:
|
||||||
# Other errors - use standard retry logic
|
# Other errors - use standard retry logic
|
||||||
print_status("Session encountered an error", "error")
|
print_status("Session encountered an error", "error")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ memory updates, recovery tracking, and Linear integration.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from claude_agent_sdk import ClaudeSDKClient
|
from claude_agent_sdk import ClaudeSDKClient
|
||||||
@@ -34,6 +35,7 @@ from ui import (
|
|||||||
print_status,
|
print_status,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .base import sanitize_error_message
|
||||||
from .memory_manager import save_session_memory
|
from .memory_manager import save_session_memory
|
||||||
from .utils import (
|
from .utils import (
|
||||||
find_subtask_in_plan,
|
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(
|
async def post_session_processing(
|
||||||
spec_dir: Path,
|
spec_dir: Path,
|
||||||
project_dir: Path,
|
project_dir: Path,
|
||||||
@@ -568,7 +657,18 @@ async def run_agent_session(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Detect specific error types for better retry handling
|
# Detect specific error types for better retry handling
|
||||||
is_concurrency = is_tool_concurrency_error(e)
|
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(
|
debug_error(
|
||||||
"session",
|
"session",
|
||||||
@@ -579,20 +679,32 @@ async def run_agent_session(
|
|||||||
tool_count=tool_count,
|
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:
|
if is_concurrency:
|
||||||
print("\n⚠️ Tool concurrency limit reached (400 error)")
|
print("\n⚠️ Tool concurrency limit reached (400 error)")
|
||||||
print(" Claude API limits concurrent tool use in a single request")
|
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:
|
else:
|
||||||
print(f"Error during agent session: {e}")
|
print(f"Error during agent session: {sanitized_error}")
|
||||||
|
|
||||||
if task_logger:
|
if task_logger:
|
||||||
task_logger.log_error(f"Session error: {e}", phase)
|
task_logger.log_error(f"Session error: {sanitized_error}", phase)
|
||||||
|
|
||||||
error_info = {
|
error_info = {
|
||||||
"type": error_type,
|
"type": error_type,
|
||||||
"message": str(e),
|
"message": sanitized_error,
|
||||||
"exception_type": type(e).__name__,
|
"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"
|
QA_FIXING = "qa_fixing"
|
||||||
COMPLETE = "complete"
|
COMPLETE = "complete"
|
||||||
FAILED = "failed"
|
FAILED = "failed"
|
||||||
|
# Pause states for intelligent error recovery
|
||||||
|
RATE_LIMIT_PAUSED = "rate_limit_paused"
|
||||||
|
AUTH_FAILURE_PAUSED = "auth_failure_paused"
|
||||||
|
|
||||||
|
|
||||||
def emit_phase(
|
def emit_phase(
|
||||||
@@ -31,8 +34,19 @@ def emit_phase(
|
|||||||
*,
|
*,
|
||||||
progress: int | None = None,
|
progress: int | None = None,
|
||||||
subtask: str | None = None,
|
subtask: str | None = None,
|
||||||
|
reset_timestamp: int | None = None,
|
||||||
|
profile_id: str | None = 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
|
phase_value = phase.value if isinstance(phase, ExecutionPhase) else phase
|
||||||
|
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
@@ -48,6 +62,12 @@ def emit_phase(
|
|||||||
if subtask is not None:
|
if subtask is not None:
|
||||||
payload["subtask"] = subtask
|
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:
|
try:
|
||||||
print(f"{PHASE_MARKER_PREFIX}{json.dumps(payload, default=str)}", flush=True)
|
print(f"{PHASE_MARKER_PREFIX}{json.dumps(payload, default=str)}", flush=True)
|
||||||
except (OSError, UnicodeEncodeError) as e:
|
except (OSError, UnicodeEncodeError) as e:
|
||||||
|
|||||||
@@ -12,12 +12,8 @@ The refactored code is now organized as:
|
|||||||
- graphiti/search.py - Semantic search logic
|
- graphiti/search.py - Semantic search logic
|
||||||
- graphiti/schema.py - Graph schema definitions
|
- graphiti/schema.py - Graph schema definitions
|
||||||
|
|
||||||
This facade ensures existing imports continue to work:
|
Import from this module:
|
||||||
from graphiti_memory import GraphitiMemory, is_graphiti_enabled
|
from integrations.graphiti.memory import GraphitiMemory, is_graphiti_enabled, GroupIdMode
|
||||||
|
|
||||||
New code should prefer importing from the graphiti package:
|
|
||||||
from graphiti import GraphitiMemory
|
|
||||||
from graphiti.schema import GroupIdMode
|
|
||||||
|
|
||||||
For detailed documentation on the memory system architecture and usage,
|
For detailed documentation on the memory system architecture and usage,
|
||||||
see graphiti/graphiti.py.
|
see graphiti/graphiti.py.
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ async def get_graph_hints(
|
|||||||
try:
|
try:
|
||||||
from pathlib import Path
|
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
|
# Determine project directory from project_id or use current dir
|
||||||
project_dir = Path.cwd()
|
project_dir = Path.cwd()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from core.sentry import capture_exception
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from graphiti_memory import GraphitiMemory
|
from integrations.graphiti.memory import GraphitiMemory
|
||||||
|
|
||||||
|
|
||||||
def is_graphiti_memory_enabled() -> bool:
|
def is_graphiti_memory_enabled() -> bool:
|
||||||
@@ -60,7 +60,7 @@ async def get_graphiti_memory(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from graphiti_memory import GraphitiMemory, GroupIdMode
|
from integrations.graphiti.memory import GraphitiMemory, GroupIdMode
|
||||||
|
|
||||||
if project_dir is None:
|
if project_dir is None:
|
||||||
project_dir = spec_dir.parent.parent
|
project_dir = spec_dir.parent.parent
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { resolve } from 'path';
|
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.
|
* Sentry configuration embedded at build time.
|
||||||
|
|||||||
@@ -96,6 +96,8 @@
|
|||||||
"react-i18next": "^16.5.0",
|
"react-i18next": "^16.5.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-resizable-panels": "^4.2.0",
|
"react-resizable-panels": "^4.2.0",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"tailwind-merge": "^3.4.0",
|
"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 { mkdirSync, rmSync, existsSync, mkdtempSync } from 'fs';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import path from 'path';
|
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
|
// Test directories - use secure temp directory with random suffix
|
||||||
let TEST_DIR: string;
|
let TEST_DIR: string;
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ vi.mock('child_process', () => {
|
|||||||
// so when tests call vi.mocked(execFileSync).mockReturnValue(), it affects execSync too
|
// so when tests call vi.mocked(execFileSync).mockReturnValue(), it affects execSync too
|
||||||
const sharedSyncMock = vi.fn();
|
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
|
// Return a minimal ChildProcess-like object
|
||||||
const childProcess = {
|
const childProcess = {
|
||||||
stdout: { on: vi.fn() },
|
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;
|
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
|
// Return a minimal ChildProcess-like object
|
||||||
const childProcess = {
|
const childProcess = {
|
||||||
stdout: { on: vi.fn() },
|
stdout: { on: vi.fn() },
|
||||||
|
|||||||
@@ -61,15 +61,17 @@ import path from 'path';
|
|||||||
import { isValidConfigDir } from '../utils/config-path-validator';
|
import { isValidConfigDir } from '../utils/config-path-validator';
|
||||||
|
|
||||||
describe('isValidConfigDir - Security Validation', () => {
|
describe('isValidConfigDir - Security Validation', () => {
|
||||||
let originalHomedir: string;
|
let _originalHomedir: string;
|
||||||
let consoleWarnSpy: any;
|
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Store original homedir for restoration
|
// Store original homedir for restoration
|
||||||
originalHomedir = os.homedir();
|
_originalHomedir = os.homedir();
|
||||||
|
|
||||||
// Spy on console.warn to suppress warning output during tests
|
// 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(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ describe('PythonEnvManager', () => {
|
|||||||
const sitePackagesPath = 'C:\\test\\site-packages';
|
const sitePackagesPath = 'C:\\test\\site-packages';
|
||||||
|
|
||||||
// Access private property for testing
|
// Access private property for testing
|
||||||
(manager as any).sitePackagesPath = sitePackagesPath;
|
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
|
||||||
|
|
||||||
const env = manager.getPythonEnv();
|
const env = manager.getPythonEnv();
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ describe('PythonEnvManager', () => {
|
|||||||
const sitePackagesPath = 'C:\\test\\site-packages';
|
const sitePackagesPath = 'C:\\test\\site-packages';
|
||||||
|
|
||||||
// Access private property for testing
|
// Access private property for testing
|
||||||
(manager as any).sitePackagesPath = sitePackagesPath;
|
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
|
||||||
|
|
||||||
const env = manager.getPythonEnv();
|
const env = manager.getPythonEnv();
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ describe('PythonEnvManager', () => {
|
|||||||
const sitePackagesPath = '/test/site-packages';
|
const sitePackagesPath = '/test/site-packages';
|
||||||
|
|
||||||
// Access private property for testing
|
// Access private property for testing
|
||||||
(manager as any).sitePackagesPath = sitePackagesPath;
|
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
|
||||||
|
|
||||||
const env = manager.getPythonEnv();
|
const env = manager.getPythonEnv();
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ describe('PythonEnvManager', () => {
|
|||||||
const sitePackagesPath = 'C:\\test\\site-packages';
|
const sitePackagesPath = 'C:\\test\\site-packages';
|
||||||
|
|
||||||
// Access private property for testing
|
// 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'
|
// Save and clear existing PATH, then set lowercase 'Path'
|
||||||
// This simulates a Windows environment where the system has 'Path' instead of '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) {
|
for (const msg of falsePositives) {
|
||||||
const result = detectRateLimit(msg);
|
const _result = detectRateLimit(msg);
|
||||||
// Note: Some may still match secondary indicators - that's intentional
|
// Note: Some may still match secondary indicators - that's intentional
|
||||||
// The primary pattern should NOT match these
|
// The primary pattern should NOT match these
|
||||||
const primaryPattern = /Limit reached\s*[·•]\s*resets/i;
|
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)
|
// Get the mocked functions (vitest stores the implementation)
|
||||||
const { existsSync, readFileSync } = await import('fs');
|
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
|
// Save original mock implementations to restore after test
|
||||||
const originalExistsSync = (existsSync as any).getMockImplementation();
|
type ExistsSyncFn = (path: string) => boolean;
|
||||||
const originalReadFileSync = (readFileSync as any).getMockImplementation();
|
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
|
// Override existsSync to make file appear to exist
|
||||||
(existsSync as any).mockImplementation((path: string) => {
|
existsSyncMock.mockImplementation((path: string) => {
|
||||||
if (path === claudeJsonPath) {
|
if (path === claudeJsonPath) {
|
||||||
return true; // File appears to exist
|
return true; // File appears to exist
|
||||||
}
|
}
|
||||||
return originalExistsSync ? originalExistsSync(path) : false;
|
return originalExistsSync(path);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Override readFileSync to throw error for our specific file
|
// Override readFileSync to throw error for our specific file
|
||||||
(readFileSync as any).mockImplementation((path: string) => {
|
readFileSyncMock.mockImplementation((path: string) => {
|
||||||
if (path === claudeJsonPath) {
|
if (path === claudeJsonPath) {
|
||||||
throw new Error('EACCES: permission denied, open \'' + path + '\'');
|
throw new Error('EACCES: permission denied, open \'' + path + '\'');
|
||||||
}
|
}
|
||||||
return originalReadFileSync ? originalReadFileSync(path) : '';
|
return originalReadFileSync(path);
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await onboardingStatusHandler({}, null) as {
|
const result = await onboardingStatusHandler({}, null) as {
|
||||||
@@ -272,8 +279,8 @@ describe('SETTINGS_CLAUDE_CODE_GET_ONBOARDING_STATUS handler', () => {
|
|||||||
expect(result.data?.hasCompletedOnboarding).toBe(false);
|
expect(result.data?.hasCompletedOnboarding).toBe(false);
|
||||||
|
|
||||||
// Restore original mocks
|
// Restore original mocks
|
||||||
(existsSync as any).mockImplementation(originalExistsSync);
|
existsSyncMock.mockImplementation(originalExistsSync);
|
||||||
(readFileSync as any).mockImplementation(originalReadFileSync);
|
readFileSyncMock.mockImplementation(originalReadFileSync);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -178,7 +178,9 @@ describe("safeSendToRenderer", () => {
|
|||||||
|
|
||||||
describe("error handling - non-disposal errors", () => {
|
describe("error handling - non-disposal errors", () => {
|
||||||
it("catches other errors and returns false", () => {
|
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(() => {
|
mockSend.mockImplementation(() => {
|
||||||
throw new Error("Some other IPC error");
|
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", () => {
|
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 = {
|
mockWindow = {
|
||||||
isDestroyed: vi.fn(() => true),
|
isDestroyed: vi.fn(() => true),
|
||||||
@@ -242,7 +246,9 @@ describe("safeSendToRenderer", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("logs console.warn separately for different channels", () => {
|
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 = {
|
mockWindow = {
|
||||||
isDestroyed: vi.fn(() => true),
|
isDestroyed: vi.fn(() => true),
|
||||||
@@ -307,7 +313,9 @@ describe("safeSendToRenderer", () => {
|
|||||||
|
|
||||||
describe("warning pruning logic - 100-entry hard cap", () => {
|
describe("warning pruning logic - 100-entry hard cap", () => {
|
||||||
it("enforces 100-entry cap by removing oldest entries when exceeded", async () => {
|
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 = {
|
mockWindow = {
|
||||||
isDestroyed: vi.fn(() => true),
|
isDestroyed: vi.fn(() => true),
|
||||||
@@ -340,7 +348,9 @@ describe("safeSendToRenderer", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("handles many unique channels without throwing errors", async () => {
|
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 = {
|
mockWindow = {
|
||||||
isDestroyed: vi.fn(() => true),
|
isDestroyed: vi.fn(() => true),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { parsePhaseEvent } from './phase-event-parser';
|
|||||||
import {
|
import {
|
||||||
wouldPhaseRegress,
|
wouldPhaseRegress,
|
||||||
isTerminalPhase,
|
isTerminalPhase,
|
||||||
|
isPausePhase,
|
||||||
isValidExecutionPhase,
|
isValidExecutionPhase,
|
||||||
type ExecutionPhase
|
type ExecutionPhase
|
||||||
} from '../../shared/constants/phase-protocol';
|
} from '../../shared/constants/phase-protocol';
|
||||||
@@ -13,18 +14,48 @@ export class AgentEvents {
|
|||||||
log: string,
|
log: string,
|
||||||
currentPhase: ExecutionProgressData['phase'],
|
currentPhase: ExecutionProgressData['phase'],
|
||||||
isSpecRunner: boolean
|
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);
|
const structuredEvent = parsePhaseEvent(log);
|
||||||
if (structuredEvent) {
|
if (structuredEvent) {
|
||||||
return {
|
// structuredEvent.phase is validated as BackendPhase (via Zod schema),
|
||||||
phase: structuredEvent.phase as ExecutionProgressData['phase'],
|
// 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,
|
message: structuredEvent.message,
|
||||||
currentSubtask: structuredEvent.subtask
|
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
|
// 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { AgentEvents } from './agent-events';
|
|||||||
import { AgentProcessManager } from './agent-process';
|
import { AgentProcessManager } from './agent-process';
|
||||||
import { AgentQueueManager } from './agent-queue';
|
import { AgentQueueManager } from './agent-queue';
|
||||||
import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager';
|
import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager';
|
||||||
|
import type { ClaudeProfileManager } from '../claude-profile-manager';
|
||||||
|
import { getOperationRegistry } from '../claude-profile/operation-registry';
|
||||||
import {
|
import {
|
||||||
SpecCreationMetadata,
|
SpecCreationMetadata,
|
||||||
TaskExecutionOptions,
|
TaskExecutionOptions,
|
||||||
@@ -33,6 +35,8 @@ export class AgentManager extends EventEmitter {
|
|||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
swapCount: number;
|
swapCount: number;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
/** Generation counter to prevent stale cleanup after restart */
|
||||||
|
generation: number;
|
||||||
}> = new Map();
|
}> = new Map();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -57,21 +61,35 @@ export class AgentManager extends EventEmitter {
|
|||||||
// 1. Task completed successfully (code === 0), or
|
// 1. Task completed successfully (code === 0), or
|
||||||
// 2. Task failed and won't be restarted (handled by auto-swap logic)
|
// 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,
|
// Note: Auto-swap restart happens BEFORE this exit event is processed,
|
||||||
// so we need a small delay to allow restart to preserve context
|
// so we need a small delay to allow restart to preserve context
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const context = this.taskExecutionContext.get(taskId);
|
const context = this.taskExecutionContext.get(taskId);
|
||||||
if (!context) return; // Already cleaned up or restarted
|
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 task completed successfully, always clean up
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
this.taskExecutionContext.delete(taskId);
|
this.taskExecutionContext.delete(taskId);
|
||||||
|
// Unregister from OperationRegistry
|
||||||
|
getOperationRegistry().unregisterOperation(taskId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If task failed and hit max retries, clean up
|
// If task failed and hit max retries, clean up
|
||||||
if (context.swapCount >= 2) {
|
if (context.swapCount >= 2) {
|
||||||
this.taskExecutionContext.delete(taskId);
|
this.taskExecutionContext.delete(taskId);
|
||||||
|
// Unregister from OperationRegistry
|
||||||
|
getOperationRegistry().unregisterOperation(taskId);
|
||||||
}
|
}
|
||||||
// Otherwise keep context for potential restart
|
// Otherwise keep context for potential restart
|
||||||
}, 1000); // Delay to allow restart logic to run first
|
}, 1000); // Delay to allow restart logic to run first
|
||||||
@@ -85,6 +103,46 @@ export class AgentManager extends EventEmitter {
|
|||||||
this.processManager.configure(pythonPath, autoBuildSourcePath);
|
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
|
* Start spec creation process
|
||||||
*/
|
*/
|
||||||
@@ -99,7 +157,7 @@ export class AgentManager extends EventEmitter {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Pre-flight auth check: Verify active profile has valid authentication
|
// Pre-flight auth check: Verify active profile has valid authentication
|
||||||
// Ensure profile manager is initialized to prevent race condition
|
// Ensure profile manager is initialized to prevent race condition
|
||||||
let profileManager;
|
let profileManager: ClaudeProfileManager;
|
||||||
try {
|
try {
|
||||||
profileManager = await initializeClaudeProfileManager();
|
profileManager = await initializeClaudeProfileManager();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -177,6 +235,9 @@ export class AgentManager extends EventEmitter {
|
|||||||
// Store context for potential restart
|
// Store context for potential restart
|
||||||
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch, projectId);
|
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
|
// 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);
|
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId);
|
||||||
}
|
}
|
||||||
@@ -193,7 +254,7 @@ export class AgentManager extends EventEmitter {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Pre-flight auth check: Verify active profile has valid authentication
|
// Pre-flight auth check: Verify active profile has valid authentication
|
||||||
// Ensure profile manager is initialized to prevent race condition
|
// Ensure profile manager is initialized to prevent race condition
|
||||||
let profileManager;
|
let profileManager: ClaudeProfileManager;
|
||||||
try {
|
try {
|
||||||
profileManager = await initializeClaudeProfileManager();
|
profileManager = await initializeClaudeProfileManager();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -256,6 +317,9 @@ export class AgentManager extends EventEmitter {
|
|||||||
// Store context for potential restart
|
// Store context for potential restart
|
||||||
this.storeTaskContext(taskId, projectPath, specId, options, false, undefined, undefined, undefined, undefined, projectId);
|
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);
|
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)
|
// Preserve swapCount if context already exists (for restarts)
|
||||||
const existingContext = this.taskExecutionContext.get(taskId);
|
const existingContext = this.taskExecutionContext.get(taskId);
|
||||||
const swapCount = existingContext?.swapCount ?? 0;
|
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, {
|
this.taskExecutionContext.set(taskId, {
|
||||||
projectPath,
|
projectPath,
|
||||||
@@ -408,7 +474,8 @@ export class AgentManager extends EventEmitter {
|
|||||||
metadata,
|
metadata,
|
||||||
baseBranch,
|
baseBranch,
|
||||||
swapCount, // Preserve existing count instead of resetting
|
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);
|
console.log('[AgentManager] Restarting task now:', taskId);
|
||||||
if (context.isSpecCreation) {
|
if (context.isSpecCreation) {
|
||||||
console.log('[AgentManager] Restarting as spec creation');
|
console.log('[AgentManager] Restarting as spec creation');
|
||||||
|
if (!context.taskDescription) {
|
||||||
|
console.error('[AgentManager] Cannot restart spec creation: taskDescription is missing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.startSpecCreation(
|
this.startSpecCreation(
|
||||||
taskId,
|
taskId,
|
||||||
context.projectPath,
|
context.projectPath,
|
||||||
context.taskDescription!,
|
context.taskDescription,
|
||||||
context.specDir,
|
context.specDir,
|
||||||
context.metadata,
|
context.metadata,
|
||||||
context.baseBranch,
|
context.baseBranch,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function createMockProcess() {
|
|||||||
return {
|
return {
|
||||||
stdout: { on: vi.fn() },
|
stdout: { on: vi.fn() },
|
||||||
stderr: { 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') {
|
if (event === 'exit') {
|
||||||
// Simulate immediate exit with code 0
|
// Simulate immediate exit with code 0
|
||||||
setTimeout(() => callback(0), 10);
|
setTimeout(() => callback(0), 10);
|
||||||
|
|||||||
@@ -281,7 +281,11 @@ export class AgentProcessManager {
|
|||||||
} : 'NONE');
|
} : 'NONE');
|
||||||
|
|
||||||
if (!bestProfile) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,19 +310,84 @@ export class AgentProcessManager {
|
|||||||
console.log('[AgentProcess] No rate limit detected - checking for auth failure');
|
console.log('[AgentProcess] No rate limit detected - checking for auth failure');
|
||||||
const authFailureDetection = detectAuthFailure(allOutput);
|
const authFailureDetection = detectAuthFailure(allOutput);
|
||||||
|
|
||||||
if (authFailureDetection.isAuthFailure) {
|
if (!authFailureDetection.isAuthFailure) {
|
||||||
console.log('[AgentProcess] Auth failure detected:', authFailureDetection);
|
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, {
|
this.emitter.emit('auth-failure', taskId, {
|
||||||
profileId: authFailureDetection.profileId,
|
profileId: authFailureDetection.profileId,
|
||||||
failureType: authFailureDetection.failureType,
|
failureType: authFailureDetection.failureType,
|
||||||
message: authFailureDetection.message,
|
message: authFailureDetection.message,
|
||||||
originalError: authFailureDetection.originalError
|
originalError: authFailureDetection.originalError
|
||||||
});
|
});
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[AgentProcess] Process failed but no rate limit or auth failure detected');
|
return true;
|
||||||
return false;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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', () => {
|
it('should group tasks by profile', () => {
|
||||||
// Add mock processes
|
// Add mock processes
|
||||||
state.addProcess('task-1', { pid: 1001 } as any);
|
state.addProcess('task-1', {
|
||||||
state.addProcess('task-2', { pid: 1002 } as any);
|
taskId: 'task-1',
|
||||||
state.addProcess('task-3', { pid: 1003 } as any);
|
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
|
// Assign profiles
|
||||||
state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive');
|
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', () => {
|
it('should use default profile for unassigned tasks', () => {
|
||||||
// Add process without profile assignment
|
// 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();
|
const result = state.getRunningTasksByProfile();
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export interface PhaseParseResult<TPhase extends string = string> {
|
|||||||
message?: string;
|
message?: string;
|
||||||
currentSubtask?: string;
|
currentSubtask?: string;
|
||||||
progress?: number;
|
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 {
|
import {
|
||||||
EXECUTION_PHASES,
|
EXECUTION_PHASES,
|
||||||
TERMINAL_PHASES,
|
TERMINAL_PHASES,
|
||||||
|
isPausePhase,
|
||||||
type ExecutionPhase
|
type ExecutionPhase
|
||||||
} from '../../../shared/constants/phase-protocol';
|
} from '../../../shared/constants/phase-protocol';
|
||||||
import { parsePhaseEvent } from '../phase-event-parser';
|
import { parsePhaseEvent } from '../phase-event-parser';
|
||||||
@@ -40,11 +41,21 @@ export class ExecutionPhaseParser extends BasePhaseParser<ExecutionPhase> {
|
|||||||
// 1. Try structured event first (authoritative source)
|
// 1. Try structured event first (authoritative source)
|
||||||
const structuredEvent = parsePhaseEvent(log);
|
const structuredEvent = parsePhaseEvent(log);
|
||||||
if (structuredEvent) {
|
if (structuredEvent) {
|
||||||
return {
|
const result: PhaseParseResult<ExecutionPhase> = {
|
||||||
phase: structuredEvent.phase as ExecutionPhase,
|
phase: structuredEvent.phase as ExecutionPhase,
|
||||||
message: structuredEvent.message,
|
message: structuredEvent.message,
|
||||||
currentSubtask: structuredEvent.subtask
|
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
|
// 2. Terminal states can't be changed by fallback matching
|
||||||
@@ -52,7 +63,13 @@ export class ExecutionPhaseParser extends BasePhaseParser<ExecutionPhase> {
|
|||||||
return null;
|
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);
|
return this.parseFallbackPatterns(log, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ export const PhaseEventSchema = z.object({
|
|||||||
phase: BackendPhaseSchema,
|
phase: BackendPhaseSchema,
|
||||||
message: z.string().default(''),
|
message: z.string().default(''),
|
||||||
progress: z.number().int().min(0).max(100).optional(),
|
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>;
|
export type PhaseEventPayload = z.infer<typeof PhaseEventSchema>;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ChildProcess } from 'child_process';
|
import { ChildProcess } from 'child_process';
|
||||||
import type { IdeationConfig } from '../../shared/types';
|
import type { CompletablePhase, ExecutionPhase } from '../../shared/constants/phase-protocol';
|
||||||
import type { CompletablePhase } from '../../shared/constants/phase-protocol';
|
|
||||||
import type { TaskEventPayload } from './task-event-schema';
|
import type { TaskEventPayload } from './task-event-schema';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,7 +18,7 @@ export interface AgentProcess {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ExecutionProgressData {
|
export interface ExecutionProgressData {
|
||||||
phase: 'idle' | 'planning' | 'coding' | 'qa_review' | 'qa_fixing' | 'complete' | 'failed';
|
phase: ExecutionPhase;
|
||||||
phaseProgress: number;
|
phaseProgress: number;
|
||||||
overallProgress: number;
|
overallProgress: number;
|
||||||
currentSubtask?: string;
|
currentSubtask?: string;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
* - APP_UPDATE_ERROR: Error during update process
|
* - APP_UPDATE_ERROR: Error during update process
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { accessSync, constants as fsConstants } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
import { autoUpdater } from 'electron-updater';
|
import { autoUpdater } from 'electron-updater';
|
||||||
import type { UpdateInfo } from 'electron-updater';
|
import type { UpdateInfo } from 'electron-updater';
|
||||||
import { app, net } from 'electron';
|
import { app, net } from 'electron';
|
||||||
@@ -24,6 +26,7 @@ import type { BrowserWindow } from 'electron';
|
|||||||
import { IPC_CHANNELS } from '../shared/constants';
|
import { IPC_CHANNELS } from '../shared/constants';
|
||||||
import type { AppUpdateInfo } from '../shared/types';
|
import type { AppUpdateInfo } from '../shared/types';
|
||||||
import { compareVersions } from './updater/version-manager';
|
import { compareVersions } from './updater/version-manager';
|
||||||
|
import { isMacOS } from './platform';
|
||||||
|
|
||||||
// GitHub repo info for API calls
|
// GitHub repo info for API calls
|
||||||
const GITHUB_OWNER = 'AndyMik90';
|
const GITHUB_OWNER = 'AndyMik90';
|
||||||
@@ -91,10 +94,13 @@ function formatReleaseNotes(releaseNotes: UpdateInfo['releaseNotes']): string |
|
|||||||
*/
|
*/
|
||||||
export function setUpdateChannel(channel: UpdateChannel): void {
|
export function setUpdateChannel(channel: UpdateChannel): void {
|
||||||
autoUpdater.channel = channel;
|
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
|
// Clear any downloaded update info when channel changes to prevent showing
|
||||||
// an Install button for an update from a different channel
|
// an Install button for an update from a different channel
|
||||||
downloadedUpdateInfo = null;
|
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
|
// 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);
|
console.error('[app-updater] Update error:', error);
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_ERROR, {
|
mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_ERROR, {
|
||||||
message: error.message,
|
message: error.message
|
||||||
stack: error.stack
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -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
|
* Quit and install update
|
||||||
* Called from IPC handler when user confirms installation
|
* 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');
|
console.warn('[app-updater] Quitting and installing update');
|
||||||
autoUpdater.quitAndInstall(false, true);
|
autoUpdater.quitAndInstall(false, true);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -402,7 +451,7 @@ async function fetchLatestStableRelease(): Promise<AppUpdateInfo | null> {
|
|||||||
|
|
||||||
const version = latestStable.tag_name.replace(/^v/, '');
|
const version = latestStable.tag_name.replace(/^v/, '');
|
||||||
// Sanitize version string for logging (remove control characters and limit length)
|
// 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);
|
const safeVersion = String(version).replace(/[\x00-\x1f\x7f]/g, '').slice(0, 50);
|
||||||
console.warn('[app-updater] Found latest stable release:', safeVersion);
|
console.warn('[app-updater] Found latest stable release:', safeVersion);
|
||||||
|
|
||||||
@@ -483,11 +532,8 @@ export async function setUpdateChannelWithDowngradeCheck(
|
|||||||
channel: UpdateChannel,
|
channel: UpdateChannel,
|
||||||
triggerDowngradeCheck = false
|
triggerDowngradeCheck = false
|
||||||
): Promise<AppUpdateInfo | null> {
|
): Promise<AppUpdateInfo | null> {
|
||||||
autoUpdater.channel = channel;
|
// Use the shared channel-setting function to avoid code duplication
|
||||||
// Clear any downloaded update info when channel changes to prevent showing
|
setUpdateChannel(channel);
|
||||||
// an Install button for an update from a different channel
|
|
||||||
downloadedUpdateInfo = null;
|
|
||||||
console.warn(`[app-updater] Update channel set to: ${channel}`);
|
|
||||||
|
|
||||||
// If switching to stable and downgrade check requested, look for stable version
|
// If switching to stable and downgrade check requested, look for stable version
|
||||||
if (channel === 'latest' && triggerDowngradeCheck) {
|
if (channel === 'latest' && triggerDowngradeCheck) {
|
||||||
@@ -509,8 +555,8 @@ export async function setUpdateChannelWithDowngradeCheck(
|
|||||||
* Uses electron-updater with allowDowngrade enabled to download older stable versions
|
* Uses electron-updater with allowDowngrade enabled to download older stable versions
|
||||||
*/
|
*/
|
||||||
export async function downloadStableVersion(): Promise<void> {
|
export async function downloadStableVersion(): Promise<void> {
|
||||||
// Switch to stable channel
|
// Switch to stable channel (resets allowPrerelease and clears downloadedUpdateInfo)
|
||||||
autoUpdater.channel = 'latest';
|
setUpdateChannel('latest');
|
||||||
// Enable downgrade to allow downloading older versions (e.g., stable when on beta)
|
// Enable downgrade to allow downloading older versions (e.g., stable when on beta)
|
||||||
autoUpdater.allowDowngrade = true;
|
autoUpdater.allowDowngrade = true;
|
||||||
console.warn('[app-updater] Downloading stable version (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 _pythonPath: string | null = null;
|
||||||
private claudePath: string;
|
private claudePath: string;
|
||||||
private autoBuildSourcePath: string = '';
|
private autoBuildSourcePath: string = '';
|
||||||
private cachedEnv: Record<string, string> | null = null;
|
|
||||||
private debugEnabled: boolean | null = null;
|
private debugEnabled: boolean | null = null;
|
||||||
private generator: ChangelogGenerator | null = null;
|
private generator: ChangelogGenerator | null = null;
|
||||||
private versionSuggester: VersionSuggester | null = null;
|
private versionSuggester: VersionSuggester | null = null;
|
||||||
@@ -454,7 +453,7 @@ export class ChangelogService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parts = currentVersion.split('.').map(Number);
|
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';
|
return '1.0.0';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,7 +490,7 @@ export class ChangelogService extends EventEmitter {
|
|||||||
* Suggest version using AI analysis of git commits
|
* Suggest version using AI analysis of git commits
|
||||||
*/
|
*/
|
||||||
async suggestVersionFromCommits(
|
async suggestVersionFromCommits(
|
||||||
projectPath: string,
|
_projectPath: string,
|
||||||
commits: import('../../shared/types').GitCommit[],
|
commits: import('../../shared/types').GitCommit[],
|
||||||
currentVersion?: string
|
currentVersion?: string
|
||||||
): Promise<{ version: string; reason: string }> {
|
): Promise<{ version: string; reason: string }> {
|
||||||
@@ -502,7 +501,7 @@ export class ChangelogService extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parts = currentVersion.split('.').map(Number);
|
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' };
|
return { version: '1.0.0', reason: 'Invalid current version, resetting to 1.0.0' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -198,7 +198,6 @@ except Exception as e:
|
|||||||
case 'minor':
|
case 'minor':
|
||||||
newVersion = `${major}.${minor + 1}.0`;
|
newVersion = `${major}.${minor + 1}.0`;
|
||||||
break;
|
break;
|
||||||
case 'patch':
|
|
||||||
default:
|
default:
|
||||||
newVersion = `${major}.${minor}.${patch + 1}`;
|
newVersion = `${major}.${minor}.${patch + 1}`;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
clearRateLimitEvents as clearRateLimitEventsImpl
|
clearRateLimitEvents as clearRateLimitEventsImpl
|
||||||
} from './claude-profile/rate-limit-manager';
|
} from './claude-profile/rate-limit-manager';
|
||||||
import {
|
import {
|
||||||
loadProfileStore,
|
|
||||||
loadProfileStoreAsync,
|
loadProfileStoreAsync,
|
||||||
saveProfileStore,
|
saveProfileStore,
|
||||||
ProfileStoreData,
|
ProfileStoreData,
|
||||||
@@ -196,31 +195,6 @@ export class ClaudeProfileManager {
|
|||||||
return this.initialized;
|
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
|
* 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,
|
executablePath: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
timeout: number,
|
timeout: number,
|
||||||
identifier: string
|
_identifier: string
|
||||||
): string | null {
|
): string | null {
|
||||||
try {
|
try {
|
||||||
const result = execFileSync(executablePath, args, {
|
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
|
sessionThreshold: 95, // Consider switching at 95% session usage
|
||||||
weeklyThreshold: 99, // Consider switching at 99% weekly usage
|
weeklyThreshold: 99, // Consider switching at 99% weekly usage
|
||||||
autoSwitchOnRateLimit: false, // Prompt user by default
|
autoSwitchOnRateLimit: false, // Prompt user by default
|
||||||
|
autoSwitchOnAuthFailure: false, // Prompt user by default on auth failures
|
||||||
usageCheckInterval: 30000 // Check every 30s when enabled (0 = disabled)
|
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
|
* Expand ~ in path to home directory
|
||||||
*/
|
*/
|
||||||
export function expandHomePath(path: string): string {
|
export function expandHomePath(path: string): string {
|
||||||
if (path && path.startsWith('~')) {
|
if (path?.startsWith('~')) {
|
||||||
const home = homedir();
|
const home = homedir();
|
||||||
return path.replace(/^~/, home);
|
return path.replace(/^~/, home);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
refreshOAuthToken,
|
refreshOAuthToken,
|
||||||
ensureValidToken,
|
ensureValidToken,
|
||||||
reactiveTokenRefresh,
|
reactiveTokenRefresh,
|
||||||
type TokenRefreshResult
|
|
||||||
} from './token-refresh';
|
} from './token-refresh';
|
||||||
|
|
||||||
// Mock credential-utils
|
// Mock credential-utils
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { detectProvider as sharedDetectProvider, type ApiProvider } from '../../
|
|||||||
import { getCredentialsFromKeychain, clearKeychainCache } from './credential-utils';
|
import { getCredentialsFromKeychain, clearKeychainCache } from './credential-utils';
|
||||||
import { reactiveTokenRefresh, ensureValidToken } from './token-refresh';
|
import { reactiveTokenRefresh, ensureValidToken } from './token-refresh';
|
||||||
import { isProfileRateLimited } from './rate-limit-manager';
|
import { isProfileRateLimited } from './rate-limit-manager';
|
||||||
|
import { getOperationRegistry } from './operation-registry';
|
||||||
|
|
||||||
// Re-export for backward compatibility
|
// Re-export for backward compatibility
|
||||||
export type { ApiProvider };
|
export type { ApiProvider };
|
||||||
@@ -775,7 +776,7 @@ export class UsageMonitor extends EventEmitter {
|
|||||||
const activeProfile = profilesFile.profiles.find(
|
const activeProfile = profilesFile.profiles.find(
|
||||||
(p) => p.id === profilesFile.activeProfileId
|
(p) => p.id === profilesFile.activeProfileId
|
||||||
);
|
);
|
||||||
if (activeProfile && activeProfile.apiKey) {
|
if (activeProfile?.apiKey) {
|
||||||
this.debugLog('[UsageMonitor:TRACE] Using API profile credential: ' + activeProfile.name);
|
this.debugLog('[UsageMonitor:TRACE] Using API profile credential: ' + activeProfile.name);
|
||||||
return activeProfile.apiKey;
|
return activeProfile.apiKey;
|
||||||
}
|
}
|
||||||
@@ -1333,7 +1334,7 @@ export class UsageMonitor extends EventEmitter {
|
|||||||
let baseUrl: string;
|
let baseUrl: string;
|
||||||
let provider: ApiProvider;
|
let provider: ApiProvider;
|
||||||
|
|
||||||
if (activeProfile && activeProfile.isAPIProfile) {
|
if (activeProfile?.isAPIProfile) {
|
||||||
// Use the pre-determined profile to avoid race conditions
|
// Use the pre-determined profile to avoid race conditions
|
||||||
// Trust the activeProfile data and use baseUrl directly
|
// Trust the activeProfile data and use baseUrl directly
|
||||||
baseUrl = activeProfile.baseUrl;
|
baseUrl = activeProfile.baseUrl;
|
||||||
@@ -1347,7 +1348,7 @@ export class UsageMonitor extends EventEmitter {
|
|||||||
const profilesFile = await loadProfilesFile();
|
const profilesFile = await loadProfilesFile();
|
||||||
apiProfile = profilesFile.profiles.find(p => p.id === profileId);
|
apiProfile = profilesFile.profiles.find(p => p.id === profileId);
|
||||||
|
|
||||||
if (apiProfile && apiProfile.apiKey) {
|
if (apiProfile?.apiKey) {
|
||||||
// API profile found
|
// API profile found
|
||||||
baseUrl = apiProfile.baseUrl;
|
baseUrl = apiProfile.baseUrl;
|
||||||
provider = detectProvider(baseUrl);
|
provider = detectProvider(baseUrl);
|
||||||
@@ -2007,6 +2008,46 @@ export class UsageMonitor extends EventEmitter {
|
|||||||
limitType
|
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
|
// Note: Don't immediately check new profile - let normal interval handle it
|
||||||
// This prevents cascading swaps if multiple profiles are near limits
|
// This prevents cascading swaps if multiple profiles are near limits
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ function getNpmGlobalPrefix(): string | null {
|
|||||||
const normalizedPath = path.normalize(binPath);
|
const normalizedPath = path.normalize(binPath);
|
||||||
|
|
||||||
return fs.existsSync(normalizedPath) ? normalizedPath : null;
|
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
|
// 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
|
// This happens when the packaged app launches from GUI without full shell environment
|
||||||
if (isWindows()) {
|
if (isWindows()) {
|
||||||
|
|||||||
@@ -157,8 +157,7 @@ function createWindow(): void {
|
|||||||
const display = screen.getPrimaryDisplay();
|
const display = screen.getPrimaryDisplay();
|
||||||
// Validate the returned object has expected structure with valid dimensions
|
// Validate the returned object has expected structure with valid dimensions
|
||||||
if (
|
if (
|
||||||
display &&
|
display?.workAreaSize &&
|
||||||
display.workAreaSize &&
|
|
||||||
typeof display.workAreaSize.width === 'number' &&
|
typeof display.workAreaSize.width === 'number' &&
|
||||||
typeof display.workAreaSize.height === 'number' &&
|
typeof display.workAreaSize.height === 'number' &&
|
||||||
display.workAreaSize.width > 0 &&
|
display.workAreaSize.width > 0 &&
|
||||||
@@ -495,7 +494,7 @@ app.whenReady().then(() => {
|
|||||||
// Setup event forwarding from usage monitor to renderer
|
// Setup event forwarding from usage monitor to renderer
|
||||||
initializeUsageMonitorForwarding(mainWindow);
|
initializeUsageMonitorForwarding(mainWindow);
|
||||||
|
|
||||||
// Start the usage monitor
|
// Start the usage monitor (uses unified OperationRegistry for proactive restart)
|
||||||
const usageMonitor = getUsageMonitor();
|
const usageMonitor = getUsageMonitor();
|
||||||
usageMonitor.start();
|
usageMonitor.start();
|
||||||
console.warn('[main] Usage monitor initialized and started (after profile load)');
|
console.warn('[main] Usage monitor initialized and started (after profile load)');
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ import { InsightsPaths } from './paths';
|
|||||||
export class SessionManager {
|
export class SessionManager {
|
||||||
private sessions: Map<string, InsightsSession> = new Map();
|
private sessions: Map<string, InsightsSession> = new Map();
|
||||||
private storage: SessionStorage;
|
private storage: SessionStorage;
|
||||||
private paths: InsightsPaths;
|
|
||||||
|
|
||||||
constructor(storage: SessionStorage, paths: InsightsPaths) {
|
constructor(storage: SessionStorage, _paths: InsightsPaths) {
|
||||||
this.storage = storage;
|
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,
|
IPC_CHANNELS.APP_UPDATE_INSTALL,
|
||||||
async (): Promise<IPCResult> => {
|
async (): Promise<IPCResult> => {
|
||||||
try {
|
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();
|
quitAndInstall();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} 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();
|
const mainWindow = getMainWindow();
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
mainWindow.webContents.send(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo);
|
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
|
* @param currentInstalled - Optional currently installed version. If provided and newer than
|
||||||
* cached latest, cache will be invalidated and fresh data fetched.
|
* cached latest, cache will be invalidated and fresh data fetched.
|
||||||
* This handles the case where CLI was updated while app was running.
|
* 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> {
|
async function fetchLatestVersion(currentInstalled?: string | null, forceRefresh?: boolean): Promise<string> {
|
||||||
// Check cache first
|
// Check cache first (unless force refresh is requested)
|
||||||
if (cachedLatestVersion && Date.now() - cachedLatestVersion.timestamp < CACHE_DURATION_MS) {
|
if (!forceRefresh && cachedLatestVersion && Date.now() - cachedLatestVersion.timestamp < CACHE_DURATION_MS) {
|
||||||
const cachedVersion = cachedLatestVersion.version;
|
const cachedVersion = cachedLatestVersion.version;
|
||||||
|
|
||||||
// Invalidate cache if installed version is newer than cached latest
|
// Invalidate cache if installed version is newer than cached latest
|
||||||
@@ -881,7 +883,7 @@ function checkProfileAuthentication(configDir: string): AuthCheckResult {
|
|||||||
const data = JSON.parse(content);
|
const data = JSON.parse(content);
|
||||||
|
|
||||||
// Check for oauthAccount with emailAddress
|
// Check for oauthAccount with emailAddress
|
||||||
if (data.oauthAccount && data.oauthAccount.emailAddress) {
|
if (data.oauthAccount?.emailAddress) {
|
||||||
return {
|
return {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
email: data.oauthAccount.emailAddress,
|
email: data.oauthAccount.emailAddress,
|
||||||
@@ -907,7 +909,7 @@ function checkProfileAuthentication(configDir: string): AuthCheckResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.oauthAccount && data.oauthAccount.emailAddress) {
|
if (data.oauthAccount?.emailAddress) {
|
||||||
return {
|
return {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
email: data.oauthAccount.emailAddress,
|
email: data.oauthAccount.emailAddress,
|
||||||
@@ -958,9 +960,9 @@ export function registerClaudeCodeHandlers(): void {
|
|||||||
// Check Claude Code version
|
// Check Claude Code version
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION,
|
IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION,
|
||||||
async (): Promise<IPCResult<ClaudeCodeVersionInfo>> => {
|
async (_event, forceRefresh?: boolean): Promise<IPCResult<ClaudeCodeVersionInfo>> => {
|
||||||
try {
|
try {
|
||||||
console.warn('[Claude Code] Checking version...');
|
console.warn('[Claude Code] Checking version...', forceRefresh ? '(force refresh)' : '');
|
||||||
|
|
||||||
// Get installed version via cli-tool-manager
|
// Get installed version via cli-tool-manager
|
||||||
let detectionResult;
|
let detectionResult;
|
||||||
@@ -977,10 +979,11 @@ export function registerClaudeCodeHandlers(): void {
|
|||||||
|
|
||||||
// Fetch latest version from npm
|
// Fetch latest version from npm
|
||||||
// Pass installed version to invalidate cache if installed > cached (handles CLI update while app running)
|
// 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;
|
let latest: string;
|
||||||
try {
|
try {
|
||||||
console.warn('[Claude Code] Fetching latest version from npm...');
|
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);
|
console.warn('[Claude Code] Latest version:', latest);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Claude Code] Failed to fetch latest version, continuing with unknown:', error);
|
console.warn('[Claude Code] Failed to fetch latest version, continuing with unknown:', error);
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ import type {
|
|||||||
} from '../../../shared/types';
|
} from '../../../shared/types';
|
||||||
import { projectStore } from '../../project-store';
|
import { projectStore } from '../../project-store';
|
||||||
import { getMemoryService, isKuzuAvailable } from '../../memory-service';
|
import { getMemoryService, isKuzuAvailable } from '../../memory-service';
|
||||||
import {
|
|
||||||
getGraphitiDatabaseDetails
|
|
||||||
} from './utils';
|
|
||||||
import { getEffectiveSourcePath } from '../../updater/path-resolver';
|
import { getEffectiveSourcePath } from '../../updater/path-resolver';
|
||||||
import {
|
import {
|
||||||
loadGraphitiStateFromSpecs,
|
loadGraphitiStateFromSpecs,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ type ResolvedClaudeCliInvocation =
|
|||||||
| { command: string; env: Record<string, string> }
|
| { command: string; env: Record<string, string> }
|
||||||
| { error: string };
|
| { error: string };
|
||||||
|
|
||||||
function resolveClaudeCliInvocation(): ResolvedClaudeCliInvocation {
|
function _resolveClaudeCliInvocation(): ResolvedClaudeCliInvocation {
|
||||||
try {
|
try {
|
||||||
const invocation = getClaudeCliInvocation();
|
const invocation = getClaudeCliInvocation();
|
||||||
if (!invocation?.command) {
|
if (!invocation?.command) {
|
||||||
|
|||||||
@@ -518,7 +518,7 @@ describe('GitHub OAuth Handlers', () => {
|
|||||||
mockFindExecutable.mockReturnValue('/usr/local/bin/gh');
|
mockFindExecutable.mockReturnValue('/usr/local/bin/gh');
|
||||||
|
|
||||||
// Mock execFileSync for version check
|
// Mock execFileSync for version check
|
||||||
mockExecFileSync.mockImplementation((cmd: string, args?: string[]) => {
|
mockExecFileSync.mockImplementation((_cmd: string, args?: string[]) => {
|
||||||
if (args && args[0] === '--version') {
|
if (args && args[0] === '--version') {
|
||||||
return 'gh version 2.65.0 (2024-01-15)\n';
|
return 'gh version 2.65.0 (2024-01-15)\n';
|
||||||
}
|
}
|
||||||
@@ -553,7 +553,7 @@ describe('GitHub OAuth Handlers', () => {
|
|||||||
|
|
||||||
describe('gh Auth Check Handler', () => {
|
describe('gh Auth Check Handler', () => {
|
||||||
it('should return authenticated: true with username when logged in', async () => {
|
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') {
|
if (args && args[0] === 'auth' && args[1] === 'status') {
|
||||||
return 'Logged in to github.com as testuser\n';
|
return 'Logged in to github.com as testuser\n';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function sendComplete(
|
|||||||
* Investigate a GitHub issue and create a task
|
* Investigate a GitHub issue and create a task
|
||||||
*/
|
*/
|
||||||
export function registerInvestigateIssue(
|
export function registerInvestigateIssue(
|
||||||
agentManager: AgentManager,
|
_agentManager: AgentManager,
|
||||||
getMainWindow: () => BrowserWindow | null
|
getMainWindow: () => BrowserWindow | null
|
||||||
): void {
|
): void {
|
||||||
ipcMain.on(
|
ipcMain.on(
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ const GITHUB_DEVICE_URL = 'https://github.com/login/device';
|
|||||||
*/
|
*/
|
||||||
function parseDeviceCode(output: string): string | null {
|
function parseDeviceCode(output: string): string | null {
|
||||||
const match = output.match(DEVICE_CODE_PATTERN);
|
const match = output.match(DEVICE_CODE_PATTERN);
|
||||||
if (match && match[1]) {
|
if (match?.[1]) {
|
||||||
// Normalize: replace space with hyphen (GitHub expects XXXX-XXXX format)
|
// Normalize: replace space with hyphen (GitHub expects XXXX-XXXX format)
|
||||||
const normalizedCode = match[1].replace(' ', '-');
|
const normalizedCode = match[1].replace(' ', '-');
|
||||||
debugLog('Device code extracted successfully (code redacted for security)');
|
debugLog('Device code extracted successfully (code redacted for security)');
|
||||||
|
|||||||
@@ -1307,6 +1307,9 @@ async function runPRReview(
|
|||||||
// Build environment with project settings
|
// Build environment with project settings
|
||||||
const subprocessEnv = await getRunnerEnv(getClaudeMdEnv(project));
|
const subprocessEnv = await getRunnerEnv(getClaudeMdEnv(project));
|
||||||
|
|
||||||
|
// Create operation ID for this review
|
||||||
|
const reviewKey = getReviewKey(project.id, prNumber);
|
||||||
|
|
||||||
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
|
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
|
||||||
pythonPath: getPythonPath(backendPath),
|
pythonPath: getPythonPath(backendPath),
|
||||||
args,
|
args,
|
||||||
@@ -1341,10 +1344,17 @@ async function runPRReview(
|
|||||||
debugLog("Review result loaded", { findingsCount: reviewResult.findings.length });
|
debugLog("Review result loaded", { findingsCount: reviewResult.findings.length });
|
||||||
return reviewResult;
|
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
|
// Register the running process (keep legacy registry for cancel support)
|
||||||
const reviewKey = getReviewKey(project.id, prNumber);
|
|
||||||
runningReviews.set(reviewKey, childProcess);
|
runningReviews.set(reviewKey, childProcess);
|
||||||
debugLog("Registered review process", { reviewKey, pid: childProcess.pid });
|
debugLog("Registered review process", { reviewKey, pid: childProcess.pid });
|
||||||
|
|
||||||
@@ -2748,6 +2758,12 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
|
|||||||
});
|
});
|
||||||
return reviewResult;
|
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)
|
// Update registry with actual process (replacing placeholder)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { AuthFailureInfo, BillingFailureInfo } from '../../../../shared/typ
|
|||||||
import { parsePythonCommand } from '../../../python-detector';
|
import { parsePythonCommand } from '../../../python-detector';
|
||||||
import { detectAuthFailure, detectBillingFailure } from '../../../rate-limit-detector';
|
import { detectAuthFailure, detectBillingFailure } from '../../../rate-limit-detector';
|
||||||
import { getClaudeProfileManager } from '../../../claude-profile-manager';
|
import { getClaudeProfileManager } from '../../../claude-profile-manager';
|
||||||
|
import { getOperationRegistry, type OperationType } from '../../../claude-profile/operation-registry';
|
||||||
import { isWindows, isMacOS } from '../../../platform';
|
import { isWindows, isMacOS } from '../../../platform';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
@@ -73,6 +74,23 @@ export interface SubprocessOptions {
|
|||||||
progressPattern?: RegExp;
|
progressPattern?: RegExp;
|
||||||
/** Additional environment variables to pass to the subprocess */
|
/** Additional environment variables to pass to the subprocess */
|
||||||
env?: Record<string, string>;
|
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(),
|
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) => {
|
const promise = new Promise<SubprocessResult<T>>((resolve) => {
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
@@ -305,6 +384,11 @@ export function runPythonSubprocess<T = unknown>(
|
|||||||
// Treat null exit code (killed with SIGKILL) as failure, not success
|
// Treat null exit code (killed with SIGKILL) as failure, not success
|
||||||
const exitCode = code ?? -1;
|
const exitCode = code ?? -1;
|
||||||
|
|
||||||
|
// Unregister from OperationRegistry when process exits
|
||||||
|
if (options.operationRegistration) {
|
||||||
|
getOperationRegistry().unregisterOperation(options.operationRegistration.operationId);
|
||||||
|
}
|
||||||
|
|
||||||
// Debug logging only in development mode
|
// Debug logging only in development mode
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.log('[DEBUG] Process exited with code:', exitCode, '(raw:', code, ')');
|
console.log('[DEBUG] Process exited with code:', exitCode, '(raw:', code, ')');
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import type {
|
|||||||
GitLabAutoFixQueueItem,
|
GitLabAutoFixQueueItem,
|
||||||
GitLabAutoFixProgress,
|
GitLabAutoFixProgress,
|
||||||
GitLabIssueBatch,
|
GitLabIssueBatch,
|
||||||
GitLabBatchProgress,
|
|
||||||
GitLabAnalyzePreviewResult,
|
GitLabAnalyzePreviewResult,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ function sendError(
|
|||||||
* Register investigation handler
|
* Register investigation handler
|
||||||
*/
|
*/
|
||||||
export function registerInvestigateIssue(
|
export function registerInvestigateIssue(
|
||||||
agentManager: AgentManager,
|
_agentManager: AgentManager,
|
||||||
getMainWindow: () => BrowserWindow | null
|
getMainWindow: () => BrowserWindow | null
|
||||||
): void {
|
): void {
|
||||||
ipcMain.on(
|
ipcMain.on(
|
||||||
@@ -129,16 +129,11 @@ export function registerInvestigateIssue(
|
|||||||
message: 'Analyzing issue with AI...'
|
message: 'Analyzing issue with AI...'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build context for investigation
|
// Note: Context building previously done here has been moved to createSpecForIssue utility.
|
||||||
let context = buildIssueContext(issue, config.project, config.instanceUrl);
|
// The buildIssueContext() function and selectedNotes processing are now handled internally
|
||||||
|
// by the spec creation pipeline. This avoids duplicate context generation.
|
||||||
if (selectedNotes.length > 0) {
|
// TODO: If advanced context customization is needed in the future, consider extracting
|
||||||
context += '\n\n## Selected Comments\n';
|
// context building into a reusable utility function.
|
||||||
for (const note of selectedNotes) {
|
|
||||||
context += `\n### Comment by ${note.author.username} (${new Date(note.created_at).toLocaleDateString()})\n`;
|
|
||||||
context += note.body + '\n';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use agent manager to investigate
|
// Use agent manager to investigate
|
||||||
// Note: This is a simplified version - full implementation would use Claude SDK
|
// 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 { readSettingsFile } from '../../settings-utils';
|
||||||
import type { Project, AppSettings } from '../../../shared/types';
|
import type { Project, AppSettings } from '../../../shared/types';
|
||||||
import type {
|
import type {
|
||||||
MRReviewFinding,
|
|
||||||
MRReviewResult,
|
MRReviewResult,
|
||||||
MRReviewProgress,
|
MRReviewProgress,
|
||||||
NewCommitsCheck,
|
NewCommitsCheck,
|
||||||
|
|||||||
@@ -248,13 +248,13 @@ export function registerStartGlabAuth(): void {
|
|||||||
env: getAugmentedEnv()
|
env: getAugmentedEnv()
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = '';
|
let _output = '';
|
||||||
let errorOutput = '';
|
let errorOutput = '';
|
||||||
let browserOpened = false;
|
let browserOpened = false;
|
||||||
|
|
||||||
glabProcess.stdout?.on('data', (data) => {
|
glabProcess.stdout?.on('data', (data) => {
|
||||||
const chunk = data.toString('utf-8');
|
const chunk = data.toString('utf-8');
|
||||||
output += chunk;
|
_output += chunk;
|
||||||
debugLog('glab stdout:', chunk);
|
debugLog('glab stdout:', chunk);
|
||||||
|
|
||||||
// Try to open browser if URL detected
|
// Try to open browser if URL detected
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import path from 'path';
|
|||||||
import type { Project } from '../../../shared/types';
|
import type { Project } from '../../../shared/types';
|
||||||
import type { GitLabAPIIssue, GitLabConfig } from './types';
|
import type { GitLabAPIIssue, GitLabConfig } from './types';
|
||||||
import { labelMatchesWholeWord } from '../shared/label-utils';
|
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.
|
* 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.
|
* Spawns a subprocess to run Ollama detection/management commands with a 10-second timeout.
|
||||||
* Used to check Ollama status, list models, and manage downloads.
|
* 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:
|
* Supported commands:
|
||||||
* - 'check-status': Verify Ollama service is running
|
* - 'check-status': Verify Ollama service is running
|
||||||
* - 'list-models': Get all available models
|
* - '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)
|
* @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
|
* @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(
|
async function executeOllamaDetector(
|
||||||
command: string,
|
command: string,
|
||||||
baseUrl?: 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 }> {
|
): Promise<{ success: boolean; data?: unknown; error?: string }> {
|
||||||
// Use configured Python path (venv if ready, otherwise bundled/system)
|
// Use configured Python path (venv if ready, otherwise bundled/system)
|
||||||
// Note: ollama_model_detector.py doesn't require dotenv, but using venv is safer
|
// Note: ollama_model_detector.py doesn't require dotenv, but using venv is safer
|
||||||
@@ -483,7 +521,7 @@ export function registerMemoryHandlers(): void {
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Basic validation for other providers
|
// Basic validation for other providers
|
||||||
llmResult = config.apiKey && config.apiKey.trim()
|
llmResult = config.apiKey?.trim()
|
||||||
? {
|
? {
|
||||||
success: true,
|
success: true,
|
||||||
message: `${config.llmProvider} API key format appears valid`,
|
message: `${config.llmProvider} API key format appears valid`,
|
||||||
@@ -703,7 +741,7 @@ export function registerMemoryHandlers(): void {
|
|||||||
async (
|
async (
|
||||||
event,
|
event,
|
||||||
modelName: string,
|
modelName: string,
|
||||||
baseUrl?: string
|
_baseUrl?: string
|
||||||
): Promise<IPCResult<OllamaPullResult>> => {
|
): Promise<IPCResult<OllamaPullResult>> => {
|
||||||
try {
|
try {
|
||||||
// Use configured Python path (venv if ready, otherwise bundled/system)
|
// 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 type { APIProfile, ProfileFormData, ProfilesFile, TestConnectionResult, DiscoverModelsResult } from '@shared/types/profile';
|
||||||
import {
|
import {
|
||||||
loadProfilesFile,
|
loadProfilesFile,
|
||||||
saveProfilesFile,
|
|
||||||
validateFilePermissions,
|
validateFilePermissions,
|
||||||
getProfilesFilePath,
|
getProfilesFilePath,
|
||||||
atomicModifyProfiles,
|
atomicModifyProfiles,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { ipcMain, app } from 'electron';
|
import { ipcMain, app } from 'electron';
|
||||||
import { existsSync, readFileSync } from 'fs';
|
import { existsSync, } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { execFileSync } from 'child_process';
|
import { execFileSync } from 'child_process';
|
||||||
import { is } from '@electron-toolkit/utils';
|
|
||||||
import { IPC_CHANNELS } from '../../shared/constants';
|
import { IPC_CHANNELS } from '../../shared/constants';
|
||||||
import type {
|
import type {
|
||||||
Project,
|
Project,
|
||||||
@@ -233,8 +232,6 @@ function detectMainBranch(projectPath: string): string | null {
|
|||||||
return branches[0] || null;
|
return branches[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure all Python-dependent services with the managed Python path
|
* Configure all Python-dependent services with the managed Python path
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
IPCResult,
|
IPCResult,
|
||||||
Roadmap,
|
Roadmap,
|
||||||
RoadmapFeature,
|
|
||||||
RoadmapFeatureStatus,
|
RoadmapFeatureStatus,
|
||||||
RoadmapGenerationStatus,
|
RoadmapGenerationStatus,
|
||||||
PersistedRoadmapProgress,
|
PersistedRoadmapProgress,
|
||||||
|
|||||||
@@ -511,7 +511,7 @@ export function registerSettingsHandlers(
|
|||||||
error: `Path is not a directory: ${resolvedPath}`
|
error: `Path is not a directory: ${resolvedPath}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (statError) {
|
} catch (_statError) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Cannot access path: ${resolvedPath}`
|
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)
|
* 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 path from 'path';
|
||||||
import { minimatch } from 'minimatch';
|
import { minimatch } from 'minimatch';
|
||||||
import { existsSync, readdirSync, statSync, readFileSync, promises as fsPromises } from 'fs';
|
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 { homedir } from 'os';
|
||||||
import { projectStore } from '../../project-store';
|
import { projectStore } from '../../project-store';
|
||||||
import { getConfiguredPythonPath, PythonEnvManager, pythonEnvManager as pythonEnvManagerSingleton } from '../../python-env-manager';
|
import { getConfiguredPythonPath, PythonEnvManager, pythonEnvManager as pythonEnvManagerSingleton } from '../../python-env-manager';
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
findTaskWorktree,
|
findTaskWorktree,
|
||||||
} from '../../worktree-paths';
|
} from '../../worktree-paths';
|
||||||
import { persistPlanStatus, updateTaskMetadataPrUrl } from './plan-file-utils';
|
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 { cleanupWorktree } from '../../utils/worktree-cleanup';
|
||||||
import { killProcessGracefully } from '../../platform';
|
import { killProcessGracefully } from '../../platform';
|
||||||
import { stripAnsiCodes } from '../../../shared/utils/ansi-sanitizer';
|
import { stripAnsiCodes } from '../../../shared/utils/ansi-sanitizer';
|
||||||
@@ -1067,7 +1067,7 @@ async function detectLinuxApps(): Promise<Set<string>> {
|
|||||||
function isAppInstalled(
|
function isAppInstalled(
|
||||||
appNames: string[],
|
appNames: string[],
|
||||||
specificPaths: string[],
|
specificPaths: string[],
|
||||||
platform: string
|
_platform: string
|
||||||
): { installed: boolean; foundPath: string } {
|
): { installed: boolean; foundPath: string } {
|
||||||
// First, check the cached app list (fast)
|
// First, check the cached app list (fast)
|
||||||
for (const name of appNames) {
|
for (const name of appNames) {
|
||||||
@@ -2568,7 +2568,7 @@ export function registerWorktreeHandlers(
|
|||||||
encoding: 'utf-8'
|
encoding: 'utf-8'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (gitStatus && gitStatus.trim()) {
|
if (gitStatus?.trim()) {
|
||||||
// Parse the status output to get file names
|
// Parse the status output to get file names
|
||||||
// Format: XY filename (where X and Y are status chars, then space, then filename)
|
// Format: XY filename (where X and Y are status chars, then space, then filename)
|
||||||
uncommittedFiles = gitStatus
|
uncommittedFiles = gitStatus
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { TerminalManager } from '../terminal-manager';
|
|||||||
import { projectStore } from '../project-store';
|
import { projectStore } from '../project-store';
|
||||||
import { terminalNameGenerator } from '../terminal-name-generator';
|
import { terminalNameGenerator } from '../terminal-name-generator';
|
||||||
import { readSettingsFileAsync } from '../settings-utils';
|
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 { migrateSession } from '../claude-profile/session-utils';
|
||||||
import { createProfileDirectory } from '../claude-profile/profile-utils';
|
import { createProfileDirectory } from '../claude-profile/profile-utils';
|
||||||
import { isValidConfigDir } from '../utils/config-path-validator';
|
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
|
// Flush logs to disk every 2 seconds to balance performance vs data safety
|
||||||
private readonly FLUSH_INTERVAL_MS = 2000;
|
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
|
// Keep last N sessions
|
||||||
private readonly MAX_SESSIONS_TO_KEEP = 10;
|
private readonly MAX_SESSIONS_TO_KEEP = 10;
|
||||||
|
|
||||||
@@ -205,7 +203,7 @@ export class LogService {
|
|||||||
const sessionId = file.replace('session-', '').replace('.log', '');
|
const sessionId = file.replace('session-', '').replace('.log', '');
|
||||||
|
|
||||||
// Parse session ID back to date
|
// 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
|
// Replace first 2 dashes with actual dashes, rest with colons
|
||||||
if (offset < 10) return '-';
|
if (offset < 10) return '-';
|
||||||
if (offset === 10) return 'T';
|
if (offset === 10) return 'T';
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ function getWindowsShellConfig(preferredShell?: ShellType): ShellConfig {
|
|||||||
/**
|
/**
|
||||||
* Get Unix shell configuration
|
* Get Unix shell configuration
|
||||||
*/
|
*/
|
||||||
function getUnixShellConfig(preferredShell?: ShellType): ShellConfig {
|
function getUnixShellConfig(_preferredShell?: ShellType): ShellConfig {
|
||||||
const shellPath = process.env.SHELL || '/bin/zsh';
|
const shellPath = process.env.SHELL || '/bin/zsh';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, Dirent
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, ImplementationPlan, ReviewReason, PlanSubtask, KanbanPreferences, ExecutionPhase } from '../shared/types';
|
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 { getAutoBuildPath, isInitialized } from './project-initializer';
|
||||||
import { getTaskWorktreeDir } from './worktree-paths';
|
import { getTaskWorktreeDir } from './worktree-paths';
|
||||||
import { isValidTaskId, findAllSpecPaths } from './utils/spec-path-helpers';
|
import { findAllSpecPaths } from './utils/spec-path-helpers';
|
||||||
|
|
||||||
interface TabState {
|
interface TabState {
|
||||||
openProjectIds: string[];
|
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>();
|
const taskMap = new Map<string, Task>();
|
||||||
for (const task of allTasks) {
|
for (const task of allTasks) {
|
||||||
const existing = taskMap.get(task.id);
|
const existing = taskMap.get(task.id);
|
||||||
if (!existing || task.location === 'worktree') {
|
if (!existing) {
|
||||||
|
// First occurrence wins
|
||||||
taskMap.set(task.id, task);
|
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(
|
private loadTasksFromSpecsDir(
|
||||||
specsDir: string,
|
specsDir: string,
|
||||||
basePath: string,
|
_basePath: string,
|
||||||
location: 'main' | 'worktree',
|
location: 'main' | 'worktree',
|
||||||
projectId: string,
|
projectId: string,
|
||||||
specsBaseDir: string
|
_specsBaseDir: string
|
||||||
): Task[] {
|
): Task[] {
|
||||||
const tasks: Task[] = [];
|
const tasks: Task[] = [];
|
||||||
let specDirs: Dirent[] = [];
|
let specDirs: Dirent[] = [];
|
||||||
@@ -492,7 +518,7 @@ export class ProjectStore {
|
|||||||
// "# Specification: Title" -> "Title"
|
// "# Specification: Title" -> "Title"
|
||||||
// "# Title" -> "Title"
|
// "# Title" -> "Title"
|
||||||
const titleMatch = specContent.match(/^#\s+(?:Quick Spec:|Specification:)?\s*(.+)$/m);
|
const titleMatch = specContent.match(/^#\s+(?:Quick Spec:|Specification:)?\s*(.+)$/m);
|
||||||
if (titleMatch && titleMatch[1]) {
|
if (titleMatch?.[1]) {
|
||||||
title = titleMatch[1].trim();
|
title = titleMatch[1].trim();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -500,15 +526,33 @@ export class ProjectStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use persisted executionPhase (from text parser) or xstateState for exact restoration
|
// Use xstateState as the authoritative source for execution phase when available.
|
||||||
// Priority: executionPhase > xstateState > inferred from status
|
// 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 persistedPhase = (plan as { executionPhase?: string } | null)?.executionPhase as ExecutionPhase | undefined;
|
||||||
const xstateState = (plan as { xstateState?: string } | null)?.xstateState;
|
const xstateState = (plan as { xstateState?: string } | null)?.xstateState;
|
||||||
const executionProgress = persistedPhase
|
|
||||||
? { phase: persistedPhase, phaseProgress: 50, overallProgress: 50 }
|
// XState settled states take priority - these override any stale persisted phase
|
||||||
: xstateState
|
// because the task is no longer actively running
|
||||||
? this.inferExecutionProgressFromXState(xstateState)
|
const isSettledState = xstateState && XSTATE_SETTLED_STATES.has(xstateState);
|
||||||
: this.inferExecutionProgress(plan?.status);
|
|
||||||
|
// 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({
|
tasks.push({
|
||||||
id: dir.name, // Use spec directory name as ID
|
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
|
/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
|
* Result of rate limit detection
|
||||||
*/
|
*/
|
||||||
@@ -109,7 +128,7 @@ export interface RateLimitDetectionResult {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
/** Original error message */
|
/** Original error message (truncated to 500 chars for security) */
|
||||||
originalError?: string;
|
originalError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +212,7 @@ export function detectRateLimit(
|
|||||||
id: bestProfile.id,
|
id: bestProfile.id,
|
||||||
name: bestProfile.name
|
name: bestProfile.name
|
||||||
} : undefined,
|
} : undefined,
|
||||||
originalError: output
|
originalError: sanitizeErrorOutput(output)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +230,7 @@ export function detectRateLimit(
|
|||||||
id: bestProfile.id,
|
id: bestProfile.id,
|
||||||
name: bestProfile.name
|
name: bestProfile.name
|
||||||
} : undefined,
|
} : 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.';
|
return 'Your Claude session has expired. Please re-authenticate in Settings > Claude Profiles.';
|
||||||
case 'invalid':
|
case 'invalid':
|
||||||
return 'Invalid Claude credentials. Please check your OAuth token or re-authenticate in Settings > Claude Profiles.';
|
return 'Invalid Claude credentials. Please check your OAuth token or re-authenticate in Settings > Claude Profiles.';
|
||||||
case 'unknown':
|
|
||||||
default:
|
default:
|
||||||
return 'Claude authentication failed. Please verify your authentication in Settings > Claude Profiles.';
|
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.';
|
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':
|
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.';
|
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:
|
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.';
|
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,
|
profileId: effectiveProfileId,
|
||||||
failureType,
|
failureType,
|
||||||
message: getAuthFailureMessage(failureType),
|
message: getAuthFailureMessage(failureType),
|
||||||
originalError: output
|
originalError: sanitizeErrorOutput(output)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,7 +392,7 @@ export function detectBillingFailure(
|
|||||||
profileId: effectiveProfileId,
|
profileId: effectiveProfileId,
|
||||||
failureType,
|
failureType,
|
||||||
message: getBillingFailureMessage(failureType),
|
message: getBillingFailureMessage(failureType),
|
||||||
originalError: output
|
originalError: sanitizeErrorOutput(output)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -631,7 +648,7 @@ export interface SDKRateLimitInfo {
|
|||||||
};
|
};
|
||||||
/** When detected */
|
/** When detected */
|
||||||
detectedAt: Date;
|
detectedAt: Date;
|
||||||
/** Original error message */
|
/** Original error message (truncated to 500 chars for security) */
|
||||||
originalError?: string;
|
originalError?: string;
|
||||||
|
|
||||||
// Auto-swap information
|
// 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.
|
* If a worktree exists for a task NOT in this release, it won't block the release.
|
||||||
*/
|
*/
|
||||||
export class ReleaseService extends EventEmitter {
|
export class ReleaseService extends EventEmitter {
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse CHANGELOG.md to extract releaseable versions.
|
* 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.
|
* This allows us to scope worktree checks to only those tasks.
|
||||||
*/
|
*/
|
||||||
getTasksForVersion(
|
getTasksForVersion(
|
||||||
projectPath: string,
|
_projectPath: string,
|
||||||
version: string,
|
version: string,
|
||||||
tasks: Task[]
|
tasks: Task[]
|
||||||
): { taskIds: string[]; specIds: string[] } {
|
): { taskIds: string[]; specIds: string[] } {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ function getTracesSampleRate(): number {
|
|||||||
const envValue = buildTimeValue || process.env.SENTRY_TRACES_SAMPLE_RATE;
|
const envValue = buildTimeValue || process.env.SENTRY_TRACES_SAMPLE_RATE;
|
||||||
if (envValue) {
|
if (envValue) {
|
||||||
const parsed = parseFloat(envValue);
|
const parsed = parseFloat(envValue);
|
||||||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ function getProfilesSampleRate(): number {
|
|||||||
const envValue = buildTimeValue || process.env.SENTRY_PROFILES_SAMPLE_RATE;
|
const envValue = buildTimeValue || process.env.SENTRY_PROFILES_SAMPLE_RATE;
|
||||||
if (envValue) {
|
if (envValue) {
|
||||||
const parsed = parseFloat(envValue);
|
const parsed = parseFloat(envValue);
|
||||||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
getAPIProfileEnv,
|
getAPIProfileEnv,
|
||||||
testConnection
|
testConnection
|
||||||
} from './profile-service';
|
} from './profile-service';
|
||||||
import type { APIProfile, ProfilesFile, TestConnectionResult } from '../../shared/types/profile';
|
import type { ProfilesFile, } from '../../shared/types/profile';
|
||||||
|
|
||||||
// Mock profile-manager
|
// Mock profile-manager
|
||||||
vi.mock('../utils/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');
|
throw new Error('Profile not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = file.profiles[profileIndex];
|
|
||||||
|
|
||||||
// Active Profile Check: Cannot delete active profile (AC3)
|
// Active Profile Check: Cannot delete active profile (AC3)
|
||||||
if (file.activeProfileId === id) {
|
if (file.activeProfileId === id) {
|
||||||
throw new Error('Cannot delete active profile. Please switch to another profile or OAuth first.');
|
throw new Error('Cannot delete active profile. Please switch to another profile or OAuth first.');
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
testConnection,
|
testConnection,
|
||||||
discoverModels
|
discoverModels
|
||||||
} from './profile-service';
|
} 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
|
// Mock Anthropic SDK - use vi.hoisted to properly hoist the mock variable
|
||||||
const { mockModelsList, mockMessagesCreate } = vi.hoisted(() => ({
|
const { mockModelsList, mockMessagesCreate } = vi.hoisted(() => ({
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
SDKSessionRecoveryCoordinator,
|
SDKSessionRecoveryCoordinator,
|
||||||
getRecoveryCoordinator,
|
getRecoveryCoordinator,
|
||||||
type SDKOperationType,
|
|
||||||
type RecoveryCoordinatorConfig
|
|
||||||
} from './sdk-session-recovery-coordinator';
|
} from './sdk-session-recovery-coordinator';
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
|
|||||||
@@ -1,6 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* SDK Session Recovery Coordinator
|
* 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.
|
* Central coordinator for all SDK operations and rate limit recovery.
|
||||||
* Part of the intelligent rate limit recovery system (Phase 9: Unified Coordination).
|
* 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
|
* 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:
|
* This singleton coordinates all SDK operations across the application:
|
||||||
* - Tasks (via AgentManager)
|
* - Tasks (via AgentManager)
|
||||||
* - Background operations (roadmap, ideation, changelog)
|
* - Background operations (roadmap, ideation, changelog)
|
||||||
@@ -531,6 +553,7 @@ export class SDKSessionRecoveryCoordinator extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the global coordinator instance
|
* Get the global coordinator instance
|
||||||
|
* @deprecated Use getOperationRegistry() from '../claude-profile/operation-registry' instead.
|
||||||
*/
|
*/
|
||||||
export function getRecoveryCoordinator(): SDKSessionRecoveryCoordinator {
|
export function getRecoveryCoordinator(): SDKSessionRecoveryCoordinator {
|
||||||
return SDKSessionRecoveryCoordinator.getInstance();
|
return SDKSessionRecoveryCoordinator.getInstance();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { existsSync, readFileSync, watchFile } from 'fs';
|
import { existsSync, readFileSync, } from 'fs';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import type { TaskLogs, TaskLogPhase, TaskLogStreamChunk, TaskPhaseLog } from '../shared/types';
|
import type { TaskLogs, TaskLogPhase, TaskLogStreamChunk, TaskPhaseLog } from '../shared/types';
|
||||||
import { findTaskWorktree } from './worktree-paths';
|
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.
|
* watches both locations and merges logs from both sources.
|
||||||
*/
|
*/
|
||||||
export class TaskLogService extends EventEmitter {
|
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 logCache: Map<string, TaskLogs> = new Map();
|
||||||
private pollIntervals: Map<string, NodeJS.Timeout> = new Map();
|
private pollIntervals: Map<string, NodeJS.Timeout> = new Map();
|
||||||
// Store paths being watched for each specId (main + worktree)
|
// 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)
|
// Poll interval for watching log changes (more reliable than fs.watch on some systems)
|
||||||
private readonly POLL_INTERVAL_MS = 1000;
|
private readonly POLL_INTERVAL_MS = 1000;
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load task logs from a single spec directory
|
* Load task logs from a single spec directory
|
||||||
* Returns cached logs if the file is corrupted (e.g., mid-write by Python backend)
|
* 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;
|
let worktreeSpecDir: string | null = null;
|
||||||
|
|
||||||
if (watchedInfo && watchedInfo[1].worktreeSpecDir) {
|
if (watchedInfo?.[1].worktreeSpecDir) {
|
||||||
worktreeSpecDir = watchedInfo[1].worktreeSpecDir;
|
worktreeSpecDir = watchedInfo[1].worktreeSpecDir;
|
||||||
} else if (projectPath && specsRelPath && specId) {
|
} else if (projectPath && specsRelPath && specId) {
|
||||||
// Calculate worktree path from provided params
|
// Calculate worktree path from provided params
|
||||||
|
|||||||
@@ -123,7 +123,14 @@ export class TaskStateManager {
|
|||||||
} else if (!currentState && task.reviewReason === 'plan_review') {
|
} else if (!currentState && task.reviewReason === 'plan_review') {
|
||||||
// Fallback: No actor exists (e.g., after app restart), use task data
|
// Fallback: No actor exists (e.g., after app restart), use task data
|
||||||
this.handleUiEvent(taskId, { type: 'PLAN_APPROVED' }, task, project);
|
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 {
|
} 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);
|
this.handleUiEvent(taskId, { type: 'USER_RESUMED' }, task, project);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -413,7 +420,6 @@ export class TaskStateManager {
|
|||||||
stateValue = 'error';
|
stateValue = 'error';
|
||||||
contextReviewReason = reviewReason ?? 'errors';
|
contextReviewReason = reviewReason ?? 'errors';
|
||||||
break;
|
break;
|
||||||
case 'backlog':
|
|
||||||
default:
|
default:
|
||||||
stateValue = 'backlog';
|
stateValue = 'backlog';
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1555,7 +1555,7 @@ async function waitForClaudeExit(
|
|||||||
export async function switchClaudeProfile(
|
export async function switchClaudeProfile(
|
||||||
terminal: TerminalProcess,
|
terminal: TerminalProcess,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
getWindow: WindowGetter,
|
_getWindow: WindowGetter,
|
||||||
invokeClaudeCallback: (terminalId: string, cwd: string | undefined, profileId: string, dangerouslySkipPermissions?: boolean) => Promise<void>,
|
invokeClaudeCallback: (terminalId: string, cwd: string | undefined, profileId: string, dangerouslySkipPermissions?: boolean) => Promise<void>,
|
||||||
clearRateLimitCallback: (terminalId: string) => void
|
clearRateLimitCallback: (terminalId: string) => void
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): 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 {
|
export function extractClaudeSessionId(data: string): string | null {
|
||||||
for (const pattern of CLAUDE_SESSION_PATTERNS) {
|
for (const pattern of CLAUDE_SESSION_PATTERNS) {
|
||||||
const match = data.match(pattern);
|
const match = data.match(pattern);
|
||||||
if (match && match[1]) {
|
if (match?.[1]) {
|
||||||
return match[1];
|
return match[1];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ export function extractEmail(data: string): string | null {
|
|||||||
|
|
||||||
for (const pattern of EMAIL_PATTERNS) {
|
for (const pattern of EMAIL_PATTERNS) {
|
||||||
const match = cleanData.match(pattern);
|
const match = cleanData.match(pattern);
|
||||||
if (match && match[1]) {
|
if (match?.[1]) {
|
||||||
return match[1];
|
return match[1];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import * as net from 'net';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { spawn, ChildProcess } from 'child_process';
|
import { spawn, ChildProcess } from 'child_process';
|
||||||
import { app } from 'electron';
|
|
||||||
import { isWindows, GRACEFUL_KILL_TIMEOUT_MS } from '../platform';
|
import { isWindows, GRACEFUL_KILL_TIMEOUT_MS } from '../platform';
|
||||||
import { getIsShuttingDown } from './pty-manager';
|
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;
|
this.isShuttingDown = true;
|
||||||
|
|
||||||
// Kill the daemon process if we spawned it
|
// Kill the daemon process if we spawned it
|
||||||
if (this.daemonProcess && this.daemonProcess.pid) {
|
if (this.daemonProcess?.pid) {
|
||||||
try {
|
try {
|
||||||
if (isWindows()) {
|
if (isWindows()) {
|
||||||
// Windows: use taskkill to force kill process tree
|
// Windows: use taskkill to force kill process tree
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { promises as fsPromises } from 'fs';
|
import { promises as fsPromises } from 'fs';
|
||||||
import path from 'path';
|
|
||||||
import { app } from 'electron';
|
|
||||||
import {
|
import {
|
||||||
loadProfilesFile,
|
loadProfilesFile,
|
||||||
saveProfilesFile,
|
saveProfilesFile,
|
||||||
@@ -48,7 +46,7 @@ vi.mock('fs', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('profile-manager', () => {
|
describe('profile-manager', () => {
|
||||||
const mockProfilesPath = '/mock/userdata/profiles.json';
|
const _mockProfilesPath = '/mock/userdata/profiles.json';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export async function loadProfilesFile(): Promise<ProfilesFile> {
|
|||||||
const content = await fs.readFile(filePath, 'utf-8');
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
const data = JSON.parse(content) as ProfilesFile;
|
const data = JSON.parse(content) as ProfilesFile;
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
// File doesn't exist or is corrupted - return default
|
// File doesn't exist or is corrupted - return default
|
||||||
return {
|
return {
|
||||||
profiles: [],
|
profiles: [],
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export class SpecNumberLock {
|
|||||||
const pidStr = readFileSync(this.lockFile, 'utf-8').trim();
|
const pidStr = readFileSync(this.lockFile, 'utf-8').trim();
|
||||||
const pid = parseInt(pidStr, 10);
|
const pid = parseInt(pidStr, 10);
|
||||||
|
|
||||||
if (!isNaN(pid) && !this.isProcessRunning(pid)) {
|
if (!Number.isNaN(pid) && !this.isProcessRunning(pid)) {
|
||||||
// Stale lock - remove it
|
// Stale lock - remove it
|
||||||
try {
|
try {
|
||||||
unlinkSync(this.lockFile);
|
unlinkSync(this.lockFile);
|
||||||
@@ -194,7 +194,7 @@ export class SpecNumberLock {
|
|||||||
const match = entry.name.match(/^(\d{3})-/);
|
const match = entry.name.match(/^(\d{3})-/);
|
||||||
if (match) {
|
if (match) {
|
||||||
const num = parseInt(match[1], 10);
|
const num = parseInt(match[1], 10);
|
||||||
if (!isNaN(num)) {
|
if (!Number.isNaN(num)) {
|
||||||
maxNum = Math.max(maxNum, num);
|
maxNum = Math.max(maxNum, num);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
AppUpdateProgress,
|
AppUpdateProgress,
|
||||||
AppUpdateAvailableEvent,
|
AppUpdateAvailableEvent,
|
||||||
AppUpdateDownloadedEvent,
|
AppUpdateDownloadedEvent,
|
||||||
|
AppUpdateErrorEvent,
|
||||||
IPCResult
|
IPCResult
|
||||||
} from '../../shared/types';
|
} from '../../shared/types';
|
||||||
import { createIpcListener, invokeIpc, IpcListenerCleanup } from './modules/ipc-utils';
|
import { createIpcListener, invokeIpc, IpcListenerCleanup } from './modules/ipc-utils';
|
||||||
@@ -34,6 +35,12 @@ export interface AppUpdateAPI {
|
|||||||
onAppUpdateStableDowngrade: (
|
onAppUpdateStableDowngrade: (
|
||||||
callback: (info: AppUpdateInfo) => void
|
callback: (info: AppUpdateInfo) => void
|
||||||
) => IpcListenerCleanup;
|
) => 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),
|
invokeIpc(IPC_CHANNELS.APP_UPDATE_DOWNLOAD_STABLE),
|
||||||
|
|
||||||
installAppUpdate: (): void => {
|
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> =>
|
getAppVersion: (): Promise<string> =>
|
||||||
@@ -79,5 +88,15 @@ export const createAppUpdateAPI = (): AppUpdateAPI => ({
|
|||||||
onAppUpdateStableDowngrade: (
|
onAppUpdateStableDowngrade: (
|
||||||
callback: (info: AppUpdateInfo) => void
|
callback: (info: AppUpdateInfo) => void
|
||||||
): IpcListenerCleanup =>
|
): 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
|
* Check Claude Code CLI version status
|
||||||
* Returns installed version, latest version, and whether update is available
|
* 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
|
* Install or update Claude Code CLI
|
||||||
@@ -118,8 +119,8 @@ export interface ClaudeCodeAPI {
|
|||||||
* Creates the Claude Code API implementation
|
* Creates the Claude Code API implementation
|
||||||
*/
|
*/
|
||||||
export const createClaudeCodeAPI = (): ClaudeCodeAPI => ({
|
export const createClaudeCodeAPI = (): ClaudeCodeAPI => ({
|
||||||
checkClaudeCodeVersion: (): Promise<ClaudeCodeVersionResult> =>
|
checkClaudeCodeVersion: (forceRefresh?: boolean): Promise<ClaudeCodeVersionResult> =>
|
||||||
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION),
|
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION, forceRefresh),
|
||||||
|
|
||||||
installClaudeCode: (): Promise<ClaudeCodeInstallResult> =>
|
installClaudeCode: (): Promise<ClaudeCodeInstallResult> =>
|
||||||
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_INSTALL),
|
invokeIpc(IPC_CHANNELS.CLAUDE_CODE_INSTALL),
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export const createProfileAPI = (): ProfileAPI => ({
|
|||||||
const requestId = ++testConnectionRequestId;
|
const requestId = ++testConnectionRequestId;
|
||||||
|
|
||||||
// Check if already aborted before initiating request
|
// Check if already aborted before initiating request
|
||||||
if (signal && signal.aborted) {
|
if (signal?.aborted) {
|
||||||
return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'));
|
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);
|
console.log('[preload/profile-api] Request ID:', requestId);
|
||||||
|
|
||||||
// Check if already aborted before initiating request
|
// Check if already aborted before initiating request
|
||||||
if (signal && signal.aborted) {
|
if (signal?.aborted) {
|
||||||
console.log('[preload/profile-api] Already aborted, rejecting');
|
console.log('[preload/profile-api] Already aborted, rejecting');
|
||||||
return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'));
|
return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface TaskAPI {
|
|||||||
options?: import('../../shared/types').TaskRecoveryOptions
|
options?: import('../../shared/types').TaskRecoveryOptions
|
||||||
) => Promise<IPCResult<TaskRecoveryResult>>;
|
) => Promise<IPCResult<TaskRecoveryResult>>;
|
||||||
checkTaskRunning: (taskId: string) => Promise<IPCResult<boolean>>;
|
checkTaskRunning: (taskId: string) => Promise<IPCResult<boolean>>;
|
||||||
|
resumePausedTask: (taskId: string) => Promise<IPCResult>;
|
||||||
|
|
||||||
// Image Operations
|
// Image Operations
|
||||||
loadImageThumbnail: (projectPath: string, specId: string, imagePath: string) => Promise<IPCResult<string>>;
|
loadImageThumbnail: (projectPath: string, specId: string, imagePath: string) => Promise<IPCResult<string>>;
|
||||||
@@ -144,6 +145,9 @@ export const createTaskAPI = (): TaskAPI => ({
|
|||||||
checkTaskRunning: (taskId: string): Promise<IPCResult<boolean>> =>
|
checkTaskRunning: (taskId: string): Promise<IPCResult<boolean>> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_CHECK_RUNNING, taskId),
|
ipcRenderer.invoke(IPC_CHANNELS.TASK_CHECK_RUNNING, taskId),
|
||||||
|
|
||||||
|
resumePausedTask: (taskId: string): Promise<IPCResult> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.TASK_RESUME_PAUSED, taskId),
|
||||||
|
|
||||||
// Image Operations
|
// Image Operations
|
||||||
loadImageThumbnail: (projectPath: string, specId: string, imagePath: string): Promise<IPCResult<string>> =>
|
loadImageThumbnail: (projectPath: string, specId: string, imagePath: string): Promise<IPCResult<string>> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.TASK_LOAD_IMAGE_THUMBNAIL, projectPath, specId, imagePath),
|
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)
|
// 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
|
// Track if settings have been loaded at least once
|
||||||
const [settingsHaveLoaded, setSettingsHaveLoaded] = useState(false);
|
const [settingsHaveLoaded, setSettingsHaveLoaded] = useState(false);
|
||||||
@@ -314,7 +314,7 @@ export function App() {
|
|||||||
i18n.changeLanguage(settings.language);
|
i18n.changeLanguage(settings.language);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only run when settings.language changes, not on every i18n object change
|
// 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
|
// Sync spell check language with i18n language
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -368,7 +368,7 @@ export function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInitSuccess(false);
|
setInitSuccess(false);
|
||||||
setInitError(null);
|
setInitError(null);
|
||||||
}, [selectedProjectId]);
|
}, []);
|
||||||
|
|
||||||
// Check if selected project needs initialization (e.g., .auto-claude folder was deleted)
|
// Check if selected project needs initialization (e.g., .auto-claude folder was deleted)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -445,7 +445,7 @@ export function App() {
|
|||||||
console.error('[App] Failed to restore sessions:', err);
|
console.error('[App] Failed to restore sessions:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [activeProjectId, selectedProjectId, selectedProject?.path, selectedProject?.name]);
|
}, [activeProjectId, selectedProjectId, selectedProject?.path]);
|
||||||
|
|
||||||
// Apply theme on load
|
// Apply theme on load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -587,7 +587,7 @@ export function App() {
|
|||||||
setSelectedTask(updatedTask);
|
setSelectedTask(updatedTask);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally omit selectedTask object to prevent infinite re-render loop
|
// 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) => {
|
const handleTaskClick = (task: Task) => {
|
||||||
setSelectedTask(task);
|
setSelectedTask(task);
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ import type {
|
|||||||
RoadmapPhase,
|
RoadmapPhase,
|
||||||
RoadmapFeaturePriority,
|
RoadmapFeaturePriority,
|
||||||
RoadmapFeatureStatus,
|
RoadmapFeatureStatus,
|
||||||
FeatureSource
|
|
||||||
} from '../../shared/types';
|
} from '../../shared/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function AgentProfileSelector({
|
|||||||
const [showPhaseDetails, setShowPhaseDetails] = useState(false);
|
const [showPhaseDetails, setShowPhaseDetails] = useState(false);
|
||||||
|
|
||||||
const isCustom = profileId === 'custom';
|
const isCustom = profileId === 'custom';
|
||||||
const isAuto = profileId === 'auto';
|
const _isAuto = profileId === 'auto';
|
||||||
|
|
||||||
// Use provided phase configs or defaults
|
// Use provided phase configs or defaults
|
||||||
const currentPhaseModels = phaseModels || DEFAULT_PHASE_MODELS;
|
const currentPhaseModels = phaseModels || DEFAULT_PHASE_MODELS;
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
Loader2,
|
Loader2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
AlertTriangle,
|
|
||||||
Lock
|
Lock
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
@@ -48,7 +47,7 @@ import {
|
|||||||
} from './ui/dialog';
|
} from './ui/dialog';
|
||||||
import { useSettingsStore } from '../stores/settings-store';
|
import { useSettingsStore } from '../stores/settings-store';
|
||||||
import { useProjectStore } from '../stores/project-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 { CustomMcpDialog } from './CustomMcpDialog';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
@@ -59,7 +58,6 @@ import {
|
|||||||
useResolvedAgentSettings,
|
useResolvedAgentSettings,
|
||||||
resolveAgentSettings as resolveAgentModelConfig,
|
resolveAgentSettings as resolveAgentModelConfig,
|
||||||
type AgentSettingsSource,
|
type AgentSettingsSource,
|
||||||
type ResolvedAgentSettings,
|
|
||||||
} from '../hooks';
|
} from '../hooks';
|
||||||
import type { ModelTypeShort, ThinkingLevel } from '../../shared/types/settings';
|
import type { ModelTypeShort, ThinkingLevel } from '../../shared/types/settings';
|
||||||
|
|
||||||
@@ -912,7 +910,7 @@ export function AgentTools() {
|
|||||||
[server.id]: result.data!,
|
[server.id]: result.data!,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
setServerHealthStatus(prev => ({
|
setServerHealthStatus(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[server.id]: {
|
[server.id]: {
|
||||||
@@ -945,14 +943,14 @@ export function AgentTools() {
|
|||||||
...prev,
|
...prev,
|
||||||
[server.id]: {
|
[server.id]: {
|
||||||
serverId: server.id,
|
serverId: server.id,
|
||||||
status: result.data!.success ? 'healthy' : 'unhealthy',
|
status: result.data?.success ? 'healthy' : 'unhealthy',
|
||||||
message: result.data!.message,
|
message: result.data?.message,
|
||||||
responseTime: result.data!.responseTime,
|
responseTime: result.data?.responseTime,
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
setServerHealthStatus(prev => ({
|
setServerHealthStatus(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[server.id]: {
|
[server.id]: {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 ReactMarkdown, { type Components } from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
import rehypeRaw from "rehype-raw";
|
||||||
|
import rehypeSanitize from "rehype-sanitize";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Progress } from "./ui/progress";
|
import { Progress } from "./ui/progress";
|
||||||
import {
|
import {
|
||||||
@@ -70,6 +72,7 @@ export function AppUpdateNotification() {
|
|||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
const [isDownloaded, setIsDownloaded] = useState(false);
|
const [isDownloaded, setIsDownloaded] = useState(false);
|
||||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||||
|
const [showReadOnlyWarning, setShowReadOnlyWarning] = useState(false);
|
||||||
|
|
||||||
// Create markdown components with translated accessibility text
|
// Create markdown components with translated accessibility text
|
||||||
const markdownComponents: Components = useMemo(
|
const markdownComponents: Components = useMemo(
|
||||||
@@ -88,6 +91,7 @@ export function AppUpdateNotification() {
|
|||||||
setIsDownloaded(false);
|
setIsDownloaded(false);
|
||||||
setDownloadProgress(null);
|
setDownloadProgress(null);
|
||||||
setDownloadError(null);
|
setDownloadError(null);
|
||||||
|
setShowReadOnlyWarning(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
return cleanup;
|
return cleanup;
|
||||||
@@ -99,6 +103,8 @@ export function AppUpdateNotification() {
|
|||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
setIsDownloaded(true);
|
setIsDownloaded(true);
|
||||||
setDownloadProgress(null);
|
setDownloadProgress(null);
|
||||||
|
setDownloadError(null);
|
||||||
|
setShowReadOnlyWarning(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
return cleanup;
|
return cleanup;
|
||||||
@@ -113,6 +119,26 @@ export function AppUpdateNotification() {
|
|||||||
return cleanup;
|
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 () => {
|
const handleDownload = async () => {
|
||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
setDownloadError(null);
|
setDownloadError(null);
|
||||||
@@ -189,7 +215,11 @@ export function AppUpdateNotification() {
|
|||||||
{updateInfo.releaseNotes && (
|
{updateInfo.releaseNotes && (
|
||||||
<div className="bg-background rounded-lg p-4 max-h-64 overflow-y-auto border border-border/50">
|
<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">
|
<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}
|
{updateInfo.releaseNotes}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,7 +231,7 @@ export function AppUpdateNotification() {
|
|||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full text-xs text-muted-foreground gap-1"
|
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(
|
aria-label={t(
|
||||||
"dialogs:appUpdate.claudeCodeChangelogAriaLabel",
|
"dialogs:appUpdate.claudeCodeChangelogAriaLabel",
|
||||||
"View Claude Code Changelog (opens in new window)"
|
"View Claude Code Changelog (opens in new window)"
|
||||||
@@ -238,8 +268,23 @@ export function AppUpdateNotification() {
|
|||||||
</div>
|
</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 */}
|
{/* 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">
|
<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" />
|
<CheckCircle2 className="h-5 w-5 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
@@ -260,7 +305,7 @@ export function AppUpdateNotification() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isDownloaded ? (
|
{isDownloaded ? (
|
||||||
<Button onClick={handleInstall}>
|
<Button onClick={handleInstall} disabled={showReadOnlyWarning}>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
{t("dialogs:appUpdate.installAndRestart", "Install and Restart")}
|
{t("dialogs:appUpdate.installAndRestart", "Install and Restart")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -237,8 +237,7 @@ export function AuthStatusIndicator() {
|
|||||||
|
|
||||||
{/* Profile details for API profiles */}
|
{/* Profile details for API profiles */}
|
||||||
{!isOAuth && (
|
{!isOAuth && (
|
||||||
<>
|
<div className="pt-2 border-t space-y-2">
|
||||||
<div className="pt-2 border-t space-y-2">
|
|
||||||
{/* Profile name with icon */}
|
{/* Profile name with icon */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
@@ -272,7 +271,6 @@ export function AuthStatusIndicator() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
|
|||||||
@@ -74,14 +74,14 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
|
|||||||
const [showPathChangeWarning, setShowPathChangeWarning] = useState(false);
|
const [showPathChangeWarning, setShowPathChangeWarning] = useState(false);
|
||||||
|
|
||||||
// Check Claude Code version
|
// Check Claude Code version
|
||||||
const checkVersion = useCallback(async () => {
|
const checkVersion = useCallback(async (forceRefresh = false) => {
|
||||||
try {
|
try {
|
||||||
if (!window.electronAPI?.checkClaudeCodeVersion) {
|
if (!window.electronAPI?.checkClaudeCodeVersion) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await window.electronAPI.checkClaudeCodeVersion();
|
const result = await window.electronAPI.checkClaudeCodeVersion(forceRefresh);
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setVersionInfo(result.data);
|
setVersionInfo(result.data);
|
||||||
@@ -486,7 +486,7 @@ export function ClaudeCodeStatusBadge({ className }: ClaudeCodeStatusBadgeProps)
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1"
|
className="gap-1"
|
||||||
onClick={() => checkVersion()}
|
onClick={() => checkVersion(true)}
|
||||||
disabled={status === "loading"}
|
disabled={status === "loading"}
|
||||||
>
|
>
|
||||||
<RefreshCw className={cn("h-3 w-3", status === "loading" && "animate-spin")} />
|
<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 { RadioGroup, RadioGroupItem } from './ui/radio-group';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { CustomMcpServer } from '../../shared/types';
|
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 {
|
interface CustomMcpDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function EnvConfigModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
}, [open]);
|
}, [open, selectedProfileId]);
|
||||||
|
|
||||||
// Listen for OAuth token from terminal
|
// Listen for OAuth token from terminal
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export function FileAutocomplete({
|
|||||||
// Reset selection when results change
|
// Reset selection when results change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
}, [filteredFiles]);
|
}, []);
|
||||||
|
|
||||||
// Scroll selected item into view
|
// Scroll selected item into view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function FileTreeItem({
|
|||||||
// This handles cases where component unmounts mid-drag or dragend doesn't fire
|
// This handles cases where component unmounts mid-drag or dragend doesn't fire
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (dragImageRef.current && dragImageRef.current.parentNode) {
|
if (dragImageRef.current?.parentNode) {
|
||||||
dragImageRef.current.parentNode.removeChild(dragImageRef.current);
|
dragImageRef.current.parentNode.removeChild(dragImageRef.current);
|
||||||
dragImageRef.current = null;
|
dragImageRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -151,7 +151,7 @@ export function FileTreeItem({
|
|||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
|
||||||
// Clean up drag image element
|
// Clean up drag image element
|
||||||
if (dragImageRef.current && dragImageRef.current.parentNode) {
|
if (dragImageRef.current?.parentNode) {
|
||||||
dragImageRef.current.parentNode.removeChild(dragImageRef.current);
|
dragImageRef.current.parentNode.removeChild(dragImageRef.current);
|
||||||
dragImageRef.current = null;
|
dragImageRef.current = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function GitSetupModal({
|
|||||||
const [step, setStep] = useState<'info' | 'initializing' | 'success'>('info');
|
const [step, setStep] = useState<'info' | 'initializing' | 'success'>('info');
|
||||||
|
|
||||||
const needsGitInit = gitStatus && !gitStatus.isGitRepo;
|
const needsGitInit = gitStatus && !gitStatus.isGitRepo;
|
||||||
const _needsCommit = gitStatus && gitStatus.isGitRepo && !gitStatus.hasCommits;
|
const _needsCommit = gitStatus?.isGitRepo && !gitStatus.hasCommits;
|
||||||
|
|
||||||
const handleInitializeGit = async () => {
|
const handleInitializeGit = async () => {
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export function Insights({ projectId }: InsightsProps) {
|
|||||||
if (isUserAtBottom && viewportEl) {
|
if (isUserAtBottom && viewportEl) {
|
||||||
viewportEl.scrollTop = viewportEl.scrollHeight;
|
viewportEl.scrollTop = viewportEl.scrollHeight;
|
||||||
}
|
}
|
||||||
}, [session?.messages, streamingContent, isUserAtBottom, viewportEl]);
|
}, [isUserAtBottom, viewportEl]);
|
||||||
|
|
||||||
// Focus textarea on mount
|
// Focus textarea on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -160,7 +160,7 @@ export function Insights({ projectId }: InsightsProps) {
|
|||||||
// Reset taskCreated when switching sessions
|
// Reset taskCreated when switching sessions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTaskCreated(new Set());
|
setTaskCreated(new Set());
|
||||||
}, [session?.id]);
|
}, []);
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
const message = inputValue.trim();
|
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