Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29d6c8cb14 | |||
| 01295eff99 | |||
| a41e5a0eba | |||
| 5ed85e86ab | |||
| 071379a109 | |||
| 0ddd740bd1 | |||
| 2e2b82365f | |||
| acb131b721 | |||
| df528f0650 | |||
| ff91a1af0f | |||
| 6d0222fa9c | |||
| fe08c644c4 | |||
| a5e3cc9a2a | |||
| 4587162e43 | |||
| b4e6b2fe43 | |||
| d9cd300fee | |||
| f5a7e26d99 | |||
| 5f63daa3cc | |||
| e6e8da17c8 | |||
| 9317148b6b | |||
| 4730206214 | |||
| ae703be9f3 | |||
| 5293fb3996 | |||
| 8030c59f25 | |||
| ab91f7baf8 | |||
| a2c3507d61 | |||
| 26134c289c | |||
| 303b3781a4 |
@@ -174,4 +174,3 @@ OPUS_ANALYSIS_AND_IDEAS.md
|
||||
/shared_docs
|
||||
logs/security/
|
||||
Agents.md
|
||||
packages/
|
||||
|
||||
@@ -40,6 +40,17 @@ Auto Claude is a desktop application (+ CLI) where users describe a goal and AI
|
||||
|
||||
**PR target** — Always target the `develop` branch for PRs to AndyMik90/Auto-Claude, NOT `main`.
|
||||
|
||||
## Memory (claude-mem)
|
||||
|
||||
This project uses claude-mem for persistent memory across sessions. MCP search tools are available — use them proactively:
|
||||
|
||||
- **Before modifying code** — Search for past work on the same files/features: `search(query="<file or feature>")`. Check if there are known bugs, decisions, or patterns to follow.
|
||||
- **When debugging** — Search for prior encounters with the same error or symptom: `search(query="<error message>", type="bugfix")`.
|
||||
- **When making architectural decisions** — Check for past decisions: `search(query="<topic>", type="decision")`.
|
||||
- **When resuming work** — Use `timeline(anchor=<recent_id>)` to understand where things left off.
|
||||
|
||||
Follow the 3-layer workflow: `search` (cheap index) → `timeline` (context) → `get_observations` (full details only for relevant IDs). Never fetch full details without filtering first.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
|
||||
@@ -35,18 +35,18 @@
|
||||
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
|
||||
|
||||
<!-- BETA_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.1)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.2)
|
||||
<!-- BETA_VERSION_BADGE_END -->
|
||||
|
||||
<!-- BETA_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.6-beta.1-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.1-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.1-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.1-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.1-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.1-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.1/Auto-Claude-2.7.6-beta.1-linux-x86_64.flatpak) |
|
||||
| **Windows** | [Auto-Claude-2.7.6-beta.2-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.2-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.2-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-x86_64.flatpak) |
|
||||
<!-- BETA_DOWNLOADS_END -->
|
||||
|
||||
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
|
||||
|
||||
@@ -19,5 +19,5 @@ Quick Start:
|
||||
See README.md for full documentation.
|
||||
"""
|
||||
|
||||
__version__ = "2.7.6-beta.1"
|
||||
__version__ = "2.7.6-beta.2"
|
||||
__author__ = "Auto Claude Team"
|
||||
|
||||
@@ -6,6 +6,7 @@ Shared imports, types, and constants used across agent modules.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -20,3 +21,76 @@ INITIAL_RETRY_DELAY_SECONDS = (
|
||||
2 # Initial retry delay (doubles each retry: 2s, 4s, 8s, 16s, 32s)
|
||||
)
|
||||
MAX_RETRY_DELAY_SECONDS = 32 # Cap retry delay at 32 seconds
|
||||
|
||||
# Pause file constants for intelligent error recovery
|
||||
# These files signal pause/resume between frontend and backend
|
||||
RATE_LIMIT_PAUSE_FILE = "RATE_LIMIT_PAUSE" # Created when rate limited
|
||||
AUTH_FAILURE_PAUSE_FILE = "AUTH_PAUSE" # Created when auth fails
|
||||
RESUME_FILE = "RESUME" # Created by frontend to signal resume
|
||||
|
||||
# Maximum time to wait for rate limit reset (2 hours)
|
||||
# If reset time is beyond this, task should fail rather than wait indefinitely
|
||||
MAX_RATE_LIMIT_WAIT_SECONDS = 7200
|
||||
|
||||
# Wait intervals for pause/resume checking
|
||||
RATE_LIMIT_CHECK_INTERVAL_SECONDS = (
|
||||
30 # Check for RESUME file every 30 seconds during rate limit wait
|
||||
)
|
||||
AUTH_RESUME_CHECK_INTERVAL_SECONDS = 10 # Check for re-authentication every 10 seconds
|
||||
AUTH_RESUME_MAX_WAIT_SECONDS = 86400 # Maximum wait for re-authentication (24 hours)
|
||||
|
||||
|
||||
def sanitize_error_message(error_message: str, max_length: int = 500) -> str:
|
||||
"""
|
||||
Sanitize error messages to remove potentially sensitive information.
|
||||
|
||||
Redacts:
|
||||
- API keys (sk-..., key-...)
|
||||
- Bearer tokens
|
||||
- Token/secret values
|
||||
|
||||
Args:
|
||||
error_message: The raw error message to sanitize
|
||||
max_length: Maximum length to truncate to (default 500)
|
||||
|
||||
Returns:
|
||||
Sanitized and truncated error message
|
||||
"""
|
||||
if not error_message:
|
||||
return ""
|
||||
|
||||
# Redact patterns that look like API keys or tokens
|
||||
# Pattern: sk-... (OpenAI/Anthropic keys like sk-ant-api03-...)
|
||||
sanitized = re.sub(
|
||||
r"\bsk-[a-zA-Z0-9._\-]{20,}\b", "[REDACTED_API_KEY]", error_message
|
||||
)
|
||||
|
||||
# Pattern: key-... (generic API keys)
|
||||
sanitized = re.sub(r"\bkey-[a-zA-Z0-9._\-]{20,}\b", "[REDACTED_API_KEY]", sanitized)
|
||||
|
||||
# Pattern: Bearer ... (bearer tokens)
|
||||
sanitized = re.sub(
|
||||
r"\bBearer\s+[a-zA-Z0-9._\-]{20,}\b", "Bearer [REDACTED_TOKEN]", sanitized
|
||||
)
|
||||
|
||||
# Pattern: token= or token: followed by long strings
|
||||
sanitized = re.sub(
|
||||
r"(token[=:]\s*)[a-zA-Z0-9._\-]{20,}\b",
|
||||
r"\1[REDACTED_TOKEN]",
|
||||
sanitized,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Pattern: secret= or secret: followed by strings
|
||||
sanitized = re.sub(
|
||||
r"(secret[=:]\s*)[a-zA-Z0-9._\-]{20,}\b",
|
||||
r"\1[REDACTED_SECRET]",
|
||||
sanitized,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Truncate to max length
|
||||
if len(sanitized) > max_length:
|
||||
sanitized = sanitized[:max_length] + "..."
|
||||
|
||||
return sanitized
|
||||
|
||||
@@ -6,8 +6,11 @@ Main autonomous agent loop that runs the coder agent to implement subtasks.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
@@ -57,11 +60,19 @@ from ui import (
|
||||
)
|
||||
|
||||
from .base import (
|
||||
AUTH_FAILURE_PAUSE_FILE,
|
||||
AUTH_RESUME_CHECK_INTERVAL_SECONDS,
|
||||
AUTH_RESUME_MAX_WAIT_SECONDS,
|
||||
AUTO_CONTINUE_DELAY_SECONDS,
|
||||
HUMAN_INTERVENTION_FILE,
|
||||
INITIAL_RETRY_DELAY_SECONDS,
|
||||
MAX_CONCURRENCY_RETRIES,
|
||||
MAX_RATE_LIMIT_WAIT_SECONDS,
|
||||
MAX_RETRY_DELAY_SECONDS,
|
||||
RATE_LIMIT_CHECK_INTERVAL_SECONDS,
|
||||
RATE_LIMIT_PAUSE_FILE,
|
||||
RESUME_FILE,
|
||||
sanitize_error_message,
|
||||
)
|
||||
from .memory_manager import debug_memory_system_status, get_graphiti_context
|
||||
from .session import post_session_processing, run_agent_session
|
||||
@@ -76,6 +87,219 @@ from .utils import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_and_clear_resume_file(
|
||||
resume_file: Path,
|
||||
pause_file: Path,
|
||||
fallback_resume_file: Path | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if resume file exists and clean up both resume and pause files.
|
||||
|
||||
Also checks a fallback location (main project spec dir) in case the frontend
|
||||
couldn't find the worktree and only wrote the RESUME file there.
|
||||
|
||||
Args:
|
||||
resume_file: Path to RESUME file
|
||||
pause_file: Path to pause file (RATE_LIMIT_PAUSE or AUTH_PAUSE)
|
||||
fallback_resume_file: Optional fallback RESUME file path (e.g. main project spec dir)
|
||||
|
||||
Returns:
|
||||
True if resume file existed (early resume), False otherwise
|
||||
"""
|
||||
found = resume_file.exists()
|
||||
|
||||
# Check fallback location if primary not found
|
||||
if not found and fallback_resume_file and fallback_resume_file.exists():
|
||||
found = True
|
||||
try:
|
||||
fallback_resume_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(f"Error cleaning up fallback resume file: {e}")
|
||||
|
||||
if found:
|
||||
try:
|
||||
resume_file.unlink(missing_ok=True)
|
||||
pause_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(
|
||||
f"Error cleaning up resume files: {e} (resume: {resume_file}, pause: {pause_file})"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def wait_for_rate_limit_reset(
|
||||
spec_dir: Path,
|
||||
wait_seconds: float,
|
||||
source_spec_dir: Path | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Wait for rate limit reset with periodic checks for resume/cancel.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory to check for RESUME file
|
||||
wait_seconds: Maximum time to wait in seconds
|
||||
source_spec_dir: Optional main project spec dir as fallback for RESUME file
|
||||
|
||||
Returns:
|
||||
True if resumed early, False if waited full duration
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
start_time = loop.time()
|
||||
resume_file = spec_dir / RESUME_FILE
|
||||
pause_file = spec_dir / RATE_LIMIT_PAUSE_FILE
|
||||
fallback_resume = (source_spec_dir / RESUME_FILE) if source_spec_dir else None
|
||||
|
||||
while True:
|
||||
# Check elapsed time using loop.time() to avoid drift
|
||||
elapsed = max(0, loop.time() - start_time) # Ensure non-negative
|
||||
if elapsed >= wait_seconds:
|
||||
break
|
||||
|
||||
# Check if user requested resume
|
||||
if _check_and_clear_resume_file(resume_file, pause_file, fallback_resume):
|
||||
return True
|
||||
|
||||
# Wait for next check interval or remaining time
|
||||
sleep_time = min(RATE_LIMIT_CHECK_INTERVAL_SECONDS, wait_seconds - elapsed)
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
# Clean up pause file after wait completes
|
||||
try:
|
||||
pause_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(f"Error cleaning up pause file {pause_file}: {e}")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def wait_for_auth_resume(
|
||||
spec_dir: Path,
|
||||
source_spec_dir: Path | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Wait for user re-authentication signal.
|
||||
|
||||
Blocks until:
|
||||
- RESUME file is created (user completed re-auth in UI)
|
||||
- AUTH_PAUSE file is deleted (alternative resume signal)
|
||||
- Maximum wait timeout is reached (24 hours)
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory to monitor for signal files
|
||||
source_spec_dir: Optional main project spec dir as fallback for RESUME file
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
start_time = loop.time()
|
||||
resume_file = spec_dir / RESUME_FILE
|
||||
pause_file = spec_dir / AUTH_FAILURE_PAUSE_FILE
|
||||
fallback_resume = (source_spec_dir / RESUME_FILE) if source_spec_dir else None
|
||||
|
||||
while True:
|
||||
# Check elapsed time using loop.time() to avoid drift
|
||||
elapsed = max(0, loop.time() - start_time) # Ensure non-negative
|
||||
if elapsed >= AUTH_RESUME_MAX_WAIT_SECONDS:
|
||||
break
|
||||
|
||||
# Check for resume signals
|
||||
if (
|
||||
_check_and_clear_resume_file(resume_file, pause_file, fallback_resume)
|
||||
or not pause_file.exists()
|
||||
):
|
||||
# If pause file was deleted externally, still clean up resume file if it exists
|
||||
if not pause_file.exists():
|
||||
try:
|
||||
resume_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(f"Error cleaning up resume file {resume_file}: {e}")
|
||||
return
|
||||
|
||||
await asyncio.sleep(AUTH_RESUME_CHECK_INTERVAL_SECONDS)
|
||||
|
||||
# Timeout reached - clean up and return
|
||||
print_status(
|
||||
"Authentication wait timeout reached (24 hours) - resuming with original credentials",
|
||||
"warning",
|
||||
)
|
||||
try:
|
||||
pause_file.unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug(f"Error cleaning up pause file {pause_file} after timeout: {e}")
|
||||
|
||||
|
||||
def parse_rate_limit_reset_time(error_info: dict | None) -> int | None:
|
||||
"""
|
||||
Parse rate limit reset time from error info.
|
||||
|
||||
Attempts to extract reset time from various formats in error messages.
|
||||
|
||||
TIMEZONE ASSUMPTIONS:
|
||||
- "in X minutes/hours" patterns are timezone-safe (relative time)
|
||||
- "at HH:MM" patterns assume LOCAL timezone, which is reasonable since:
|
||||
1. The user sees timestamps in their local timezone
|
||||
2. The wait calculation happens locally using datetime.now()
|
||||
3. If the API returns UTC "at" times, this would need adjustment
|
||||
(but Claude API typically returns relative times like "in X minutes")
|
||||
|
||||
Args:
|
||||
error_info: Error info dict with 'message' key
|
||||
|
||||
Returns:
|
||||
Unix timestamp of reset time, or None if not parseable
|
||||
"""
|
||||
if not error_info:
|
||||
return None
|
||||
|
||||
message = error_info.get("message", "")
|
||||
|
||||
# Try to find patterns like "resets at 3:00 PM" or "in 5 minutes"
|
||||
# Pattern: "in X minutes/hours" (timezone-safe - relative time)
|
||||
in_time_match = re.search(r"in\s+(\d+)\s*(minute|hour|min|hr)s?", message, re.I)
|
||||
if in_time_match:
|
||||
amount = int(in_time_match.group(1))
|
||||
unit = in_time_match.group(2).lower()
|
||||
if unit.startswith("hour") or unit.startswith("hr"):
|
||||
delta = timedelta(hours=amount)
|
||||
else:
|
||||
delta = timedelta(minutes=amount)
|
||||
return int((datetime.now() + delta).timestamp())
|
||||
|
||||
# Pattern: "at HH:MM" (12 or 24 hour)
|
||||
at_time_match = re.search(r"at\s+(\d{1,2}):(\d{2})(?:\s*(am|pm))?", message, re.I)
|
||||
if at_time_match:
|
||||
try:
|
||||
hour = int(at_time_match.group(1))
|
||||
minute = int(at_time_match.group(2))
|
||||
meridiem = at_time_match.group(3)
|
||||
|
||||
# Validate hour range when meridiem is present
|
||||
# Hours should be 1-12 for AM/PM format
|
||||
if meridiem and not (1 <= hour <= 12):
|
||||
return None
|
||||
|
||||
if meridiem:
|
||||
if meridiem.lower() == "pm" and hour < 12:
|
||||
hour += 12
|
||||
elif meridiem.lower() == "am" and hour == 12:
|
||||
hour = 0
|
||||
|
||||
# Validate hour and minute ranges
|
||||
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
||||
return None
|
||||
|
||||
now = datetime.now()
|
||||
reset_time = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if reset_time <= now:
|
||||
reset_time += timedelta(days=1)
|
||||
return int(reset_time.timestamp())
|
||||
except ValueError:
|
||||
# Invalid time values - return None to fall back to standard retry
|
||||
return None
|
||||
|
||||
# No pattern matched - return None to let caller decide retry behavior
|
||||
return None
|
||||
|
||||
|
||||
async def run_autonomous_agent(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
@@ -682,6 +906,135 @@ async def run_autonomous_agent(
|
||||
current_retry_delay * 2, MAX_RETRY_DELAY_SECONDS
|
||||
)
|
||||
|
||||
elif error_info and error_info.get("type") == "rate_limit":
|
||||
# Rate limit error - intelligent wait for reset
|
||||
_reset_concurrency_state()
|
||||
|
||||
reset_timestamp = parse_rate_limit_reset_time(error_info)
|
||||
if reset_timestamp:
|
||||
wait_seconds = reset_timestamp - datetime.now().timestamp()
|
||||
|
||||
# Handle negative wait_seconds (reset time in the past)
|
||||
if wait_seconds <= 0:
|
||||
print_status(
|
||||
"Rate limit reset time already passed - retrying immediately",
|
||||
"warning",
|
||||
)
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
await asyncio.sleep(2) # Brief delay before retry
|
||||
continue
|
||||
|
||||
if wait_seconds > MAX_RATE_LIMIT_WAIT_SECONDS:
|
||||
# Wait time too long - fail the task
|
||||
print_status("Rate limit wait time too long", "error")
|
||||
print(
|
||||
f"Reset time would require waiting {wait_seconds / 3600:.1f} hours"
|
||||
)
|
||||
print(
|
||||
f"Maximum wait is {MAX_RATE_LIMIT_WAIT_SECONDS / 3600:.1f} hours"
|
||||
)
|
||||
emit_phase(
|
||||
ExecutionPhase.FAILED,
|
||||
"Rate limit wait time exceeds maximum allowed",
|
||||
)
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
break
|
||||
|
||||
# Emit pause phase with reset time for frontend
|
||||
wait_minutes = wait_seconds / 60
|
||||
emit_phase(
|
||||
ExecutionPhase.RATE_LIMIT_PAUSED,
|
||||
f"Rate limit - resuming in {wait_minutes:.0f} minutes",
|
||||
reset_timestamp=reset_timestamp,
|
||||
)
|
||||
|
||||
# Create pause file for frontend detection
|
||||
# Sanitize error message to prevent exposing sensitive data
|
||||
raw_error = error_info.get("message", "Rate limit reached")
|
||||
sanitized_error = (
|
||||
sanitize_error_message(raw_error, max_length=500)
|
||||
or "Rate limit reached"
|
||||
)
|
||||
pause_data = {
|
||||
"paused_at": datetime.now().isoformat(),
|
||||
"reset_timestamp": reset_timestamp,
|
||||
"error": sanitized_error,
|
||||
}
|
||||
pause_file = spec_dir / RATE_LIMIT_PAUSE_FILE
|
||||
pause_file.write_text(json.dumps(pause_data), encoding="utf-8")
|
||||
|
||||
print_status(
|
||||
f"Rate limited - waiting {wait_minutes:.0f} minutes for reset",
|
||||
"warning",
|
||||
)
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
|
||||
# Wait with periodic checks for resume signal
|
||||
resumed_early = await wait_for_rate_limit_reset(
|
||||
spec_dir, wait_seconds, source_spec_dir
|
||||
)
|
||||
if resumed_early:
|
||||
print_status("Resumed early by user", "success")
|
||||
|
||||
# Resume execution
|
||||
emit_phase(ExecutionPhase.CODING, "Resuming after rate limit")
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
continue # Resume the loop
|
||||
else:
|
||||
# Couldn't parse reset time - fall back to standard retry
|
||||
print_status("Rate limit hit (unknown reset time)", "warning")
|
||||
print(muted("Will retry with a fresh session..."))
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS)
|
||||
_reset_concurrency_state()
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
continue
|
||||
|
||||
elif error_info and error_info.get("type") == "authentication":
|
||||
# Authentication error - pause for user re-authentication
|
||||
_reset_concurrency_state()
|
||||
|
||||
emit_phase(
|
||||
ExecutionPhase.AUTH_FAILURE_PAUSED,
|
||||
"Re-authentication required",
|
||||
)
|
||||
|
||||
# Create pause file for frontend detection
|
||||
# Sanitize error message to prevent exposing sensitive data
|
||||
raw_error = error_info.get("message", "Authentication failed")
|
||||
sanitized_error = (
|
||||
sanitize_error_message(raw_error, max_length=500)
|
||||
or "Authentication failed"
|
||||
)
|
||||
pause_data = {
|
||||
"paused_at": datetime.now().isoformat(),
|
||||
"error": sanitized_error,
|
||||
"requires_action": "re-authenticate",
|
||||
}
|
||||
pause_file = spec_dir / AUTH_FAILURE_PAUSE_FILE
|
||||
pause_file.write_text(json.dumps(pause_data), encoding="utf-8")
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(" AUTHENTICATION REQUIRED")
|
||||
print("=" * 70)
|
||||
print()
|
||||
print("OAuth token is invalid or expired.")
|
||||
print("Please re-authenticate in the Auto Claude settings.")
|
||||
print()
|
||||
print("The task will automatically resume once you re-authenticate.")
|
||||
print()
|
||||
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
|
||||
# Wait for user to complete re-authentication
|
||||
await wait_for_auth_resume(spec_dir, source_spec_dir)
|
||||
|
||||
print_status("Authentication restored - resuming", "success")
|
||||
emit_phase(ExecutionPhase.CODING, "Resuming after re-authentication")
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
continue # Resume the loop
|
||||
|
||||
else:
|
||||
# Other errors - use standard retry logic
|
||||
print_status("Session encountered an error", "error")
|
||||
|
||||
@@ -7,6 +7,7 @@ memory updates, recovery tracking, and Linear integration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from claude_agent_sdk import ClaudeSDKClient
|
||||
@@ -34,6 +35,7 @@ from ui import (
|
||||
print_status,
|
||||
)
|
||||
|
||||
from .base import sanitize_error_message
|
||||
from .memory_manager import save_session_memory
|
||||
from .utils import (
|
||||
find_subtask_in_plan,
|
||||
@@ -68,6 +70,93 @@ def is_tool_concurrency_error(error: Exception) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def is_rate_limit_error(error: Exception) -> bool:
|
||||
"""
|
||||
Check if an error is a rate limit error (429 or similar).
|
||||
|
||||
Rate limit errors occur when the API usage quota is exceeded,
|
||||
either for session limits or weekly limits.
|
||||
|
||||
Args:
|
||||
error: The exception to check
|
||||
|
||||
Returns:
|
||||
True if this is a rate limit error, False otherwise
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
|
||||
# Check for HTTP 429 with word boundaries to avoid false positives
|
||||
if re.search(r"\b429\b", error_str):
|
||||
return True
|
||||
|
||||
# Check for other rate limit indicators
|
||||
return any(
|
||||
p in error_str
|
||||
for p in [
|
||||
"limit reached",
|
||||
"rate limit",
|
||||
"too many requests",
|
||||
"usage limit",
|
||||
"quota exceeded",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def is_authentication_error(error: Exception) -> bool:
|
||||
"""
|
||||
Check if an error is an authentication error (401, token expired, etc.).
|
||||
|
||||
Authentication errors occur when OAuth tokens are invalid, expired,
|
||||
or have been revoked (e.g., after token refresh on another process).
|
||||
|
||||
Validation approach:
|
||||
- HTTP 401 status code is checked with word boundaries to minimize false positives
|
||||
- Additional string patterns are validated against lowercase error messages
|
||||
- Patterns are designed to match known Claude API and OAuth error formats
|
||||
|
||||
Known false positive risks:
|
||||
- Generic error messages containing "unauthorized" or "access denied" may match
|
||||
even if not related to authentication (e.g., file permission errors)
|
||||
- Error messages containing these keywords in user-provided content could match
|
||||
- Mitigation: HTTP 401 check provides strong signal; string patterns are secondary
|
||||
|
||||
Real-world validation:
|
||||
- Pattern matching has been tested against actual Claude API error responses
|
||||
- False positive rate is acceptable given the recovery mechanism (prompt user to re-auth)
|
||||
- If false positive occurs, user can simply resume without re-authenticating
|
||||
|
||||
Args:
|
||||
error: The exception to check
|
||||
|
||||
Returns:
|
||||
True if this is an authentication error, False otherwise
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
|
||||
# Check for HTTP 401 with word boundaries to avoid false positives
|
||||
if re.search(r"\b401\b", error_str):
|
||||
return True
|
||||
|
||||
# Check for other authentication indicators
|
||||
# NOTE: "authentication failed" and "authentication error" are more specific patterns
|
||||
# to reduce false positives from generic "authentication" mentions
|
||||
return any(
|
||||
p in error_str
|
||||
for p in [
|
||||
"authentication failed",
|
||||
"authentication error",
|
||||
"unauthorized",
|
||||
"invalid token",
|
||||
"token expired",
|
||||
"authentication_error",
|
||||
"invalid_token",
|
||||
"token_expired",
|
||||
"not authenticated",
|
||||
"http 401",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def post_session_processing(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
@@ -568,7 +657,18 @@ async def run_agent_session(
|
||||
except Exception as e:
|
||||
# Detect specific error types for better retry handling
|
||||
is_concurrency = is_tool_concurrency_error(e)
|
||||
error_type = "tool_concurrency" if is_concurrency else "other"
|
||||
is_rate_limit = is_rate_limit_error(e)
|
||||
is_auth = is_authentication_error(e)
|
||||
|
||||
# Classify error type for appropriate handling
|
||||
if is_concurrency:
|
||||
error_type = "tool_concurrency"
|
||||
elif is_rate_limit:
|
||||
error_type = "rate_limit"
|
||||
elif is_auth:
|
||||
error_type = "authentication"
|
||||
else:
|
||||
error_type = "other"
|
||||
|
||||
debug_error(
|
||||
"session",
|
||||
@@ -579,20 +679,32 @@ async def run_agent_session(
|
||||
tool_count=tool_count,
|
||||
)
|
||||
|
||||
# Log concurrency errors prominently
|
||||
# Sanitize error message to remove potentially sensitive data
|
||||
# Must happen BEFORE printing to stdout, since stdout is captured by the frontend
|
||||
sanitized_error = sanitize_error_message(str(e))
|
||||
|
||||
# Log errors prominently based on type
|
||||
if is_concurrency:
|
||||
print("\n⚠️ Tool concurrency limit reached (400 error)")
|
||||
print(" Claude API limits concurrent tool use in a single request")
|
||||
print(f" Error: {str(e)[:200]}\n")
|
||||
print(f" Error: {sanitized_error[:200]}\n")
|
||||
elif is_rate_limit:
|
||||
print("\n⚠️ Rate limit reached")
|
||||
print(" API usage quota exceeded - waiting for reset")
|
||||
print(f" Error: {sanitized_error[:200]}\n")
|
||||
elif is_auth:
|
||||
print("\n⚠️ Authentication error")
|
||||
print(" OAuth token may be invalid or expired")
|
||||
print(f" Error: {sanitized_error[:200]}\n")
|
||||
else:
|
||||
print(f"Error during agent session: {e}")
|
||||
print(f"Error during agent session: {sanitized_error}")
|
||||
|
||||
if task_logger:
|
||||
task_logger.log_error(f"Session error: {e}", phase)
|
||||
task_logger.log_error(f"Session error: {sanitized_error}", phase)
|
||||
|
||||
error_info = {
|
||||
"type": error_type,
|
||||
"message": str(e),
|
||||
"message": sanitized_error,
|
||||
"exception_type": type(e).__name__,
|
||||
}
|
||||
return "error", str(e), error_info
|
||||
return "error", sanitized_error, error_info
|
||||
|
||||
@@ -56,14 +56,10 @@ def _apply_qa_update(
|
||||
"ready_for_qa_revalidation": status == "fixes_applied",
|
||||
}
|
||||
|
||||
# Update plan status to match QA result
|
||||
# This ensures the UI shows the correct column after QA
|
||||
if status == "approved":
|
||||
plan["status"] = "human_review"
|
||||
plan["planStatus"] = "review"
|
||||
elif status == "rejected":
|
||||
plan["status"] = "human_review"
|
||||
plan["planStatus"] = "review"
|
||||
# NOTE: Do NOT write plan["status"] or plan["planStatus"] here.
|
||||
# The frontend XState task state machine owns status transitions.
|
||||
# Writing status here races with XState's persistPlanStatusAndReasonSync()
|
||||
# and can clobber the reviewReason field, causing tasks to appear "incomplete".
|
||||
|
||||
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
@@ -87,7 +87,10 @@ def handle_build_command(
|
||||
debug_success,
|
||||
)
|
||||
from phase_config import get_phase_model
|
||||
from prompts_pkg.prompts import get_base_branch_from_metadata
|
||||
from prompts_pkg.prompts import (
|
||||
get_base_branch_from_metadata,
|
||||
get_use_local_branch_from_metadata,
|
||||
)
|
||||
from qa_loop import run_qa_validation_loop, should_run_qa
|
||||
|
||||
from .utils import print_banner, validate_environment
|
||||
@@ -203,6 +206,9 @@ def handle_build_command(
|
||||
base_branch = metadata_branch
|
||||
debug("run.py", f"Using base branch from task metadata: {base_branch}")
|
||||
|
||||
# Check if user requested local branch (preserves gitignored files like .env)
|
||||
use_local_branch = get_use_local_branch_from_metadata(spec_dir)
|
||||
|
||||
if workspace_mode == WorkspaceMode.ISOLATED:
|
||||
# Keep reference to original spec directory for syncing progress back
|
||||
source_spec_dir = spec_dir
|
||||
@@ -213,6 +219,7 @@ def handle_build_command(
|
||||
workspace_mode,
|
||||
source_spec_dir=spec_dir,
|
||||
base_branch=base_branch,
|
||||
use_local_branch=use_local_branch,
|
||||
)
|
||||
# Use the localized spec directory (inside worktree) for AI access
|
||||
if localized_spec_dir:
|
||||
|
||||
@@ -23,6 +23,9 @@ class ExecutionPhase(str, Enum):
|
||||
QA_FIXING = "qa_fixing"
|
||||
COMPLETE = "complete"
|
||||
FAILED = "failed"
|
||||
# Pause states for intelligent error recovery
|
||||
RATE_LIMIT_PAUSED = "rate_limit_paused"
|
||||
AUTH_FAILURE_PAUSED = "auth_failure_paused"
|
||||
|
||||
|
||||
def emit_phase(
|
||||
@@ -31,8 +34,19 @@ def emit_phase(
|
||||
*,
|
||||
progress: int | None = None,
|
||||
subtask: str | None = None,
|
||||
reset_timestamp: int | None = None,
|
||||
profile_id: str | None = None,
|
||||
) -> None:
|
||||
"""Emit structured phase event to stdout for frontend parsing."""
|
||||
"""Emit structured phase event to stdout for frontend parsing.
|
||||
|
||||
Args:
|
||||
phase: The execution phase (e.g., PLANNING, CODING, RATE_LIMIT_PAUSED)
|
||||
message: Optional message describing the phase state
|
||||
progress: Optional progress percentage (0-100)
|
||||
subtask: Optional subtask identifier
|
||||
reset_timestamp: Optional Unix timestamp for rate limit reset time
|
||||
profile_id: Optional profile ID that triggered the pause
|
||||
"""
|
||||
phase_value = phase.value if isinstance(phase, ExecutionPhase) else phase
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
@@ -48,6 +62,12 @@ def emit_phase(
|
||||
if subtask is not None:
|
||||
payload["subtask"] = subtask
|
||||
|
||||
if reset_timestamp is not None:
|
||||
payload["reset_timestamp"] = reset_timestamp
|
||||
|
||||
if profile_id is not None:
|
||||
payload["profile_id"] = profile_id
|
||||
|
||||
try:
|
||||
print(f"{PHASE_MARKER_PREFIX}{json.dumps(payload, default=str)}", flush=True)
|
||||
except (OSError, UnicodeEncodeError) as e:
|
||||
|
||||
@@ -325,6 +325,7 @@ def setup_workspace(
|
||||
mode: WorkspaceMode,
|
||||
source_spec_dir: Path | None = None,
|
||||
base_branch: str | None = None,
|
||||
use_local_branch: bool = False,
|
||||
) -> tuple[Path, WorktreeManager | None, Path | None]:
|
||||
"""
|
||||
Set up the workspace based on user's choice.
|
||||
@@ -337,6 +338,7 @@ def setup_workspace(
|
||||
mode: The workspace mode to use
|
||||
source_spec_dir: Optional source spec directory to copy to worktree
|
||||
base_branch: Base branch for worktree creation (default: current branch)
|
||||
use_local_branch: If True, use local branch directly instead of preferring origin/branch
|
||||
|
||||
Returns:
|
||||
Tuple of (working_directory, worktree_manager or None, localized_spec_dir or None)
|
||||
@@ -357,7 +359,9 @@ def setup_workspace(
|
||||
# Ensure timeline tracking hook is installed (once per session)
|
||||
ensure_timeline_hook_installed(project_dir)
|
||||
|
||||
manager = WorktreeManager(project_dir, base_branch=base_branch)
|
||||
manager = WorktreeManager(
|
||||
project_dir, base_branch=base_branch, use_local_branch=use_local_branch
|
||||
)
|
||||
manager.setup()
|
||||
|
||||
# Get or create worktree for THIS SPECIFIC SPEC
|
||||
|
||||
@@ -186,9 +186,15 @@ class WorktreeManager:
|
||||
CLI_TIMEOUT = 60 # 1 minute for CLI commands (gh/glab)
|
||||
CLI_QUERY_TIMEOUT = 30 # 30 seconds for CLI queries (gh/glab)
|
||||
|
||||
def __init__(self, project_dir: Path, base_branch: str | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
project_dir: Path,
|
||||
base_branch: str | None = None,
|
||||
use_local_branch: bool = False,
|
||||
):
|
||||
self.project_dir = project_dir
|
||||
self.base_branch = base_branch or self._detect_base_branch()
|
||||
self.use_local_branch = use_local_branch
|
||||
self.worktrees_dir = project_dir / ".auto-claude" / "worktrees" / "tasks"
|
||||
self._merge_lock = asyncio.Lock()
|
||||
|
||||
@@ -695,18 +701,23 @@ class WorktreeManager:
|
||||
else:
|
||||
# Branch doesn't exist - create new branch from remote or local base
|
||||
# Determine the start point for the worktree
|
||||
remote_ref = f"origin/{self.base_branch}"
|
||||
start_point = self.base_branch # Default to local branch
|
||||
|
||||
# Check if remote ref exists and use it as the source of truth
|
||||
check_remote = self._run_git(["rev-parse", "--verify", remote_ref])
|
||||
if check_remote.returncode == 0:
|
||||
start_point = remote_ref
|
||||
print(f"Creating worktree from remote: {remote_ref}")
|
||||
if self.use_local_branch:
|
||||
# User explicitly requested local branch - skip auto-switch to remote
|
||||
# This preserves gitignored files (.env, configs) that may not exist on remote
|
||||
print(f"Creating worktree from local branch: {self.base_branch}")
|
||||
else:
|
||||
print(
|
||||
f"Remote ref {remote_ref} not found, using local branch: {self.base_branch}"
|
||||
)
|
||||
# Check if remote ref exists and use it as the source of truth
|
||||
remote_ref = f"origin/{self.base_branch}"
|
||||
check_remote = self._run_git(["rev-parse", "--verify", remote_ref])
|
||||
if check_remote.returncode == 0:
|
||||
start_point = remote_ref
|
||||
print(f"Creating worktree from remote: {remote_ref}")
|
||||
else:
|
||||
print(
|
||||
f"Remote ref {remote_ref} not found, using local branch: {self.base_branch}"
|
||||
)
|
||||
|
||||
# Create worktree with new branch from the start point
|
||||
result = self._run_git(
|
||||
|
||||
@@ -91,11 +91,14 @@ class IdeationGenerator:
|
||||
prompt += f"\n{additional_context}\n"
|
||||
|
||||
# Create client with thinking budget
|
||||
# Use agent_type="ideation" to avoid loading unnecessary MCP servers
|
||||
# which can cause 60-second timeout delays
|
||||
client = create_client(
|
||||
self.project_dir,
|
||||
self.output_dir,
|
||||
resolve_model_id(self.model),
|
||||
max_thinking_tokens=self.thinking_budget,
|
||||
agent_type="ideation",
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -184,11 +187,13 @@ Common fixes:
|
||||
Write the fixed JSON to the file now.
|
||||
"""
|
||||
|
||||
# Use agent_type="ideation" for recovery agent as well
|
||||
client = create_client(
|
||||
self.project_dir,
|
||||
self.output_dir,
|
||||
resolve_model_id(self.model),
|
||||
max_thinking_tokens=self.thinking_budget,
|
||||
agent_type="ideation",
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -28,6 +28,7 @@ from .types import IdeationPhaseResult
|
||||
|
||||
# Configuration
|
||||
MAX_RETRIES = 3
|
||||
IDEATION_TIMEOUT_SECONDS = 5 * 60 # 5 minutes max for all ideation types
|
||||
|
||||
|
||||
class IdeationOrchestrator:
|
||||
@@ -173,16 +174,45 @@ class IdeationOrchestrator:
|
||||
"progress",
|
||||
)
|
||||
|
||||
# Create tasks for all enabled types
|
||||
ideation_tasks = [
|
||||
self.output_streamer.stream_ideation_result(
|
||||
ideation_type, self.phase_executor, MAX_RETRIES
|
||||
# Create tasks explicitly so we can cancel them on timeout
|
||||
ideation_task_objs = [
|
||||
asyncio.create_task(
|
||||
self.output_streamer.stream_ideation_result(
|
||||
ideation_type, self.phase_executor, MAX_RETRIES
|
||||
)
|
||||
)
|
||||
for ideation_type in self.enabled_types
|
||||
]
|
||||
|
||||
# Run all ideation types concurrently
|
||||
ideation_results = await asyncio.gather(*ideation_tasks, return_exceptions=True)
|
||||
# Run all ideation types concurrently with timeout protection
|
||||
# 5 minute timeout prevents infinite hangs if one type stalls
|
||||
try:
|
||||
ideation_results = await asyncio.wait_for(
|
||||
asyncio.gather(*ideation_task_objs, return_exceptions=True),
|
||||
timeout=IDEATION_TIMEOUT_SECONDS,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
print_status(
|
||||
"Ideation generation timed out after 5 minutes",
|
||||
"error",
|
||||
)
|
||||
# Cancel all pending tasks to prevent resource leaks
|
||||
for task in ideation_task_objs:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
# Wait for cancellation to complete and preserve results from completed tasks
|
||||
# Tasks that finished before timeout will return their results;
|
||||
# cancelled tasks will return CancelledError
|
||||
results_after_cancel = await asyncio.gather(
|
||||
*ideation_task_objs, return_exceptions=True
|
||||
)
|
||||
# Convert CancelledError to timeout exception, preserve completed results
|
||||
ideation_results = [
|
||||
Exception("Ideation timed out")
|
||||
if isinstance(res, asyncio.CancelledError)
|
||||
else res
|
||||
for res in results_after_cancel
|
||||
]
|
||||
|
||||
# Process results
|
||||
for i, result in enumerate(ideation_results):
|
||||
|
||||
@@ -12,12 +12,8 @@ The refactored code is now organized as:
|
||||
- graphiti/search.py - Semantic search logic
|
||||
- graphiti/schema.py - Graph schema definitions
|
||||
|
||||
This facade ensures existing imports continue to work:
|
||||
from graphiti_memory import GraphitiMemory, is_graphiti_enabled
|
||||
|
||||
New code should prefer importing from the graphiti package:
|
||||
from graphiti import GraphitiMemory
|
||||
from graphiti.schema import GroupIdMode
|
||||
Import from this module:
|
||||
from integrations.graphiti.memory import GraphitiMemory, is_graphiti_enabled, GroupIdMode
|
||||
|
||||
For detailed documentation on the memory system architecture and usage,
|
||||
see graphiti/graphiti.py.
|
||||
|
||||
@@ -62,7 +62,7 @@ async def get_graph_hints(
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
from graphiti_memory import GraphitiMemory, GroupIdMode
|
||||
from integrations.graphiti.memory import GraphitiMemory, GroupIdMode
|
||||
|
||||
# Determine project directory from project_id or use current dir
|
||||
project_dir = Path.cwd()
|
||||
|
||||
@@ -17,7 +17,7 @@ from core.sentry import capture_exception
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from graphiti_memory import GraphitiMemory
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
|
||||
|
||||
def is_graphiti_memory_enabled() -> bool:
|
||||
@@ -60,7 +60,7 @@ async def get_graphiti_memory(
|
||||
return None
|
||||
|
||||
try:
|
||||
from graphiti_memory import GraphitiMemory, GroupIdMode
|
||||
from integrations.graphiti.memory import GraphitiMemory, GroupIdMode
|
||||
|
||||
if project_dir is None:
|
||||
project_dir = spec_dir.parent.parent
|
||||
|
||||
@@ -81,6 +81,31 @@ def get_base_branch_from_metadata(spec_dir: Path) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def get_use_local_branch_from_metadata(spec_dir: Path) -> bool:
|
||||
"""
|
||||
Read useLocalBranch from task_metadata.json if it exists.
|
||||
|
||||
When True, the worktree should be created from the local branch directly
|
||||
instead of preferring origin/branch. This preserves gitignored files
|
||||
(.env, configs) that may not exist on the remote.
|
||||
|
||||
Args:
|
||||
spec_dir: Directory containing the spec files
|
||||
|
||||
Returns:
|
||||
True if useLocalBranch is set in metadata, False otherwise
|
||||
"""
|
||||
metadata_path = spec_dir / "task_metadata.json"
|
||||
if metadata_path.exists():
|
||||
try:
|
||||
with open(metadata_path, encoding="utf-8") as f:
|
||||
metadata = json.load(f)
|
||||
return bool(metadata.get("useLocalBranch", False))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
# Alias for backwards compatibility (internal use)
|
||||
_get_base_branch_from_metadata = get_base_branch_from_metadata
|
||||
|
||||
|
||||
@@ -17,14 +17,19 @@ Key Design:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from claude_agent_sdk import AgentDefinition
|
||||
# Note: AgentDefinition import kept for backwards compatibility but no longer used
|
||||
# The Task tool's custom subagent_type feature is broken in Claude Code CLI
|
||||
# See: https://github.com/anthropics/claude-code/issues/8697
|
||||
from claude_agent_sdk import AgentDefinition # noqa: F401
|
||||
|
||||
try:
|
||||
from ...core.client import create_client
|
||||
@@ -48,6 +53,7 @@ try:
|
||||
AgentAgreement,
|
||||
FindingValidationResponse,
|
||||
ParallelOrchestratorResponse,
|
||||
SpecialistResponse,
|
||||
)
|
||||
from .sdk_utils import process_sdk_stream
|
||||
except (ImportError, ValueError, SystemError):
|
||||
@@ -72,10 +78,56 @@ except (ImportError, ValueError, SystemError):
|
||||
AgentAgreement,
|
||||
FindingValidationResponse,
|
||||
ParallelOrchestratorResponse,
|
||||
SpecialistResponse,
|
||||
)
|
||||
from services.sdk_utils import process_sdk_stream
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Specialist Configuration for Parallel SDK Sessions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpecialistConfig:
|
||||
"""Configuration for a specialist agent in parallel SDK sessions."""
|
||||
|
||||
name: str
|
||||
prompt_file: str
|
||||
tools: list[str]
|
||||
description: str
|
||||
|
||||
|
||||
# Define specialist configurations
|
||||
# Each specialist runs as its own SDK session with its own system prompt and tools
|
||||
SPECIALIST_CONFIGS: list[SpecialistConfig] = [
|
||||
SpecialistConfig(
|
||||
name="security",
|
||||
prompt_file="pr_security_agent.md",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
description="Security vulnerabilities, OWASP Top 10, auth issues, injection, XSS",
|
||||
),
|
||||
SpecialistConfig(
|
||||
name="quality",
|
||||
prompt_file="pr_quality_agent.md",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
description="Code quality, complexity, duplication, error handling, patterns",
|
||||
),
|
||||
SpecialistConfig(
|
||||
name="logic",
|
||||
prompt_file="pr_logic_agent.md",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
description="Logic correctness, edge cases, algorithms, race conditions",
|
||||
),
|
||||
SpecialistConfig(
|
||||
name="codebase-fit",
|
||||
prompt_file="pr_codebase_fit_agent.md",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
description="Naming conventions, ecosystem fit, architectural alignment",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check if debug mode is enabled
|
||||
@@ -335,6 +387,304 @@ class ParallelOrchestratorReviewer:
|
||||
),
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# Parallel SDK Sessions Implementation
|
||||
# =========================================================================
|
||||
# This replaces the broken Task tool subagent approach.
|
||||
# Each specialist runs as its own SDK session in parallel via asyncio.gather()
|
||||
# See: https://github.com/anthropics/claude-code/issues/8697
|
||||
|
||||
def _build_specialist_prompt(
|
||||
self,
|
||||
config: SpecialistConfig,
|
||||
context: PRContext,
|
||||
project_root: Path,
|
||||
) -> str:
|
||||
"""Build the full prompt for a specialist agent.
|
||||
|
||||
Args:
|
||||
config: Specialist configuration
|
||||
context: PR context with files and patches
|
||||
project_root: Working directory for the agent
|
||||
|
||||
Returns:
|
||||
Full system prompt with context injected
|
||||
"""
|
||||
# Load base prompt from file
|
||||
base_prompt = self._load_prompt(config.prompt_file)
|
||||
if not base_prompt:
|
||||
base_prompt = f"You are a {config.name} specialist for PR review."
|
||||
|
||||
# Inject working directory using the existing helper
|
||||
with_working_dir = create_working_dir_injector(project_root)
|
||||
prompt_with_cwd = with_working_dir(
|
||||
base_prompt,
|
||||
f"You are a {config.name} specialist. Find {config.description}.",
|
||||
)
|
||||
|
||||
# Build file list
|
||||
files_list = []
|
||||
for file in context.changed_files:
|
||||
files_list.append(
|
||||
f"- `{file.path}` (+{file.additions}/-{file.deletions}) - {file.status}"
|
||||
)
|
||||
|
||||
# Build diff content (limited to avoid context overflow)
|
||||
patches = []
|
||||
MAX_DIFF_CHARS = 150_000 # Smaller limit per specialist
|
||||
|
||||
for file in context.changed_files:
|
||||
if file.patch:
|
||||
patches.append(f"\n### File: {file.path}\n{file.patch}")
|
||||
|
||||
diff_content = "\n".join(patches)
|
||||
if len(diff_content) > MAX_DIFF_CHARS:
|
||||
diff_content = diff_content[:MAX_DIFF_CHARS] + "\n\n... (diff truncated)"
|
||||
|
||||
# Compose full prompt with PR context
|
||||
pr_context = f"""
|
||||
## PR Context
|
||||
|
||||
**PR #{context.pr_number}**: {context.title}
|
||||
|
||||
**Description:**
|
||||
{context.description or "(No description provided)"}
|
||||
|
||||
### Changed Files ({len(context.changed_files)} files, +{context.total_additions}/-{context.total_deletions})
|
||||
{chr(10).join(files_list)}
|
||||
|
||||
### Diff
|
||||
{diff_content}
|
||||
|
||||
## Your Task
|
||||
|
||||
Analyze this PR for {config.description}.
|
||||
Use the Read, Grep, and Glob tools to explore the codebase as needed.
|
||||
Report findings with specific file paths, line numbers, and code evidence.
|
||||
"""
|
||||
|
||||
return prompt_with_cwd + pr_context
|
||||
|
||||
async def _run_specialist_session(
|
||||
self,
|
||||
config: SpecialistConfig,
|
||||
context: PRContext,
|
||||
project_root: Path,
|
||||
model: str,
|
||||
thinking_budget: int | None,
|
||||
) -> tuple[str, list[PRReviewFinding]]:
|
||||
"""Run a single specialist as its own SDK session.
|
||||
|
||||
Args:
|
||||
config: Specialist configuration
|
||||
context: PR context
|
||||
project_root: Working directory
|
||||
model: Model to use
|
||||
thinking_budget: Max thinking tokens
|
||||
|
||||
Returns:
|
||||
Tuple of (specialist_name, findings)
|
||||
"""
|
||||
safe_print(
|
||||
f"[Specialist:{config.name}] Starting analysis...",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Build the specialist prompt with PR context
|
||||
prompt = self._build_specialist_prompt(config, context, project_root)
|
||||
|
||||
try:
|
||||
# Create SDK client for this specialist
|
||||
# Note: Agent type uses the generic "pr_reviewer" since individual
|
||||
# specialist types aren't registered in AGENT_CONFIGS. The specialist-specific
|
||||
# system prompt handles differentiation.
|
||||
client = create_client(
|
||||
project_dir=project_root,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_reviewer",
|
||||
max_thinking_tokens=thinking_budget,
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": SpecialistResponse.model_json_schema(),
|
||||
},
|
||||
)
|
||||
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
|
||||
# Process SDK stream
|
||||
stream_result = await process_sdk_stream(
|
||||
client=client,
|
||||
context_name=f"Specialist:{config.name}",
|
||||
model=model,
|
||||
system_prompt=prompt,
|
||||
agent_definitions={}, # No subagents for specialists
|
||||
)
|
||||
|
||||
error = stream_result.get("error")
|
||||
if error:
|
||||
logger.error(
|
||||
f"[Specialist:{config.name}] SDK stream failed: {error}"
|
||||
)
|
||||
safe_print(
|
||||
f"[Specialist:{config.name}] Analysis failed: {error}",
|
||||
flush=True,
|
||||
)
|
||||
return (config.name, [])
|
||||
|
||||
# Parse structured output
|
||||
structured_output = stream_result.get("structured_output")
|
||||
findings = self._parse_specialist_output(
|
||||
config.name, structured_output, stream_result.get("result_text", "")
|
||||
)
|
||||
|
||||
safe_print(
|
||||
f"[Specialist:{config.name}] Complete: {len(findings)} findings",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return (config.name, findings)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[Specialist:{config.name}] Session failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
safe_print(
|
||||
f"[Specialist:{config.name}] Error: {e}",
|
||||
flush=True,
|
||||
)
|
||||
return (config.name, [])
|
||||
|
||||
def _parse_specialist_output(
|
||||
self,
|
||||
specialist_name: str,
|
||||
structured_output: dict[str, Any] | None,
|
||||
result_text: str,
|
||||
) -> list[PRReviewFinding]:
|
||||
"""Parse findings from specialist output.
|
||||
|
||||
Args:
|
||||
specialist_name: Name of the specialist
|
||||
structured_output: Structured JSON output if available
|
||||
result_text: Raw text output as fallback
|
||||
|
||||
Returns:
|
||||
List of PRReviewFinding objects
|
||||
"""
|
||||
findings = []
|
||||
|
||||
if structured_output:
|
||||
try:
|
||||
result = SpecialistResponse.model_validate(structured_output)
|
||||
|
||||
for f in result.findings:
|
||||
finding_id = hashlib.md5(
|
||||
f"{f.file}:{f.line}:{f.title}".encode(),
|
||||
usedforsecurity=False,
|
||||
).hexdigest()[:12]
|
||||
|
||||
category = map_category(f.category)
|
||||
|
||||
try:
|
||||
severity = ReviewSeverity(f.severity.lower())
|
||||
except ValueError:
|
||||
severity = ReviewSeverity.MEDIUM
|
||||
|
||||
finding = PRReviewFinding(
|
||||
id=finding_id,
|
||||
file=f.file,
|
||||
line=f.line,
|
||||
end_line=f.end_line,
|
||||
title=f.title,
|
||||
description=f.description,
|
||||
category=category,
|
||||
severity=severity,
|
||||
suggested_fix=f.suggested_fix or "",
|
||||
evidence=f.evidence,
|
||||
source_agents=[specialist_name],
|
||||
is_impact_finding=f.is_impact_finding,
|
||||
)
|
||||
findings.append(finding)
|
||||
|
||||
logger.info(
|
||||
f"[Specialist:{specialist_name}] Parsed {len(findings)} findings from structured output"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[Specialist:{specialist_name}] Failed to parse structured output: {e}"
|
||||
)
|
||||
# Fall through to text parsing
|
||||
|
||||
if not findings and result_text:
|
||||
# Fallback to text parsing
|
||||
findings = self._parse_text_output(result_text)
|
||||
for f in findings:
|
||||
f.source_agents = [specialist_name]
|
||||
|
||||
return findings
|
||||
|
||||
async def _run_parallel_specialists(
|
||||
self,
|
||||
context: PRContext,
|
||||
project_root: Path,
|
||||
model: str,
|
||||
thinking_budget: int | None,
|
||||
) -> tuple[list[PRReviewFinding], list[str]]:
|
||||
"""Run all specialists in parallel and collect findings.
|
||||
|
||||
Args:
|
||||
context: PR context
|
||||
project_root: Working directory
|
||||
model: Model to use
|
||||
thinking_budget: Max thinking tokens
|
||||
|
||||
Returns:
|
||||
Tuple of (all_findings, agents_invoked)
|
||||
"""
|
||||
safe_print(
|
||||
f"[ParallelOrchestrator] Launching {len(SPECIALIST_CONFIGS)} specialists in parallel...",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Create tasks for all specialists
|
||||
tasks = [
|
||||
self._run_specialist_session(
|
||||
config=config,
|
||||
context=context,
|
||||
project_root=project_root,
|
||||
model=model,
|
||||
thinking_budget=thinking_budget,
|
||||
)
|
||||
for config in SPECIALIST_CONFIGS
|
||||
]
|
||||
|
||||
# Run all specialists in parallel
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Collect findings and track which agents ran
|
||||
all_findings: list[PRReviewFinding] = []
|
||||
agents_invoked: list[str] = []
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"[ParallelOrchestrator] Specialist task failed: {result}")
|
||||
continue
|
||||
|
||||
specialist_name, findings = result
|
||||
agents_invoked.append(specialist_name)
|
||||
all_findings.extend(findings)
|
||||
|
||||
safe_print(
|
||||
f"[ParallelOrchestrator] All specialists complete. "
|
||||
f"Total findings: {len(all_findings)}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return (all_findings, agents_invoked)
|
||||
|
||||
def _build_orchestrator_prompt(self, context: PRContext) -> str:
|
||||
"""Build full prompt for orchestrator with PR context."""
|
||||
# Load orchestrator prompt
|
||||
@@ -720,11 +1070,6 @@ The SDK will run invoked agents in parallel automatically.
|
||||
# LLM agents now discover relevant files themselves via Read, Grep, Glob tools
|
||||
# No need to pre-scan the codebase programmatically
|
||||
|
||||
# Build orchestrator prompt AFTER worktree creation and related files rescan
|
||||
prompt = self._build_orchestrator_prompt(context)
|
||||
# Capture agent definitions for debug logging (with worktree path)
|
||||
agent_defs = self._define_specialist_agents(project_root)
|
||||
|
||||
# Use model and thinking level from config (user settings)
|
||||
# Resolve model shorthand via environment variable override if configured
|
||||
model_shorthand = self.config.model or "sonnet"
|
||||
@@ -737,122 +1082,39 @@ The SDK will run invoked agents in parallel automatically.
|
||||
f"thinking_level={thinking_level}, thinking_budget={thinking_budget}"
|
||||
)
|
||||
|
||||
# Create client with subagents defined
|
||||
# SDK handles parallel execution when Claude invokes multiple Task tools
|
||||
client = self._create_sdk_client(project_root, model, thinking_budget)
|
||||
|
||||
self._report_progress(
|
||||
"orchestrating",
|
||||
40,
|
||||
"Orchestrator delegating to specialist agents...",
|
||||
"Running specialist agents in parallel...",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Run orchestrator session using shared SDK stream processor
|
||||
# Retry logic for tool use concurrency errors
|
||||
MAX_RETRIES = 3
|
||||
RETRY_DELAY = 2.0 # seconds between retries
|
||||
# =================================================================
|
||||
# PARALLEL SDK SESSIONS APPROACH
|
||||
# =================================================================
|
||||
# Instead of using broken Task tool subagents, we spawn each
|
||||
# specialist as its own SDK session and run them in parallel.
|
||||
# See: https://github.com/anthropics/claude-code/issues/8697
|
||||
#
|
||||
# This gives us:
|
||||
# - True parallel execution via asyncio.gather()
|
||||
# - Full control over each specialist's tools and prompts
|
||||
# - No dependency on broken CLI features
|
||||
# =================================================================
|
||||
|
||||
result_text = ""
|
||||
structured_output = None
|
||||
agents_invoked = []
|
||||
msg_count = 0
|
||||
last_error = None
|
||||
# Run all specialists in parallel
|
||||
findings, agents_invoked = await self._run_parallel_specialists(
|
||||
context=context,
|
||||
project_root=project_root,
|
||||
model=model,
|
||||
thinking_budget=thinking_budget,
|
||||
)
|
||||
|
||||
for attempt in range(MAX_RETRIES):
|
||||
if attempt > 0:
|
||||
logger.info(
|
||||
f"[ParallelOrchestrator] Retry attempt {attempt}/{MAX_RETRIES - 1} "
|
||||
f"after tool concurrency error"
|
||||
)
|
||||
safe_print(
|
||||
f"[ParallelOrchestrator] Retry {attempt}/{MAX_RETRIES - 1} "
|
||||
f"(tool concurrency error detected)"
|
||||
)
|
||||
# Small delay before retry
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(RETRY_DELAY)
|
||||
|
||||
# Recreate client for retry (fresh session)
|
||||
client = self._create_sdk_client(
|
||||
project_root, model, thinking_budget
|
||||
)
|
||||
|
||||
try:
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
|
||||
safe_print(
|
||||
f"[ParallelOrchestrator] Running orchestrator ({model})...",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Process SDK stream with shared utility
|
||||
stream_result = await process_sdk_stream(
|
||||
client=client,
|
||||
context_name="ParallelOrchestrator",
|
||||
model=model,
|
||||
system_prompt=prompt,
|
||||
agent_definitions=agent_defs,
|
||||
)
|
||||
|
||||
error = stream_result.get("error")
|
||||
|
||||
# Check for tool concurrency error specifically
|
||||
if (
|
||||
error == "tool_use_concurrency_error"
|
||||
and attempt < MAX_RETRIES - 1
|
||||
):
|
||||
logger.warning(
|
||||
f"[ParallelOrchestrator] Tool concurrency error on attempt {attempt + 1}, "
|
||||
f"will retry..."
|
||||
)
|
||||
last_error = error
|
||||
continue # Retry
|
||||
|
||||
# Check for other stream processing errors
|
||||
if error:
|
||||
logger.error(
|
||||
f"[ParallelOrchestrator] SDK stream failed: {error}"
|
||||
)
|
||||
raise RuntimeError(f"SDK stream processing failed: {error}")
|
||||
|
||||
# Success - extract results and break retry loop
|
||||
result_text = stream_result["result_text"]
|
||||
structured_output = stream_result["structured_output"]
|
||||
agents_invoked = stream_result["agents_invoked"]
|
||||
msg_count = stream_result["msg_count"]
|
||||
break # Success, exit retry loop
|
||||
|
||||
except Exception as e:
|
||||
# Check if this is a retryable error
|
||||
error_str = str(e).lower()
|
||||
is_retryable = (
|
||||
"400" in error_str
|
||||
or "concurrency" in error_str
|
||||
or "tool_use" in error_str
|
||||
)
|
||||
|
||||
if is_retryable and attempt < MAX_RETRIES - 1:
|
||||
logger.warning(
|
||||
f"[ParallelOrchestrator] Retryable error on attempt {attempt + 1}: {e}"
|
||||
)
|
||||
last_error = str(e)
|
||||
continue # Retry
|
||||
|
||||
# Not retryable or out of retries - re-raise
|
||||
raise
|
||||
else:
|
||||
# All retries exhausted
|
||||
logger.error(
|
||||
f"[ParallelOrchestrator] Failed after {MAX_RETRIES} attempts. "
|
||||
f"Last error: {last_error}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Orchestrator failed after {MAX_RETRIES} retry attempts. "
|
||||
f"Last error: {last_error}"
|
||||
)
|
||||
# Log results
|
||||
logger.info(
|
||||
f"[ParallelOrchestrator] Parallel specialists complete: "
|
||||
f"{len(findings)} findings from {len(agents_invoked)} agents"
|
||||
)
|
||||
|
||||
self._report_progress(
|
||||
"finalizing",
|
||||
@@ -861,20 +1123,9 @@ The SDK will run invoked agents in parallel automatically.
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Parse findings from output (structured output also returns agents)
|
||||
findings, agents_from_structured = self._extract_structured_output(
|
||||
structured_output, result_text
|
||||
)
|
||||
|
||||
# Use agents from structured output (more reliable than streaming detection)
|
||||
final_agents = (
|
||||
agents_from_structured if agents_from_structured else agents_invoked
|
||||
)
|
||||
logger.info(
|
||||
f"[ParallelOrchestrator] Session complete. Agents invoked: {final_agents}"
|
||||
)
|
||||
# Log completion with agent info
|
||||
safe_print(
|
||||
f"[ParallelOrchestrator] Complete. Agents invoked: {final_agents}",
|
||||
f"[ParallelOrchestrator] Complete. Agents invoked: {agents_invoked}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
@@ -977,7 +1228,7 @@ The SDK will run invoked agents in parallel automatically.
|
||||
verdict_reasoning=verdict_reasoning,
|
||||
blockers=blockers,
|
||||
findings=unique_findings,
|
||||
agents_invoked=final_agents,
|
||||
agents_invoked=agents_invoked,
|
||||
)
|
||||
|
||||
# Map verdict to overall_status
|
||||
|
||||
@@ -537,6 +537,51 @@ class ValidationSummary(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class SpecialistFinding(BaseModel):
|
||||
"""A finding from a specialist agent (used in parallel SDK sessions)."""
|
||||
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
category: Literal[
|
||||
"security", "quality", "logic", "performance", "pattern", "test", "docs"
|
||||
] = Field(description="Issue category")
|
||||
title: str = Field(description="Brief issue title (max 80 chars)")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
file: str = Field(description="File path where issue was found")
|
||||
line: int = Field(0, description="Line number of the issue")
|
||||
end_line: int | None = Field(None, description="End line number if multi-line")
|
||||
suggested_fix: str | None = Field(None, description="How to fix this issue")
|
||||
evidence: str = Field(
|
||||
min_length=1,
|
||||
description="Actual code snippet examined that shows the issue. Required.",
|
||||
)
|
||||
is_impact_finding: bool = Field(
|
||||
False,
|
||||
description="True if this is about affected code outside the PR (callers, dependencies)",
|
||||
)
|
||||
|
||||
|
||||
class SpecialistResponse(BaseModel):
|
||||
"""Response schema for individual specialist agent (parallel SDK sessions).
|
||||
|
||||
Used when each specialist runs as its own SDK session rather than via Task tool.
|
||||
"""
|
||||
|
||||
specialist_name: str = Field(
|
||||
description="Name of the specialist (security, quality, logic, codebase-fit)"
|
||||
)
|
||||
analysis_summary: str = Field(description="Brief summary of what was analyzed")
|
||||
files_examined: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of files that were examined",
|
||||
)
|
||||
findings: list[SpecialistFinding] = Field(
|
||||
default_factory=list,
|
||||
description="Issues found during analysis",
|
||||
)
|
||||
|
||||
|
||||
class ParallelOrchestratorResponse(BaseModel):
|
||||
"""Complete response schema for parallel orchestrator PR review."""
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { resolve } from 'path';
|
||||
import { config as dotenvConfig } from 'dotenv';
|
||||
|
||||
// Load .env file for build-time constants (Sentry DSN, etc.)
|
||||
dotenvConfig({ path: resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* Sentry configuration embedded at build time.
|
||||
@@ -43,7 +47,9 @@ export default defineConfig({
|
||||
'debug',
|
||||
'ms',
|
||||
// Minimatch for glob pattern matching in worktree handlers
|
||||
'minimatch'
|
||||
'minimatch',
|
||||
// XState for task state machine
|
||||
'xstate'
|
||||
]
|
||||
})],
|
||||
build: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "auto-claude-ui",
|
||||
"version": "2.7.6-beta.1",
|
||||
"version": "2.7.6-beta.2",
|
||||
"type": "module",
|
||||
"description": "Desktop UI for Auto Claude autonomous coding framework",
|
||||
"homepage": "https://github.com/AndyMik90/Auto-Claude",
|
||||
@@ -96,6 +96,8 @@
|
||||
"react-i18next": "^16.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.2.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"semver": "^7.7.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdirSync, rmSync, existsSync, mkdtempSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import type { ClaudeProfile, IPCResult, TerminalCreateOptions } from '../../shared/types';
|
||||
import type { ClaudeProfile, IPCResult, } from '../../shared/types';
|
||||
|
||||
// Test directories - use secure temp directory with random suffix
|
||||
let TEST_DIR: string;
|
||||
|
||||
@@ -297,7 +297,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
// Simulate stdout data (must include newline for buffered output processing)
|
||||
mockStdout.emit('data', Buffer.from('Test log output\n'));
|
||||
|
||||
expect(logHandler).toHaveBeenCalledWith('task-1', 'Test log output\n');
|
||||
expect(logHandler).toHaveBeenCalledWith('task-1', 'Test log output\n', undefined);
|
||||
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
|
||||
|
||||
it('should emit log events from stderr', async () => {
|
||||
@@ -313,7 +313,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
// Simulate stderr data (must include newline for buffered output processing)
|
||||
mockStderr.emit('data', Buffer.from('Progress: 50%\n'));
|
||||
|
||||
expect(logHandler).toHaveBeenCalledWith('task-1', 'Progress: 50%\n');
|
||||
expect(logHandler).toHaveBeenCalledWith('task-1', 'Progress: 50%\n', undefined);
|
||||
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
|
||||
|
||||
it('should emit exit event when process exits', async () => {
|
||||
@@ -329,8 +329,8 @@ describe('Subprocess Spawn Integration', () => {
|
||||
// Simulate process exit
|
||||
mockProcess.emit('exit', 0);
|
||||
|
||||
// Exit event includes taskId, exit code, and process type
|
||||
expect(exitHandler).toHaveBeenCalledWith('task-1', 0, expect.any(String));
|
||||
// Exit event includes taskId, exit code, process type, and optional projectId
|
||||
expect(exitHandler).toHaveBeenCalledWith('task-1', 0, expect.any(String), undefined);
|
||||
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
|
||||
|
||||
it('should emit error event when process errors', async () => {
|
||||
@@ -346,7 +346,7 @@ describe('Subprocess Spawn Integration', () => {
|
||||
// Simulate process error
|
||||
mockProcess.emit('error', new Error('Spawn failed'));
|
||||
|
||||
expect(errorHandler).toHaveBeenCalledWith('task-1', 'Spawn failed');
|
||||
expect(errorHandler).toHaveBeenCalledWith('task-1', 'Spawn failed', undefined);
|
||||
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
|
||||
|
||||
it('should kill task and remove from tracking', async () => {
|
||||
|
||||
@@ -69,7 +69,7 @@ vi.mock('child_process', () => {
|
||||
// so when tests call vi.mocked(execFileSync).mockReturnValue(), it affects execSync too
|
||||
const sharedSyncMock = vi.fn();
|
||||
|
||||
const mockExecFile = vi.fn((cmd: unknown, args: unknown, options: unknown, callback: unknown) => {
|
||||
const mockExecFile = vi.fn((_cmd: unknown, _args: unknown, _options: unknown, callback: unknown) => {
|
||||
// Return a minimal ChildProcess-like object
|
||||
const childProcess = {
|
||||
stdout: { on: vi.fn() },
|
||||
@@ -86,7 +86,7 @@ const mockExecFile = vi.fn((cmd: unknown, args: unknown, options: unknown, callb
|
||||
return childProcess as unknown as import('child_process').ChildProcess;
|
||||
});
|
||||
|
||||
const mockExec = vi.fn((cmd: unknown, options: unknown, callback: unknown) => {
|
||||
const mockExec = vi.fn((_cmd: unknown, _options: unknown, callback: unknown) => {
|
||||
// Return a minimal ChildProcess-like object
|
||||
const childProcess = {
|
||||
stdout: { on: vi.fn() },
|
||||
|
||||
@@ -61,15 +61,17 @@ import path from 'path';
|
||||
import { isValidConfigDir } from '../utils/config-path-validator';
|
||||
|
||||
describe('isValidConfigDir - Security Validation', () => {
|
||||
let originalHomedir: string;
|
||||
let consoleWarnSpy: any;
|
||||
let _originalHomedir: string;
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Store original homedir for restoration
|
||||
originalHomedir = os.homedir();
|
||||
_originalHomedir = os.homedir();
|
||||
|
||||
// Spy on console.warn to suppress warning output during tests
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {
|
||||
/* intentionally empty - suppress console output during tests */
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -564,7 +564,7 @@ describe("IPC Handlers", { timeout: 30000 }, () => {
|
||||
|
||||
expect(result).toHaveProperty("success", true);
|
||||
const data = (result as { data: { theme: string } }).data;
|
||||
expect(data).toHaveProperty("theme", "system");
|
||||
expect(data).toHaveProperty("theme", "dark");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('PythonEnvManager', () => {
|
||||
const sitePackagesPath = 'C:\\test\\site-packages';
|
||||
|
||||
// Access private property for testing
|
||||
(manager as any).sitePackagesPath = sitePackagesPath;
|
||||
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
|
||||
|
||||
const env = manager.getPythonEnv();
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('PythonEnvManager', () => {
|
||||
const sitePackagesPath = 'C:\\test\\site-packages';
|
||||
|
||||
// Access private property for testing
|
||||
(manager as any).sitePackagesPath = sitePackagesPath;
|
||||
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
|
||||
|
||||
const env = manager.getPythonEnv();
|
||||
|
||||
@@ -125,7 +125,7 @@ describe('PythonEnvManager', () => {
|
||||
const sitePackagesPath = '/test/site-packages';
|
||||
|
||||
// Access private property for testing
|
||||
(manager as any).sitePackagesPath = sitePackagesPath;
|
||||
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
|
||||
|
||||
const env = manager.getPythonEnv();
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('PythonEnvManager', () => {
|
||||
const sitePackagesPath = 'C:\\test\\site-packages';
|
||||
|
||||
// Access private property for testing
|
||||
(manager as any).sitePackagesPath = sitePackagesPath;
|
||||
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
|
||||
|
||||
// Save and clear existing PATH, then set lowercase 'Path'
|
||||
// This simulates a Windows environment where the system has 'Path' instead of 'PATH'
|
||||
|
||||
@@ -507,7 +507,7 @@ describe('Rate Limit Edge Cases', () => {
|
||||
];
|
||||
|
||||
for (const msg of falsePositives) {
|
||||
const result = detectRateLimit(msg);
|
||||
const _result = detectRateLimit(msg);
|
||||
// Note: Some may still match secondary indicators - that's intentional
|
||||
// The primary pattern should NOT match these
|
||||
const primaryPattern = /Limit reached\s*[·•]\s*resets/i;
|
||||
|
||||
@@ -243,24 +243,31 @@ describe('SETTINGS_CLAUDE_CODE_GET_ONBOARDING_STATUS handler', () => {
|
||||
// Get the mocked functions (vitest stores the implementation)
|
||||
const { existsSync, readFileSync } = await import('fs');
|
||||
|
||||
// Cast to vi.Mock for accessing mock methods
|
||||
type MockFn = ReturnType<typeof vi.fn>;
|
||||
const existsSyncMock = existsSync as unknown as MockFn;
|
||||
const readFileSyncMock = readFileSync as unknown as MockFn;
|
||||
|
||||
// Save original mock implementations to restore after test
|
||||
const originalExistsSync = (existsSync as any).getMockImplementation();
|
||||
const originalReadFileSync = (readFileSync as any).getMockImplementation();
|
||||
type ExistsSyncFn = (path: string) => boolean;
|
||||
type ReadFileSyncFn = (path: string) => string;
|
||||
const originalExistsSync: ExistsSyncFn = (existsSyncMock.getMockImplementation() as ExistsSyncFn | undefined) ?? (() => false);
|
||||
const originalReadFileSync: ReadFileSyncFn = (readFileSyncMock.getMockImplementation() as ReadFileSyncFn | undefined) ?? (() => '');
|
||||
|
||||
// Override existsSync to make file appear to exist
|
||||
(existsSync as any).mockImplementation((path: string) => {
|
||||
existsSyncMock.mockImplementation((path: string) => {
|
||||
if (path === claudeJsonPath) {
|
||||
return true; // File appears to exist
|
||||
}
|
||||
return originalExistsSync ? originalExistsSync(path) : false;
|
||||
return originalExistsSync(path);
|
||||
});
|
||||
|
||||
// Override readFileSync to throw error for our specific file
|
||||
(readFileSync as any).mockImplementation((path: string) => {
|
||||
readFileSyncMock.mockImplementation((path: string) => {
|
||||
if (path === claudeJsonPath) {
|
||||
throw new Error('EACCES: permission denied, open \'' + path + '\'');
|
||||
}
|
||||
return originalReadFileSync ? originalReadFileSync(path) : '';
|
||||
return originalReadFileSync(path);
|
||||
});
|
||||
|
||||
const result = await onboardingStatusHandler({}, null) as {
|
||||
@@ -272,8 +279,8 @@ describe('SETTINGS_CLAUDE_CODE_GET_ONBOARDING_STATUS handler', () => {
|
||||
expect(result.data?.hasCompletedOnboarding).toBe(false);
|
||||
|
||||
// Restore original mocks
|
||||
(existsSync as any).mockImplementation(originalExistsSync);
|
||||
(readFileSync as any).mockImplementation(originalReadFileSync);
|
||||
existsSyncMock.mockImplementation(originalExistsSync);
|
||||
readFileSyncMock.mockImplementation(originalReadFileSync);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -390,6 +390,83 @@ describe('TaskStateManager', () => {
|
||||
// Should have sent PROCESS_EXITED event with unexpected=true
|
||||
// This should transition to error state
|
||||
});
|
||||
|
||||
it('should NOT mark exit code 0 as unexpected (plan_review stays intact)', () => {
|
||||
// Simulate: PLANNING_STARTED → PLANNING_COMPLETE (requireReview) → process exits code 0
|
||||
const planningStarted = {
|
||||
type: 'PLANNING_STARTED',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-1',
|
||||
sequence: 0
|
||||
};
|
||||
|
||||
const planningComplete = {
|
||||
type: 'PLANNING_COMPLETE',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-2',
|
||||
sequence: 1,
|
||||
hasSubtasks: false,
|
||||
subtaskCount: 0,
|
||||
requireReviewBeforeCoding: true
|
||||
};
|
||||
|
||||
manager.handleTaskEvent(mockTask.id, planningStarted, mockTask, mockProject);
|
||||
manager.handleTaskEvent(mockTask.id, planningComplete, mockTask, mockProject);
|
||||
|
||||
// XState should be in plan_review now
|
||||
expect(manager.getCurrentState(mockTask.id)).toBe('plan_review');
|
||||
|
||||
// Process exits with code 0 - should NOT transition to error
|
||||
manager.handleProcessExited(mockTask.id, 0, mockTask, mockProject);
|
||||
|
||||
// PLANNING_COMPLETE is a terminal event, so handleProcessExited should skip entirely
|
||||
// Task should remain in plan_review
|
||||
expect(manager.getCurrentState(mockTask.id)).toBe('plan_review');
|
||||
});
|
||||
|
||||
it('should treat PLANNING_COMPLETE as a terminal event', () => {
|
||||
// PLANNING_COMPLETE should prevent handleProcessExited from running
|
||||
const planningStarted = {
|
||||
type: 'PLANNING_STARTED',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-1',
|
||||
sequence: 0
|
||||
};
|
||||
|
||||
const planningComplete = {
|
||||
type: 'PLANNING_COMPLETE',
|
||||
taskId: mockTask.id,
|
||||
specId: mockTask.specId,
|
||||
projectId: mockProject.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventId: 'evt-2',
|
||||
sequence: 1,
|
||||
hasSubtasks: true,
|
||||
subtaskCount: 3,
|
||||
requireReviewBeforeCoding: false
|
||||
};
|
||||
|
||||
manager.handleTaskEvent(mockTask.id, planningStarted, mockTask, mockProject);
|
||||
manager.handleTaskEvent(mockTask.id, planningComplete, mockTask, mockProject);
|
||||
|
||||
// XState should be in coding (no review required)
|
||||
expect(manager.getCurrentState(mockTask.id)).toBe('coding');
|
||||
|
||||
// Process exits with code 1 - should still skip because PLANNING_COMPLETE is terminal
|
||||
manager.handleProcessExited(mockTask.id, 1, mockTask, mockProject);
|
||||
|
||||
// Task should remain in coding, NOT transition to error
|
||||
expect(manager.getCurrentState(mockTask.id)).toBe('coding');
|
||||
});
|
||||
});
|
||||
|
||||
describe('actor state restoration', () => {
|
||||
|
||||
@@ -178,7 +178,9 @@ describe("safeSendToRenderer", () => {
|
||||
|
||||
describe("error handling - non-disposal errors", () => {
|
||||
it("catches other errors and returns false", () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
|
||||
/* intentionally empty - suppress console output during tests */
|
||||
});
|
||||
|
||||
mockSend.mockImplementation(() => {
|
||||
throw new Error("Some other IPC error");
|
||||
@@ -216,7 +218,9 @@ describe("safeSendToRenderer", () => {
|
||||
});
|
||||
|
||||
it("logs console.warn only once for multiple consecutive calls to same channel", () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||
/* intentionally empty - suppress console output during tests */
|
||||
});
|
||||
|
||||
mockWindow = {
|
||||
isDestroyed: vi.fn(() => true),
|
||||
@@ -242,7 +246,9 @@ describe("safeSendToRenderer", () => {
|
||||
});
|
||||
|
||||
it("logs console.warn separately for different channels", () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||
/* intentionally empty - suppress console output during tests */
|
||||
});
|
||||
|
||||
mockWindow = {
|
||||
isDestroyed: vi.fn(() => true),
|
||||
@@ -307,7 +313,9 @@ describe("safeSendToRenderer", () => {
|
||||
|
||||
describe("warning pruning logic - 100-entry hard cap", () => {
|
||||
it("enforces 100-entry cap by removing oldest entries when exceeded", async () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||
/* intentionally empty - suppress console output during tests */
|
||||
});
|
||||
|
||||
mockWindow = {
|
||||
isDestroyed: vi.fn(() => true),
|
||||
@@ -340,7 +348,9 @@ describe("safeSendToRenderer", () => {
|
||||
});
|
||||
|
||||
it("handles many unique channels without throwing errors", async () => {
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||
/* intentionally empty - suppress console output during tests */
|
||||
});
|
||||
|
||||
mockWindow = {
|
||||
isDestroyed: vi.fn(() => true),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { parsePhaseEvent } from './phase-event-parser';
|
||||
import {
|
||||
wouldPhaseRegress,
|
||||
isTerminalPhase,
|
||||
isPausePhase,
|
||||
isValidExecutionPhase,
|
||||
type ExecutionPhase
|
||||
} from '../../shared/constants/phase-protocol';
|
||||
@@ -13,18 +14,48 @@ export class AgentEvents {
|
||||
log: string,
|
||||
currentPhase: ExecutionProgressData['phase'],
|
||||
isSpecRunner: boolean
|
||||
): { phase: ExecutionProgressData['phase']; message?: string; currentSubtask?: string } | null {
|
||||
): {
|
||||
phase: ExecutionProgressData['phase'];
|
||||
message?: string;
|
||||
currentSubtask?: string;
|
||||
resetTimestamp?: number;
|
||||
profileId?: string;
|
||||
} | null {
|
||||
const structuredEvent = parsePhaseEvent(log);
|
||||
if (structuredEvent) {
|
||||
return {
|
||||
phase: structuredEvent.phase as ExecutionProgressData['phase'],
|
||||
// structuredEvent.phase is validated as BackendPhase (via Zod schema),
|
||||
// which is a subset of ExecutionPhase, so this assertion is safe
|
||||
const result: {
|
||||
phase: ExecutionProgressData['phase'];
|
||||
message?: string;
|
||||
currentSubtask?: string;
|
||||
resetTimestamp?: number;
|
||||
profileId?: string;
|
||||
} = {
|
||||
phase: structuredEvent.phase as ExecutionPhase,
|
||||
message: structuredEvent.message,
|
||||
currentSubtask: structuredEvent.subtask
|
||||
};
|
||||
|
||||
// Include pause phase metadata if present
|
||||
if (structuredEvent.reset_timestamp !== undefined) {
|
||||
result.resetTimestamp = structuredEvent.reset_timestamp;
|
||||
}
|
||||
if (structuredEvent.profile_id !== undefined) {
|
||||
result.profileId = structuredEvent.profile_id;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Terminal states can't be changed by fallback matching
|
||||
if (isTerminalPhase(currentPhase as ExecutionPhase)) {
|
||||
if (isTerminalPhase(currentPhase)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pause phases should only be changed by structured events
|
||||
// Don't allow fallback text matching to transition out of pause phases
|
||||
if (isPausePhase(currentPhase)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { AgentEvents } from './agent-events';
|
||||
import { AgentProcessManager } from './agent-process';
|
||||
import { AgentQueueManager } from './agent-queue';
|
||||
import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager';
|
||||
import type { ClaudeProfileManager } from '../claude-profile-manager';
|
||||
import { getOperationRegistry } from '../claude-profile/operation-registry';
|
||||
import {
|
||||
SpecCreationMetadata,
|
||||
TaskExecutionOptions,
|
||||
@@ -32,6 +34,9 @@ export class AgentManager extends EventEmitter {
|
||||
metadata?: SpecCreationMetadata;
|
||||
baseBranch?: string;
|
||||
swapCount: number;
|
||||
projectId?: string;
|
||||
/** Generation counter to prevent stale cleanup after restart */
|
||||
generation: number;
|
||||
}> = new Map();
|
||||
|
||||
constructor() {
|
||||
@@ -51,26 +56,40 @@ export class AgentManager extends EventEmitter {
|
||||
});
|
||||
|
||||
// Listen for task completion to clean up context (prevent memory leak)
|
||||
this.on('exit', (taskId: string, code: number | null) => {
|
||||
this.on('exit', (taskId: string, code: number | null, _processType?: string, _projectId?: string) => {
|
||||
// Clean up context when:
|
||||
// 1. Task completed successfully (code === 0), or
|
||||
// 2. Task failed and won't be restarted (handled by auto-swap logic)
|
||||
|
||||
// Capture generation at exit time to prevent race conditions with restarts
|
||||
const contextAtExit = this.taskExecutionContext.get(taskId);
|
||||
const generationAtExit = contextAtExit?.generation;
|
||||
|
||||
// Note: Auto-swap restart happens BEFORE this exit event is processed,
|
||||
// so we need a small delay to allow restart to preserve context
|
||||
setTimeout(() => {
|
||||
const context = this.taskExecutionContext.get(taskId);
|
||||
if (!context) return; // Already cleaned up or restarted
|
||||
|
||||
// Check if the context's generation matches - if not, a restart incremented it
|
||||
// and this cleanup is for a stale exit event that shouldn't affect the new task
|
||||
if (generationAtExit !== undefined && context.generation !== generationAtExit) {
|
||||
return; // Stale exit event - task was restarted, don't clean up new context
|
||||
}
|
||||
|
||||
// If task completed successfully, always clean up
|
||||
if (code === 0) {
|
||||
this.taskExecutionContext.delete(taskId);
|
||||
// Unregister from OperationRegistry
|
||||
getOperationRegistry().unregisterOperation(taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
// If task failed and hit max retries, clean up
|
||||
if (context.swapCount >= 2) {
|
||||
this.taskExecutionContext.delete(taskId);
|
||||
// Unregister from OperationRegistry
|
||||
getOperationRegistry().unregisterOperation(taskId);
|
||||
}
|
||||
// Otherwise keep context for potential restart
|
||||
}, 1000); // Delay to allow restart logic to run first
|
||||
@@ -84,6 +103,46 @@ export class AgentManager extends EventEmitter {
|
||||
this.processManager.configure(pythonPath, autoBuildSourcePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a task with the unified OperationRegistry for proactive swap support.
|
||||
* Extracted helper to avoid code duplication between spec creation and task execution.
|
||||
* @private
|
||||
*/
|
||||
private registerTaskWithOperationRegistry(
|
||||
taskId: string,
|
||||
operationType: 'spec-creation' | 'task-execution',
|
||||
metadata: Record<string, unknown>
|
||||
): void {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
if (!activeProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep internal state tracking for backward compatibility
|
||||
this.assignProfileToTask(taskId, activeProfile.id, activeProfile.name, 'proactive');
|
||||
|
||||
// Register with unified registry for proactive swap
|
||||
// Note: We don't provide a stopFn because restartTask() already handles stopping
|
||||
// the task internally via killTask() before restarting. Providing a separate
|
||||
// stopFn would cause a redundant double-kill during profile swaps.
|
||||
const operationRegistry = getOperationRegistry();
|
||||
operationRegistry.registerOperation(
|
||||
taskId,
|
||||
operationType,
|
||||
activeProfile.id,
|
||||
activeProfile.name,
|
||||
(newProfileId: string) => this.restartTask(taskId, newProfileId),
|
||||
{ metadata }
|
||||
);
|
||||
console.log('[AgentManager] Task registered with OperationRegistry:', {
|
||||
taskId,
|
||||
profileId: activeProfile.id,
|
||||
profileName: activeProfile.name,
|
||||
type: operationType
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start spec creation process
|
||||
*/
|
||||
@@ -93,11 +152,12 @@ export class AgentManager extends EventEmitter {
|
||||
taskDescription: string,
|
||||
specDir?: string,
|
||||
metadata?: SpecCreationMetadata,
|
||||
baseBranch?: string
|
||||
baseBranch?: string,
|
||||
projectId?: string
|
||||
): Promise<void> {
|
||||
// Pre-flight auth check: Verify active profile has valid authentication
|
||||
// Ensure profile manager is initialized to prevent race condition
|
||||
let profileManager;
|
||||
let profileManager: ClaudeProfileManager;
|
||||
try {
|
||||
profileManager = await initializeClaudeProfileManager();
|
||||
} catch (error) {
|
||||
@@ -173,10 +233,13 @@ export class AgentManager extends EventEmitter {
|
||||
}
|
||||
|
||||
// Store context for potential restart
|
||||
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch);
|
||||
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch, projectId);
|
||||
|
||||
// Register with unified OperationRegistry for proactive swap support
|
||||
this.registerTaskWithOperationRegistry(taskId, 'spec-creation', { projectPath, taskDescription, specDir });
|
||||
|
||||
// Note: This is spec-creation but it chains to task-execution via run.py
|
||||
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
|
||||
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,11 +249,12 @@ export class AgentManager extends EventEmitter {
|
||||
taskId: string,
|
||||
projectPath: string,
|
||||
specId: string,
|
||||
options: TaskExecutionOptions = {}
|
||||
options: TaskExecutionOptions = {},
|
||||
projectId?: string
|
||||
): Promise<void> {
|
||||
// Pre-flight auth check: Verify active profile has valid authentication
|
||||
// Ensure profile manager is initialized to prevent race condition
|
||||
let profileManager;
|
||||
let profileManager: ClaudeProfileManager;
|
||||
try {
|
||||
profileManager = await initializeClaudeProfileManager();
|
||||
} catch (error) {
|
||||
@@ -251,9 +315,12 @@ export class AgentManager extends EventEmitter {
|
||||
// which allows per-phase configuration for planner, coder, and QA phases
|
||||
|
||||
// Store context for potential restart
|
||||
this.storeTaskContext(taskId, projectPath, specId, options, false);
|
||||
this.storeTaskContext(taskId, projectPath, specId, options, false, undefined, undefined, undefined, undefined, projectId);
|
||||
|
||||
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -262,7 +329,8 @@ export class AgentManager extends EventEmitter {
|
||||
async startQAProcess(
|
||||
taskId: string,
|
||||
projectPath: string,
|
||||
specId: string
|
||||
specId: string,
|
||||
projectId?: string
|
||||
): Promise<void> {
|
||||
// Ensure Python environment is ready before spawning process (prevents exit code 127 race condition)
|
||||
const pythonStatus = await this.processManager.ensurePythonEnvReady('AgentManager');
|
||||
@@ -290,7 +358,7 @@ export class AgentManager extends EventEmitter {
|
||||
|
||||
const args = [runPath, '--spec', specId, '--project-dir', projectPath, '--qa'];
|
||||
|
||||
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process');
|
||||
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process', projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -387,11 +455,14 @@ export class AgentManager extends EventEmitter {
|
||||
taskDescription?: string,
|
||||
specDir?: string,
|
||||
metadata?: SpecCreationMetadata,
|
||||
baseBranch?: string
|
||||
baseBranch?: string,
|
||||
projectId?: string
|
||||
): void {
|
||||
// Preserve swapCount if context already exists (for restarts)
|
||||
const existingContext = this.taskExecutionContext.get(taskId);
|
||||
const swapCount = existingContext?.swapCount ?? 0;
|
||||
// Increment generation on each store (restarts) to invalidate pending cleanup callbacks
|
||||
const generation = (existingContext?.generation ?? 0) + 1;
|
||||
|
||||
this.taskExecutionContext.set(taskId, {
|
||||
projectPath,
|
||||
@@ -402,7 +473,9 @@ export class AgentManager extends EventEmitter {
|
||||
specDir,
|
||||
metadata,
|
||||
baseBranch,
|
||||
swapCount // Preserve existing count instead of resetting
|
||||
swapCount, // Preserve existing count instead of resetting
|
||||
projectId,
|
||||
generation, // Incremented to prevent stale exit cleanup
|
||||
});
|
||||
}
|
||||
|
||||
@@ -458,13 +531,18 @@ export class AgentManager extends EventEmitter {
|
||||
console.log('[AgentManager] Restarting task now:', taskId);
|
||||
if (context.isSpecCreation) {
|
||||
console.log('[AgentManager] Restarting as spec creation');
|
||||
if (!context.taskDescription) {
|
||||
console.error('[AgentManager] Cannot restart spec creation: taskDescription is missing');
|
||||
return;
|
||||
}
|
||||
this.startSpecCreation(
|
||||
taskId,
|
||||
context.projectPath,
|
||||
context.taskDescription!,
|
||||
context.taskDescription,
|
||||
context.specDir,
|
||||
context.metadata,
|
||||
context.baseBranch
|
||||
context.baseBranch,
|
||||
context.projectId
|
||||
);
|
||||
} else {
|
||||
console.log('[AgentManager] Restarting as task execution');
|
||||
@@ -472,7 +550,8 @@ export class AgentManager extends EventEmitter {
|
||||
taskId,
|
||||
context.projectPath,
|
||||
context.specId,
|
||||
context.options
|
||||
context.options,
|
||||
context.projectId
|
||||
);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
@@ -13,7 +13,7 @@ function createMockProcess() {
|
||||
return {
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
on: vi.fn((event: string, callback: any) => {
|
||||
on: vi.fn((event: string, callback: (code: number) => void) => {
|
||||
if (event === 'exit') {
|
||||
// Simulate immediate exit with code 0
|
||||
setTimeout(() => callback(0), 10);
|
||||
|
||||
@@ -281,7 +281,11 @@ export class AgentProcessManager {
|
||||
} : 'NONE');
|
||||
|
||||
if (!bestProfile) {
|
||||
console.log('[AgentProcess] No alternative profile available - falling back to manual modal');
|
||||
// Single account case: let backend handle with intelligent pause
|
||||
// Don't show manual modal - backend will pause intelligently and resume when ready
|
||||
console.log('[AgentProcess] No alternative profile - backend will handle with intelligent pause');
|
||||
// Return false to let handleProcessFailure emit sdk-rate-limit event
|
||||
// The frontend can then show appropriate UI (e.g., "Paused until X time")
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -306,19 +310,84 @@ export class AgentProcessManager {
|
||||
console.log('[AgentProcess] No rate limit detected - checking for auth failure');
|
||||
const authFailureDetection = detectAuthFailure(allOutput);
|
||||
|
||||
if (authFailureDetection.isAuthFailure) {
|
||||
console.log('[AgentProcess] Auth failure detected:', authFailureDetection);
|
||||
if (!authFailureDetection.isAuthFailure) {
|
||||
console.log('[AgentProcess] Process failed but no rate limit or auth failure detected');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[AgentProcess] Auth failure detected:', authFailureDetection);
|
||||
|
||||
// Try auto-swap if enabled
|
||||
const wasHandled = this.handleAuthFailureWithAutoSwap(taskId, authFailureDetection);
|
||||
|
||||
if (!wasHandled) {
|
||||
// Fall back to UI notification
|
||||
this.emitter.emit('auth-failure', taskId, {
|
||||
profileId: authFailureDetection.profileId,
|
||||
failureType: authFailureDetection.failureType,
|
||||
message: authFailureDetection.message,
|
||||
originalError: authFailureDetection.originalError
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('[AgentProcess] Process failed but no rate limit or auth failure detected');
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to auto-swap to another profile on authentication failure.
|
||||
* Only works when autoSwitchOnAuthFailure is enabled and an alternative
|
||||
* authenticated profile is available.
|
||||
*/
|
||||
private handleAuthFailureWithAutoSwap(
|
||||
taskId: string,
|
||||
authFailureDetection: ReturnType<typeof detectAuthFailure>
|
||||
): boolean {
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const autoSwitchSettings = profileManager.getAutoSwitchSettings();
|
||||
|
||||
console.log('[AgentProcess] Auth failure auto-switch settings:', {
|
||||
enabled: autoSwitchSettings.enabled,
|
||||
autoSwitchOnAuthFailure: autoSwitchSettings.autoSwitchOnAuthFailure
|
||||
});
|
||||
|
||||
// Check if auto-switch on auth failure is enabled
|
||||
if (!autoSwitchSettings.enabled || !autoSwitchSettings.autoSwitchOnAuthFailure) {
|
||||
console.log('[AgentProcess] Auth failure auto-switch disabled - falling back to UI');
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentProfileId = authFailureDetection.profileId;
|
||||
const bestProfile = profileManager.getBestAvailableProfile(currentProfileId);
|
||||
|
||||
console.log('[AgentProcess] Best available profile for auth failure swap:', bestProfile ? {
|
||||
id: bestProfile.id,
|
||||
name: bestProfile.name,
|
||||
isAuthenticated: bestProfile.isAuthenticated
|
||||
} : 'NONE');
|
||||
|
||||
// Verify the best profile is actually authenticated
|
||||
if (!bestProfile || !bestProfile.isAuthenticated) {
|
||||
console.log('[AgentProcess] No authenticated alternative profile - falling back to UI');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[AgentProcess] AUTH-FAILURE AUTO-SWAP:', currentProfileId, '->', bestProfile.id);
|
||||
profileManager.setActiveProfile(bestProfile.id);
|
||||
|
||||
// Emit auth-failure event with swap metadata for UI notification
|
||||
this.emitter.emit('auth-failure', taskId, {
|
||||
profileId: authFailureDetection.profileId,
|
||||
failureType: authFailureDetection.failureType,
|
||||
message: authFailureDetection.message,
|
||||
originalError: authFailureDetection.originalError,
|
||||
wasAutoSwapped: true,
|
||||
swappedToProfile: { id: bestProfile.id, name: bestProfile.name }
|
||||
});
|
||||
|
||||
// Reuse existing restart event
|
||||
console.log('[AgentProcess] Emitting auto-swap-restart-task event for auth failure:', taskId);
|
||||
this.emitter.emit('auto-swap-restart-task', taskId, bestProfile.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -510,7 +579,8 @@ export class AgentProcessManager {
|
||||
cwd: string,
|
||||
args: string[],
|
||||
extraEnv: Record<string, string> = {},
|
||||
processType: ProcessType = 'task-execution'
|
||||
processType: ProcessType = 'task-execution',
|
||||
projectId?: string
|
||||
): Promise<void> {
|
||||
const isSpecRunner = processType === 'spec-creation';
|
||||
this.killProcess(taskId);
|
||||
@@ -562,7 +632,7 @@ export class AgentProcessManager {
|
||||
// spawn() failed synchronously (e.g., command not found, permission denied)
|
||||
// Clean up tracking entry and propagate error
|
||||
this.state.deleteProcess(taskId);
|
||||
this.emitter.emit('error', taskId, err instanceof Error ? err.message : String(err));
|
||||
this.emitter.emit('error', taskId, err instanceof Error ? err.message : String(err), projectId);
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -609,7 +679,7 @@ export class AgentProcessManager {
|
||||
message: isSpecRunner ? 'Starting spec creation...' : 'Starting build process...',
|
||||
sequenceNumber: ++sequenceNumber,
|
||||
completedPhases: [...completedPhases]
|
||||
});
|
||||
}, projectId);
|
||||
|
||||
const isDebug = ['true', '1', 'yes', 'on'].includes(process.env.DEBUG?.toLowerCase() ?? '');
|
||||
|
||||
@@ -629,7 +699,7 @@ export class AgentProcessManager {
|
||||
const taskEvent = parseTaskEvent(line);
|
||||
if (taskEvent) {
|
||||
console.log(`[AgentProcess:${taskId}] Parsed task event:`, taskEvent.type, taskEvent);
|
||||
this.emitter.emit('task-event', taskId, taskEvent);
|
||||
this.emitter.emit('task-event', taskId, taskEvent, projectId);
|
||||
}
|
||||
|
||||
const phaseUpdate = this.events.parseExecutionPhase(line, currentPhase, isSpecRunner);
|
||||
@@ -689,7 +759,7 @@ export class AgentProcessManager {
|
||||
message: lastMessage,
|
||||
sequenceNumber: ++sequenceNumber,
|
||||
completedPhases: [...completedPhases]
|
||||
});
|
||||
}, projectId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -709,7 +779,7 @@ export class AgentProcessManager {
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
this.emitter.emit('log', taskId, line + '\n');
|
||||
this.emitter.emit('log', taskId, line + '\n', projectId);
|
||||
processLog(line);
|
||||
if (isDebug) {
|
||||
console.log(`[Agent:${taskId}] ${line}`);
|
||||
@@ -730,11 +800,11 @@ export class AgentProcessManager {
|
||||
|
||||
childProcess.on('exit', (code: number | null) => {
|
||||
if (stdoutBuffer.trim()) {
|
||||
this.emitter.emit('log', taskId, stdoutBuffer + '\n');
|
||||
this.emitter.emit('log', taskId, stdoutBuffer + '\n', projectId);
|
||||
processLog(stdoutBuffer);
|
||||
}
|
||||
if (stderrBuffer.trim()) {
|
||||
this.emitter.emit('log', taskId, stderrBuffer + '\n');
|
||||
this.emitter.emit('log', taskId, stderrBuffer + '\n', projectId);
|
||||
processLog(stderrBuffer);
|
||||
}
|
||||
|
||||
@@ -749,7 +819,7 @@ export class AgentProcessManager {
|
||||
console.log('[AgentProcess] Process failed with code:', code, 'for task:', taskId);
|
||||
const wasHandled = this.handleProcessFailure(taskId, allOutput, processType);
|
||||
if (wasHandled) {
|
||||
this.emitter.emit('exit', taskId, code, processType);
|
||||
this.emitter.emit('exit', taskId, code, processType, projectId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -762,10 +832,10 @@ export class AgentProcessManager {
|
||||
message: `Process exited with code ${code}`,
|
||||
sequenceNumber: ++sequenceNumber,
|
||||
completedPhases: [...completedPhases]
|
||||
});
|
||||
}, projectId);
|
||||
}
|
||||
|
||||
this.emitter.emit('exit', taskId, code, processType);
|
||||
this.emitter.emit('exit', taskId, code, processType, projectId);
|
||||
});
|
||||
|
||||
// Handle process error
|
||||
@@ -780,9 +850,9 @@ export class AgentProcessManager {
|
||||
message: `Error: ${err.message}`,
|
||||
sequenceNumber: ++sequenceNumber,
|
||||
completedPhases: [...completedPhases]
|
||||
});
|
||||
}, projectId);
|
||||
|
||||
this.emitter.emit('error', taskId, err.message);
|
||||
this.emitter.emit('error', taskId, err.message, projectId);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -417,7 +417,12 @@ export class AgentQueueManager {
|
||||
|
||||
// Track completed types for progress calculation
|
||||
const completedTypes = new Set<string>();
|
||||
const totalTypes = 7; // Default all types
|
||||
// Derive totalTypes from --types argument instead of hardcoding
|
||||
const typesArgIndex = args.findIndex((arg) => arg === '--types');
|
||||
const totalTypes =
|
||||
typesArgIndex > -1 && args[typesArgIndex + 1]
|
||||
? args[typesArgIndex + 1].split(',').length
|
||||
: 6; // Default to 6 if not specified
|
||||
|
||||
// Handle stdout - explicitly decode as UTF-8 for cross-platform Unicode support
|
||||
childProcess.stdout?.on('data', (data: Buffer) => {
|
||||
|
||||
@@ -24,9 +24,24 @@ describe('AgentState - Queue Routing', () => {
|
||||
|
||||
it('should group tasks by profile', () => {
|
||||
// Add mock processes
|
||||
state.addProcess('task-1', { pid: 1001 } as any);
|
||||
state.addProcess('task-2', { pid: 1002 } as any);
|
||||
state.addProcess('task-3', { pid: 1003 } as any);
|
||||
state.addProcess('task-1', {
|
||||
taskId: 'task-1',
|
||||
process: { pid: 1001 } as unknown as import('child_process').ChildProcess,
|
||||
startedAt: new Date(),
|
||||
spawnId: 1
|
||||
});
|
||||
state.addProcess('task-2', {
|
||||
taskId: 'task-2',
|
||||
process: { pid: 1002 } as unknown as import('child_process').ChildProcess,
|
||||
startedAt: new Date(),
|
||||
spawnId: 2
|
||||
});
|
||||
state.addProcess('task-3', {
|
||||
taskId: 'task-3',
|
||||
process: { pid: 1003 } as unknown as import('child_process').ChildProcess,
|
||||
startedAt: new Date(),
|
||||
spawnId: 3
|
||||
});
|
||||
|
||||
// Assign profiles
|
||||
state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive');
|
||||
@@ -42,7 +57,12 @@ describe('AgentState - Queue Routing', () => {
|
||||
|
||||
it('should use default profile for unassigned tasks', () => {
|
||||
// Add process without profile assignment
|
||||
state.addProcess('task-1', { pid: 1001 } as any);
|
||||
state.addProcess('task-1', {
|
||||
taskId: 'task-1',
|
||||
process: { pid: 1001 } as unknown as import('child_process').ChildProcess,
|
||||
startedAt: new Date(),
|
||||
spawnId: 1
|
||||
});
|
||||
|
||||
const result = state.getRunningTasksByProfile();
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ export interface PhaseParseResult<TPhase extends string = string> {
|
||||
message?: string;
|
||||
currentSubtask?: string;
|
||||
progress?: number;
|
||||
// Pause phase metadata
|
||||
resetTimestamp?: number; // Unix timestamp for rate limit reset
|
||||
profileId?: string; // Profile that hit the limit
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import { BasePhaseParser, type PhaseParseResult, type PhaseParserContext } from
|
||||
import {
|
||||
EXECUTION_PHASES,
|
||||
TERMINAL_PHASES,
|
||||
isPausePhase,
|
||||
type ExecutionPhase
|
||||
} from '../../../shared/constants/phase-protocol';
|
||||
import { parsePhaseEvent } from '../phase-event-parser';
|
||||
@@ -40,11 +41,21 @@ export class ExecutionPhaseParser extends BasePhaseParser<ExecutionPhase> {
|
||||
// 1. Try structured event first (authoritative source)
|
||||
const structuredEvent = parsePhaseEvent(log);
|
||||
if (structuredEvent) {
|
||||
return {
|
||||
const result: PhaseParseResult<ExecutionPhase> = {
|
||||
phase: structuredEvent.phase as ExecutionPhase,
|
||||
message: structuredEvent.message,
|
||||
currentSubtask: structuredEvent.subtask
|
||||
};
|
||||
|
||||
// Include pause phase metadata if present
|
||||
if (structuredEvent.reset_timestamp !== undefined) {
|
||||
result.resetTimestamp = structuredEvent.reset_timestamp;
|
||||
}
|
||||
if (structuredEvent.profile_id !== undefined) {
|
||||
result.profileId = structuredEvent.profile_id;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. Terminal states can't be changed by fallback matching
|
||||
@@ -52,7 +63,13 @@ export class ExecutionPhaseParser extends BasePhaseParser<ExecutionPhase> {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. Fall back to text pattern matching
|
||||
// 3. Pause phases should only be changed by structured events
|
||||
// Don't allow fallback text matching to transition out of pause phases
|
||||
if (isPausePhase(context.currentPhase)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. Fall back to text pattern matching
|
||||
return this.parseFallbackPatterns(log, context);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ export const PhaseEventSchema = z.object({
|
||||
phase: BackendPhaseSchema,
|
||||
message: z.string().default(''),
|
||||
progress: z.number().int().min(0).max(100).optional(),
|
||||
subtask: z.string().optional()
|
||||
subtask: z.string().optional(),
|
||||
// Pause phase metadata
|
||||
reset_timestamp: z.number().int().optional(), // Unix timestamp for rate limit reset
|
||||
profile_id: z.string().optional() // Profile that hit the limit
|
||||
});
|
||||
|
||||
export type PhaseEventPayload = z.infer<typeof PhaseEventSchema>;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ChildProcess } from 'child_process';
|
||||
import type { IdeationConfig } from '../../shared/types';
|
||||
import type { CompletablePhase } from '../../shared/constants/phase-protocol';
|
||||
import type { CompletablePhase, ExecutionPhase } from '../../shared/constants/phase-protocol';
|
||||
import type { TaskEventPayload } from './task-event-schema';
|
||||
|
||||
/**
|
||||
@@ -19,7 +18,7 @@ export interface AgentProcess {
|
||||
}
|
||||
|
||||
export interface ExecutionProgressData {
|
||||
phase: 'idle' | 'planning' | 'coding' | 'qa_review' | 'qa_fixing' | 'complete' | 'failed';
|
||||
phase: ExecutionPhase;
|
||||
phaseProgress: number;
|
||||
overallProgress: number;
|
||||
currentSubtask?: string;
|
||||
@@ -31,11 +30,11 @@ export interface ExecutionProgressData {
|
||||
export type ProcessType = 'spec-creation' | 'task-execution' | 'qa-process';
|
||||
|
||||
export interface AgentManagerEvents {
|
||||
log: (taskId: string, log: string) => void;
|
||||
error: (taskId: string, error: string) => void;
|
||||
exit: (taskId: string, code: number | null, processType: ProcessType) => void;
|
||||
'execution-progress': (taskId: string, progress: ExecutionProgressData) => void;
|
||||
'task-event': (taskId: string, event: TaskEventPayload) => void;
|
||||
log: (taskId: string, log: string, projectId?: string) => void;
|
||||
error: (taskId: string, error: string, projectId?: string) => void;
|
||||
exit: (taskId: string, code: number | null, processType: ProcessType, projectId?: string) => void;
|
||||
'execution-progress': (taskId: string, progress: ExecutionProgressData, projectId?: string) => void;
|
||||
'task-event': (taskId: string, event: TaskEventPayload, projectId?: string) => void;
|
||||
}
|
||||
|
||||
// IdeationConfig now imported from shared types to maintain consistency
|
||||
@@ -50,6 +49,7 @@ export interface TaskExecutionOptions {
|
||||
workers?: number;
|
||||
baseBranch?: string;
|
||||
useWorktree?: boolean; // If false, use --direct mode (no worktree isolation)
|
||||
useLocalBranch?: boolean; // If true, use local branch directly instead of preferring origin/branch
|
||||
}
|
||||
|
||||
export interface SpecCreationMetadata {
|
||||
@@ -73,6 +73,7 @@ export interface SpecCreationMetadata {
|
||||
thinkingLevel?: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
|
||||
// Workspace mode - whether to use worktree isolation
|
||||
useWorktree?: boolean; // If false, use --direct mode (no worktree isolation)
|
||||
useLocalBranch?: boolean; // If true, use local branch directly instead of preferring origin/branch
|
||||
}
|
||||
|
||||
export interface IdeationProgressData {
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* - APP_UPDATE_ERROR: Error during update process
|
||||
*/
|
||||
|
||||
import { accessSync, constants as fsConstants } from 'fs';
|
||||
import path from 'path';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import type { UpdateInfo } from 'electron-updater';
|
||||
import { app, net } from 'electron';
|
||||
@@ -24,6 +26,7 @@ import type { BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS } from '../shared/constants';
|
||||
import type { AppUpdateInfo } from '../shared/types';
|
||||
import { compareVersions } from './updater/version-manager';
|
||||
import { isMacOS } from './platform';
|
||||
|
||||
// GitHub repo info for API calls
|
||||
const GITHUB_OWNER = 'AndyMik90';
|
||||
@@ -91,10 +94,13 @@ function formatReleaseNotes(releaseNotes: UpdateInfo['releaseNotes']): string |
|
||||
*/
|
||||
export function setUpdateChannel(channel: UpdateChannel): void {
|
||||
autoUpdater.channel = channel;
|
||||
// Enable pre-release scanning when beta channel is selected
|
||||
// This allows electron-updater to find beta releases on GitHub
|
||||
autoUpdater.allowPrerelease = channel === 'beta';
|
||||
// Clear any downloaded update info when channel changes to prevent showing
|
||||
// an Install button for an update from a different channel
|
||||
downloadedUpdateInfo = null;
|
||||
console.warn(`[app-updater] Update channel set to: ${channel}`);
|
||||
console.warn(`[app-updater] Update channel set to: ${channel}, allowPrerelease: ${autoUpdater.allowPrerelease}`);
|
||||
}
|
||||
|
||||
// Enable more verbose logging in debug mode
|
||||
@@ -187,8 +193,7 @@ export function initializeAppUpdater(window: BrowserWindow, betaUpdates = false)
|
||||
console.error('[app-updater] Update error:', error);
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_ERROR, {
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -293,13 +298,57 @@ export async function downloadUpdate(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the app is running from a read-only volume (e.g., DMG on macOS)
|
||||
* Returns true if the app cannot be updated in place
|
||||
*/
|
||||
function isRunningFromReadOnlyVolume(): boolean {
|
||||
if (!isMacOS()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const appPath = app.getAppPath();
|
||||
|
||||
// Check if the filesystem is read-only by testing write access.
|
||||
// We don't use a /Volumes/ prefix check because writable external drives
|
||||
// (USB, external SSDs) are also mounted under /Volumes/ on macOS.
|
||||
try {
|
||||
// Navigate from app.asar to the Contents/ directory (app.asar -> Resources -> Contents)
|
||||
const contentsPath = path.resolve(appPath, '..', '..');
|
||||
|
||||
// Try to check if we can write to the app bundle's parent directory
|
||||
accessSync(path.dirname(contentsPath), fsConstants.W_OK);
|
||||
return false;
|
||||
} catch (error: unknown) {
|
||||
// Only treat as read-only if the filesystem itself is read-only (EROFS).
|
||||
// Permission errors (EACCES) in managed/enterprise environments should not
|
||||
// block updates — the updater may still have elevated access.
|
||||
const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined;
|
||||
return code === 'EROFS';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quit and install update
|
||||
* Called from IPC handler when user confirms installation
|
||||
* Returns false if running from a read-only volume (update cannot proceed)
|
||||
*/
|
||||
export function quitAndInstall(): void {
|
||||
export function quitAndInstall(): boolean {
|
||||
// Check if running from read-only volume before attempting install
|
||||
if (isRunningFromReadOnlyVolume()) {
|
||||
console.warn('[app-updater] Cannot install: running from read-only volume');
|
||||
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_READONLY_VOLUME, {
|
||||
appPath: app.getAppPath()
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
console.warn('[app-updater] Quitting and installing update');
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -402,7 +451,7 @@ async function fetchLatestStableRelease(): Promise<AppUpdateInfo | null> {
|
||||
|
||||
const version = latestStable.tag_name.replace(/^v/, '');
|
||||
// Sanitize version string for logging (remove control characters and limit length)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: Intentionally matching control chars for sanitization
|
||||
const safeVersion = String(version).replace(/[\x00-\x1f\x7f]/g, '').slice(0, 50);
|
||||
console.warn('[app-updater] Found latest stable release:', safeVersion);
|
||||
|
||||
@@ -483,11 +532,8 @@ export async function setUpdateChannelWithDowngradeCheck(
|
||||
channel: UpdateChannel,
|
||||
triggerDowngradeCheck = false
|
||||
): Promise<AppUpdateInfo | null> {
|
||||
autoUpdater.channel = channel;
|
||||
// Clear any downloaded update info when channel changes to prevent showing
|
||||
// an Install button for an update from a different channel
|
||||
downloadedUpdateInfo = null;
|
||||
console.warn(`[app-updater] Update channel set to: ${channel}`);
|
||||
// Use the shared channel-setting function to avoid code duplication
|
||||
setUpdateChannel(channel);
|
||||
|
||||
// If switching to stable and downgrade check requested, look for stable version
|
||||
if (channel === 'latest' && triggerDowngradeCheck) {
|
||||
@@ -509,8 +555,8 @@ export async function setUpdateChannelWithDowngradeCheck(
|
||||
* Uses electron-updater with allowDowngrade enabled to download older stable versions
|
||||
*/
|
||||
export async function downloadStableVersion(): Promise<void> {
|
||||
// Switch to stable channel
|
||||
autoUpdater.channel = 'latest';
|
||||
// Switch to stable channel (resets allowPrerelease and clears downloadedUpdateInfo)
|
||||
setUpdateChannel('latest');
|
||||
// Enable downgrade to allow downloading older versions (e.g., stable when on beta)
|
||||
autoUpdater.allowDowngrade = true;
|
||||
console.warn('[app-updater] Downloading stable version (allowDowngrade=true)...');
|
||||
|
||||
@@ -44,7 +44,6 @@ export class ChangelogService extends EventEmitter {
|
||||
private _pythonPath: string | null = null;
|
||||
private claudePath: string;
|
||||
private autoBuildSourcePath: string = '';
|
||||
private cachedEnv: Record<string, string> | null = null;
|
||||
private debugEnabled: boolean | null = null;
|
||||
private generator: ChangelogGenerator | null = null;
|
||||
private versionSuggester: VersionSuggester | null = null;
|
||||
@@ -454,7 +453,7 @@ export class ChangelogService extends EventEmitter {
|
||||
}
|
||||
|
||||
const parts = currentVersion.split('.').map(Number);
|
||||
if (parts.length !== 3 || parts.some(isNaN)) {
|
||||
if (parts.length !== 3 || parts.some(Number.isNaN)) {
|
||||
return '1.0.0';
|
||||
}
|
||||
|
||||
@@ -491,7 +490,7 @@ export class ChangelogService extends EventEmitter {
|
||||
* Suggest version using AI analysis of git commits
|
||||
*/
|
||||
async suggestVersionFromCommits(
|
||||
projectPath: string,
|
||||
_projectPath: string,
|
||||
commits: import('../../shared/types').GitCommit[],
|
||||
currentVersion?: string
|
||||
): Promise<{ version: string; reason: string }> {
|
||||
@@ -502,7 +501,7 @@ export class ChangelogService extends EventEmitter {
|
||||
}
|
||||
|
||||
const parts = currentVersion.split('.').map(Number);
|
||||
if (parts.length !== 3 || parts.some(isNaN)) {
|
||||
if (parts.length !== 3 || parts.some(Number.isNaN)) {
|
||||
return { version: '1.0.0', reason: 'Invalid current version, resetting to 1.0.0' };
|
||||
}
|
||||
|
||||
|
||||
@@ -198,7 +198,6 @@ except Exception as e:
|
||||
case 'minor':
|
||||
newVersion = `${major}.${minor + 1}.0`;
|
||||
break;
|
||||
case 'patch':
|
||||
default:
|
||||
newVersion = `${major}.${minor}.${patch + 1}`;
|
||||
break;
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
clearRateLimitEvents as clearRateLimitEventsImpl
|
||||
} from './claude-profile/rate-limit-manager';
|
||||
import {
|
||||
loadProfileStore,
|
||||
loadProfileStoreAsync,
|
||||
saveProfileStore,
|
||||
ProfileStoreData,
|
||||
@@ -43,7 +42,7 @@ import {
|
||||
shouldProactivelySwitch as shouldProactivelySwitchImpl,
|
||||
getProfilesSortedByAvailability as getProfilesSortedByAvailabilityImpl
|
||||
} from './claude-profile/profile-scorer';
|
||||
import { getCredentialsFromKeychain, normalizeWindowsPath } from './claude-profile/credential-utils';
|
||||
import { getCredentialsFromKeychain, normalizeWindowsPath, updateProfileSubscriptionMetadata } from './claude-profile/credential-utils';
|
||||
import {
|
||||
CLAUDE_PROFILES_DIR,
|
||||
generateProfileId as generateProfileIdImpl,
|
||||
@@ -96,6 +95,10 @@ export class ClaudeProfileManager {
|
||||
// This repairs emails that were truncated due to ANSI escape codes in terminal output
|
||||
this.migrateCorruptedEmails();
|
||||
|
||||
// Populate missing subscription metadata for existing profiles
|
||||
// This reads subscriptionType and rateLimitTier from Keychain credentials
|
||||
this.populateSubscriptionMetadata();
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
@@ -133,6 +136,58 @@ export class ClaudeProfileManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate missing subscription metadata (subscriptionType, rateLimitTier) for existing profiles.
|
||||
*
|
||||
* This reads from Keychain credentials and updates profiles that don't have this metadata.
|
||||
* Runs on initialization to ensure existing profiles get the subscription info for UI display.
|
||||
*/
|
||||
private populateSubscriptionMetadata(): void {
|
||||
let needsSave = false;
|
||||
|
||||
for (const profile of this.data.profiles) {
|
||||
if (!profile.configDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if profile already has subscription metadata
|
||||
if (profile.subscriptionType && profile.rateLimitTier) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Expand ~ to home directory
|
||||
const expandedConfigDir = normalizeWindowsPath(
|
||||
profile.configDir.startsWith('~')
|
||||
? profile.configDir.replace(/^~/, homedir())
|
||||
: profile.configDir
|
||||
);
|
||||
|
||||
// Use helper with onlyIfMissing option to preserve existing values
|
||||
const result = updateProfileSubscriptionMetadata(profile, expandedConfigDir, { onlyIfMissing: true });
|
||||
|
||||
if (result.subscriptionTypeUpdated) {
|
||||
needsSave = true;
|
||||
console.warn('[ClaudeProfileManager] Populated subscriptionType for profile:', {
|
||||
profileId: profile.id,
|
||||
subscriptionType: result.subscriptionType
|
||||
});
|
||||
}
|
||||
|
||||
if (result.rateLimitTierUpdated) {
|
||||
needsSave = true;
|
||||
console.warn('[ClaudeProfileManager] Populated rateLimitTier for profile:', {
|
||||
profileId: profile.id,
|
||||
rateLimitTier: result.rateLimitTier
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (needsSave) {
|
||||
this.save();
|
||||
console.warn('[ClaudeProfileManager] Subscription metadata population complete');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the profile manager has been initialized
|
||||
*/
|
||||
@@ -140,31 +195,6 @@ export class ClaudeProfileManager {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load profiles from disk
|
||||
*/
|
||||
private load(): ProfileStoreData {
|
||||
const loadedData = loadProfileStore(this.storePath);
|
||||
if (loadedData) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('[ClaudeProfileManager] Loaded profiles:', {
|
||||
count: loadedData.profiles.length,
|
||||
activeProfileId: loadedData.activeProfileId,
|
||||
profiles: loadedData.profiles.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
email: p.email,
|
||||
isDefault: p.isDefault
|
||||
}))
|
||||
});
|
||||
}
|
||||
return loadedData;
|
||||
}
|
||||
|
||||
// Return default with a single "Default" profile
|
||||
return this.createDefaultData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default profile data
|
||||
*
|
||||
|
||||
@@ -0,0 +1,673 @@
|
||||
/**
|
||||
* Unit tests for OperationRegistry
|
||||
*
|
||||
* Tests cover:
|
||||
* - Singleton pattern
|
||||
* - Operation registration/unregistration
|
||||
* - Profile-based querying
|
||||
* - Summary generation
|
||||
* - Operation restart functionality
|
||||
* - Event emissions
|
||||
* - Edge cases
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
getOperationRegistry,
|
||||
resetOperationRegistry,
|
||||
type OperationType,
|
||||
} from '../operation-registry';
|
||||
|
||||
describe('OperationRegistry', () => {
|
||||
beforeEach(() => {
|
||||
// Reset registry before each test
|
||||
resetOperationRegistry();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up after each test
|
||||
resetOperationRegistry();
|
||||
});
|
||||
|
||||
describe('Singleton Pattern', () => {
|
||||
it('should return the same instance on multiple calls', () => {
|
||||
const instance1 = getOperationRegistry();
|
||||
const instance2 = getOperationRegistry();
|
||||
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
|
||||
it('should create new instance after reset', () => {
|
||||
const instance1 = getOperationRegistry();
|
||||
resetOperationRegistry();
|
||||
const instance2 = getOperationRegistry();
|
||||
|
||||
expect(instance1).not.toBe(instance2);
|
||||
});
|
||||
|
||||
it('should clear all operations on reset', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'spec-creation',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart
|
||||
);
|
||||
|
||||
expect(registry.getOperationCount()).toBe(1);
|
||||
|
||||
resetOperationRegistry();
|
||||
const newRegistry = getOperationRegistry();
|
||||
|
||||
expect(newRegistry.getOperationCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerOperation', () => {
|
||||
it('should register a basic operation', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'spec-creation',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart
|
||||
);
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation).toBeDefined();
|
||||
expect(operation?.id).toBe('op1');
|
||||
expect(operation?.type).toBe('spec-creation');
|
||||
expect(operation?.profileId).toBe('profile1');
|
||||
expect(operation?.profileName).toBe('Profile 1');
|
||||
expect(operation?.restartFn).toBe(mockRestart);
|
||||
expect(operation?.startedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should register operation with optional stopFn', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
const mockStop = vi.fn();
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'pr-review',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart,
|
||||
{ stopFn: mockStop }
|
||||
);
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation?.stopFn).toBe(mockStop);
|
||||
});
|
||||
|
||||
it('should register operation with metadata', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
const metadata = { projectId: 'proj1', prNumber: 123 };
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'pr-review',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart,
|
||||
{ metadata }
|
||||
);
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation?.metadata).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('should emit operation-registered event', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
const eventListener = vi.fn();
|
||||
|
||||
registry.on('operation-registered', eventListener);
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'task-execution',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart
|
||||
);
|
||||
|
||||
expect(eventListener).toHaveBeenCalledTimes(1);
|
||||
const emittedOperation = eventListener.mock.calls[0][0];
|
||||
expect(emittedOperation.id).toBe('op1');
|
||||
expect(emittedOperation.type).toBe('task-execution');
|
||||
});
|
||||
|
||||
it('should increment operation count', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
expect(registry.getOperationCount()).toBe(0);
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
expect(registry.getOperationCount()).toBe(1);
|
||||
|
||||
registry.registerOperation('op2', 'roadmap', 'profile1', 'Profile 1', mockRestart);
|
||||
expect(registry.getOperationCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('should allow registering multiple operation types', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
const types: OperationType[] = [
|
||||
'spec-creation',
|
||||
'task-execution',
|
||||
'pr-review',
|
||||
'mr-review',
|
||||
'insights',
|
||||
'roadmap',
|
||||
'changelog',
|
||||
'ideation',
|
||||
'triage',
|
||||
'other',
|
||||
];
|
||||
|
||||
types.forEach((type, index) => {
|
||||
registry.registerOperation(
|
||||
`op${index}`,
|
||||
type,
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart
|
||||
);
|
||||
});
|
||||
|
||||
expect(registry.getOperationCount()).toBe(types.length);
|
||||
|
||||
types.forEach((type, index) => {
|
||||
const op = registry.getOperation(`op${index}`);
|
||||
expect(op?.type).toBe(type);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unregisterOperation', () => {
|
||||
it('should unregister an existing operation', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
expect(registry.getOperation('op1')).toBeDefined();
|
||||
|
||||
registry.unregisterOperation('op1');
|
||||
expect(registry.getOperation('op1')).toBeUndefined();
|
||||
expect(registry.getOperationCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should emit operation-unregistered event', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
const eventListener = vi.fn();
|
||||
|
||||
registry.on('operation-unregistered', eventListener);
|
||||
|
||||
registry.registerOperation('op1', 'task-execution', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.unregisterOperation('op1');
|
||||
|
||||
expect(eventListener).toHaveBeenCalledTimes(1);
|
||||
expect(eventListener).toHaveBeenCalledWith('op1', 'task-execution');
|
||||
});
|
||||
|
||||
it('should handle unregistering non-existent operation gracefully', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const eventListener = vi.fn();
|
||||
|
||||
registry.on('operation-unregistered', eventListener);
|
||||
|
||||
// Should not throw
|
||||
expect(() => registry.unregisterOperation('non-existent')).not.toThrow();
|
||||
|
||||
// Should not emit event for non-existent operation
|
||||
expect(eventListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should decrement operation count', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'roadmap', 'profile1', 'Profile 1', mockRestart);
|
||||
expect(registry.getOperationCount()).toBe(2);
|
||||
|
||||
registry.unregisterOperation('op1');
|
||||
expect(registry.getOperationCount()).toBe(1);
|
||||
|
||||
registry.unregisterOperation('op2');
|
||||
expect(registry.getOperationCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOperation', () => {
|
||||
it('should retrieve operation by id', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'pr-review', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation).toBeDefined();
|
||||
expect(operation?.id).toBe('op1');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent operation', () => {
|
||||
const registry = getOperationRegistry();
|
||||
|
||||
const operation = registry.getOperation('non-existent');
|
||||
expect(operation).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOperationsByProfile', () => {
|
||||
it('should return operations for a specific profile', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
|
||||
|
||||
const profile1Ops = registry.getOperationsByProfile('profile1');
|
||||
expect(profile1Ops).toHaveLength(2);
|
||||
expect(profile1Ops.map(op => op.id)).toEqual(['op1', 'op2']);
|
||||
|
||||
const profile2Ops = registry.getOperationsByProfile('profile2');
|
||||
expect(profile2Ops).toHaveLength(1);
|
||||
expect(profile2Ops[0].id).toBe('op3');
|
||||
});
|
||||
|
||||
it('should return empty array for profile with no operations', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
const profile2Ops = registry.getOperationsByProfile('profile2');
|
||||
expect(profile2Ops).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllOperationsByProfile', () => {
|
||||
it('should return all operations grouped by profile', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
|
||||
registry.registerOperation('op4', 'roadmap', 'profile3', 'Profile 3', mockRestart);
|
||||
|
||||
const allOps = registry.getAllOperationsByProfile();
|
||||
|
||||
expect(Object.keys(allOps)).toEqual(['profile1', 'profile2', 'profile3']);
|
||||
expect(allOps['profile1']).toHaveLength(2);
|
||||
expect(allOps['profile2']).toHaveLength(1);
|
||||
expect(allOps['profile3']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should return empty object when no operations', () => {
|
||||
const registry = getOperationRegistry();
|
||||
|
||||
const allOps = registry.getAllOperationsByProfile();
|
||||
expect(allOps).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSummary', () => {
|
||||
it('should return correct summary with no operations', () => {
|
||||
const registry = getOperationRegistry();
|
||||
|
||||
const summary = registry.getSummary();
|
||||
expect(summary.totalRunning).toBe(0);
|
||||
expect(summary.byProfile).toEqual({});
|
||||
expect(summary.byType).toEqual({});
|
||||
});
|
||||
|
||||
it('should count operations by profile', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
|
||||
|
||||
const summary = registry.getSummary();
|
||||
|
||||
expect(summary.totalRunning).toBe(3);
|
||||
expect(summary.byProfile['profile1']).toEqual(['op1', 'op2']);
|
||||
expect(summary.byProfile['profile2']).toEqual(['op3']);
|
||||
});
|
||||
|
||||
it('should count operations by type', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op4', 'insights', 'profile2', 'Profile 2', mockRestart);
|
||||
|
||||
const summary = registry.getSummary();
|
||||
|
||||
expect(summary.byType['spec-creation']).toBe(2);
|
||||
expect(summary.byType['pr-review']).toBe(1);
|
||||
expect(summary.byType['insights']).toBe(1);
|
||||
});
|
||||
|
||||
it('should return complete summary with multiple profiles and types', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
|
||||
registry.registerOperation('op4', 'insights', 'profile2', 'Profile 2', mockRestart);
|
||||
registry.registerOperation('op5', 'roadmap', 'profile3', 'Profile 3', mockRestart);
|
||||
|
||||
const summary = registry.getSummary();
|
||||
|
||||
expect(summary.totalRunning).toBe(5);
|
||||
expect(Object.keys(summary.byProfile)).toHaveLength(3);
|
||||
expect(Object.keys(summary.byType)).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restartOperationsOnProfile', () => {
|
||||
it('should restart all operations on a profile', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart1 = vi.fn().mockResolvedValue(true);
|
||||
const mockRestart2 = vi.fn().mockResolvedValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
|
||||
|
||||
const count = await registry.restartOperationsOnProfile(
|
||||
'profile1',
|
||||
'profile2',
|
||||
'Profile 2'
|
||||
);
|
||||
|
||||
expect(count).toBe(2);
|
||||
expect(mockRestart1).toHaveBeenCalledWith('profile2');
|
||||
expect(mockRestart2).toHaveBeenCalledWith('profile2');
|
||||
|
||||
// Verify profile was updated
|
||||
const op1 = registry.getOperation('op1');
|
||||
const op2 = registry.getOperation('op2');
|
||||
expect(op1?.profileId).toBe('profile2');
|
||||
expect(op1?.profileName).toBe('Profile 2');
|
||||
expect(op2?.profileId).toBe('profile2');
|
||||
expect(op2?.profileName).toBe('Profile 2');
|
||||
});
|
||||
|
||||
it('should call stopFn before restart if provided', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockStop = vi.fn().mockResolvedValue(undefined);
|
||||
const mockRestart = vi.fn().mockResolvedValue(true);
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'pr-review',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart,
|
||||
{ stopFn: mockStop }
|
||||
);
|
||||
|
||||
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
|
||||
|
||||
expect(mockStop).toHaveBeenCalledTimes(1);
|
||||
expect(mockRestart).toHaveBeenCalledWith('profile2');
|
||||
// Ensure stopFn was called before restartFn
|
||||
expect(mockStop.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockRestart.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 0 when no operations on profile', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockResolvedValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
const count = await registry.restartOperationsOnProfile(
|
||||
'profile2',
|
||||
'profile3',
|
||||
'Profile 3'
|
||||
);
|
||||
|
||||
expect(count).toBe(0);
|
||||
expect(mockRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle restart failure gracefully', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart1 = vi.fn().mockResolvedValue(true);
|
||||
const mockRestart2 = vi.fn().mockResolvedValue(false); // Fails
|
||||
const mockRestart3 = vi.fn().mockRejectedValue(new Error('Restart error')); // Throws
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile1', 'Profile 1', mockRestart3);
|
||||
|
||||
const count = await registry.restartOperationsOnProfile(
|
||||
'profile1',
|
||||
'profile2',
|
||||
'Profile 2'
|
||||
);
|
||||
|
||||
// Only op1 succeeded
|
||||
expect(count).toBe(1);
|
||||
|
||||
// op1 should have updated profile
|
||||
const op1 = registry.getOperation('op1');
|
||||
expect(op1?.profileId).toBe('profile2');
|
||||
|
||||
// op2 and op3 should still have old profile
|
||||
const op2 = registry.getOperation('op2');
|
||||
const op3 = registry.getOperation('op3');
|
||||
expect(op2?.profileId).toBe('profile1');
|
||||
expect(op3?.profileId).toBe('profile1');
|
||||
});
|
||||
|
||||
it('should emit operation-restarted event for each successful restart', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart1 = vi.fn().mockResolvedValue(true);
|
||||
const mockRestart2 = vi.fn().mockResolvedValue(true);
|
||||
const eventListener = vi.fn();
|
||||
|
||||
registry.on('operation-restarted', eventListener);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
|
||||
|
||||
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
|
||||
|
||||
expect(eventListener).toHaveBeenCalledTimes(2);
|
||||
expect(eventListener).toHaveBeenCalledWith('op1', 'profile1', 'profile2');
|
||||
expect(eventListener).toHaveBeenCalledWith('op2', 'profile1', 'profile2');
|
||||
});
|
||||
|
||||
it('should emit operations-restarted event after restart', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart1 = vi.fn().mockResolvedValue(true);
|
||||
const mockRestart2 = vi.fn().mockResolvedValue(true);
|
||||
const eventListener = vi.fn();
|
||||
|
||||
registry.on('operations-restarted', eventListener);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
|
||||
|
||||
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
|
||||
|
||||
expect(eventListener).toHaveBeenCalledTimes(1);
|
||||
expect(eventListener).toHaveBeenCalledWith(2, 'profile1', 'profile2');
|
||||
});
|
||||
|
||||
it('should not emit operations-restarted event if no restarts succeeded', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockResolvedValue(false);
|
||||
const eventListener = vi.fn();
|
||||
|
||||
registry.on('operations-restarted', eventListener);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
|
||||
|
||||
expect(eventListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle synchronous restart functions', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true); // Synchronous return
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
const count = await registry.restartOperationsOnProfile(
|
||||
'profile1',
|
||||
'profile2',
|
||||
'Profile 2'
|
||||
);
|
||||
|
||||
expect(count).toBe(1);
|
||||
expect(mockRestart).toHaveBeenCalledWith('profile2');
|
||||
|
||||
const op1 = registry.getOperation('op1');
|
||||
expect(op1?.profileId).toBe('profile2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateOperationProfile', () => {
|
||||
it('should update profile for existing operation', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
registry.updateOperationProfile('op1', 'profile2', 'Profile 2');
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation?.profileId).toBe('profile2');
|
||||
expect(operation?.profileName).toBe('Profile 2');
|
||||
});
|
||||
|
||||
it('should handle updating non-existent operation gracefully', () => {
|
||||
const registry = getOperationRegistry();
|
||||
|
||||
// Should not throw
|
||||
expect(() =>
|
||||
registry.updateOperationProfile('non-existent', 'profile2', 'Profile 2')
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear all operations', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
|
||||
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
|
||||
|
||||
expect(registry.getOperationCount()).toBe(3);
|
||||
|
||||
registry.clear();
|
||||
|
||||
expect(registry.getOperationCount()).toBe(0);
|
||||
expect(registry.getSummary().totalRunning).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle registering operation with same id (overwrites)', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart1 = vi.fn().mockReturnValue(true);
|
||||
const mockRestart2 = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
|
||||
registry.registerOperation('op1', 'task-execution', 'profile2', 'Profile 2', mockRestart2);
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation?.type).toBe('task-execution');
|
||||
expect(operation?.profileId).toBe('profile2');
|
||||
expect(registry.getOperationCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle multiple unregisters of same operation', () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockReturnValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
registry.unregisterOperation('op1');
|
||||
expect(registry.getOperationCount()).toBe(0);
|
||||
|
||||
// Second unregister should not throw or cause issues
|
||||
registry.unregisterOperation('op1');
|
||||
expect(registry.getOperationCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle restart with no operations gracefully', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
|
||||
const count = await registry.restartOperationsOnProfile(
|
||||
'profile1',
|
||||
'profile2',
|
||||
'Profile 2'
|
||||
);
|
||||
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it('should preserve operation metadata through restart', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockResolvedValue(true);
|
||||
const metadata = { projectId: 'proj1', prNumber: 123 };
|
||||
|
||||
registry.registerOperation(
|
||||
'op1',
|
||||
'pr-review',
|
||||
'profile1',
|
||||
'Profile 1',
|
||||
mockRestart,
|
||||
{ metadata }
|
||||
);
|
||||
|
||||
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
|
||||
|
||||
const operation = registry.getOperation('op1');
|
||||
expect(operation?.metadata).toEqual(metadata);
|
||||
});
|
||||
|
||||
it('should preserve startedAt timestamp through restart', async () => {
|
||||
const registry = getOperationRegistry();
|
||||
const mockRestart = vi.fn().mockResolvedValue(true);
|
||||
|
||||
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
|
||||
|
||||
const originalOp = registry.getOperation('op1');
|
||||
const originalStartTime = originalOp?.startedAt;
|
||||
|
||||
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
|
||||
|
||||
const updatedOp = registry.getOperation('op1');
|
||||
expect(updatedOp?.startedAt).toBe(originalStartTime);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -345,7 +345,7 @@ function executeCredentialRead(
|
||||
executablePath: string,
|
||||
args: string[],
|
||||
timeout: number,
|
||||
identifier: string
|
||||
_identifier: string
|
||||
): string | null {
|
||||
try {
|
||||
const result = execFileSync(executablePath, args, {
|
||||
@@ -2205,3 +2205,101 @@ export function updateKeychainCredentials(
|
||||
|
||||
return { success: false, error: `Unsupported platform: ${process.platform}` };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Profile Subscription Metadata Helper
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Result of updating profile subscription metadata
|
||||
*/
|
||||
export interface UpdateSubscriptionMetadataResult {
|
||||
/** Whether subscriptionType was updated */
|
||||
subscriptionTypeUpdated: boolean;
|
||||
/** Whether rateLimitTier was updated */
|
||||
rateLimitTierUpdated: boolean;
|
||||
/** The subscriptionType value (if found) */
|
||||
subscriptionType?: string | null;
|
||||
/** The rateLimitTier value (if found) */
|
||||
rateLimitTier?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for updateProfileSubscriptionMetadata
|
||||
*/
|
||||
export interface UpdateSubscriptionMetadataOptions {
|
||||
/**
|
||||
* If true, only update fields that are currently missing (undefined/null/empty).
|
||||
* This is useful for migration/initialization code that should not overwrite existing values.
|
||||
* Default: false (always update if credentials have values)
|
||||
*/
|
||||
onlyIfMissing?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a profile's subscription metadata (subscriptionType, rateLimitTier) from Keychain credentials.
|
||||
*
|
||||
* This helper centralizes the common pattern of reading subscription info from Keychain
|
||||
* and updating a profile object. It's used after OAuth login, onboarding completion,
|
||||
* and profile authentication verification.
|
||||
*
|
||||
* NOTE: This function mutates the profile object directly. The caller is responsible
|
||||
* for saving the profile after calling this function.
|
||||
*
|
||||
* @param profile - The profile object to update (must have subscriptionType and rateLimitTier properties)
|
||||
* @param configDirOrCredentials - Either a config directory path to read credentials from,
|
||||
* or pre-fetched FullOAuthCredentials to avoid redundant reads
|
||||
* @param options - Optional settings like onlyIfMissing
|
||||
* @returns Information about what was updated
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Option 1: Pass configDir - helper fetches credentials
|
||||
* const result = updateProfileSubscriptionMetadata(profile, profile.configDir);
|
||||
*
|
||||
* // Option 2: Pass pre-fetched credentials (more efficient when already fetched)
|
||||
* const fullCreds = getFullCredentialsFromKeychain(profile.configDir);
|
||||
* const result = updateProfileSubscriptionMetadata(profile, fullCreds);
|
||||
*
|
||||
* // Option 3: Only populate if missing (for migration/initialization)
|
||||
* const result = updateProfileSubscriptionMetadata(profile, profile.configDir, { onlyIfMissing: true });
|
||||
*
|
||||
* if (result.subscriptionTypeUpdated || result.rateLimitTierUpdated) {
|
||||
* profileManager.saveProfile(profile);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function updateProfileSubscriptionMetadata(
|
||||
profile: { subscriptionType?: string | null; rateLimitTier?: string | null },
|
||||
configDirOrCredentials: string | undefined | FullOAuthCredentials,
|
||||
options?: UpdateSubscriptionMetadataOptions
|
||||
): UpdateSubscriptionMetadataResult {
|
||||
const result: UpdateSubscriptionMetadataResult = {
|
||||
subscriptionTypeUpdated: false,
|
||||
rateLimitTierUpdated: false,
|
||||
};
|
||||
|
||||
const onlyIfMissing = options?.onlyIfMissing ?? false;
|
||||
|
||||
// Determine if we received pre-fetched credentials or a configDir
|
||||
const fullCreds: FullOAuthCredentials =
|
||||
typeof configDirOrCredentials === 'object' && configDirOrCredentials !== null
|
||||
? configDirOrCredentials
|
||||
: getFullCredentialsFromKeychain(configDirOrCredentials);
|
||||
|
||||
// Update subscriptionType if credentials have it and (not onlyIfMissing OR profile doesn't have it)
|
||||
if (fullCreds.subscriptionType && (!onlyIfMissing || !profile.subscriptionType)) {
|
||||
profile.subscriptionType = fullCreds.subscriptionType;
|
||||
result.subscriptionTypeUpdated = true;
|
||||
result.subscriptionType = fullCreds.subscriptionType;
|
||||
}
|
||||
|
||||
// Update rateLimitTier if credentials have it and (not onlyIfMissing OR profile doesn't have it)
|
||||
if (fullCreds.rateLimitTier && (!onlyIfMissing || !profile.rateLimitTier)) {
|
||||
profile.rateLimitTier = fullCreds.rateLimitTier;
|
||||
result.rateLimitTierUpdated = true;
|
||||
result.rateLimitTier = fullCreds.rateLimitTier;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* Unified registry for ALL Claude Agent SDK operations.
|
||||
*
|
||||
* This is the single source of truth for tracking running operations that use
|
||||
* Claude profiles. It enables:
|
||||
* 1. Proactive account swapping - restart operations on a different profile
|
||||
* 2. Rate limit recovery - know which operations to restart after auth refresh
|
||||
* 3. Usage attribution - track which profile is being used by which operation
|
||||
*
|
||||
* Operations include:
|
||||
* - Autonomous tasks (spec creation, task execution)
|
||||
* - GitHub PR reviews
|
||||
* - GitLab MR reviews
|
||||
* - Insights analysis
|
||||
* - Roadmap generation
|
||||
* - Changelog generation
|
||||
* - Any other Claude SDK subprocess
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
/**
|
||||
* Types of operations that use Claude SDK
|
||||
*/
|
||||
export type OperationType =
|
||||
| 'spec-creation'
|
||||
| 'task-execution'
|
||||
| 'pr-review'
|
||||
| 'mr-review'
|
||||
| 'insights'
|
||||
| 'roadmap'
|
||||
| 'changelog'
|
||||
| 'ideation'
|
||||
| 'triage'
|
||||
| 'other';
|
||||
|
||||
/**
|
||||
* Registered operation entry
|
||||
*
|
||||
* IMPORTANT: Object reference stability during restarts
|
||||
* =====================================================
|
||||
* When an operation is restarted via restartFn, the restartFn implementation may
|
||||
* choose to re-register the operation (creating a new RegisteredOperation object)
|
||||
* OR update the existing one. Either approach is valid:
|
||||
*
|
||||
* 1. RE-REGISTRATION (AgentManager pattern):
|
||||
* - restartFn calls registerOperation() which replaces the Map entry
|
||||
* - Creates a new RegisteredOperation object with fresh closures
|
||||
* - Previous object references become stale and should not be used
|
||||
* - Callers MUST call getOperation(id) again to get the fresh reference
|
||||
*
|
||||
* 2. IN-PLACE UPDATE (alternative pattern):
|
||||
* - restartFn updates internal state but doesn't re-register
|
||||
* - Object reference remains valid
|
||||
* - Registry calls updateOperationProfile() to sync profileId
|
||||
*
|
||||
* BEST PRACTICE for consumers:
|
||||
* - Don't hold long-lived references to RegisteredOperation objects
|
||||
* - Always use getOperation(id) to get current state
|
||||
* - Subscribe to 'operation-restarted' events to know when to refresh
|
||||
* - If you must hold a reference, listen for 'operation-restarted' and refresh it
|
||||
*/
|
||||
export interface RegisteredOperation {
|
||||
/** Unique operation ID */
|
||||
id: string;
|
||||
/** Type of operation */
|
||||
type: OperationType;
|
||||
/** Profile ID currently being used */
|
||||
profileId: string;
|
||||
/** Profile name for logging */
|
||||
profileName: string;
|
||||
/** When the operation started */
|
||||
startedAt: Date;
|
||||
/** Optional metadata (project ID, PR number, etc.) */
|
||||
metadata?: Record<string, unknown>;
|
||||
/**
|
||||
* Function to restart this operation with a new profile.
|
||||
* Returns true if restart was initiated successfully.
|
||||
* The registry will update the profileId after successful restart.
|
||||
*
|
||||
* IMPORTANT: This function may re-register the operation (creating a new object)
|
||||
* or update in-place. Callers should use getOperation(id) after restart to get
|
||||
* the current reference.
|
||||
*/
|
||||
restartFn: (newProfileId: string) => boolean | Promise<boolean>;
|
||||
/**
|
||||
* Optional function to stop the operation.
|
||||
* Called before restart if provided.
|
||||
*/
|
||||
stopFn?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Events emitted by the operation registry
|
||||
*
|
||||
* NOTE: This interface is defined for documentation purposes only. It describes the event types
|
||||
* that ClaudeOperationRegistry can emit, but is not currently enforced at the type system level.
|
||||
* EventEmitter uses runtime event names, so type-safe event binding would require additional
|
||||
* type assertion infrastructure. This interface serves as documentation for consumers of the
|
||||
* operation registry to know which events are available and their callback signatures.
|
||||
*/
|
||||
export interface OperationRegistryEvents {
|
||||
'operation-registered': (operation: RegisteredOperation) => void;
|
||||
'operation-unregistered': (operationId: string, type: OperationType) => void;
|
||||
'operation-restarted': (operationId: string, oldProfileId: string, newProfileId: string) => void;
|
||||
'operations-restarted': (count: number, oldProfileId: string, newProfileId: string) => void;
|
||||
'operation-profile-updated': (operationId: string, oldProfileId: string, newProfileId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton registry for Claude SDK operations
|
||||
*
|
||||
* CONSUMER GUIDELINES: Object Reference Stability
|
||||
* ================================================
|
||||
* Operations may be restarted during profile swaps. When this happens:
|
||||
*
|
||||
* 1. The operation's restartFn is called with a new profileId
|
||||
* 2. The restartFn may choose to:
|
||||
* a) Re-register the operation (creates new RegisteredOperation object), OR
|
||||
* b) Update internal state without re-registering (keeps same object)
|
||||
*
|
||||
* 3. Either pattern is valid, but has implications for consumers:
|
||||
* - Pattern (a): Previous object references become stale
|
||||
* - Pattern (b): Object references remain valid
|
||||
*
|
||||
* BEST PRACTICES for consumers:
|
||||
* - Don't hold long-lived references to RegisteredOperation objects
|
||||
* - Always use getOperation(id) to get current state when needed
|
||||
* - Subscribe to 'operation-restarted' events to know when state may have changed
|
||||
* - Use hasOperation(id) to verify an operation is still registered
|
||||
*
|
||||
* EXAMPLE: Safely working with operation references
|
||||
* ```typescript
|
||||
* const registry = getOperationRegistry();
|
||||
*
|
||||
* // Initial fetch
|
||||
* let operation = registry.getOperation('task-123');
|
||||
*
|
||||
* // Listen for restarts
|
||||
* registry.onOperationRestarted((operationId, oldProfileId, newProfileId) => {
|
||||
* if (operationId === 'task-123') {
|
||||
* // Refresh reference after restart
|
||||
* operation = registry.getOperation('task-123');
|
||||
* console.log('Operation restarted with new profile:', newProfileId);
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* // When accessing operation state later, prefer fresh fetch:
|
||||
* const currentOp = registry.getOperation('task-123');
|
||||
* if (currentOp) {
|
||||
* console.log('Current profile:', currentOp.profileId);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
class ClaudeOperationRegistry extends EventEmitter {
|
||||
private operations: Map<string, RegisteredOperation> = new Map();
|
||||
private debugMode: boolean;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.debugMode = process.env.DEBUG === 'true';
|
||||
}
|
||||
|
||||
private debugLog(...args: unknown[]): void {
|
||||
if (this.debugMode) {
|
||||
console.log('[OperationRegistry]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new operation
|
||||
*/
|
||||
registerOperation(
|
||||
id: string,
|
||||
type: OperationType,
|
||||
profileId: string,
|
||||
profileName: string,
|
||||
restartFn: RegisteredOperation['restartFn'],
|
||||
options?: {
|
||||
stopFn?: RegisteredOperation['stopFn'];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
): void {
|
||||
const operation: RegisteredOperation = {
|
||||
id,
|
||||
type,
|
||||
profileId,
|
||||
profileName,
|
||||
startedAt: new Date(),
|
||||
restartFn,
|
||||
stopFn: options?.stopFn,
|
||||
metadata: options?.metadata,
|
||||
};
|
||||
|
||||
this.operations.set(id, operation);
|
||||
this.debugLog('Operation registered:', {
|
||||
id,
|
||||
type,
|
||||
profileId,
|
||||
profileName,
|
||||
metadata: options?.metadata,
|
||||
});
|
||||
|
||||
this.emit('operation-registered', operation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister an operation (when it completes or is cancelled)
|
||||
*/
|
||||
unregisterOperation(id: string): void {
|
||||
const operation = this.operations.get(id);
|
||||
if (operation) {
|
||||
this.operations.delete(id);
|
||||
this.debugLog('Operation unregistered:', { id, type: operation.type });
|
||||
this.emit('operation-unregistered', id, operation.type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all operations running on a specific profile
|
||||
*/
|
||||
getOperationsByProfile(profileId: string): RegisteredOperation[] {
|
||||
const result: RegisteredOperation[] = [];
|
||||
for (const op of this.operations.values()) {
|
||||
if (op.profileId === profileId) {
|
||||
result.push(op);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all running operations grouped by profile
|
||||
*/
|
||||
getAllOperationsByProfile(): Record<string, RegisteredOperation[]> {
|
||||
const result: Record<string, RegisteredOperation[]> = {};
|
||||
for (const op of this.operations.values()) {
|
||||
if (!result[op.profileId]) {
|
||||
result[op.profileId] = [];
|
||||
}
|
||||
result[op.profileId].push(op);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get operation by ID
|
||||
*
|
||||
* IMPORTANT: Always call this method to get the current operation state.
|
||||
* Don't hold long-lived references to RegisteredOperation objects, as they
|
||||
* may become stale after a restart. Instead, call getOperation(id) whenever
|
||||
* you need current state, or subscribe to 'operation-restarted' events.
|
||||
*/
|
||||
getOperation(id: string): RegisteredOperation | undefined {
|
||||
return this.operations.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an operation exists and is currently registered.
|
||||
* Use this to verify an operation reference is still valid.
|
||||
*
|
||||
* @param id - Operation ID to check
|
||||
* @returns true if operation exists in registry, false otherwise
|
||||
*/
|
||||
hasOperation(id: string): boolean {
|
||||
return this.operations.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of running operations
|
||||
*/
|
||||
getOperationCount(): number {
|
||||
return this.operations.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary of running operations for logging
|
||||
*/
|
||||
getSummary(): {
|
||||
totalRunning: number;
|
||||
byProfile: Record<string, string[]>;
|
||||
byType: Record<OperationType, number>;
|
||||
} {
|
||||
const byProfile: Record<string, string[]> = {};
|
||||
const byType: Record<string, number> = {};
|
||||
|
||||
for (const op of this.operations.values()) {
|
||||
// By profile
|
||||
if (!byProfile[op.profileId]) {
|
||||
byProfile[op.profileId] = [];
|
||||
}
|
||||
byProfile[op.profileId].push(op.id);
|
||||
|
||||
// By type
|
||||
byType[op.type] = (byType[op.type] || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
totalRunning: this.operations.size,
|
||||
byProfile,
|
||||
byType: byType as Record<OperationType, number>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart all operations running on a specific profile with a new profile.
|
||||
* This is called by UsageMonitor during proactive swaps.
|
||||
*
|
||||
* IMPORTANT: Object reference stability after restart
|
||||
* ====================================================
|
||||
* When operations are restarted, their restartFn implementations may:
|
||||
* 1. Re-register the operation (AgentManager pattern) - creates new object
|
||||
* 2. Update in-place (alternative pattern) - keeps same object
|
||||
*
|
||||
* For consumers holding operation references:
|
||||
* - Your reference may become stale if the operation re-registers
|
||||
* - Always call getOperation(id) after this method to get fresh reference
|
||||
* - Or subscribe to 'operation-restarted' events and refresh on each event
|
||||
*
|
||||
* This method emits:
|
||||
* - 'operation-restarted' for each successful restart (use this to refresh refs)
|
||||
* - 'operations-restarted' once with total count
|
||||
* - 'operation-profile-updated' for each profile update
|
||||
*
|
||||
* @param oldProfileId - Profile ID to migrate away from
|
||||
* @param newProfileId - Profile ID to migrate to
|
||||
* @param newProfileName - Profile name for logging
|
||||
* @returns Number of operations that were restarted
|
||||
*/
|
||||
async restartOperationsOnProfile(
|
||||
oldProfileId: string,
|
||||
newProfileId: string,
|
||||
newProfileName: string
|
||||
): Promise<number> {
|
||||
const operations = this.getOperationsByProfile(oldProfileId);
|
||||
|
||||
if (operations.length === 0) {
|
||||
this.debugLog('No operations to restart on profile:', oldProfileId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log('[OperationRegistry] Restarting', operations.length, 'operations:', {
|
||||
from: oldProfileId,
|
||||
to: newProfileId,
|
||||
operations: operations.map(op => ({ id: op.id, type: op.type })),
|
||||
});
|
||||
|
||||
let restartedCount = 0;
|
||||
|
||||
for (const op of operations) {
|
||||
try {
|
||||
// Stop the operation first if a stop function is provided
|
||||
if (op.stopFn) {
|
||||
this.debugLog('Stopping operation before restart:', op.id);
|
||||
await op.stopFn();
|
||||
}
|
||||
|
||||
// Call the restart function
|
||||
this.debugLog('Restarting operation:', op.id, 'with profile:', newProfileId);
|
||||
const success = await op.restartFn(newProfileId);
|
||||
|
||||
if (success) {
|
||||
restartedCount++;
|
||||
|
||||
// Update the profile for operations that weren't re-registered during restart.
|
||||
// For AgentManager tasks, restartFn may create a NEW object in the Map,
|
||||
// in which case this update is harmless (updates the new reference).
|
||||
// For other operations, this ensures the profile is properly updated.
|
||||
this.updateOperationProfile(op.id, newProfileId, newProfileName);
|
||||
|
||||
// Re-fetch from Map to get the current object (restartFn may have
|
||||
// re-registered the operation with a new object)
|
||||
const currentOp = this.operations.get(op.id);
|
||||
|
||||
console.log('[OperationRegistry] Operation restarted successfully:', {
|
||||
id: op.id,
|
||||
type: currentOp?.type ?? op.type,
|
||||
newProfile: newProfileName,
|
||||
});
|
||||
|
||||
this.emit('operation-restarted', op.id, oldProfileId, newProfileId);
|
||||
} else {
|
||||
console.warn('[OperationRegistry] Operation restart returned false:', op.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[OperationRegistry] Failed to restart operation:', op.id, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (restartedCount > 0) {
|
||||
this.emit('operations-restarted', restartedCount, oldProfileId, newProfileId);
|
||||
}
|
||||
|
||||
console.log('[OperationRegistry] Restart complete:', {
|
||||
total: operations.length,
|
||||
succeeded: restartedCount,
|
||||
failed: operations.length - restartedCount,
|
||||
});
|
||||
|
||||
return restartedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the profile assignment for an operation (e.g., after restart)
|
||||
*/
|
||||
updateOperationProfile(id: string, newProfileId: string, newProfileName: string): void {
|
||||
const operation = this.operations.get(id);
|
||||
if (operation) {
|
||||
const oldProfileId = operation.profileId;
|
||||
operation.profileId = newProfileId;
|
||||
operation.profileName = newProfileName;
|
||||
this.debugLog('Operation profile updated:', {
|
||||
id,
|
||||
from: oldProfileId,
|
||||
to: newProfileId,
|
||||
});
|
||||
this.emit('operation-profile-updated', id, oldProfileId, newProfileId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registered operations (for testing or cleanup)
|
||||
*/
|
||||
clear(): void {
|
||||
this.operations.clear();
|
||||
this.debugLog('All operations cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe event subscription: operation-registered
|
||||
* Subscribe to operation registration events
|
||||
*/
|
||||
onOperationRegistered(callback: (operation: RegisteredOperation) => void): () => void {
|
||||
this.on('operation-registered', callback);
|
||||
return () => this.off('operation-registered', callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe event subscription: operation-unregistered
|
||||
* Subscribe to operation unregistration events
|
||||
*/
|
||||
onOperationUnregistered(callback: (operationId: string, type: OperationType) => void): () => void {
|
||||
this.on('operation-unregistered', callback);
|
||||
return () => this.off('operation-unregistered', callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe event subscription: operation-restarted
|
||||
* Subscribe to individual operation restart events
|
||||
*/
|
||||
onOperationRestarted(callback: (operationId: string, oldProfileId: string, newProfileId: string) => void): () => void {
|
||||
this.on('operation-restarted', callback);
|
||||
return () => this.off('operation-restarted', callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe event subscription: operations-restarted
|
||||
* Subscribe to batch operation restart events
|
||||
*/
|
||||
onOperationsRestarted(callback: (count: number, oldProfileId: string, newProfileId: string) => void): () => void {
|
||||
this.on('operations-restarted', callback);
|
||||
return () => this.off('operations-restarted', callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe event subscription: operation-profile-updated
|
||||
* Subscribe to operation profile update events
|
||||
*/
|
||||
onOperationProfileUpdated(callback: (operationId: string, oldProfileId: string, newProfileId: string) => void): () => void {
|
||||
this.on('operation-profile-updated', callback);
|
||||
return () => this.off('operation-profile-updated', callback);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let registryInstance: ClaudeOperationRegistry | null = null;
|
||||
|
||||
/**
|
||||
* Get the singleton ClaudeOperationRegistry instance
|
||||
*/
|
||||
export function getOperationRegistry(): ClaudeOperationRegistry {
|
||||
if (!registryInstance) {
|
||||
registryInstance = new ClaudeOperationRegistry();
|
||||
}
|
||||
return registryInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the registry (for testing)
|
||||
*/
|
||||
export function resetOperationRegistry(): void {
|
||||
if (registryInstance) {
|
||||
registryInstance.clear();
|
||||
registryInstance.removeAllListeners();
|
||||
}
|
||||
registryInstance = null;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export const DEFAULT_AUTO_SWITCH_SETTINGS: ClaudeAutoSwitchSettings = {
|
||||
sessionThreshold: 95, // Consider switching at 95% session usage
|
||||
weeklyThreshold: 99, // Consider switching at 99% weekly usage
|
||||
autoSwitchOnRateLimit: false, // Prompt user by default
|
||||
autoSwitchOnAuthFailure: false, // Prompt user by default on auth failures
|
||||
usageCheckInterval: 30000 // Check every 30s when enabled (0 = disabled)
|
||||
};
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ export function hasValidToken(profile: ClaudeProfile): boolean {
|
||||
* Expand ~ in path to home directory
|
||||
*/
|
||||
export function expandHomePath(path: string): string {
|
||||
if (path && path.startsWith('~')) {
|
||||
if (path?.startsWith('~')) {
|
||||
const home = homedir();
|
||||
return path.replace(/^~/, home);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
refreshOAuthToken,
|
||||
ensureValidToken,
|
||||
reactiveTokenRefresh,
|
||||
type TokenRefreshResult
|
||||
} from './token-refresh';
|
||||
|
||||
// Mock credential-utils
|
||||
|
||||
@@ -19,6 +19,7 @@ import { detectProvider as sharedDetectProvider, type ApiProvider } from '../../
|
||||
import { getCredentialsFromKeychain, clearKeychainCache } from './credential-utils';
|
||||
import { reactiveTokenRefresh, ensureValidToken } from './token-refresh';
|
||||
import { isProfileRateLimited } from './rate-limit-manager';
|
||||
import { getOperationRegistry } from './operation-registry';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export type { ApiProvider };
|
||||
@@ -775,7 +776,7 @@ export class UsageMonitor extends EventEmitter {
|
||||
const activeProfile = profilesFile.profiles.find(
|
||||
(p) => p.id === profilesFile.activeProfileId
|
||||
);
|
||||
if (activeProfile && activeProfile.apiKey) {
|
||||
if (activeProfile?.apiKey) {
|
||||
this.debugLog('[UsageMonitor:TRACE] Using API profile credential: ' + activeProfile.name);
|
||||
return activeProfile.apiKey;
|
||||
}
|
||||
@@ -1333,7 +1334,7 @@ export class UsageMonitor extends EventEmitter {
|
||||
let baseUrl: string;
|
||||
let provider: ApiProvider;
|
||||
|
||||
if (activeProfile && activeProfile.isAPIProfile) {
|
||||
if (activeProfile?.isAPIProfile) {
|
||||
// Use the pre-determined profile to avoid race conditions
|
||||
// Trust the activeProfile data and use baseUrl directly
|
||||
baseUrl = activeProfile.baseUrl;
|
||||
@@ -1347,7 +1348,7 @@ export class UsageMonitor extends EventEmitter {
|
||||
const profilesFile = await loadProfilesFile();
|
||||
apiProfile = profilesFile.profiles.find(p => p.id === profileId);
|
||||
|
||||
if (apiProfile && apiProfile.apiKey) {
|
||||
if (apiProfile?.apiKey) {
|
||||
// API profile found
|
||||
baseUrl = apiProfile.baseUrl;
|
||||
provider = detectProvider(baseUrl);
|
||||
@@ -2007,6 +2008,46 @@ export class UsageMonitor extends EventEmitter {
|
||||
limitType
|
||||
});
|
||||
|
||||
// PROACTIVE OPERATION RESTART: Stop and restart all running Claude SDK operations with new profile credentials
|
||||
// This includes autonomous tasks, PR reviews, insights, roadmap, etc.
|
||||
// Claude Agent SDK sessions maintain state independently of auth tokens, so no progress is lost
|
||||
const operationRegistry = getOperationRegistry();
|
||||
const operationSummary = operationRegistry.getSummary();
|
||||
const operationIdsOnOldProfile = operationSummary.byProfile[currentProfileId] || [];
|
||||
|
||||
// Always log running operations info for debugging
|
||||
console.log('[UsageMonitor] PROACTIVE-SWAP: Checking running operations:', {
|
||||
oldProfileId: currentProfileId,
|
||||
newProfileId: bestAccount.id,
|
||||
totalRunning: operationSummary.totalRunning,
|
||||
byProfile: operationSummary.byProfile,
|
||||
byType: operationSummary.byType,
|
||||
operationIdsOnOldProfile: operationIdsOnOldProfile
|
||||
});
|
||||
|
||||
if (operationIdsOnOldProfile.length > 0) {
|
||||
console.log('[UsageMonitor] PROACTIVE-SWAP: Found', operationIdsOnOldProfile.length, 'operations to restart:', operationIdsOnOldProfile);
|
||||
|
||||
// Restart all operations on the old profile with the new profile
|
||||
const restartedCount = await operationRegistry.restartOperationsOnProfile(
|
||||
currentProfileId,
|
||||
bestAccount.id,
|
||||
bestAccount.name
|
||||
);
|
||||
|
||||
// Emit event for tracking/logging
|
||||
this.emit('proactive-operations-restarted', {
|
||||
fromProfile: { id: currentProfileId, name: fromProfileName },
|
||||
toProfile: { id: bestAccount.id, name: bestAccount.name },
|
||||
operationIds: operationIdsOnOldProfile,
|
||||
restartedCount,
|
||||
limitType,
|
||||
timestamp: new Date()
|
||||
});
|
||||
} else {
|
||||
console.log('[UsageMonitor] PROACTIVE-SWAP: No operations running on old profile', currentProfileId, '- swap complete without restart');
|
||||
}
|
||||
|
||||
// Note: Don't immediately check new profile - let normal interval handle it
|
||||
// This prevents cascading swaps if multiple profiles are near limits
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ function getNpmGlobalPrefix(): string | null {
|
||||
const normalizedPath = path.normalize(binPath);
|
||||
|
||||
return fs.existsSync(normalizedPath) ? normalizedPath : null;
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
// Fallback for Windows: try default npm global location when npm.cmd is not in PATH
|
||||
// This happens when the packaged app launches from GUI without full shell environment
|
||||
if (isWindows()) {
|
||||
|
||||
@@ -157,8 +157,7 @@ function createWindow(): void {
|
||||
const display = screen.getPrimaryDisplay();
|
||||
// Validate the returned object has expected structure with valid dimensions
|
||||
if (
|
||||
display &&
|
||||
display.workAreaSize &&
|
||||
display?.workAreaSize &&
|
||||
typeof display.workAreaSize.width === 'number' &&
|
||||
typeof display.workAreaSize.height === 'number' &&
|
||||
display.workAreaSize.width > 0 &&
|
||||
@@ -495,7 +494,7 @@ app.whenReady().then(() => {
|
||||
// Setup event forwarding from usage monitor to renderer
|
||||
initializeUsageMonitorForwarding(mainWindow);
|
||||
|
||||
// Start the usage monitor
|
||||
// Start the usage monitor (uses unified OperationRegistry for proactive restart)
|
||||
const usageMonitor = getUsageMonitor();
|
||||
usageMonitor.start();
|
||||
console.warn('[main] Usage monitor initialized and started (after profile load)');
|
||||
|
||||
@@ -9,11 +9,10 @@ import { InsightsPaths } from './paths';
|
||||
export class SessionManager {
|
||||
private sessions: Map<string, InsightsSession> = new Map();
|
||||
private storage: SessionStorage;
|
||||
private paths: InsightsPaths;
|
||||
|
||||
constructor(storage: SessionStorage, paths: InsightsPaths) {
|
||||
constructor(storage: SessionStorage, _paths: InsightsPaths) {
|
||||
this.storage = storage;
|
||||
this.paths = paths;
|
||||
// Note: paths parameter kept for API compatibility but not currently used
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Tests for the XState settled-state guard logic used in agent-events-handlers.
|
||||
*
|
||||
* The guard prevents execution-progress events from overwriting XState's
|
||||
* persisted status when the state machine has already settled into a
|
||||
* terminal/review state.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { XSTATE_SETTLED_STATES, XSTATE_TO_PHASE, TASK_STATE_NAMES } from '../../../shared/state-machines';
|
||||
|
||||
describe('XSTATE_SETTLED_STATES', () => {
|
||||
it('should contain the expected settled states', () => {
|
||||
expect(XSTATE_SETTLED_STATES.has('plan_review')).toBe(true);
|
||||
expect(XSTATE_SETTLED_STATES.has('human_review')).toBe(true);
|
||||
expect(XSTATE_SETTLED_STATES.has('error')).toBe(true);
|
||||
expect(XSTATE_SETTLED_STATES.has('creating_pr')).toBe(true);
|
||||
expect(XSTATE_SETTLED_STATES.has('pr_created')).toBe(true);
|
||||
expect(XSTATE_SETTLED_STATES.has('done')).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT contain active processing states', () => {
|
||||
expect(XSTATE_SETTLED_STATES.has('backlog')).toBe(false);
|
||||
expect(XSTATE_SETTLED_STATES.has('planning')).toBe(false);
|
||||
expect(XSTATE_SETTLED_STATES.has('coding')).toBe(false);
|
||||
expect(XSTATE_SETTLED_STATES.has('qa_review')).toBe(false);
|
||||
expect(XSTATE_SETTLED_STATES.has('qa_fixing')).toBe(false);
|
||||
});
|
||||
|
||||
it('should only contain valid task state names', () => {
|
||||
const validNames = new Set(TASK_STATE_NAMES);
|
||||
for (const state of XSTATE_SETTLED_STATES) {
|
||||
expect(validNames.has(state as typeof TASK_STATE_NAMES[number])).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('settled state guard behavior', () => {
|
||||
/**
|
||||
* Simulates the guard logic from agent-events-handlers execution-progress handler.
|
||||
* Returns true if the event should be blocked (XState is in a settled state).
|
||||
*/
|
||||
function shouldBlockExecutionProgress(currentXState: string | undefined): boolean {
|
||||
return !!(currentXState && XSTATE_SETTLED_STATES.has(currentXState));
|
||||
}
|
||||
|
||||
it('should block execution-progress when XState is in plan_review', () => {
|
||||
// After PLANNING_COMPLETE with requireReviewBeforeCoding=true,
|
||||
// process exits with code 1 emitting phase='failed' — must be blocked
|
||||
expect(shouldBlockExecutionProgress('plan_review')).toBe(true);
|
||||
});
|
||||
|
||||
it('should block execution-progress when XState is in human_review', () => {
|
||||
// After QA_PASSED, any stale events from the dying process must be blocked
|
||||
expect(shouldBlockExecutionProgress('human_review')).toBe(true);
|
||||
});
|
||||
|
||||
it('should block execution-progress when XState is in error', () => {
|
||||
// After PLANNING_FAILED/CODING_FAILED, stale events must not overwrite error status
|
||||
expect(shouldBlockExecutionProgress('error')).toBe(true);
|
||||
});
|
||||
|
||||
it('should block execution-progress when XState is in done', () => {
|
||||
expect(shouldBlockExecutionProgress('done')).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow execution-progress when XState is in planning', () => {
|
||||
expect(shouldBlockExecutionProgress('planning')).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow execution-progress when XState is in coding', () => {
|
||||
// After USER_RESUMED from error, XState transitions to coding synchronously.
|
||||
// New agent events should flow through normally.
|
||||
expect(shouldBlockExecutionProgress('coding')).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow execution-progress when XState is in qa_review', () => {
|
||||
expect(shouldBlockExecutionProgress('qa_review')).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow execution-progress when no XState actor exists', () => {
|
||||
// No actor yet (first event for this task) — must not block
|
||||
expect(shouldBlockExecutionProgress(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('XSTATE_TO_PHASE', () => {
|
||||
it('should have a mapping for every task state', () => {
|
||||
for (const state of TASK_STATE_NAMES) {
|
||||
expect(XSTATE_TO_PHASE[state]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should map settled states to non-active phases', () => {
|
||||
// Settled states should map to phases that indicate completion or stoppage
|
||||
expect(XSTATE_TO_PHASE['plan_review']).toBe('planning');
|
||||
expect(XSTATE_TO_PHASE['human_review']).toBe('complete');
|
||||
expect(XSTATE_TO_PHASE['error']).toBe('failed');
|
||||
expect(XSTATE_TO_PHASE['done']).toBe('complete');
|
||||
expect(XSTATE_TO_PHASE['pr_created']).toBe('complete');
|
||||
expect(XSTATE_TO_PHASE['creating_pr']).toBe('complete');
|
||||
});
|
||||
|
||||
it('should map active states to processing phases', () => {
|
||||
expect(XSTATE_TO_PHASE['planning']).toBe('planning');
|
||||
expect(XSTATE_TO_PHASE['coding']).toBe('coding');
|
||||
expect(XSTATE_TO_PHASE['qa_review']).toBe('qa_review');
|
||||
expect(XSTATE_TO_PHASE['qa_fixing']).toBe('qa_fixing');
|
||||
});
|
||||
|
||||
it('should return undefined for unknown states', () => {
|
||||
expect(XSTATE_TO_PHASE['nonexistent']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,13 @@ import type {
|
||||
AuthFailureInfo,
|
||||
ImplementationPlan,
|
||||
} from "../../shared/types";
|
||||
import { XSTATE_SETTLED_STATES, XSTATE_TO_PHASE, mapStateToLegacy } from "../../shared/state-machines";
|
||||
import { AgentManager } from "../agent";
|
||||
import type { ProcessType, ExecutionProgressData } from "../agent";
|
||||
import { titleGenerator } from "../title-generator";
|
||||
import { fileWatcher } from "../file-watcher";
|
||||
import { notificationService } from "../notification-service";
|
||||
import { persistPlanLastEventSync, getPlanPath, persistPlanPhaseSync } from "./task/plan-file-utils";
|
||||
import { persistPlanLastEventSync, getPlanPath, persistPlanPhaseSync, persistPlanStatusAndReasonSync } from "./task/plan-file-utils";
|
||||
import { findTaskWorktree } from "../worktree-paths";
|
||||
import { findTaskAndProject } from "./task/shared";
|
||||
import { safeSendToRenderer } from "./utils";
|
||||
@@ -32,16 +33,22 @@ export function registerAgenteventsHandlers(
|
||||
// Agent Manager Events → Renderer
|
||||
// ============================================
|
||||
|
||||
agentManager.on("log", (taskId: string, log: string) => {
|
||||
// Include projectId for multi-project filtering (issue #723)
|
||||
const { project } = findTaskAndProject(taskId);
|
||||
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_LOG, taskId, log, project?.id);
|
||||
agentManager.on("log", (taskId: string, log: string, projectId?: string) => {
|
||||
// Use projectId from event when available; fall back to lookup for backward compatibility
|
||||
if (!projectId) {
|
||||
const { project } = findTaskAndProject(taskId);
|
||||
projectId = project?.id;
|
||||
}
|
||||
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_LOG, taskId, log, projectId);
|
||||
});
|
||||
|
||||
agentManager.on("error", (taskId: string, error: string) => {
|
||||
// Include projectId for multi-project filtering (issue #723)
|
||||
const { project } = findTaskAndProject(taskId);
|
||||
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_ERROR, taskId, error, project?.id);
|
||||
agentManager.on("error", (taskId: string, error: string, projectId?: string) => {
|
||||
// Use projectId from event when available; fall back to lookup for backward compatibility
|
||||
if (!projectId) {
|
||||
const { project } = findTaskAndProject(taskId);
|
||||
projectId = project?.id;
|
||||
}
|
||||
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_ERROR, taskId, error, projectId);
|
||||
});
|
||||
|
||||
// Handle SDK rate limit events from agent manager
|
||||
@@ -82,10 +89,10 @@ export function registerAgenteventsHandlers(
|
||||
safeSendToRenderer(getMainWindow, IPC_CHANNELS.CLAUDE_AUTH_FAILURE, authFailureInfo);
|
||||
});
|
||||
|
||||
agentManager.on("exit", (taskId: string, code: number | null, processType: ProcessType) => {
|
||||
// Get task + project for context and multi-project filtering (issue #723)
|
||||
const { task: exitTask, project: exitProject } = findTaskAndProject(taskId);
|
||||
const exitProjectId = exitProject?.id;
|
||||
agentManager.on("exit", (taskId: string, code: number | null, processType: ProcessType, projectId?: string) => {
|
||||
// Use projectId from event to scope the lookup (prevents cross-project contamination)
|
||||
const { task: exitTask, project: exitProject } = findTaskAndProject(taskId, projectId);
|
||||
const exitProjectId = exitProject?.id || projectId;
|
||||
|
||||
taskStateManager.handleProcessExited(taskId, code, exitTask, exitProject);
|
||||
|
||||
@@ -109,7 +116,7 @@ export function registerAgenteventsHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
const { task, project } = findTaskAndProject(taskId);
|
||||
const { task, project } = findTaskAndProject(taskId, projectId);
|
||||
if (!task || !project) return;
|
||||
|
||||
const taskTitle = task.title || task.specId;
|
||||
@@ -120,11 +127,11 @@ export function registerAgenteventsHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
agentManager.on("task-event", (taskId: string, event) => {
|
||||
console.log(`[agent-events-handlers] Received task-event for ${taskId}:`, event.type, event);
|
||||
agentManager.on("task-event", (taskId: string, event, projectId?: string) => {
|
||||
console.debug(`[agent-events-handlers] Received task-event for ${taskId}:`, event.type, event);
|
||||
|
||||
if (taskStateManager.getLastSequence(taskId) === undefined) {
|
||||
const { task, project } = findTaskAndProject(taskId);
|
||||
const { task, project } = findTaskAndProject(taskId, projectId);
|
||||
if (task && project) {
|
||||
try {
|
||||
const planPath = getPlanPath(project, task);
|
||||
@@ -140,20 +147,20 @@ export function registerAgenteventsHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
const { task, project } = findTaskAndProject(taskId);
|
||||
const { task, project } = findTaskAndProject(taskId, projectId);
|
||||
if (!task || !project) {
|
||||
console.log(`[agent-events-handlers] No task/project found for ${taskId}`);
|
||||
console.debug(`[agent-events-handlers] No task/project found for ${taskId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[agent-events-handlers] Task state before handleTaskEvent:`, {
|
||||
console.debug(`[agent-events-handlers] Task state before handleTaskEvent:`, {
|
||||
status: task.status,
|
||||
reviewReason: task.reviewReason,
|
||||
phase: task.executionProgress?.phase
|
||||
});
|
||||
|
||||
const accepted = taskStateManager.handleTaskEvent(taskId, event, task, project);
|
||||
console.log(`[agent-events-handlers] Event ${event.type} accepted: ${accepted}`);
|
||||
console.debug(`[agent-events-handlers] Event ${event.type} accepted: ${accepted}`);
|
||||
if (!accepted) {
|
||||
return;
|
||||
}
|
||||
@@ -176,14 +183,27 @@ export function registerAgenteventsHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
agentManager.on("execution-progress", (taskId: string, progress: ExecutionProgressData) => {
|
||||
// Use shared helper to find task and project (issue #723 - deduplicate lookup)
|
||||
const { task, project } = findTaskAndProject(taskId);
|
||||
const taskProjectId = project?.id;
|
||||
agentManager.on("execution-progress", (taskId: string, progress: ExecutionProgressData, projectId?: string) => {
|
||||
// Use projectId from event to scope the lookup (prevents cross-project contamination)
|
||||
const { task, project } = findTaskAndProject(taskId, projectId);
|
||||
const taskProjectId = project?.id || projectId;
|
||||
|
||||
// Check if XState has already established a terminal/review state for this task.
|
||||
// XState is the source of truth for status. When XState is in a terminal state
|
||||
// (e.g., plan_review after PLANNING_COMPLETE), execution-progress events from the
|
||||
// agent process are stale and must not overwrite XState's persisted status.
|
||||
//
|
||||
// Example: When requireReviewBeforeCoding=true, the process exits with code 1 after
|
||||
// PLANNING_COMPLETE. The exit handler emits execution-progress with phase='failed',
|
||||
// which would incorrectly overwrite status='human_review' with status='error' via
|
||||
// persistPlanPhaseSync, and send a 'failed' phase to the renderer overwriting the
|
||||
// 'planning' phase that XState already emitted via emitPhaseFromState.
|
||||
const currentXState = taskStateManager.getCurrentState(taskId);
|
||||
const xstateInTerminalState = currentXState && XSTATE_SETTLED_STATES.has(currentXState);
|
||||
|
||||
// Persist phase to plan file for restoration on app refresh
|
||||
// Must persist to BOTH main project and worktree (if exists) since task may be loaded from either
|
||||
if (task && project && progress.phase) {
|
||||
if (task && project && progress.phase && !xstateInTerminalState) {
|
||||
const mainPlanPath = getPlanPath(project, task);
|
||||
persistPlanPhaseSync(mainPlanPath, progress.phase, project.id);
|
||||
|
||||
@@ -201,9 +221,16 @@ export function registerAgenteventsHandlers(
|
||||
persistPlanPhaseSync(worktreePlanPath, progress.phase, project.id);
|
||||
}
|
||||
}
|
||||
} else if (xstateInTerminalState && progress.phase) {
|
||||
console.debug(`[agent-events-handlers] Skipping persistPlanPhaseSync for ${taskId}: XState in '${currentXState}', not overwriting with phase '${progress.phase}'`);
|
||||
}
|
||||
|
||||
// Include projectId in execution progress event for multi-project filtering
|
||||
// Skip sending execution-progress to renderer when XState has settled.
|
||||
// XState's emitPhaseFromState already sent the correct phase to the renderer.
|
||||
if (xstateInTerminalState) {
|
||||
console.debug(`[agent-events-handlers] Skipping execution-progress to renderer for ${taskId}: XState in '${currentXState}', ignoring phase '${progress.phase}'`);
|
||||
return;
|
||||
}
|
||||
safeSendToRenderer(
|
||||
getMainWindow,
|
||||
IPC_CHANNELS.TASK_EXECUTION_PROGRESS,
|
||||
@@ -218,13 +245,42 @@ export function registerAgenteventsHandlers(
|
||||
// ============================================
|
||||
|
||||
fileWatcher.on("progress", (taskId: string, plan: ImplementationPlan) => {
|
||||
// Use shared helper to find project (issue #723 - deduplicate lookup)
|
||||
const { project } = findTaskAndProject(taskId);
|
||||
// File watcher events don't carry projectId — fall back to lookup
|
||||
const { task, project } = findTaskAndProject(taskId);
|
||||
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_PROGRESS, taskId, plan, project?.id);
|
||||
|
||||
// Re-stamp XState status fields if the backend overwrote the plan file without them.
|
||||
// The planner agent writes implementation_plan.json via the Write tool, which replaces
|
||||
// the entire file and strips the frontend's status/xstateState/executionPhase fields.
|
||||
// This causes tasks to snap back to backlog on refresh.
|
||||
const planWithStatus = plan as { xstateState?: string; executionPhase?: string; status?: string };
|
||||
const currentXState = taskStateManager.getCurrentState(taskId);
|
||||
if (currentXState && !planWithStatus.xstateState && task && project) {
|
||||
console.debug(`[agent-events-handlers] Re-stamping XState status on plan file for ${taskId} (state: ${currentXState})`);
|
||||
const mainPlanPath = getPlanPath(project, task);
|
||||
const { status, reviewReason } = mapStateToLegacy(currentXState);
|
||||
const phase = XSTATE_TO_PHASE[currentXState] || 'idle';
|
||||
persistPlanStatusAndReasonSync(mainPlanPath, status, reviewReason, project.id, currentXState, phase);
|
||||
|
||||
// Also re-stamp worktree copy if it exists
|
||||
const worktreePath = findTaskWorktree(project.path, task.specId);
|
||||
if (worktreePath) {
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const worktreePlanPath = path.join(
|
||||
worktreePath,
|
||||
specsBaseDir,
|
||||
task.specId,
|
||||
AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN
|
||||
);
|
||||
if (existsSync(worktreePlanPath)) {
|
||||
persistPlanStatusAndReasonSync(worktreePlanPath, status, reviewReason, project.id, currentXState, phase);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fileWatcher.on("error", (taskId: string, error: string) => {
|
||||
// Include projectId for multi-project filtering (issue #723)
|
||||
// File watcher events don't carry projectId — fall back to lookup
|
||||
const { project } = findTaskAndProject(taskId);
|
||||
safeSendToRenderer(getMainWindow, IPC_CHANNELS.TASK_ERROR, taskId, error, project?.id);
|
||||
});
|
||||
|
||||
@@ -95,6 +95,10 @@ export function registerAppUpdateHandlers(): void {
|
||||
IPC_CHANNELS.APP_UPDATE_INSTALL,
|
||||
async (): Promise<IPCResult> => {
|
||||
try {
|
||||
// quitAndInstall() returns false if blocked by read-only volume,
|
||||
// but the user is notified via APP_UPDATE_READONLY_VOLUME event instead.
|
||||
// The preload fires this as fire-and-forget, so the return value is
|
||||
// only consumed by the .catch() handler for unexpected errors.
|
||||
quitAndInstall();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -52,7 +52,7 @@ export function registerChangelogHandlers(
|
||||
}
|
||||
});
|
||||
|
||||
changelogService.on('rate-limit', (projectId: string, rateLimitInfo: import('../../shared/types').SDKRateLimitInfo) => {
|
||||
changelogService.on('rate-limit', (_projectId: string, rateLimitInfo: import('../../shared/types').SDKRateLimitInfo) => {
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send(IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo);
|
||||
|
||||
@@ -23,7 +23,7 @@ import { isSecurePath } from '../utils/windows-paths';
|
||||
import { isWindows, isMacOS, isLinux } from '../platform';
|
||||
import { getClaudeProfileManager } from '../claude-profile-manager';
|
||||
import { isValidConfigDir } from '../utils/config-path-validator';
|
||||
import { clearKeychainCache, getCredentialsFromKeychain } from '../claude-profile/credential-utils';
|
||||
import { clearKeychainCache, getCredentialsFromKeychain, updateProfileSubscriptionMetadata } from '../claude-profile/credential-utils';
|
||||
import { getUsageMonitor } from '../claude-profile/usage-monitor';
|
||||
import semver from 'semver';
|
||||
|
||||
@@ -216,10 +216,12 @@ async function scanClaudeInstallations(activePath: string | null): Promise<Claud
|
||||
* @param currentInstalled - Optional currently installed version. If provided and newer than
|
||||
* cached latest, cache will be invalidated and fresh data fetched.
|
||||
* This handles the case where CLI was updated while app was running.
|
||||
* @param forceRefresh - If true, bypasses the cache and fetches fresh data from npm.
|
||||
* Use this when the user explicitly clicks a "Refresh" button.
|
||||
*/
|
||||
async function fetchLatestVersion(currentInstalled?: string | null): Promise<string> {
|
||||
// Check cache first
|
||||
if (cachedLatestVersion && Date.now() - cachedLatestVersion.timestamp < CACHE_DURATION_MS) {
|
||||
async function fetchLatestVersion(currentInstalled?: string | null, forceRefresh?: boolean): Promise<string> {
|
||||
// Check cache first (unless force refresh is requested)
|
||||
if (!forceRefresh && cachedLatestVersion && Date.now() - cachedLatestVersion.timestamp < CACHE_DURATION_MS) {
|
||||
const cachedVersion = cachedLatestVersion.version;
|
||||
|
||||
// Invalidate cache if installed version is newer than cached latest
|
||||
@@ -881,7 +883,7 @@ function checkProfileAuthentication(configDir: string): AuthCheckResult {
|
||||
const data = JSON.parse(content);
|
||||
|
||||
// Check for oauthAccount with emailAddress
|
||||
if (data.oauthAccount && data.oauthAccount.emailAddress) {
|
||||
if (data.oauthAccount?.emailAddress) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: data.oauthAccount.emailAddress,
|
||||
@@ -907,7 +909,7 @@ function checkProfileAuthentication(configDir: string): AuthCheckResult {
|
||||
};
|
||||
}
|
||||
|
||||
if (data.oauthAccount && data.oauthAccount.emailAddress) {
|
||||
if (data.oauthAccount?.emailAddress) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: data.oauthAccount.emailAddress,
|
||||
@@ -958,9 +960,9 @@ export function registerClaudeCodeHandlers(): void {
|
||||
// Check Claude Code version
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION,
|
||||
async (): Promise<IPCResult<ClaudeCodeVersionInfo>> => {
|
||||
async (_event, forceRefresh?: boolean): Promise<IPCResult<ClaudeCodeVersionInfo>> => {
|
||||
try {
|
||||
console.warn('[Claude Code] Checking version...');
|
||||
console.warn('[Claude Code] Checking version...', forceRefresh ? '(force refresh)' : '');
|
||||
|
||||
// Get installed version via cli-tool-manager
|
||||
let detectionResult;
|
||||
@@ -977,10 +979,11 @@ export function registerClaudeCodeHandlers(): void {
|
||||
|
||||
// Fetch latest version from npm
|
||||
// Pass installed version to invalidate cache if installed > cached (handles CLI update while app running)
|
||||
// Pass forceRefresh to bypass cache when user explicitly clicks Refresh
|
||||
let latest: string;
|
||||
try {
|
||||
console.warn('[Claude Code] Fetching latest version from npm...');
|
||||
latest = await fetchLatestVersion(installed);
|
||||
latest = await fetchLatestVersion(installed, forceRefresh);
|
||||
console.warn('[Claude Code] Latest version:', latest);
|
||||
} catch (error) {
|
||||
console.warn('[Claude Code] Failed to fetch latest version, continuing with unknown:', error);
|
||||
@@ -1370,7 +1373,7 @@ export function registerClaudeCodeHandlers(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// If authenticated, update the profile with the email
|
||||
// If authenticated, update the profile with metadata from credentials
|
||||
// NOTE: We intentionally do NOT store the OAuth token in the profile.
|
||||
// Storing the token causes AutoClaude to use a stale cached token instead of
|
||||
// letting Claude CLI read fresh tokens from Keychain (which auto-refreshes).
|
||||
@@ -1384,7 +1387,11 @@ export function registerClaudeCodeHandlers(): void {
|
||||
profile.email = result.email;
|
||||
}
|
||||
|
||||
// Save profile metadata (email, isAuthenticated) but NOT the OAuth token
|
||||
// Update subscription metadata from Keychain credentials
|
||||
// These are needed to display "Max" vs "Pro" in the UI
|
||||
updateProfileSubscriptionMetadata(profile, expandedConfigDir);
|
||||
|
||||
// Save profile metadata (email, isAuthenticated, subscriptionType, rateLimitTier) but NOT the OAuth token
|
||||
profileManager.saveProfile(profile);
|
||||
|
||||
// CRITICAL: Clear keychain cache for this profile's configDir
|
||||
|
||||
@@ -12,9 +12,6 @@ import type {
|
||||
} from '../../../shared/types';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { getMemoryService, isKuzuAvailable } from '../../memory-service';
|
||||
import {
|
||||
getGraphitiDatabaseDetails
|
||||
} from './utils';
|
||||
import { getEffectiveSourcePath } from '../../updater/path-resolver';
|
||||
import {
|
||||
loadGraphitiStateFromSpecs,
|
||||
|
||||
@@ -32,7 +32,7 @@ type ResolvedClaudeCliInvocation =
|
||||
| { command: string; env: Record<string, string> }
|
||||
| { error: string };
|
||||
|
||||
function resolveClaudeCliInvocation(): ResolvedClaudeCliInvocation {
|
||||
function _resolveClaudeCliInvocation(): ResolvedClaudeCliInvocation {
|
||||
try {
|
||||
const invocation = getClaudeCliInvocation();
|
||||
if (!invocation?.command) {
|
||||
|
||||
@@ -518,7 +518,7 @@ describe('GitHub OAuth Handlers', () => {
|
||||
mockFindExecutable.mockReturnValue('/usr/local/bin/gh');
|
||||
|
||||
// Mock execFileSync for version check
|
||||
mockExecFileSync.mockImplementation((cmd: string, args?: string[]) => {
|
||||
mockExecFileSync.mockImplementation((_cmd: string, args?: string[]) => {
|
||||
if (args && args[0] === '--version') {
|
||||
return 'gh version 2.65.0 (2024-01-15)\n';
|
||||
}
|
||||
@@ -553,7 +553,7 @@ describe('GitHub OAuth Handlers', () => {
|
||||
|
||||
describe('gh Auth Check Handler', () => {
|
||||
it('should return authenticated: true with username when logged in', async () => {
|
||||
mockExecFileSync.mockImplementation((cmd: string, args?: string[]) => {
|
||||
mockExecFileSync.mockImplementation((_cmd: string, args?: string[]) => {
|
||||
if (args && args[0] === 'auth' && args[1] === 'status') {
|
||||
return 'Logged in to github.com as testuser\n';
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ function sendComplete(
|
||||
* Investigate a GitHub issue and create a task
|
||||
*/
|
||||
export function registerInvestigateIssue(
|
||||
agentManager: AgentManager,
|
||||
_agentManager: AgentManager,
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
ipcMain.on(
|
||||
|
||||
@@ -117,7 +117,7 @@ const GITHUB_DEVICE_URL = 'https://github.com/login/device';
|
||||
*/
|
||||
function parseDeviceCode(output: string): string | null {
|
||||
const match = output.match(DEVICE_CODE_PATTERN);
|
||||
if (match && match[1]) {
|
||||
if (match?.[1]) {
|
||||
// Normalize: replace space with hyphen (GitHub expects XXXX-XXXX format)
|
||||
const normalizedCode = match[1].replace(' ', '-');
|
||||
debugLog('Device code extracted successfully (code redacted for security)');
|
||||
|
||||
@@ -40,6 +40,23 @@ import {
|
||||
* GraphQL response type for PR list query
|
||||
* Note: repository can be null if the repo doesn't exist or user lacks access
|
||||
*/
|
||||
interface GraphQLPRNode {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state: string;
|
||||
author: { login: string } | null;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
changedFiles: number;
|
||||
assignees: { nodes: Array<{ login: string }> };
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface GraphQLPRListResponse {
|
||||
data: {
|
||||
repository: {
|
||||
@@ -48,28 +65,37 @@ interface GraphQLPRListResponse {
|
||||
hasNextPage: boolean;
|
||||
endCursor: string | null;
|
||||
};
|
||||
nodes: Array<{
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
state: string;
|
||||
author: { login: string } | null;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
changedFiles: number;
|
||||
assignees: { nodes: Array<{ login: string }> };
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
url: string;
|
||||
}>;
|
||||
nodes: GraphQLPRNode[];
|
||||
};
|
||||
} | null;
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a GraphQL PR node to the frontend PRData format.
|
||||
* Shared between listPRs and listMorePRs handlers.
|
||||
*/
|
||||
function mapGraphQLPRToData(pr: GraphQLPRNode): PRData {
|
||||
return {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
body: pr.body ?? "",
|
||||
state: pr.state.toLowerCase(),
|
||||
author: { login: pr.author?.login ?? "unknown" },
|
||||
headRefName: pr.headRefName,
|
||||
baseRefName: pr.baseRefName,
|
||||
additions: pr.additions,
|
||||
deletions: pr.deletions,
|
||||
changedFiles: pr.changedFiles,
|
||||
assignees: pr.assignees.nodes.map((a) => ({ login: a.login })),
|
||||
files: [],
|
||||
createdAt: pr.createdAt,
|
||||
updatedAt: pr.updatedAt,
|
||||
htmlUrl: pr.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a GraphQL request to GitHub API
|
||||
*/
|
||||
@@ -487,6 +513,7 @@ export interface PRData {
|
||||
export interface PRListResult {
|
||||
prs: PRData[];
|
||||
hasNextPage: boolean; // True if more PRs exist beyond the 100 limit
|
||||
endCursor?: string | null; // Cursor for fetching next page (null if no more pages)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -870,6 +897,16 @@ function parseLogLine(line: string): { source: string; content: string; isError:
|
||||
};
|
||||
}
|
||||
|
||||
// Check for parallel SDK specialist logs (Specialist:name format)
|
||||
const specialistMatch = line.match(/^\[Specialist:([\w-]+)\]\s*(.*)$/);
|
||||
if (specialistMatch) {
|
||||
return {
|
||||
source: `Specialist:${specialistMatch[1]}`,
|
||||
content: specialistMatch[2],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = line.match(pattern);
|
||||
if (match) {
|
||||
@@ -970,8 +1007,9 @@ function getPhaseFromSource(source: string): PRLogPhase {
|
||||
|
||||
if (contextSources.includes(source)) return "context";
|
||||
if (analysisSources.includes(source)) return "analysis";
|
||||
// Specialist agents (Agent:xxx) are part of analysis phase
|
||||
// Specialist agents (Agent:xxx and Specialist:xxx) are part of analysis phase
|
||||
if (source.startsWith("Agent:")) return "analysis";
|
||||
if (source.startsWith("Specialist:")) return "analysis";
|
||||
if (synthesisSources.includes(source)) return "synthesis";
|
||||
return "synthesis"; // Default to synthesis for unknown sources
|
||||
}
|
||||
@@ -1269,6 +1307,9 @@ async function runPRReview(
|
||||
// Build environment with project settings
|
||||
const subprocessEnv = await getRunnerEnv(getClaudeMdEnv(project));
|
||||
|
||||
// Create operation ID for this review
|
||||
const reviewKey = getReviewKey(project.id, prNumber);
|
||||
|
||||
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
|
||||
pythonPath: getPythonPath(backendPath),
|
||||
args,
|
||||
@@ -1303,10 +1344,17 @@ async function runPRReview(
|
||||
debugLog("Review result loaded", { findingsCount: reviewResult.findings.length });
|
||||
return reviewResult;
|
||||
},
|
||||
// Register with OperationRegistry for proactive swap support
|
||||
operationRegistration: {
|
||||
operationId: `pr-review:${reviewKey}`,
|
||||
operationType: 'pr-review',
|
||||
metadata: { projectId: project.id, prNumber, repo },
|
||||
// PR reviews don't support restart (would need to refetch PR data)
|
||||
// The review will complete or fail, and user can retry manually
|
||||
},
|
||||
});
|
||||
|
||||
// Register the running process
|
||||
const reviewKey = getReviewKey(project.id, prNumber);
|
||||
// Register the running process (keep legacy registry for cancel support)
|
||||
runningReviews.set(reviewKey, childProcess);
|
||||
debugLog("Registered review process", { reviewKey, pid: childProcess.pid });
|
||||
|
||||
@@ -1336,13 +1384,75 @@ async function runPRReview(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared helper to fetch PRs via GraphQL API.
|
||||
* Used by both listPRs and listMorePRs handlers to avoid code duplication.
|
||||
*/
|
||||
async function fetchPRsFromGraphQL(
|
||||
config: { token: string; repo: string },
|
||||
cursor: string | null,
|
||||
debugContext: string
|
||||
): Promise<PRListResult> {
|
||||
// Parse owner/repo from config - must be exactly "owner/repo" format
|
||||
const normalizedRepo = normalizeRepoReference(config.repo);
|
||||
const repoParts = normalizedRepo.split("/");
|
||||
if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) {
|
||||
debugLog("Invalid repo format - expected 'owner/repo'", {
|
||||
repo: config.repo,
|
||||
normalized: normalizedRepo,
|
||||
context: debugContext,
|
||||
});
|
||||
return { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
const [owner, repo] = repoParts;
|
||||
|
||||
try {
|
||||
// Use GraphQL API to get PRs with diff stats (REST list endpoint doesn't include them)
|
||||
// Fetches up to 100 open PRs (GitHub GraphQL max per request)
|
||||
const response = await githubGraphQL<GraphQLPRListResponse>(
|
||||
config.token,
|
||||
LIST_PRS_QUERY,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
first: 100, // GitHub GraphQL max is 100
|
||||
after: cursor,
|
||||
}
|
||||
);
|
||||
|
||||
// Handle case where repository doesn't exist or user lacks access
|
||||
if (!response.data.repository) {
|
||||
debugLog("Repository not found or access denied", { owner, repo, context: debugContext });
|
||||
return { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
|
||||
const { nodes: prNodes, pageInfo } = response.data.repository.pullRequests;
|
||||
|
||||
debugLog(`Fetched PRs via GraphQL (${debugContext})`, {
|
||||
count: prNodes.length,
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
endCursor: pageInfo.endCursor,
|
||||
});
|
||||
return {
|
||||
prs: prNodes.map(mapGraphQLPRToData),
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
endCursor: pageInfo.endCursor,
|
||||
};
|
||||
} catch (error) {
|
||||
debugLog(`Failed to fetch PRs (${debugContext})`, {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
return { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register PR-related handlers
|
||||
*/
|
||||
export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): void {
|
||||
debugLog("Registering PR handlers");
|
||||
|
||||
// List open PRs - fetches up to 100 open PRs at once, returns hasNextPage from API
|
||||
// List open PRs - fetches up to 100 open PRs at once, returns hasNextPage and endCursor from API
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_PR_LIST,
|
||||
async (_, projectId: string): Promise<PRListResult> => {
|
||||
@@ -1351,69 +1461,28 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
|
||||
const config = getGitHubConfig(project);
|
||||
if (!config) {
|
||||
debugLog("No GitHub config found for project");
|
||||
return { prs: [], hasNextPage: false };
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse owner/repo from config - must be exactly "owner/repo" format
|
||||
const normalizedRepo = normalizeRepoReference(config.repo);
|
||||
const repoParts = normalizedRepo.split("/");
|
||||
if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) {
|
||||
debugLog("Invalid repo format - expected 'owner/repo'", { repo: config.repo, normalized: normalizedRepo });
|
||||
return { prs: [], hasNextPage: false };
|
||||
}
|
||||
const [owner, repo] = repoParts;
|
||||
|
||||
// Use GraphQL API to get PRs with diff stats (REST list endpoint doesn't include them)
|
||||
// Fetches up to 100 open PRs (GitHub GraphQL max per request)
|
||||
const response = await githubGraphQL<GraphQLPRListResponse>(
|
||||
config.token,
|
||||
LIST_PRS_QUERY,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
first: 100, // GitHub GraphQL max is 100
|
||||
after: null, // Start from beginning
|
||||
}
|
||||
);
|
||||
|
||||
// Handle case where repository doesn't exist or user lacks access
|
||||
if (!response.data.repository) {
|
||||
debugLog("Repository not found or access denied", { owner, repo });
|
||||
return { prs: [], hasNextPage: false };
|
||||
}
|
||||
|
||||
const { nodes: prNodes, pageInfo } = response.data.repository.pullRequests;
|
||||
|
||||
debugLog("Fetched PRs via GraphQL", { count: prNodes.length, hasNextPage: pageInfo.hasNextPage });
|
||||
return {
|
||||
prs: prNodes.map((pr) => ({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
body: pr.body ?? "",
|
||||
state: pr.state.toLowerCase(),
|
||||
author: { login: pr.author?.login ?? "unknown" },
|
||||
headRefName: pr.headRefName,
|
||||
baseRefName: pr.baseRefName,
|
||||
additions: pr.additions,
|
||||
deletions: pr.deletions,
|
||||
changedFiles: pr.changedFiles,
|
||||
assignees: pr.assignees.nodes.map((a) => ({ login: a.login })),
|
||||
files: [],
|
||||
createdAt: pr.createdAt,
|
||||
updatedAt: pr.updatedAt,
|
||||
htmlUrl: pr.url,
|
||||
})),
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
};
|
||||
} catch (error) {
|
||||
debugLog("Failed to fetch PRs", {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
return { prs: [], hasNextPage: false };
|
||||
return { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
return fetchPRsFromGraphQL(config, null, "initial");
|
||||
});
|
||||
return result ?? { prs: [], hasNextPage: false };
|
||||
return result ?? { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
);
|
||||
|
||||
// Load more PRs (pagination) - fetches next page of PRs using cursor
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_PR_LIST_MORE,
|
||||
async (_, projectId: string, cursor: string): Promise<PRListResult> => {
|
||||
debugLog("listMorePRs handler called", { projectId, cursor });
|
||||
const result = await withProjectOrNull(projectId, async (project) => {
|
||||
const config = getGitHubConfig(project);
|
||||
if (!config) {
|
||||
debugLog("No GitHub config found for project");
|
||||
return { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
return fetchPRsFromGraphQL(config, cursor, "pagination");
|
||||
});
|
||||
return result ?? { prs: [], hasNextPage: false, endCursor: null };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2689,6 +2758,12 @@ export function registerPRHandlers(getMainWindow: () => BrowserWindow | null): v
|
||||
});
|
||||
return reviewResult;
|
||||
},
|
||||
// Register with OperationRegistry for proactive swap support
|
||||
operationRegistration: {
|
||||
operationId: `pr-followup-review:${reviewKey}`,
|
||||
operationType: 'pr-review',
|
||||
metadata: { projectId: project.id, prNumber, repo, isFollowup: true },
|
||||
},
|
||||
});
|
||||
|
||||
// Update registry with actual process (replacing placeholder)
|
||||
|
||||
@@ -19,8 +19,7 @@ import { getWhichCommand } from '../../platform';
|
||||
*/
|
||||
function checkGhCli(): { installed: boolean; error?: string } {
|
||||
try {
|
||||
const checkCmd = `${getWhichCommand()} gh`;
|
||||
execSync(checkCmd, { encoding: 'utf-8', stdio: 'pipe' });
|
||||
execFileSync(getWhichCommand(), ['gh'], { encoding: 'utf-8', stdio: 'pipe' });
|
||||
return { installed: true };
|
||||
} catch {
|
||||
return {
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { AuthFailureInfo, BillingFailureInfo } from '../../../../shared/typ
|
||||
import { parsePythonCommand } from '../../../python-detector';
|
||||
import { detectAuthFailure, detectBillingFailure } from '../../../rate-limit-detector';
|
||||
import { getClaudeProfileManager } from '../../../claude-profile-manager';
|
||||
import { getOperationRegistry, type OperationType } from '../../../claude-profile/operation-registry';
|
||||
import { isWindows, isMacOS } from '../../../platform';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
@@ -73,6 +74,23 @@ export interface SubprocessOptions {
|
||||
progressPattern?: RegExp;
|
||||
/** Additional environment variables to pass to the subprocess */
|
||||
env?: Record<string, string>;
|
||||
/**
|
||||
* Operation registration for proactive swap support.
|
||||
* If provided, the operation will be registered with the unified OperationRegistry.
|
||||
*/
|
||||
operationRegistration?: {
|
||||
/** Unique operation ID */
|
||||
operationId: string;
|
||||
/** Operation type for categorization */
|
||||
operationType: OperationType;
|
||||
/** Optional metadata for the operation */
|
||||
metadata?: Record<string, unknown>;
|
||||
/**
|
||||
* Function to restart the operation with a new profile.
|
||||
* Should call the original function with refreshed environment.
|
||||
*/
|
||||
restartFn?: (newProfileId: string) => boolean | Promise<boolean>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,6 +144,67 @@ export function runPythonSubprocess<T = unknown>(
|
||||
detached: !isWindows(),
|
||||
});
|
||||
|
||||
// Register with OperationRegistry for proactive swap support
|
||||
if (options.operationRegistration) {
|
||||
const { operationId, operationType, metadata, restartFn } = options.operationRegistration;
|
||||
const profileManager = getClaudeProfileManager();
|
||||
const activeProfile = profileManager.getActiveProfile();
|
||||
|
||||
if (activeProfile) {
|
||||
const operationRegistry = getOperationRegistry();
|
||||
|
||||
// Create a stop function that kills the subprocess.
|
||||
// Note: This sends SIGTERM and returns immediately without waiting for process exit.
|
||||
//
|
||||
// Timing dependency for restarts:
|
||||
// - For subprocess-runner operations, restartFn returns false so no race condition
|
||||
// (operations are non-resumable and won't be restarted, just stopped gracefully)
|
||||
// - For AgentManager operations, there's a 500ms setTimeout delay in restartTask
|
||||
// (see agent-manager.ts line 528) that mitigates the race between kill and restart
|
||||
//
|
||||
// RestartFn implementations should handle potential overlap between process termination
|
||||
// and restart initialization if not using the setTimeout pattern.
|
||||
const stopFn = async () => {
|
||||
if (child.pid) {
|
||||
try {
|
||||
if (!isWindows()) {
|
||||
process.kill(-child.pid, 'SIGTERM');
|
||||
} else {
|
||||
execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], () => {});
|
||||
}
|
||||
} catch {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Register with OperationRegistry for tracking and proactive swap support.
|
||||
// For operations that provide a restartFn, UsageMonitor can restart them with a new profile.
|
||||
// For operations without restartFn (e.g., PR reviews which are non-resumable due to one-shot workflow),
|
||||
// we register with a no-op restartFn that returns false. This allows the swap to stop the operation
|
||||
// gracefully without attempting restart. The operation will be killed when the profile swaps,
|
||||
// which is the correct behavior for non-resumable operations.
|
||||
operationRegistry.registerOperation(
|
||||
operationId,
|
||||
operationType,
|
||||
activeProfile.id,
|
||||
activeProfile.name,
|
||||
restartFn || (() => false), // Use provided restartFn or a no-op for non-resumable operations
|
||||
{
|
||||
stopFn,
|
||||
metadata: { ...metadata, pythonPath: options.pythonPath, cwd: options.cwd }
|
||||
}
|
||||
);
|
||||
|
||||
console.log('[SubprocessRunner] Operation registered with OperationRegistry:', {
|
||||
operationId,
|
||||
operationType,
|
||||
profileId: activeProfile.id,
|
||||
profileName: activeProfile.name
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const promise = new Promise<SubprocessResult<T>>((resolve) => {
|
||||
|
||||
let stdout = '';
|
||||
@@ -305,6 +384,11 @@ export function runPythonSubprocess<T = unknown>(
|
||||
// Treat null exit code (killed with SIGKILL) as failure, not success
|
||||
const exitCode = code ?? -1;
|
||||
|
||||
// Unregister from OperationRegistry when process exits
|
||||
if (options.operationRegistration) {
|
||||
getOperationRegistry().unregisterOperation(options.operationRegistration.operationId);
|
||||
}
|
||||
|
||||
// Debug logging only in development mode
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[DEBUG] Process exited with code:', exitCode, '(raw:', code, ')');
|
||||
|
||||
@@ -21,7 +21,6 @@ import type {
|
||||
GitLabAutoFixQueueItem,
|
||||
GitLabAutoFixProgress,
|
||||
GitLabIssueBatch,
|
||||
GitLabBatchProgress,
|
||||
GitLabAnalyzePreviewResult,
|
||||
} from './types';
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ function sendError(
|
||||
* Register investigation handler
|
||||
*/
|
||||
export function registerInvestigateIssue(
|
||||
agentManager: AgentManager,
|
||||
_agentManager: AgentManager,
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
ipcMain.on(
|
||||
@@ -129,16 +129,11 @@ export function registerInvestigateIssue(
|
||||
message: 'Analyzing issue with AI...'
|
||||
});
|
||||
|
||||
// Build context for investigation
|
||||
let context = buildIssueContext(issue, config.project, config.instanceUrl);
|
||||
|
||||
if (selectedNotes.length > 0) {
|
||||
context += '\n\n## Selected Comments\n';
|
||||
for (const note of selectedNotes) {
|
||||
context += `\n### Comment by ${note.author.username} (${new Date(note.created_at).toLocaleDateString()})\n`;
|
||||
context += note.body + '\n';
|
||||
}
|
||||
}
|
||||
// Note: Context building previously done here has been moved to createSpecForIssue utility.
|
||||
// The buildIssueContext() function and selectedNotes processing are now handled internally
|
||||
// by the spec creation pipeline. This avoids duplicate context generation.
|
||||
// TODO: If advanced context customization is needed in the future, consider extracting
|
||||
// context building into a reusable utility function.
|
||||
|
||||
// Use agent manager to investigate
|
||||
// Note: This is a simplified version - full implementation would use Claude SDK
|
||||
|
||||
@@ -21,7 +21,6 @@ import { getGitLabConfig, gitlabFetch, encodeProjectPath } from './utils';
|
||||
import { readSettingsFile } from '../../settings-utils';
|
||||
import type { Project, AppSettings } from '../../../shared/types';
|
||||
import type {
|
||||
MRReviewFinding,
|
||||
MRReviewResult,
|
||||
MRReviewProgress,
|
||||
NewCommitsCheck,
|
||||
|
||||
@@ -248,13 +248,13 @@ export function registerStartGlabAuth(): void {
|
||||
env: getAugmentedEnv()
|
||||
});
|
||||
|
||||
let output = '';
|
||||
let _output = '';
|
||||
let errorOutput = '';
|
||||
let browserOpened = false;
|
||||
|
||||
glabProcess.stdout?.on('data', (data) => {
|
||||
const chunk = data.toString('utf-8');
|
||||
output += chunk;
|
||||
_output += chunk;
|
||||
debugLog('glab stdout:', chunk);
|
||||
|
||||
// Try to open browser if URL detected
|
||||
|
||||
@@ -8,7 +8,7 @@ import path from 'path';
|
||||
import type { Project } from '../../../shared/types';
|
||||
import type { GitLabAPIIssue, GitLabConfig } from './types';
|
||||
import { labelMatchesWholeWord } from '../shared/label-utils';
|
||||
import { stripControlChars, sanitizeText, sanitizeStringArray } from '../shared/sanitize';
|
||||
import { sanitizeText, sanitizeStringArray } from '../shared/sanitize';
|
||||
|
||||
/**
|
||||
* Simplified task info returned when creating a spec from a GitLab issue.
|
||||
|
||||
@@ -201,6 +201,10 @@ function getOllamaInstallCommand(): string {
|
||||
* Spawns a subprocess to run Ollama detection/management commands with a 10-second timeout.
|
||||
* Used to check Ollama status, list models, and manage downloads.
|
||||
*
|
||||
* Includes deduplication: identical command+baseUrl requests within 2s return the cached
|
||||
* result/promise instead of spawning a new subprocess. This prevents runaway subprocess
|
||||
* spawning from React re-render loops.
|
||||
*
|
||||
* Supported commands:
|
||||
* - 'check-status': Verify Ollama service is running
|
||||
* - 'list-models': Get all available models
|
||||
@@ -212,9 +216,43 @@ function getOllamaInstallCommand(): string {
|
||||
* @param {string} [baseUrl] - Optional Ollama API base URL (defaults to http://localhost:11434)
|
||||
* @returns {Promise<{success, data?, error?}>} Result object with success flag and data/error
|
||||
*/
|
||||
// Deduplication cache to prevent rapid-fire subprocess spawning (e.g., from React re-render loops)
|
||||
const ollamaDetectorCache = new Map<string, { promise: Promise<{ success: boolean; data?: unknown; error?: string }>; timestamp: number }>();
|
||||
const OLLAMA_CACHE_TTL_MS = 2000; // Cache results for 2 seconds
|
||||
|
||||
async function executeOllamaDetector(
|
||||
command: string,
|
||||
baseUrl?: string
|
||||
): Promise<{ success: boolean; data?: unknown; error?: string }> {
|
||||
// Deduplication: return cached promise for identical requests within TTL
|
||||
const cacheKey = `${command}:${baseUrl || 'default'}`;
|
||||
const cached = ollamaDetectorCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < OLLAMA_CACHE_TTL_MS) {
|
||||
if (process.env.DEBUG) {
|
||||
console.log('[OllamaDetector] Returning cached result for:', command);
|
||||
}
|
||||
return cached.promise;
|
||||
}
|
||||
|
||||
const promise = executeOllamaDetectorImpl(command, baseUrl);
|
||||
ollamaDetectorCache.set(cacheKey, { promise, timestamp: Date.now() });
|
||||
|
||||
// Clean up cache entry after TTL
|
||||
promise.finally(() => {
|
||||
setTimeout(() => {
|
||||
const entry = ollamaDetectorCache.get(cacheKey);
|
||||
if (entry && entry.promise === promise) {
|
||||
ollamaDetectorCache.delete(cacheKey);
|
||||
}
|
||||
}, OLLAMA_CACHE_TTL_MS);
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function executeOllamaDetectorImpl(
|
||||
command: string,
|
||||
baseUrl?: string
|
||||
): Promise<{ success: boolean; data?: unknown; error?: string }> {
|
||||
// Use configured Python path (venv if ready, otherwise bundled/system)
|
||||
// Note: ollama_model_detector.py doesn't require dotenv, but using venv is safer
|
||||
@@ -483,7 +521,7 @@ export function registerMemoryHandlers(): void {
|
||||
};
|
||||
} else {
|
||||
// Basic validation for other providers
|
||||
llmResult = config.apiKey && config.apiKey.trim()
|
||||
llmResult = config.apiKey?.trim()
|
||||
? {
|
||||
success: true,
|
||||
message: `${config.llmProvider} API key format appears valid`,
|
||||
@@ -703,7 +741,7 @@ export function registerMemoryHandlers(): void {
|
||||
async (
|
||||
event,
|
||||
modelName: string,
|
||||
baseUrl?: string
|
||||
_baseUrl?: string
|
||||
): Promise<IPCResult<OllamaPullResult>> => {
|
||||
try {
|
||||
// Use configured Python path (venv if ready, otherwise bundled/system)
|
||||
|
||||
@@ -16,7 +16,6 @@ import type { IPCResult } from '../../shared/types';
|
||||
import type { APIProfile, ProfileFormData, ProfilesFile, TestConnectionResult, DiscoverModelsResult } from '@shared/types/profile';
|
||||
import {
|
||||
loadProfilesFile,
|
||||
saveProfilesFile,
|
||||
validateFilePermissions,
|
||||
getProfilesFilePath,
|
||||
atomicModifyProfiles,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { ipcMain, app } from 'electron';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { existsSync, } from 'fs';
|
||||
import path from 'path';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { is } from '@electron-toolkit/utils';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type {
|
||||
Project,
|
||||
@@ -10,7 +9,8 @@ import type {
|
||||
IPCResult,
|
||||
InitializationResult,
|
||||
AutoBuildVersionInfo,
|
||||
GitStatus
|
||||
GitStatus,
|
||||
GitBranchDetail
|
||||
} from '../../shared/types';
|
||||
import { projectStore } from '../project-store';
|
||||
import {
|
||||
@@ -89,6 +89,96 @@ function getGitBranches(projectPath: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get structured branch information for a directory (both local and remote)
|
||||
* Returns GitBranchDetail[] with type indicators, keeping both local and remote versions
|
||||
* when a branch exists in both places (no deduplication)
|
||||
*/
|
||||
function getGitBranchesWithInfo(projectPath: string): GitBranchDetail[] {
|
||||
try {
|
||||
// First fetch to ensure we have latest remote refs
|
||||
try {
|
||||
execFileSync(getToolPath('git'), ['fetch', '--prune'], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000 // 10 second timeout for fetch
|
||||
});
|
||||
} catch {
|
||||
// Fetch may fail if offline or no remote, continue with local refs
|
||||
}
|
||||
|
||||
// Get current branch for isCurrent indicator
|
||||
let currentBranch: string | null = null;
|
||||
try {
|
||||
const currentResult = execFileSync(getToolPath('git'), ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
currentBranch = currentResult.trim() || null;
|
||||
} catch {
|
||||
// Ignore - current branch detection may fail in some edge cases
|
||||
}
|
||||
|
||||
// Get local branches
|
||||
const localResult = execFileSync(getToolPath('git'), ['branch', '--format=%(refname:short)'], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
const localBranches: GitBranchDetail[] = localResult.trim().split('\n')
|
||||
.filter(b => b.trim())
|
||||
.map(b => {
|
||||
const name = b.trim();
|
||||
return {
|
||||
name,
|
||||
type: 'local' as const,
|
||||
displayName: name,
|
||||
isCurrent: name === currentBranch
|
||||
};
|
||||
});
|
||||
|
||||
// Get remote branches
|
||||
let remoteBranches: GitBranchDetail[] = [];
|
||||
try {
|
||||
const remoteResult = execFileSync(getToolPath('git'), ['branch', '-r', '--format=%(refname:short)'], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
remoteBranches = remoteResult.trim().split('\n')
|
||||
.filter(b => b.trim())
|
||||
.map(b => b.trim())
|
||||
// Remove HEAD pointer entries like "origin/HEAD"
|
||||
.filter(b => !b.endsWith('/HEAD'))
|
||||
.map(name => ({
|
||||
name,
|
||||
type: 'remote' as const,
|
||||
displayName: name,
|
||||
isCurrent: false
|
||||
}));
|
||||
} catch {
|
||||
// Remote branches may not exist, continue with local only
|
||||
}
|
||||
|
||||
// Combine and sort: local branches first, then remote branches, alphabetically within each group
|
||||
const allBranches = [...localBranches, ...remoteBranches];
|
||||
|
||||
return allBranches.sort((a, b) => {
|
||||
// Local branches come first
|
||||
if (a.type === 'local' && b.type === 'remote') return -1;
|
||||
if (a.type === 'remote' && b.type === 'local') return 1;
|
||||
// Within same type, sort alphabetically
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current git branch for a directory
|
||||
*/
|
||||
@@ -142,8 +232,6 @@ function detectMainBranch(projectPath: string): string | null {
|
||||
return branches[0] || null;
|
||||
}
|
||||
|
||||
const settingsPath = path.join(app.getPath('userData'), 'settings.json');
|
||||
|
||||
/**
|
||||
* Configure all Python-dependent services with the managed Python path
|
||||
*/
|
||||
@@ -449,7 +537,7 @@ export function registerProjectHandlers(
|
||||
// Git Operations
|
||||
// ============================================
|
||||
|
||||
// Get all branches for a project
|
||||
// Get all branches for a project (legacy - returns string[])
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GIT_GET_BRANCHES,
|
||||
async (_, projectPath: string): Promise<IPCResult<string[]>> => {
|
||||
@@ -468,6 +556,25 @@ export function registerProjectHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
// Get all branches with structured type information (local vs remote)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GIT_GET_BRANCHES_WITH_INFO,
|
||||
async (_, projectPath: string): Promise<IPCResult<GitBranchDetail[]>> => {
|
||||
try {
|
||||
if (!existsSync(projectPath)) {
|
||||
return { success: false, error: 'Directory does not exist' };
|
||||
}
|
||||
const branches = getGitBranchesWithInfo(projectPath);
|
||||
return { success: true, data: branches };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get current branch for a project
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GIT_GET_CURRENT_BRANCH,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import type {
|
||||
IPCResult,
|
||||
Roadmap,
|
||||
RoadmapFeature,
|
||||
RoadmapFeatureStatus,
|
||||
RoadmapGenerationStatus,
|
||||
PersistedRoadmapProgress,
|
||||
|
||||
@@ -511,7 +511,7 @@ export function registerSettingsHandlers(
|
||||
error: `Path is not a directory: ${resolvedPath}`
|
||||
};
|
||||
}
|
||||
} catch (statError) {
|
||||
} catch (_statError) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Cannot access path: ${resolvedPath}`
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Tests for findTaskAndProject cross-project scoping.
|
||||
* Verifies that projectId prevents cross-project task contamination
|
||||
* when multiple projects have tasks with the same specId.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { findTaskAndProject } from '../shared';
|
||||
import type { Task, Project } from '../../../../shared/types';
|
||||
|
||||
// Mock projectStore
|
||||
const mockProjects: Project[] = [];
|
||||
const mockTasksByProject: Map<string, Task[]> = new Map();
|
||||
|
||||
vi.mock('../../../project-store', () => ({
|
||||
projectStore: {
|
||||
getProjects: () => mockProjects,
|
||||
getTasks: (projectId: string) => mockTasksByProject.get(projectId) || []
|
||||
}
|
||||
}));
|
||||
|
||||
function createTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: `task-${Date.now()}-${Math.random().toString(36).substring(7)}`,
|
||||
specId: 'test-spec',
|
||||
projectId: 'project-1',
|
||||
title: 'Test Task',
|
||||
description: 'Test',
|
||||
status: 'backlog',
|
||||
subtasks: [],
|
||||
logs: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function createProject(overrides: Partial<Project> = {}): Project {
|
||||
return {
|
||||
id: `project-${Date.now()}`,
|
||||
name: 'Test Project',
|
||||
path: '/test/project',
|
||||
createdAt: new Date().toISOString(),
|
||||
lastOpenedAt: new Date().toISOString(),
|
||||
...overrides
|
||||
} as Project;
|
||||
}
|
||||
|
||||
describe('findTaskAndProject', () => {
|
||||
beforeEach(() => {
|
||||
mockProjects.length = 0;
|
||||
mockTasksByProject.clear();
|
||||
});
|
||||
|
||||
it('should find task by specId without projectId (backward compatibility)', () => {
|
||||
const project = createProject({ id: 'proj-1' });
|
||||
const task = createTask({ id: 'task-1', specId: 'write-to-file', projectId: 'proj-1' });
|
||||
|
||||
mockProjects.push(project);
|
||||
mockTasksByProject.set('proj-1', [task]);
|
||||
|
||||
const result = findTaskAndProject('write-to-file');
|
||||
expect(result.task).toBe(task);
|
||||
expect(result.project).toBe(project);
|
||||
});
|
||||
|
||||
it('should scope search to specified project when projectId is provided', () => {
|
||||
const projectA = createProject({ id: 'proj-a', name: 'Project A' });
|
||||
const projectB = createProject({ id: 'proj-b', name: 'Project B' });
|
||||
|
||||
const taskA = createTask({ id: 'task-a', specId: 'write-to-file', projectId: 'proj-a' });
|
||||
const taskB = createTask({ id: 'task-b', specId: 'write-to-file', projectId: 'proj-b' });
|
||||
|
||||
mockProjects.push(projectA, projectB);
|
||||
mockTasksByProject.set('proj-a', [taskA]);
|
||||
mockTasksByProject.set('proj-b', [taskB]);
|
||||
|
||||
// Without projectId - returns first match (Project A)
|
||||
const resultNoScope = findTaskAndProject('write-to-file');
|
||||
expect(resultNoScope.task).toBe(taskA);
|
||||
expect(resultNoScope.project).toBe(projectA);
|
||||
|
||||
// With projectId for Project B - returns Project B's task
|
||||
const resultScopedB = findTaskAndProject('write-to-file', 'proj-b');
|
||||
expect(resultScopedB.task).toBe(taskB);
|
||||
expect(resultScopedB.project).toBe(projectB);
|
||||
|
||||
// With projectId for Project A - returns Project A's task
|
||||
const resultScopedA = findTaskAndProject('write-to-file', 'proj-a');
|
||||
expect(resultScopedA.task).toBe(taskA);
|
||||
expect(resultScopedA.project).toBe(projectA);
|
||||
});
|
||||
|
||||
it('should NOT fall back to other projects when projectId is provided but task not found', () => {
|
||||
const projectA = createProject({ id: 'proj-a' });
|
||||
const projectB = createProject({ id: 'proj-b' });
|
||||
|
||||
const taskA = createTask({ id: 'task-a', specId: 'write-to-file', projectId: 'proj-a' });
|
||||
|
||||
mockProjects.push(projectA, projectB);
|
||||
mockTasksByProject.set('proj-a', [taskA]);
|
||||
mockTasksByProject.set('proj-b', []);
|
||||
|
||||
// Search Project B (which has no tasks) — should NOT find Project A's task
|
||||
const result = findTaskAndProject('write-to-file', 'proj-b');
|
||||
expect(result.task).toBeUndefined();
|
||||
expect(result.project).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when projectId refers to a non-existent project', () => {
|
||||
const project = createProject({ id: 'proj-1' });
|
||||
const task = createTask({ id: 'task-1', specId: 'write-to-file', projectId: 'proj-1' });
|
||||
|
||||
mockProjects.push(project);
|
||||
mockTasksByProject.set('proj-1', [task]);
|
||||
|
||||
// Search with a projectId that doesn't exist — should NOT fall back
|
||||
const result = findTaskAndProject('write-to-file', 'non-existent-project');
|
||||
expect(result.task).toBeUndefined();
|
||||
expect(result.project).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when task not found in any project', () => {
|
||||
const project = createProject({ id: 'proj-1' });
|
||||
mockProjects.push(project);
|
||||
mockTasksByProject.set('proj-1', []);
|
||||
|
||||
const result = findTaskAndProject('nonexistent-task');
|
||||
expect(result.task).toBeUndefined();
|
||||
expect(result.project).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should find task by id as well as specId', () => {
|
||||
const project = createProject({ id: 'proj-1' });
|
||||
const task = createTask({ id: 'unique-uuid', specId: 'write-to-file', projectId: 'proj-1' });
|
||||
|
||||
mockProjects.push(project);
|
||||
mockTasksByProject.set('proj-1', [task]);
|
||||
|
||||
const result = findTaskAndProject('unique-uuid', 'proj-1');
|
||||
expect(result.task).toBe(task);
|
||||
expect(result.project).toBe(project);
|
||||
});
|
||||
|
||||
it('should log warning when provided projectId is not found', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
mockProjects.push(createProject({ id: 'proj-1' }));
|
||||
|
||||
findTaskAndProject('some-task', 'ghost-project');
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ghost-project'),
|
||||
// Flexible match on the rest of the message
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -246,7 +246,7 @@ export function registerTaskExecutionHandlers(
|
||||
// Start spec creation process - pass the existing spec directory
|
||||
// so spec_runner uses it instead of creating a new one
|
||||
// Also pass baseBranch so worktrees are created from the correct branch
|
||||
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDir, task.metadata, baseBranch);
|
||||
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDir, task.metadata, baseBranch, project.id);
|
||||
} else if (needsImplementation) {
|
||||
// Spec exists but no subtasks - run run.py to create implementation plan and execute
|
||||
// Read the spec.md to get the task description
|
||||
@@ -268,8 +268,10 @@ export function registerTaskExecutionHandlers(
|
||||
parallel: false, // Sequential for planning phase
|
||||
workers: 1,
|
||||
baseBranch,
|
||||
useWorktree: task.metadata?.useWorktree
|
||||
}
|
||||
useWorktree: task.metadata?.useWorktree,
|
||||
useLocalBranch: task.metadata?.useLocalBranch
|
||||
},
|
||||
project.id
|
||||
);
|
||||
} else {
|
||||
// Task has subtasks, start normal execution
|
||||
@@ -284,8 +286,10 @@ export function registerTaskExecutionHandlers(
|
||||
parallel: false,
|
||||
workers: 1,
|
||||
baseBranch,
|
||||
useWorktree: task.metadata?.useWorktree
|
||||
}
|
||||
useWorktree: task.metadata?.useWorktree,
|
||||
useLocalBranch: task.metadata?.useLocalBranch
|
||||
},
|
||||
project.id
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -495,7 +499,7 @@ export function registerTaskExecutionHandlers(
|
||||
// The QA process needs to run where the implementation_plan.json with completed subtasks is
|
||||
const qaProjectPath = hasWorktree ? worktreePath : project.path;
|
||||
console.warn('[TASK_REVIEW] Starting QA process with projectPath:', qaProjectPath);
|
||||
agentManager.startQAProcess(taskId, qaProjectPath, task.specId);
|
||||
agentManager.startQAProcess(taskId, qaProjectPath, task.specId, project.id);
|
||||
|
||||
taskStateManager.handleUiEvent(
|
||||
taskId,
|
||||
@@ -727,7 +731,7 @@ export function registerTaskExecutionHandlers(
|
||||
// No spec file - need to run spec_runner.py to create the spec
|
||||
const taskDescription = task.description || task.title;
|
||||
console.warn('[TASK_UPDATE_STATUS] Starting spec creation for:', task.specId);
|
||||
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDir, task.metadata, baseBranchForUpdate);
|
||||
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDir, task.metadata, baseBranchForUpdate, project.id);
|
||||
} else if (needsImplementation) {
|
||||
// Spec exists but no subtasks - run run.py to create implementation plan and execute
|
||||
console.warn('[TASK_UPDATE_STATUS] Starting task execution (no subtasks) for:', task.specId);
|
||||
@@ -739,8 +743,10 @@ export function registerTaskExecutionHandlers(
|
||||
parallel: false,
|
||||
workers: 1,
|
||||
baseBranch: baseBranchForUpdate,
|
||||
useWorktree: task.metadata?.useWorktree
|
||||
}
|
||||
useWorktree: task.metadata?.useWorktree,
|
||||
useLocalBranch: task.metadata?.useLocalBranch
|
||||
},
|
||||
project.id
|
||||
);
|
||||
} else {
|
||||
// Task has subtasks, start normal execution
|
||||
@@ -754,8 +760,10 @@ export function registerTaskExecutionHandlers(
|
||||
parallel: false,
|
||||
workers: 1,
|
||||
baseBranch: baseBranchForUpdate,
|
||||
useWorktree: task.metadata?.useWorktree
|
||||
}
|
||||
useWorktree: task.metadata?.useWorktree,
|
||||
useLocalBranch: task.metadata?.useLocalBranch
|
||||
},
|
||||
project.id
|
||||
);
|
||||
}
|
||||
|
||||
@@ -792,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)
|
||||
*/
|
||||
@@ -1108,7 +1183,7 @@ export function registerTaskExecutionHandlers(
|
||||
// No spec file - need to run spec_runner.py to create the spec
|
||||
const taskDescription = task.description || task.title;
|
||||
console.warn(`[Recovery] Starting spec creation for: ${task.specId}`);
|
||||
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDirForWatcher, task.metadata, baseBranchForRecovery);
|
||||
agentManager.startSpecCreation(taskId, project.path, taskDescription, specDirForWatcher, task.metadata, baseBranchForRecovery, project.id);
|
||||
} else {
|
||||
// Spec exists - run task execution
|
||||
console.warn(`[Recovery] Starting task execution for: ${task.specId}`);
|
||||
@@ -1120,8 +1195,10 @@ export function registerTaskExecutionHandlers(
|
||||
parallel: false,
|
||||
workers: 1,
|
||||
baseBranch: baseBranchForRecovery,
|
||||
useWorktree: task.metadata?.useWorktree
|
||||
}
|
||||
useWorktree: task.metadata?.useWorktree,
|
||||
useLocalBranch: task.metadata?.useLocalBranch
|
||||
},
|
||||
project.id
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -314,8 +314,8 @@ export function persistPlanPhaseSync(
|
||||
const phaseToStatus: Record<string, TaskStatus> = {
|
||||
'planning': 'in_progress',
|
||||
'coding': 'in_progress',
|
||||
'qa_review': 'in_progress',
|
||||
'qa_fixing': 'in_progress',
|
||||
'qa_review': 'ai_review',
|
||||
'qa_fixing': 'ai_review',
|
||||
'complete': 'human_review',
|
||||
'failed': 'error'
|
||||
};
|
||||
|
||||
@@ -2,21 +2,39 @@ import type { Task, Project } from '../../../shared/types';
|
||||
import { projectStore } from '../../project-store';
|
||||
|
||||
/**
|
||||
* Helper function to find task and project by taskId
|
||||
* Helper function to find task and project by taskId.
|
||||
*
|
||||
* When projectId is provided, the search is strictly scoped to that project.
|
||||
* If the task is not found in the specified project, returns undefined (does NOT
|
||||
* fall back to other projects). This prevents cross-project contamination when
|
||||
* multiple projects have tasks with the same specId.
|
||||
*
|
||||
* When projectId is NOT provided, searches all projects for backward
|
||||
* compatibility with callers that don't have projectId (e.g., file watcher events).
|
||||
*/
|
||||
export const findTaskAndProject = (taskId: string): { task: Task | undefined; project: Project | undefined } => {
|
||||
export const findTaskAndProject = (taskId: string, projectId?: string): { task: Task | undefined; project: Project | undefined } => {
|
||||
const projects = projectStore.getProjects();
|
||||
let task: Task | undefined;
|
||||
let project: Project | undefined;
|
||||
|
||||
// If projectId provided, search ONLY that project (no fallback)
|
||||
if (projectId) {
|
||||
const targetProject = projects.find((p) => p.id === projectId);
|
||||
if (!targetProject) {
|
||||
console.warn(`[findTaskAndProject] projectId "${projectId}" not found in projects list, returning undefined`);
|
||||
return { task: undefined, project: undefined };
|
||||
}
|
||||
const tasks = projectStore.getTasks(targetProject.id);
|
||||
const task = tasks.find((t) => t.id === taskId || t.specId === taskId);
|
||||
return { task, project: task ? targetProject : undefined };
|
||||
}
|
||||
|
||||
// No projectId: search all projects (backward compatibility for file watcher etc.)
|
||||
for (const p of projects) {
|
||||
const tasks = projectStore.getTasks(p.id);
|
||||
task = tasks.find((t) => t.id === taskId || t.specId === taskId);
|
||||
const task = tasks.find((t) => t.id === taskId || t.specId === taskId);
|
||||
if (task) {
|
||||
project = p;
|
||||
break;
|
||||
return { task, project: p };
|
||||
}
|
||||
}
|
||||
|
||||
return { task, project };
|
||||
return { task: undefined, project: undefined };
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeDiffFile, Worktre
|
||||
import path from 'path';
|
||||
import { minimatch } from 'minimatch';
|
||||
import { existsSync, readdirSync, statSync, readFileSync, promises as fsPromises } from 'fs';
|
||||
import { execSync, execFileSync, spawn, spawnSync, exec, execFile } from 'child_process';
|
||||
import { execFileSync, spawn, spawnSync, exec, execFile } from 'child_process';
|
||||
import { homedir } from 'os';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { getConfiguredPythonPath, PythonEnvManager, pythonEnvManager as pythonEnvManagerSingleton } from '../../python-env-manager';
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
findTaskWorktree,
|
||||
} from '../../worktree-paths';
|
||||
import { persistPlanStatus, updateTaskMetadataPrUrl } from './plan-file-utils';
|
||||
import { getIsolatedGitEnv, detectWorktreeBranch, refreshGitIndex } from '../../utils/git-isolation';
|
||||
import { getIsolatedGitEnv, refreshGitIndex } from '../../utils/git-isolation';
|
||||
import { cleanupWorktree } from '../../utils/worktree-cleanup';
|
||||
import { killProcessGracefully } from '../../platform';
|
||||
import { stripAnsiCodes } from '../../../shared/utils/ansi-sanitizer';
|
||||
@@ -1067,7 +1067,7 @@ async function detectLinuxApps(): Promise<Set<string>> {
|
||||
function isAppInstalled(
|
||||
appNames: string[],
|
||||
specificPaths: string[],
|
||||
platform: string
|
||||
_platform: string
|
||||
): { installed: boolean; foundPath: string } {
|
||||
// First, check the cached app list (fast)
|
||||
for (const name of appNames) {
|
||||
@@ -2568,7 +2568,7 @@ export function registerWorktreeHandlers(
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
if (gitStatus && gitStatus.trim()) {
|
||||
if (gitStatus?.trim()) {
|
||||
// Parse the status output to get file names
|
||||
// Format: XY filename (where X and Y are status chars, then space, then filename)
|
||||
uncommittedFiles = gitStatus
|
||||
|
||||
@@ -8,7 +8,7 @@ import { TerminalManager } from '../terminal-manager';
|
||||
import { projectStore } from '../project-store';
|
||||
import { terminalNameGenerator } from '../terminal-name-generator';
|
||||
import { readSettingsFileAsync } from '../settings-utils';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
import { debugLog, } from '../../shared/utils/debug-logger';
|
||||
import { migrateSession } from '../claude-profile/session-utils';
|
||||
import { createProfileDirectory } from '../claude-profile/profile-utils';
|
||||
import { isValidConfigDir } from '../utils/config-path-validator';
|
||||
@@ -55,10 +55,11 @@ export function registerTerminalHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_RESIZE,
|
||||
(_, id: string, cols: number, rows: number) => {
|
||||
terminalManager.resize(id, cols, rows);
|
||||
async (_, id: string, cols: number, rows: number): Promise<IPCResult<{ success: boolean }>> => {
|
||||
const success = terminalManager.resize(id, cols, rows);
|
||||
return { success, data: { success } };
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -345,9 +345,9 @@ function loadWorktreeConfig(projectPath: string, name: string): TerminalWorktree
|
||||
async function createTerminalWorktree(
|
||||
request: CreateTerminalWorktreeRequest
|
||||
): Promise<TerminalWorktreeResult> {
|
||||
const { terminalId, name, taskId, createGitBranch, projectPath, baseBranch: customBaseBranch } = request;
|
||||
const { terminalId, name, taskId, createGitBranch, projectPath, baseBranch: customBaseBranch, useLocalBranch } = request;
|
||||
|
||||
debugLog('[TerminalWorktree] Creating worktree:', { name, taskId, createGitBranch, projectPath, customBaseBranch });
|
||||
debugLog('[TerminalWorktree] Creating worktree:', { name, taskId, createGitBranch, projectPath, customBaseBranch, useLocalBranch });
|
||||
|
||||
// Validate projectPath against registered projects
|
||||
if (!isValidProjectPath(projectPath)) {
|
||||
@@ -418,8 +418,13 @@ async function createTerminalWorktree(
|
||||
// Already a remote ref, use as-is
|
||||
baseRef = baseBranch;
|
||||
debugLog('[TerminalWorktree] Using remote ref directly:', baseRef);
|
||||
} else if (useLocalBranch) {
|
||||
// User explicitly requested local branch - skip auto-switch to remote
|
||||
// This preserves gitignored files (.env, configs) that may not exist on remote
|
||||
baseRef = baseBranch;
|
||||
debugLog('[TerminalWorktree] Using local branch (explicit):', baseRef);
|
||||
} else {
|
||||
// Check if remote version exists and use it for latest code
|
||||
// Default behavior: check if remote version exists and use it for latest code
|
||||
try {
|
||||
execFileSync(getToolPath('git'), ['rev-parse', '--verify', `origin/${baseBranch}`], {
|
||||
cwd: projectPath,
|
||||
|
||||
@@ -29,8 +29,6 @@ export class LogService {
|
||||
|
||||
// Flush logs to disk every 2 seconds to balance performance vs data safety
|
||||
private readonly FLUSH_INTERVAL_MS = 2000;
|
||||
// Maximum log file size before rotation (10MB)
|
||||
private readonly MAX_LOG_SIZE_BYTES = 10 * 1024 * 1024;
|
||||
// Keep last N sessions
|
||||
private readonly MAX_SESSIONS_TO_KEEP = 10;
|
||||
|
||||
@@ -205,7 +203,7 @@ export class LogService {
|
||||
const sessionId = file.replace('session-', '').replace('.log', '');
|
||||
|
||||
// Parse session ID back to date
|
||||
const dateStr = sessionId.replace(/-/g, (match, offset) => {
|
||||
const dateStr = sessionId.replace(/-/g, (_match, offset) => {
|
||||
// Replace first 2 dashes with actual dashes, rest with colons
|
||||
if (offset < 10) return '-';
|
||||
if (offset === 10) return 'T';
|
||||
|
||||
@@ -243,7 +243,7 @@ function getWindowsShellConfig(preferredShell?: ShellType): ShellConfig {
|
||||
/**
|
||||
* Get Unix shell configuration
|
||||
*/
|
||||
function getUnixShellConfig(preferredShell?: ShellType): ShellConfig {
|
||||
function getUnixShellConfig(_preferredShell?: ShellType): ShellConfig {
|
||||
const shellPath = process.env.SHELL || '/bin/zsh';
|
||||
|
||||
return {
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { existsSync, readdirSync } from 'fs';
|
||||
import { isWindows, isMacOS, getHomebrewPath, joinPaths, getExecutableExtension } from './index';
|
||||
import { getWhereExePath } from '../utils/windows-paths';
|
||||
|
||||
/**
|
||||
* Resolve Claude CLI executable path
|
||||
@@ -174,7 +175,7 @@ export function getWindowsShellPaths(): Record<string, string[]> {
|
||||
return {};
|
||||
}
|
||||
|
||||
const systemRoot = process.env.SystemRoot || 'C:\\Windows';
|
||||
const systemRoot = process.env.SystemRoot || process.env.SYSTEMROOT || 'C:\\Windows';
|
||||
|
||||
// Note: path.join('C:', 'foo') produces 'C:foo' (relative to C: drive), not 'C:\foo'
|
||||
// We must use 'C:\\' or raw paths like 'C:\\Program Files' to get absolute paths
|
||||
@@ -297,11 +298,14 @@ export function getOllamaInstallCommand(): string {
|
||||
/**
|
||||
* Get the command to find executables in PATH
|
||||
*
|
||||
* Windows: where.exe
|
||||
* Windows: Full path to where.exe (C:\Windows\System32\where.exe)
|
||||
* Using full path ensures it works even when System32 isn't in PATH,
|
||||
* which can happen in restricted environments or when Electron doesn't
|
||||
* inherit the full system PATH.
|
||||
* Unix: which
|
||||
*/
|
||||
export function getWhichCommand(): string {
|
||||
return isWindows() ? 'where.exe' : 'which';
|
||||
return isWindows() ? getWhereExePath() : 'which';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,10 +3,11 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, Dirent
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Project, ProjectSettings, Task, TaskStatus, TaskMetadata, ImplementationPlan, ReviewReason, PlanSubtask, KanbanPreferences, ExecutionPhase } from '../shared/types';
|
||||
import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir, JSON_ERROR_PREFIX, JSON_ERROR_TITLE_SUFFIX } from '../shared/constants';
|
||||
import { DEFAULT_PROJECT_SETTINGS, AUTO_BUILD_PATHS, getSpecsDir, JSON_ERROR_PREFIX, JSON_ERROR_TITLE_SUFFIX, TASK_STATUS_PRIORITY } from '../shared/constants';
|
||||
import { XSTATE_SETTLED_STATES } from '../shared/state-machines';
|
||||
import { getAutoBuildPath, isInitialized } from './project-initializer';
|
||||
import { getTaskWorktreeDir } from './worktree-paths';
|
||||
import { isValidTaskId, findAllSpecPaths } from './utils/spec-path-helpers';
|
||||
import { findAllSpecPaths } from './utils/spec-path-helpers';
|
||||
|
||||
interface TabState {
|
||||
openProjectIds: string[];
|
||||
@@ -324,12 +325,37 @@ export class ProjectStore {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Deduplicate tasks by ID (prefer worktree version if exists in both)
|
||||
// 3. Deduplicate tasks by ID
|
||||
// CRITICAL FIX: Don't blindly prefer worktree - it may be stale!
|
||||
// If main project task is "done", it should win over worktree's "in_progress".
|
||||
// Worktrees can linger after completion, containing outdated task data.
|
||||
const taskMap = new Map<string, Task>();
|
||||
for (const task of allTasks) {
|
||||
const existing = taskMap.get(task.id);
|
||||
if (!existing || task.location === 'worktree') {
|
||||
if (!existing) {
|
||||
// First occurrence wins
|
||||
taskMap.set(task.id, task);
|
||||
} else {
|
||||
// PREFER MAIN PROJECT over worktree - main has current user changes
|
||||
// Only use status priority when both are from same location
|
||||
const existingIsMain = existing.location === 'main';
|
||||
const newIsMain = task.location === 'main';
|
||||
|
||||
if (existingIsMain && !newIsMain) {
|
||||
} else if (!existingIsMain && newIsMain) {
|
||||
// New is main, replace existing worktree
|
||||
taskMap.set(task.id, task);
|
||||
} else {
|
||||
// Same location - use status priority to determine which is more complete
|
||||
const existingPriority = TASK_STATUS_PRIORITY[existing.status] || 0;
|
||||
const newPriority = TASK_STATUS_PRIORITY[task.status] || 0;
|
||||
|
||||
if (newPriority > existingPriority) {
|
||||
// New version has higher priority (more complete status)
|
||||
taskMap.set(task.id, task);
|
||||
}
|
||||
// Otherwise keep existing version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,10 +388,10 @@ export class ProjectStore {
|
||||
*/
|
||||
private loadTasksFromSpecsDir(
|
||||
specsDir: string,
|
||||
basePath: string,
|
||||
_basePath: string,
|
||||
location: 'main' | 'worktree',
|
||||
projectId: string,
|
||||
specsBaseDir: string
|
||||
_specsBaseDir: string
|
||||
): Task[] {
|
||||
const tasks: Task[] = [];
|
||||
let specDirs: Dirent[] = [];
|
||||
@@ -492,7 +518,7 @@ export class ProjectStore {
|
||||
// "# Specification: Title" -> "Title"
|
||||
// "# Title" -> "Title"
|
||||
const titleMatch = specContent.match(/^#\s+(?:Quick Spec:|Specification:)?\s*(.+)$/m);
|
||||
if (titleMatch && titleMatch[1]) {
|
||||
if (titleMatch?.[1]) {
|
||||
title = titleMatch[1].trim();
|
||||
}
|
||||
} catch {
|
||||
@@ -500,15 +526,33 @@ export class ProjectStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Use persisted executionPhase (from text parser) or xstateState for exact restoration
|
||||
// Priority: executionPhase > xstateState > inferred from status
|
||||
// Use xstateState as the authoritative source for execution phase when available.
|
||||
// Only fall back to persistedPhase for active running states where xstateState
|
||||
// might not reflect the detailed phase yet (e.g., planning vs coding within in_progress).
|
||||
// Priority: xstateState (when settled) > persistedPhase (when running) > inferred from status
|
||||
const persistedPhase = (plan as { executionPhase?: string } | null)?.executionPhase as ExecutionPhase | undefined;
|
||||
const xstateState = (plan as { xstateState?: string } | null)?.xstateState;
|
||||
const executionProgress = persistedPhase
|
||||
? { phase: persistedPhase, phaseProgress: 50, overallProgress: 50 }
|
||||
: xstateState
|
||||
? this.inferExecutionProgressFromXState(xstateState)
|
||||
: this.inferExecutionProgress(plan?.status);
|
||||
|
||||
// XState settled states take priority - these override any stale persisted phase
|
||||
// because the task is no longer actively running
|
||||
const isSettledState = xstateState && XSTATE_SETTLED_STATES.has(xstateState);
|
||||
|
||||
// Status-based override: if the task has reached a terminal status (human_review, done, pr_created)
|
||||
// but the persisted executionPhase is stale (e.g. still "planning"), correct it.
|
||||
// This happens when the XState machine didn't persist the final phase before being reset to backlog.
|
||||
const STATUS_IMPLIES_COMPLETE: ReadonlySet<string> = new Set(['human_review', 'done', 'pr_created']);
|
||||
const phaseIsStale = persistedPhase && persistedPhase !== 'complete' && persistedPhase !== 'failed'
|
||||
&& STATUS_IMPLIES_COMPLETE.has(finalStatus);
|
||||
|
||||
const executionProgress = isSettledState
|
||||
? this.inferExecutionProgressFromXState(xstateState) // Use XState for settled states
|
||||
: phaseIsStale
|
||||
? { phase: 'complete' as ExecutionPhase, phaseProgress: 100, overallProgress: 100 } // Fix stale phase
|
||||
: persistedPhase
|
||||
? { phase: persistedPhase, phaseProgress: 50, overallProgress: 50 } // Use persisted for running
|
||||
: xstateState
|
||||
? this.inferExecutionProgressFromXState(xstateState)
|
||||
: this.inferExecutionProgress(plan?.status);
|
||||
|
||||
tasks.push({
|
||||
id: dir.name, // Use spec directory name as ID
|
||||
|
||||
@@ -92,6 +92,25 @@ const BILLING_FAILURE_PATTERNS = [
|
||||
/top\s*up\s*(your\s*)?(account|credits|balance)/i
|
||||
];
|
||||
|
||||
/**
|
||||
* Maximum length for error messages sent to renderer.
|
||||
* Truncates to prevent exposing excessive internal details.
|
||||
*/
|
||||
const MAX_ERROR_LENGTH = 500;
|
||||
|
||||
/**
|
||||
* Sanitize error output before sending to renderer.
|
||||
* Truncates long output to prevent exposing excessive internal details
|
||||
* like full paths, API responses, or stack traces.
|
||||
*/
|
||||
function sanitizeErrorOutput(output: string): string {
|
||||
// Truncate long output to limit exposure of internal details
|
||||
if (output.length > MAX_ERROR_LENGTH) {
|
||||
return output.substring(0, MAX_ERROR_LENGTH) + '... (truncated)';
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of rate limit detection
|
||||
*/
|
||||
@@ -109,7 +128,7 @@ export interface RateLimitDetectionResult {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
/** Original error message */
|
||||
/** Original error message (truncated to 500 chars for security) */
|
||||
originalError?: string;
|
||||
}
|
||||
|
||||
@@ -193,7 +212,7 @@ export function detectRateLimit(
|
||||
id: bestProfile.id,
|
||||
name: bestProfile.name
|
||||
} : undefined,
|
||||
originalError: output
|
||||
originalError: sanitizeErrorOutput(output)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,7 +230,7 @@ export function detectRateLimit(
|
||||
id: bestProfile.id,
|
||||
name: bestProfile.name
|
||||
} : undefined,
|
||||
originalError: output
|
||||
originalError: sanitizeErrorOutput(output)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -265,7 +284,6 @@ function getAuthFailureMessage(failureType: 'missing' | 'invalid' | 'expired' |
|
||||
return 'Your Claude session has expired. Please re-authenticate in Settings > Claude Profiles.';
|
||||
case 'invalid':
|
||||
return 'Invalid Claude credentials. Please check your OAuth token or re-authenticate in Settings > Claude Profiles.';
|
||||
case 'unknown':
|
||||
default:
|
||||
return 'Claude authentication failed. Please verify your authentication in Settings > Claude Profiles.';
|
||||
}
|
||||
@@ -303,7 +321,6 @@ function getBillingFailureMessage(failureType: 'insufficient_credits' | 'payment
|
||||
return 'A billing error occurred with your Claude API account. Please check your payment method or switch to another profile in Settings > Claude Profiles.';
|
||||
case 'subscription_inactive':
|
||||
return 'Your Claude API subscription is inactive or expired. Please renew your subscription or switch to another profile in Settings > Claude Profiles.';
|
||||
case 'unknown':
|
||||
default:
|
||||
return 'A billing issue was detected with your Claude API account. Please check your account status or switch to another profile in Settings > Claude Profiles.';
|
||||
}
|
||||
@@ -333,7 +350,7 @@ export function detectAuthFailure(
|
||||
profileId: effectiveProfileId,
|
||||
failureType,
|
||||
message: getAuthFailureMessage(failureType),
|
||||
originalError: output
|
||||
originalError: sanitizeErrorOutput(output)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -375,7 +392,7 @@ export function detectBillingFailure(
|
||||
profileId: effectiveProfileId,
|
||||
failureType,
|
||||
message: getBillingFailureMessage(failureType),
|
||||
originalError: output
|
||||
originalError: sanitizeErrorOutput(output)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -631,7 +648,7 @@ export interface SDKRateLimitInfo {
|
||||
};
|
||||
/** When detected */
|
||||
detectedAt: Date;
|
||||
/** Original error message */
|
||||
/** Original error message (truncated to 500 chars for security) */
|
||||
originalError?: string;
|
||||
|
||||
// Auto-swap information
|
||||
|
||||
@@ -24,9 +24,6 @@ import { refreshGitIndex } from './utils/git-isolation';
|
||||
* If a worktree exists for a task NOT in this release, it won't block the release.
|
||||
*/
|
||||
export class ReleaseService extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CHANGELOG.md to extract releaseable versions.
|
||||
@@ -75,7 +72,7 @@ export class ReleaseService extends EventEmitter {
|
||||
* This allows us to scope worktree checks to only those tasks.
|
||||
*/
|
||||
getTasksForVersion(
|
||||
projectPath: string,
|
||||
_projectPath: string,
|
||||
version: string,
|
||||
tasks: Task[]
|
||||
): { taskIds: string[]; specIds: string[] } {
|
||||
|
||||
@@ -63,7 +63,7 @@ function getTracesSampleRate(): number {
|
||||
const envValue = buildTimeValue || process.env.SENTRY_TRACES_SAMPLE_RATE;
|
||||
if (envValue) {
|
||||
const parsed = parseFloat(envValue);
|
||||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ function getProfilesSampleRate(): number {
|
||||
const envValue = buildTimeValue || process.env.SENTRY_PROFILES_SAMPLE_RATE;
|
||||
if (envValue) {
|
||||
const parsed = parseFloat(envValue);
|
||||
if (!isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
getAPIProfileEnv,
|
||||
testConnection
|
||||
} from './profile-service';
|
||||
import type { APIProfile, ProfilesFile, TestConnectionResult } from '../../shared/types/profile';
|
||||
import type { ProfilesFile, } from '../../shared/types/profile';
|
||||
|
||||
// Mock profile-manager
|
||||
vi.mock('../utils/profile-manager', () => ({
|
||||
|
||||
@@ -90,8 +90,6 @@ export async function deleteProfile(id: string): Promise<void> {
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
const profile = file.profiles[profileIndex];
|
||||
|
||||
// Active Profile Check: Cannot delete active profile (AC3)
|
||||
if (file.activeProfileId === id) {
|
||||
throw new Error('Cannot delete active profile. Please switch to another profile or OAuth first.');
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
testConnection,
|
||||
discoverModels
|
||||
} from './profile-service';
|
||||
import type { APIProfile, ProfilesFile, TestConnectionResult } from '@shared/types/profile';
|
||||
import type { ProfilesFile, } from '@shared/types/profile';
|
||||
|
||||
// Mock Anthropic SDK - use vi.hoisted to properly hoist the mock variable
|
||||
const { mockModelsList, mockMessagesCreate } = vi.hoisted(() => ({
|
||||
|
||||
@@ -8,8 +8,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
SDKSessionRecoveryCoordinator,
|
||||
getRecoveryCoordinator,
|
||||
type SDKOperationType,
|
||||
type RecoveryCoordinatorConfig
|
||||
} from './sdk-session-recovery-coordinator';
|
||||
|
||||
// Mock dependencies
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
/**
|
||||
* SDK Session Recovery Coordinator
|
||||
*
|
||||
* @deprecated This module is deprecated in favor of ClaudeOperationRegistry
|
||||
* (src/main/claude-profile/operation-registry.ts). The OperationRegistry provides
|
||||
* similar functionality with a simpler API and is actively integrated with
|
||||
* AgentManager and UsageMonitor. This module is retained for backward compatibility
|
||||
* but should not be used for new code.
|
||||
*
|
||||
* TODO: Target removal in v0.5.0 (Q2 2026). Before removal:
|
||||
* 1. Identify any remaining usages in the codebase
|
||||
* 2. Migrate all remaining consumers to ClaudeOperationRegistry
|
||||
* 3. Remove this file and associated tests
|
||||
* 4. Update imports across the codebase
|
||||
*
|
||||
* Migration guide:
|
||||
* - Use getOperationRegistry() from '../claude-profile/operation-registry'
|
||||
* - registerOperation() -> operationRegistry.registerOperation()
|
||||
* - unregisterOperation() -> operationRegistry.unregisterOperation()
|
||||
* - getOperationsByProfile() -> operationRegistry.getOperationsByProfile()
|
||||
*
|
||||
* Original description:
|
||||
* Central coordinator for all SDK operations and rate limit recovery.
|
||||
* Part of the intelligent rate limit recovery system (Phase 9: Unified Coordination).
|
||||
*
|
||||
@@ -97,6 +116,9 @@ interface PendingNotification {
|
||||
/**
|
||||
* SDKSessionRecoveryCoordinator - Central manager for SDK operations and recovery
|
||||
*
|
||||
* @deprecated Use ClaudeOperationRegistry from '../claude-profile/operation-registry' instead.
|
||||
* This class is retained for backward compatibility but is no longer actively maintained.
|
||||
*
|
||||
* This singleton coordinates all SDK operations across the application:
|
||||
* - Tasks (via AgentManager)
|
||||
* - Background operations (roadmap, ideation, changelog)
|
||||
@@ -531,6 +553,7 @@ export class SDKSessionRecoveryCoordinator extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Get the global coordinator instance
|
||||
* @deprecated Use getOperationRegistry() from '../claude-profile/operation-registry' instead.
|
||||
*/
|
||||
export function getRecoveryCoordinator(): SDKSessionRecoveryCoordinator {
|
||||
return SDKSessionRecoveryCoordinator.getInstance();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, watchFile } from 'fs';
|
||||
import { existsSync, readFileSync, } from 'fs';
|
||||
import { EventEmitter } from 'events';
|
||||
import type { TaskLogs, TaskLogPhase, TaskLogStreamChunk, TaskPhaseLog } from '../shared/types';
|
||||
import { findTaskWorktree } from './worktree-paths';
|
||||
@@ -26,7 +26,6 @@ function findWorktreeSpecDir(projectPath: string, specId: string, specsRelPath:
|
||||
* watches both locations and merges logs from both sources.
|
||||
*/
|
||||
export class TaskLogService extends EventEmitter {
|
||||
private watchers: Map<string, { watcher: ReturnType<typeof watchFile>; specDir: string }> = new Map();
|
||||
private logCache: Map<string, TaskLogs> = new Map();
|
||||
private pollIntervals: Map<string, NodeJS.Timeout> = new Map();
|
||||
// Store paths being watched for each specId (main + worktree)
|
||||
@@ -35,10 +34,6 @@ export class TaskLogService extends EventEmitter {
|
||||
// Poll interval for watching log changes (more reliable than fs.watch on some systems)
|
||||
private readonly POLL_INTERVAL_MS = 1000;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load task logs from a single spec directory
|
||||
* Returns cached logs if the file is corrupted (e.g., mid-write by Python backend)
|
||||
@@ -125,7 +120,7 @@ export class TaskLogService extends EventEmitter {
|
||||
|
||||
let worktreeSpecDir: string | null = null;
|
||||
|
||||
if (watchedInfo && watchedInfo[1].worktreeSpecDir) {
|
||||
if (watchedInfo?.[1].worktreeSpecDir) {
|
||||
worktreeSpecDir = watchedInfo[1].worktreeSpecDir;
|
||||
} else if (projectPath && specsRelPath && specId) {
|
||||
// Calculate worktree path from provided params
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ActorRefFrom } from 'xstate';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { TaskEventPayload } from './agent/task-event-schema';
|
||||
import type { Project, Task, TaskStatus, ReviewReason, ExecutionPhase } from '../shared/types';
|
||||
import { taskMachine, type TaskEvent } from '../shared/state-machines';
|
||||
import { taskMachine, XSTATE_TO_PHASE, mapStateToLegacy, type TaskEvent } from '../shared/state-machines';
|
||||
import { IPC_CHANNELS } from '../shared/constants';
|
||||
import { safeSendToRenderer } from './ipc-handlers/utils';
|
||||
import { getPlanPath, persistPlanStatusAndReasonSync } from './ipc-handlers/task/plan-file-utils';
|
||||
@@ -14,21 +14,6 @@ import path from 'path';
|
||||
|
||||
type TaskActor = ActorRefFrom<typeof taskMachine>;
|
||||
|
||||
/** Maps XState states to execution phases. Shared by mapStateToExecutionPhase and emitPhaseFromState. */
|
||||
const XSTATE_TO_PHASE: Record<string, ExecutionPhase> = {
|
||||
'backlog': 'idle',
|
||||
'planning': 'planning',
|
||||
'plan_review': 'planning',
|
||||
'coding': 'coding',
|
||||
'qa_review': 'qa_review',
|
||||
'qa_fixing': 'qa_fixing',
|
||||
'human_review': 'complete',
|
||||
'error': 'failed',
|
||||
'creating_pr': 'complete',
|
||||
'pr_created': 'complete',
|
||||
'done': 'complete'
|
||||
};
|
||||
|
||||
interface TaskContextEntry {
|
||||
task: Task;
|
||||
project: Project;
|
||||
@@ -36,6 +21,7 @@ interface TaskContextEntry {
|
||||
|
||||
const TERMINAL_EVENTS = new Set<string>([
|
||||
'QA_PASSED',
|
||||
'PLANNING_COMPLETE',
|
||||
'PLANNING_FAILED',
|
||||
'CODING_FAILED',
|
||||
'QA_MAX_ITERATIONS',
|
||||
@@ -57,10 +43,10 @@ export class TaskStateManager {
|
||||
|
||||
handleTaskEvent(taskId: string, event: TaskEventPayload, task: Task, project: Project): boolean {
|
||||
const lastSeq = this.lastSequenceByTask.get(taskId);
|
||||
console.log(`[TaskStateManager] handleTaskEvent: ${event.type} seq=${event.sequence}, lastSeq=${lastSeq}`);
|
||||
console.debug(`[TaskStateManager] handleTaskEvent: ${event.type} seq=${event.sequence}, lastSeq=${lastSeq}`);
|
||||
|
||||
if (!this.isNewSequence(taskId, event.sequence)) {
|
||||
console.log(`[TaskStateManager] Event ${event.type} DROPPED - sequence ${event.sequence} not newer than ${lastSeq}`);
|
||||
console.debug(`[TaskStateManager] Event ${event.type} DROPPED - sequence ${event.sequence} not newer than ${lastSeq}`);
|
||||
return false;
|
||||
}
|
||||
this.setTaskContext(taskId, task, project);
|
||||
@@ -72,10 +58,10 @@ export class TaskStateManager {
|
||||
|
||||
const actor = this.getOrCreateActor(taskId);
|
||||
const stateBefore = String(actor.getSnapshot().value);
|
||||
console.log(`[TaskStateManager] Sending ${event.type} to actor in state: ${stateBefore}`);
|
||||
console.debug(`[TaskStateManager] Sending ${event.type} to actor in state: ${stateBefore}`);
|
||||
actor.send(event as TaskEvent);
|
||||
const stateAfter = String(actor.getSnapshot().value);
|
||||
console.log(`[TaskStateManager] After ${event.type}: state ${stateBefore} -> ${stateAfter}`);
|
||||
console.debug(`[TaskStateManager] After ${event.type}: state ${stateBefore} -> ${stateAfter}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -92,22 +78,26 @@ export class TaskStateManager {
|
||||
return;
|
||||
}
|
||||
const actor = this.getOrCreateActor(taskId);
|
||||
// Only mark as unexpected if the process exited with a non-zero code.
|
||||
// A code-0 exit is normal (e.g., spec creation finished, plan created, waiting for review).
|
||||
// Sending unexpected:true for code-0 exits incorrectly transitions plan_review → error.
|
||||
const isUnexpected = exitCode !== 0;
|
||||
actor.send({
|
||||
type: 'PROCESS_EXITED',
|
||||
exitCode: exitCode ?? -1,
|
||||
unexpected: true
|
||||
unexpected: isUnexpected
|
||||
} satisfies TaskEvent);
|
||||
}
|
||||
|
||||
handleUiEvent(taskId: string, event: TaskEvent, task: Task, project: Project): void {
|
||||
console.log(`[TaskStateManager] handleUiEvent: ${event.type} for task ${taskId}`);
|
||||
console.debug(`[TaskStateManager] handleUiEvent: ${event.type} for task ${taskId}`);
|
||||
this.setTaskContext(taskId, task, project);
|
||||
const actor = this.getOrCreateActor(taskId);
|
||||
const stateBefore = String(actor.getSnapshot().value);
|
||||
console.log(`[TaskStateManager] Sending UI event ${event.type} to actor in state: ${stateBefore}`);
|
||||
console.debug(`[TaskStateManager] Sending UI event ${event.type} to actor in state: ${stateBefore}`);
|
||||
actor.send(event);
|
||||
const stateAfter = String(actor.getSnapshot().value);
|
||||
console.log(`[TaskStateManager] After UI event ${event.type}: state ${stateBefore} -> ${stateAfter}`);
|
||||
console.debug(`[TaskStateManager] After UI event ${event.type}: state ${stateBefore} -> ${stateAfter}`);
|
||||
}
|
||||
|
||||
handleManualStatusChange(taskId: string, status: TaskStatus, task: Task, project: Project): boolean {
|
||||
@@ -133,7 +123,14 @@ export class TaskStateManager {
|
||||
} else if (!currentState && task.reviewReason === 'plan_review') {
|
||||
// Fallback: No actor exists (e.g., after app restart), use task data
|
||||
this.handleUiEvent(taskId, { type: 'PLAN_APPROVED' }, task, project);
|
||||
} else if (currentState === 'backlog' || !currentState) {
|
||||
// Fresh start from backlog or no actor - send PLANNING_STARTED
|
||||
// USER_RESUMED only works from human_review/error states
|
||||
this.handleUiEvent(taskId, { type: 'PLANNING_STARTED' }, task, project);
|
||||
} else {
|
||||
// Already in a running state (planning, coding, qa_*) - send USER_RESUMED
|
||||
// Note: USER_RESUMED may be ignored if state doesn't handle it, but that's OK
|
||||
// since the task is already running
|
||||
this.handleUiEvent(taskId, { type: 'USER_RESUMED' }, task, project);
|
||||
}
|
||||
return true;
|
||||
@@ -220,7 +217,7 @@ export class TaskStateManager {
|
||||
private getOrCreateActor(taskId: string): TaskActor {
|
||||
const existing = this.actors.get(taskId);
|
||||
if (existing) {
|
||||
console.log(`[TaskStateManager] Using existing actor for ${taskId}, current state:`, String(existing.getSnapshot().value));
|
||||
console.debug(`[TaskStateManager] Using existing actor for ${taskId}, current state:`, String(existing.getSnapshot().value));
|
||||
return existing;
|
||||
}
|
||||
|
||||
@@ -230,14 +227,14 @@ export class TaskStateManager {
|
||||
: undefined;
|
||||
|
||||
if (contextEntry) {
|
||||
console.log(`[TaskStateManager] Creating new actor for ${taskId} from task:`, {
|
||||
console.debug(`[TaskStateManager] Creating new actor for ${taskId} from task:`, {
|
||||
status: contextEntry.task.status,
|
||||
reviewReason: contextEntry.task.reviewReason,
|
||||
phase: contextEntry.task.executionProgress?.phase,
|
||||
initialState: snapshot ? String(snapshot.value) : 'default (backlog)'
|
||||
});
|
||||
} else {
|
||||
console.log(`[TaskStateManager] Creating new actor for ${taskId} with default state (no context entry)`);
|
||||
console.debug(`[TaskStateManager] Creating new actor for ${taskId} with default state (no context entry)`);
|
||||
}
|
||||
|
||||
const actor = snapshot
|
||||
@@ -247,8 +244,7 @@ export class TaskStateManager {
|
||||
const stateValue = String(snapshot.value);
|
||||
const lastState = this.lastStateByTask.get(taskId);
|
||||
|
||||
// Debug: Log all state transitions
|
||||
console.log(`[TaskStateManager] XState transition for ${taskId}:`, {
|
||||
console.debug(`[TaskStateManager] XState transition for ${taskId}:`, {
|
||||
from: lastState,
|
||||
to: stateValue,
|
||||
contextReviewReason: snapshot.context.reviewReason
|
||||
@@ -273,8 +269,7 @@ export class TaskStateManager {
|
||||
// Map XState state to execution phase for persistence
|
||||
const executionPhase = this.mapStateToExecutionPhase(stateValue);
|
||||
|
||||
// Debug: Log the mapped status and reviewReason
|
||||
console.log(`[TaskStateManager] Emitting status for ${taskId}:`, {
|
||||
console.debug(`[TaskStateManager] Emitting status for ${taskId}:`, {
|
||||
status,
|
||||
reviewReason,
|
||||
xstateState: stateValue,
|
||||
@@ -338,7 +333,7 @@ export class TaskStateManager {
|
||||
console.warn(`[TaskStateManager] emitStatus: No main window, cannot emit status ${status} for ${taskId}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[TaskStateManager] emitStatus: Sending TASK_STATUS_CHANGE for ${taskId}:`, { status, reviewReason, projectId });
|
||||
console.debug(`[TaskStateManager] emitStatus: Sending TASK_STATUS_CHANGE for ${taskId}:`, { status, reviewReason, projectId });
|
||||
safeSendToRenderer(
|
||||
this.getMainWindow,
|
||||
IPC_CHANNELS.TASK_STATUS_CHANGE,
|
||||
@@ -425,7 +420,6 @@ export class TaskStateManager {
|
||||
stateValue = 'error';
|
||||
contextReviewReason = reviewReason ?? 'errors';
|
||||
break;
|
||||
case 'backlog':
|
||||
default:
|
||||
stateValue = 'backlog';
|
||||
break;
|
||||
@@ -441,33 +435,3 @@ export class TaskStateManager {
|
||||
}
|
||||
|
||||
export const taskStateManager = new TaskStateManager();
|
||||
|
||||
function mapStateToLegacy(
|
||||
state: string,
|
||||
reviewReason?: ReviewReason
|
||||
): { status: TaskStatus; reviewReason?: ReviewReason } {
|
||||
switch (state) {
|
||||
case 'backlog':
|
||||
return { status: 'backlog' };
|
||||
case 'planning':
|
||||
case 'coding':
|
||||
return { status: 'in_progress' };
|
||||
case 'plan_review':
|
||||
return { status: 'human_review', reviewReason: 'plan_review' };
|
||||
case 'qa_review':
|
||||
case 'qa_fixing':
|
||||
return { status: 'ai_review' };
|
||||
case 'human_review':
|
||||
return { status: 'human_review', reviewReason };
|
||||
case 'error':
|
||||
return { status: 'human_review', reviewReason: 'errors' };
|
||||
case 'creating_pr':
|
||||
return { status: 'human_review', reviewReason: 'completed' };
|
||||
case 'pr_created':
|
||||
return { status: 'pr_created' };
|
||||
case 'done':
|
||||
return { status: 'done' };
|
||||
default:
|
||||
return { status: 'backlog' };
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user