Compare commits

..

1 Commits

Author SHA1 Message Date
StillKnotKnown 5bd2211b8b fix(auth): support API profile mode without OAuth requirement
Fix authentication to work with API profiles (e.g., z.ai GLM endpoints)
without requiring Claude Code OAuth. The system now detects API profile
mode via ANTHROPIC_BASE_URL environment variable and uses the appropriate
authentication method:

- API Profile Mode: ANTHROPIC_BASE_URL set → use ANTHROPIC_AUTH_TOKEN
- OAuth Mode: ANTHROPIC_BASE_URL not set → use CLAUDE_CODE_OAUTH_TOKEN

Key change: In API profile mode, CLAUDE_CODE_OAUTH_TOKEN is NOT set in
environment, ensuring SDK uses API key instead of OAuth (SDK gives OAuth
priority when both are present).

Also fixed auth tests by replacing mocker fixture with monkeypatch.

Files changed:
- apps/backend/core/client.py: Add dual-mode auth detection
- tests/test_client.py: Add 15 comprehensive API profile tests
- tests/test_auth.py: Fix 3 tests using unavailable mocker fixture

Fixes issue where users with API profiles could not run agents despite
having valid ANTHROPIC_AUTH_TOKEN and ANTHROPIC_BASE_URL configured.
2026-01-29 22:46:47 +02:00
327 changed files with 8968 additions and 23711 deletions
+2 -5
View File
@@ -405,11 +405,11 @@ jobs:
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Setup Flatpak and verification tools
- name: Setup Flatpak
run: |
set -e
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder libarchive-tools
sudo apt-get install -y flatpak flatpak-builder
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
@@ -447,9 +447,6 @@ jobs:
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Verify Linux packages
run: cd apps/frontend && npm run verify:linux
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
+2 -5
View File
@@ -344,10 +344,10 @@ jobs:
- name: Setup Node.js and install dependencies
uses: ./.github/actions/setup-node-frontend
- name: Setup Flatpak and verification tools
- name: Setup Flatpak
run: |
sudo apt-get update
sudo apt-get install -y flatpak flatpak-builder libarchive-tools
sudo apt-get install -y flatpak flatpak-builder
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
@@ -383,9 +383,6 @@ jobs:
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Verify Linux packages
run: cd apps/frontend && npm run verify:linux
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
+1
View File
@@ -174,3 +174,4 @@ OPUS_ANALYSIS_AND_IDEAS.md
/shared_docs
logs/security/
Agents.md
packages/
-11
View File
@@ -40,17 +40,6 @@ Auto Claude is a desktop application (+ CLI) where users describe a goal and AI
**PR target** — Always target the `develop` branch for PRs to AndyMik90/Auto-Claude, NOT `main`.
## Memory (claude-mem)
This project uses claude-mem for persistent memory across sessions. MCP search tools are available — use them proactively:
- **Before modifying code** — Search for past work on the same files/features: `search(query="<file or feature>")`. Check if there are known bugs, decisions, or patterns to follow.
- **When debugging** — Search for prior encounters with the same error or symptom: `search(query="<error message>", type="bugfix")`.
- **When making architectural decisions** — Check for past decisions: `search(query="<topic>", type="decision")`.
- **When resuming work** — Use `timeline(anchor=<recent_id>)` to understand where things left off.
Follow the 3-layer workflow: `search` (cheap index) → `timeline` (context) → `get_observations` (full details only for relevant IDs). Never fetch full details without filtering first.
## Project Structure
```
+7 -7
View File
@@ -35,18 +35,18 @@
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
<!-- BETA_VERSION_BADGE -->
[![Beta](https://img.shields.io/badge/beta-2.7.6--beta.2-orange?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6-beta.2)
[![Beta](https://img.shields.io/badge/beta-2.7.2--beta.10-orange?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2-beta.10)
<!-- BETA_VERSION_BADGE_END -->
<!-- BETA_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **Windows** | [Auto-Claude-2.7.6-beta.2-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.2-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.2-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-amd64.deb) |
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-x86_64.flatpak) |
| **Windows** | [Auto-Claude-2.7.2-beta.10-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-beta.10-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.2-beta.10-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.2-beta.10-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.2-beta.10-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-amd64.deb) |
| **Linux (Flatpak)** | [Auto-Claude-2.7.2-beta.10-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-x86_64.flatpak) |
<!-- BETA_DOWNLOADS_END -->
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
+421
View File
@@ -0,0 +1,421 @@
# Token Encryption Investigation
## Issue Summary
Auto-Claude users are experiencing API 401 errors ("Invalid bearer token") because the Python backend is passing encrypted tokens (with `enc:` prefix) directly to the Claude Agent SDK without decryption. Standalone Claude Code terminals work correctly because they decrypt these tokens before use.
**Key insight from user thehaffk:** "python cant unencrypt claude token and it launches session with CLAUDE_CODE_OAUTH_TOKEN=enc:djEwtxMGISt3tQ..."
## Token Storage Format
### Encrypted Token Format
Claude Code CLI stores OAuth tokens in an encrypted format with the prefix `enc:`:
```text
enc:djEwtxMGISt3tQ...
```
This format is used when tokens are stored in:
- **macOS**: Keychain (service: "Claude Code-credentials")
- **Linux**: Secret Service API (DBus, via secretstorage library)
- **Windows**: Credential Manager / .credentials.json files
### Decrypted Token Format
Valid Claude OAuth tokens have the format:
```text
sk-ant-oat01-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```
## Current Token Flow (BROKEN)
1. **Token Storage**: Claude Code CLI stores encrypted token with `enc:` prefix in system keychain
2. **Token Retrieval**: `apps/backend/core/auth.py::get_auth_token()` retrieves token from:
- Environment variable `CLAUDE_CODE_OAUTH_TOKEN`
- OR system keychain via `get_token_from_keychain()`
3. **❌ NO DECRYPTION**: Token is returned as-is with `enc:` prefix intact
4. **SDK Initialization**: Encrypted token passed to Claude Agent SDK
5. **API Call Fails**: SDK sends encrypted token to API → 401 error
### Proof of Broken Flow
Test in `apps/backend`:
```python
import os
os.environ['CLAUDE_CODE_OAUTH_TOKEN'] = 'enc:test123'
from core.auth import get_auth_token
token = get_auth_token()
print(f"Token: {token}") # Output: "enc:test123"
print(f"Encrypted: {token.startswith('enc:')}") # Output: True
```
## How Standalone Claude Code CLI Handles Tokens
### Current Understanding
1. **Token Detection**: CLI checks if token starts with `enc:` prefix
2. **Decryption**: If encrypted, CLI decrypts using platform-specific keyring access
3. **Authentication**: Decrypted `sk-ant-oat01-` token is used for API calls
### Missing Documentation
Web search for "Claude Code CLI encrypted token enc: prefix decryption" found:
- Token storage formats (JSON with accessToken, refreshToken, expiresAt)
- Security issues (tokens exposed in debug logs before v2.1.0)
- Keychain access patterns for macOS/Linux/Windows
**❌ NOT FOUND**: Specific documentation on how Claude Code CLI decrypts `enc:` tokens
Sources:
- [Claude Code CLI over SSH on macOS: Fixing Keychain Access](https://phoenixtrap.com/2025/10/26/claude-code-cli-over-ssh-on-macos-fixing-keychain-access/)
- [Identity and Access Management - Claude Code Docs](https://code.claude.com/docs/en/iam)
- [Claude Code sessions should be encrypted | yoav.blog](https://yoav.blog/2026/01/09/claude-code-sessions-should-be-encrypted/)
## Decryption Approach Options
### Option 1: Claude Agent SDK Built-in Decryption
**Status**: NEEDS VERIFICATION
The Claude Agent SDK (`claude-agent-sdk>=0.1.19`) may handle decryption internally if:
- Token is passed to SDK still encrypted
- SDK detects `enc:` prefix
- SDK has access to system keyring for decryption
**Action Required**: Check if SDK has decryption capabilities by examining:
- SDK source code or documentation
- Whether SDK expects encrypted vs decrypted tokens
- If SDK requires specific environment variables for decryption
### Option 2: Python Backend Decryption (Recommended)
**Approach**: Implement decryption in `apps/backend/core/auth.py` before passing to SDK
**Implementation Pattern**:
```python
def get_auth_token() -> str | None:
"""Get authentication token (decrypted if necessary)."""
token = _retrieve_token_from_sources() # From env or keychain
if token and token.startswith("enc:"):
# Decrypt the token
token = decrypt_token(token)
return token
def decrypt_token(encrypted_token: str) -> str:
"""
Decrypt Claude Code encrypted token.
Args:
encrypted_token: Token with 'enc:' prefix
Returns:
Decrypted token in format 'sk-ant-oat01-...'
"""
# Remove 'enc:' prefix
encrypted_data = encrypted_token[4:]
# TODO: Implement decryption logic
# Questions to answer:
# 1. What encryption algorithm does Claude Code use?
# 2. Where is the decryption key stored?
# 3. Is the decryption key platform-specific (per-user)?
# 4. Can we reuse Claude Code's decryption mechanism?
raise NotImplementedError("Token decryption not yet implemented")
```
### Option 3: Call Claude Code CLI for Decryption
**Approach**: Use the Claude Code CLI binary to decrypt tokens
```python
def decrypt_token(encrypted_token: str) -> str:
"""Decrypt token by invoking Claude Code CLI."""
# Find claude binary
claude_path = shutil.which("claude") or "~/.local/bin/claude"
# Use CLI command to get decrypted token
# (if such a command exists - needs research)
result = subprocess.run(
[claude_path, "auth", "decrypt", encrypted_token],
capture_output=True,
text=True
)
return result.stdout.strip()
```
**Issues**:
- Requires Claude Code CLI to be installed
- No documented CLI command for token decryption
- Adds external dependency
## Required Investigation Steps
### 1. Verify SDK Decryption Capabilities
**Task**: Check if `claude-agent-sdk` handles `enc:` tokens automatically
**Method**:
```bash
# In environment with SDK installed
python3 << 'EOF'
import os
os.environ['CLAUDE_CODE_OAUTH_TOKEN'] = 'enc:...' # Real encrypted token
from claude_agent_sdk import Client
# Try creating client - does it decrypt internally?
client = Client()
# Check if authentication works
EOF
```
### 2. Reverse Engineer Claude Code CLI Decryption
**Task**: Understand how Claude CLI decrypts tokens
**Method**:
- Examine Claude CLI binary (if possible)
- Trace system calls when CLI runs (strace on Linux, dtruss on macOS)
- Check if CLI accesses specific keychain entries for decryption keys
- Look for encryption/decryption libraries used by CLI
### 3. Find Decryption Key Storage
**Task**: Locate where decryption keys are stored
**Hypothesis**: Decryption key stored in:
- macOS: Keychain (separate entry from encrypted token)
- Linux: Secret Service API
- Windows: Credential Manager
**Verification**:
```bash
# macOS: List all keychain entries
security find-generic-password -a "$(whoami)" | grep -i claude
# Linux: Use secretstorage to list all items
python3 -c "import secretstorage; ..."
```
## Recommended Decryption Approach for Python Backend
Based on investigation so far, the recommended approach is:
1. **Detect encrypted tokens**: Check for `enc:` prefix in `get_auth_token()`
2. **Decrypt before use**: Implement `decrypt_token()` function
3. **Platform-specific decryption**: Use appropriate keyring library:
- macOS: Use `subprocess` with `/usr/bin/security` to access decryption key
- Linux: Use `secretstorage` library to access Secret Service API
- Windows: Access Credential Manager or credentials.json
4. **Backward compatibility**: Support both encrypted and plaintext tokens
5. **Error handling**: Provide clear error messages if decryption fails
## Complete Token Flow Trace (Frontend → Backend)
### 1. Token Retrieval (Frontend)
**File**: `apps/frontend/src/main/services/profile-service.ts`
The frontend retrieves the OAuth token from the system keychain but **does not decrypt it**. When no API profile is active (OAuth mode), the frontend returns an empty environment object, which means it relies on:
- The token already being in the environment as `CLAUDE_CODE_OAUTH_TOKEN`
- OR the Python backend retrieving it from the keychain
**Key Code**:
```typescript
// Line 223: Returns empty object in OAuth mode, allowing
// CLAUDE_CODE_OAUTH_TOKEN to be used from system keychain
```
### 2. Environment Variable Passing (Frontend → PTY)
**File**: `apps/frontend/src/main/terminal/pty-manager.ts`
The PTY manager spawns the terminal shell with environment variables, including `CLAUDE_CODE_OAUTH_TOKEN`:
**Key Code** (Lines 149-152):
```typescript
// Remove ANTHROPIC_API_KEY to ensure Claude Code uses OAuth tokens
// (CLAUDE_CODE_OAUTH_TOKEN from profileEnv) instead of API keys
const { DEBUG: _DEBUG, ANTHROPIC_API_KEY: _ANTHROPIC_API_KEY, ...cleanEnv } = process.env;
```
**Important**: The frontend passes through whatever token value exists in the environment - it does NOT check for `enc:` prefix or decrypt it.
### 3. Token Retrieval (Backend)
**File**: `apps/backend/core/auth.py`
#### 3.1. get_auth_token()
This function retrieves the token from multiple sources:
```python
def get_auth_token() -> str | None:
# First check environment variables
for var in AUTH_TOKEN_ENV_VARS: # CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_AUTH_TOKEN
token = os.environ.get(var)
if token:
return token # ❌ Returns immediately without checking for enc: prefix
# Fallback to system credential store
return get_token_from_keychain() # ❌ Also returns without decryption
```
**Issue**: Returns token as-is with `enc:` prefix intact.
#### 3.2. require_auth_token()
This function calls `get_auth_token()` and raises an error if no token is found:
```python
def require_auth_token() -> str:
token = get_auth_token() # ❌ Gets encrypted token
if not token:
raise ValueError("No OAuth token found...")
return token # ❌ Returns encrypted token
```
**Issue**: No decryption step between retrieval and return.
#### 3.3. ensure_claude_code_oauth_token()
This function ensures the environment variable is set:
```python
def ensure_claude_code_oauth_token() -> None:
if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
return
token = get_auth_token() # ❌ Gets encrypted token
if token:
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token # ❌ Sets encrypted token
```
**Issue**: Propagates encrypted token to environment variable.
### 4. Token Usage in SDK Client Creation (Backend)
#### 4.1. Full Client Creation
**File**: `apps/backend/core/client.py` (see `create_client()` function)
```python
def create_client(...):
oauth_token = require_auth_token() # ❌ Gets encrypted token
# Ensure SDK can access it via its expected env var
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token # ❌ Sets encrypted token
```
**Issue**: Encrypted token is passed to the Claude Agent SDK, which expects a decrypted `sk-ant-oat01-` token.
#### 4.2. Simple Client Creation
**File**: `apps/backend/core/simple_client.py` (see `create_simple_client()` function)
```python
def create_simple_client(...):
# Get authentication
oauth_token = require_auth_token() # ❌ Gets encrypted token
import os
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token # ❌ Sets encrypted token
```
**Issue**: Same problem - encrypted token passed to SDK.
#### 4.3. Other Usages
**Files**:
- `apps/backend/core/workspace.py` (Line 1966) - AI merge operations
- `apps/backend/runners/insights_runner.py` - Insights analysis
- `apps/backend/runners/github/batch_issues.py` - GitHub batch operations
- `apps/backend/integrations/linear/updater.py` - Linear integration
- `apps/backend/commit_message.py` - Commit message generation
- `apps/backend/analysis/insight_extractor.py` - Code insights
- `apps/backend/merge/ai_resolver/claude_client.py` - Merge resolution
**All follow the same pattern**: Call `ensure_claude_code_oauth_token()` → encrypted token in environment → SDK receives encrypted token → API 401 error.
### 5. Where Decryption Should Be Inserted
Based on the flow analysis, decryption should be added at **the earliest point of token retrieval** to avoid duplicating decryption logic:
**RECOMMENDED INSERTION POINT**: `apps/backend/core/auth.py::get_auth_token()`
```python
def get_auth_token() -> str | None:
# First check environment variables
for var in AUTH_TOKEN_ENV_VARS:
token = os.environ.get(var)
if token:
# ✅ INSERT DECRYPTION HERE
if token.startswith("enc:"):
token = decrypt_token(token)
return token
# Fallback to system credential store
token = get_token_from_keychain()
# ✅ ALSO DECRYPT KEYCHAIN TOKENS
if token and token.startswith("enc:"):
token = decrypt_token(token)
return token
```
**Benefits of this approach**:
1. Single location for decryption logic
2. All downstream functions automatically get decrypted tokens
3. Backward compatible (plaintext tokens pass through unchanged)
4. Consistent behavior across all token sources (env vars and keychain)
**Alternative insertion points** (NOT recommended):
- `require_auth_token()` - Would need similar logic in `get_auth_token()` for non-required usage
- `create_client()` - Would need duplication in `create_simple_client()` and all other clients
- `ensure_claude_code_oauth_token()` - Would miss direct `get_auth_token()` calls
## Next Steps
1. ✅ Document current token flow and identify issue (THIS FILE - COMPLETED)
2. ✅ Trace token flow from frontend to backend (THIS FILE - COMPLETED)
3. ✅ Identify where decryption should be inserted (THIS FILE - COMPLETED)
4. ⏳ Verify if Claude Agent SDK handles decryption internally
5. ⏳ Reverse engineer or document Claude Code CLI decryption mechanism
6. ⏳ Implement `decrypt_token()` function in `apps/backend/core/auth.py`
7. ⏳ Add encryption detection and auto-decryption to `get_auth_token()`
8. ⏳ Test with real encrypted tokens on macOS and Linux
9. ⏳ Add comprehensive error handling for decryption failures
## Open Questions
1. **What encryption algorithm does Claude Code use for `enc:` tokens?**
- Possible: AES-256, ChaCha20, or similar
- Key derivation method?
2. **Where is the decryption key stored?**
- Same keychain entry as encrypted token?
- Separate keychain entry?
- Derived from system/user credentials?
3. **Does Claude Agent SDK expect encrypted or decrypted tokens?**
- If it expects decrypted: we must decrypt before passing
- If it handles encryption: we may be missing SDK configuration
4. **Is there a Claude Code CLI command to decrypt tokens?**
- `claude auth decrypt <token>`?
- `claude auth get-token`?
- No documented command found in research
5. **Can we reuse Claude Code's decryption mechanism?**
- Import decryption functions from CLI?
- Call CLI as subprocess?
- Implement decryption ourselves?
## References
- Issue: [GitHub #1223: API Error 401](https://github.com/AndyMik90/Auto-Claude/issues/1223)
- Current auth implementation: `apps/backend/core/auth.py`
- SDK client initialization: `apps/backend/core/client.py`
- Requirements: `apps/backend/requirements.txt` (includes `secretstorage>=3.3.3` for Linux)
+1 -1
View File
@@ -19,5 +19,5 @@ Quick Start:
See README.md for full documentation.
"""
__version__ = "2.7.6-beta.2"
__version__ = "2.7.5"
__author__ = "Auto Claude Team"
-74
View File
@@ -6,7 +6,6 @@ Shared imports, types, and constants used across agent modules.
"""
import logging
import re
# Configure logging
logger = logging.getLogger(__name__)
@@ -21,76 +20,3 @@ 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
+747 -1100
View File
File diff suppressed because it is too large Load Diff
+183 -183
View File
@@ -1,183 +1,183 @@
"""
Planner Agent Module
====================
Handles follow-up planner sessions for adding new subtasks to completed specs.
"""
import logging
from pathlib import Path
from core.client import create_client
from phase_config import get_phase_model, get_phase_thinking_budget
from phase_event import ExecutionPhase, emit_phase
from task_logger import (
LogPhase,
get_task_logger,
)
from ui import (
BuildState,
Icons,
StatusManager,
bold,
box,
highlight,
icon,
muted,
print_status,
)
from .session import run_agent_session
logger = logging.getLogger(__name__)
async def run_followup_planner(
project_dir: Path,
spec_dir: Path,
model: str,
verbose: bool = False,
) -> bool:
"""
Run the follow-up planner to add new subtasks to a completed spec.
This is a simplified version of run_autonomous_agent that:
1. Creates a client
2. Loads the followup planner prompt
3. Runs a single planning session
4. Returns after the plan is updated (doesn't enter coding loop)
The planner agent will:
- Read FOLLOWUP_REQUEST.md for the new task
- Read the existing implementation_plan.json
- Add new phase(s) with pending subtasks
- Update the plan status back to in_progress
Args:
project_dir: Root directory for the project
spec_dir: Directory containing the completed spec
model: Claude model to use
verbose: Whether to show detailed output
Returns:
bool: True if planning completed successfully
"""
from implementation_plan import ImplementationPlan
from prompts import get_followup_planner_prompt
# Initialize status manager for ccstatusline
status_manager = StatusManager(project_dir)
status_manager.set_active(spec_dir.name, BuildState.PLANNING)
emit_phase(ExecutionPhase.PLANNING, "Follow-up planning")
# Initialize task logger for persistent logging
task_logger = get_task_logger(spec_dir)
# Show header
content = [
bold(f"{icon(Icons.GEAR)} FOLLOW-UP PLANNER SESSION"),
"",
f"Spec: {highlight(spec_dir.name)}",
muted("Adding follow-up work to completed spec."),
"",
muted("The agent will read your FOLLOWUP_REQUEST.md and add new subtasks."),
]
print()
print(box(content, width=70, style="heavy"))
print()
# Start planning phase in task logger
if task_logger:
task_logger.start_phase(LogPhase.PLANNING, "Starting follow-up planning...")
task_logger.set_session(1)
# Create client with phase-specific model and thinking budget
# Respects task_metadata.json configuration when no CLI override
planning_model = get_phase_model(spec_dir, "planning", model)
planning_thinking_budget = get_phase_thinking_budget(spec_dir, "planning")
client = create_client(
project_dir,
spec_dir,
planning_model,
max_thinking_tokens=planning_thinking_budget,
)
# Generate follow-up planner prompt
prompt = get_followup_planner_prompt(spec_dir)
print_status("Running follow-up planner...", "progress")
print()
try:
# Run single planning session
async with client:
status, response, error_info = await run_agent_session(
client, prompt, spec_dir, verbose, phase=LogPhase.PLANNING
)
# End planning phase in task logger
if task_logger:
task_logger.end_phase(
LogPhase.PLANNING,
success=(status != "error"),
message="Follow-up planning session completed",
)
if status == "error":
print()
print_status("Follow-up planning failed", "error")
status_manager.update(state=BuildState.ERROR)
return False
# Verify the plan was updated (should have pending subtasks now)
plan_file = spec_dir / "implementation_plan.json"
if plan_file.exists():
plan = ImplementationPlan.load(plan_file)
# Check if there are any pending subtasks
all_subtasks = [c for p in plan.phases for c in p.subtasks]
pending_subtasks = [c for c in all_subtasks if c.status.value == "pending"]
if pending_subtasks:
# Reset the plan status to in_progress (in case planner didn't)
plan.reset_for_followup()
await plan.async_save(plan_file)
print()
content = [
bold(f"{icon(Icons.SUCCESS)} FOLLOW-UP PLANNING COMPLETE"),
"",
f"New pending subtasks: {highlight(str(len(pending_subtasks)))}",
f"Total subtasks: {len(all_subtasks)}",
"",
muted("Next steps:"),
f" Run: {highlight(f'python auto-claude/run.py --spec {spec_dir.name}')}",
]
print(box(content, width=70, style="heavy"))
print()
status_manager.update(state=BuildState.PAUSED)
return True
else:
print()
print_status(
"Warning: No pending subtasks found after planning", "warning"
)
print(muted("The planner may not have added new subtasks."))
print(muted("Check implementation_plan.json manually."))
status_manager.update(state=BuildState.PAUSED)
return False
else:
print()
print_status(
"Error: implementation_plan.json not found after planning", "error"
)
status_manager.update(state=BuildState.ERROR)
return False
except Exception as e:
print()
print_status(f"Follow-up planning error: {e}", "error")
if task_logger:
task_logger.log_error(f"Follow-up planning error: {e}", LogPhase.PLANNING)
status_manager.update(state=BuildState.ERROR)
return False
"""
Planner Agent Module
====================
Handles follow-up planner sessions for adding new subtasks to completed specs.
"""
import logging
from pathlib import Path
from core.client import create_client
from phase_config import get_phase_model, get_phase_thinking_budget
from phase_event import ExecutionPhase, emit_phase
from task_logger import (
LogPhase,
get_task_logger,
)
from ui import (
BuildState,
Icons,
StatusManager,
bold,
box,
highlight,
icon,
muted,
print_status,
)
from .session import run_agent_session
logger = logging.getLogger(__name__)
async def run_followup_planner(
project_dir: Path,
spec_dir: Path,
model: str,
verbose: bool = False,
) -> bool:
"""
Run the follow-up planner to add new subtasks to a completed spec.
This is a simplified version of run_autonomous_agent that:
1. Creates a client
2. Loads the followup planner prompt
3. Runs a single planning session
4. Returns after the plan is updated (doesn't enter coding loop)
The planner agent will:
- Read FOLLOWUP_REQUEST.md for the new task
- Read the existing implementation_plan.json
- Add new phase(s) with pending subtasks
- Update the plan status back to in_progress
Args:
project_dir: Root directory for the project
spec_dir: Directory containing the completed spec
model: Claude model to use
verbose: Whether to show detailed output
Returns:
bool: True if planning completed successfully
"""
from implementation_plan import ImplementationPlan
from prompts import get_followup_planner_prompt
# Initialize status manager for ccstatusline
status_manager = StatusManager(project_dir)
status_manager.set_active(spec_dir.name, BuildState.PLANNING)
emit_phase(ExecutionPhase.PLANNING, "Follow-up planning")
# Initialize task logger for persistent logging
task_logger = get_task_logger(spec_dir)
# Show header
content = [
bold(f"{icon(Icons.GEAR)} FOLLOW-UP PLANNER SESSION"),
"",
f"Spec: {highlight(spec_dir.name)}",
muted("Adding follow-up work to completed spec."),
"",
muted("The agent will read your FOLLOWUP_REQUEST.md and add new subtasks."),
]
print()
print(box(content, width=70, style="heavy"))
print()
# Start planning phase in task logger
if task_logger:
task_logger.start_phase(LogPhase.PLANNING, "Starting follow-up planning...")
task_logger.set_session(1)
# Create client with phase-specific model and thinking budget
# Respects task_metadata.json configuration when no CLI override
planning_model = get_phase_model(spec_dir, "planning", model)
planning_thinking_budget = get_phase_thinking_budget(spec_dir, "planning")
client = create_client(
project_dir,
spec_dir,
planning_model,
max_thinking_tokens=planning_thinking_budget,
)
# Generate follow-up planner prompt
prompt = get_followup_planner_prompt(spec_dir)
print_status("Running follow-up planner...", "progress")
print()
try:
# Run single planning session
async with client:
status, response, error_info = await run_agent_session(
client, prompt, spec_dir, verbose, phase=LogPhase.PLANNING
)
# End planning phase in task logger
if task_logger:
task_logger.end_phase(
LogPhase.PLANNING,
success=(status != "error"),
message="Follow-up planning session completed",
)
if status == "error":
print()
print_status("Follow-up planning failed", "error")
status_manager.update(state=BuildState.ERROR)
return False
# Verify the plan was updated (should have pending subtasks now)
plan_file = spec_dir / "implementation_plan.json"
if plan_file.exists():
plan = ImplementationPlan.load(plan_file)
# Check if there are any pending subtasks
all_subtasks = [c for p in plan.phases for c in p.subtasks]
pending_subtasks = [c for c in all_subtasks if c.status.value == "pending"]
if pending_subtasks:
# Reset the plan status to in_progress (in case planner didn't)
plan.reset_for_followup()
await plan.async_save(plan_file)
print()
content = [
bold(f"{icon(Icons.SUCCESS)} FOLLOW-UP PLANNING COMPLETE"),
"",
f"New pending subtasks: {highlight(str(len(pending_subtasks)))}",
f"Total subtasks: {len(all_subtasks)}",
"",
muted("Next steps:"),
f" Run: {highlight(f'python auto-claude/run.py --spec {spec_dir.name}')}",
]
print(box(content, width=70, style="heavy"))
print()
status_manager.update(state=BuildState.PAUSED)
return True
else:
print()
print_status(
"Warning: No pending subtasks found after planning", "warning"
)
print(muted("The planner may not have added new subtasks."))
print(muted("Check implementation_plan.json manually."))
status_manager.update(state=BuildState.PAUSED)
return False
else:
print()
print_status(
"Error: implementation_plan.json not found after planning", "error"
)
status_manager.update(state=BuildState.ERROR)
return False
except Exception as e:
print()
print_status(f"Follow-up planning error: {e}", "error")
if task_logger:
task_logger.log_error(f"Follow-up planning error: {e}", LogPhase.PLANNING)
status_manager.update(state=BuildState.ERROR)
return False
-347
View File
@@ -1,347 +0,0 @@
"""
PR Template Filler Agent Module
================================
Detects GitHub PR templates in a project and uses Claude to intelligently
fill them based on code changes, spec context, commit history, and branch info.
"""
import logging
from pathlib import Path
from core.client import create_client
from task_logger import LogPhase, get_task_logger
from .session import run_agent_session
logger = logging.getLogger(__name__)
# Maximum diff size (in characters) before truncating to file-level summaries
MAX_DIFF_CHARS = 30_000
def detect_pr_template(project_dir: Path | str) -> str | None:
"""
Detect a GitHub PR template in the project.
Searches for:
1. .github/PULL_REQUEST_TEMPLATE.md (single template)
2. .github/PULL_REQUEST_TEMPLATE/ directory (picks the first .md file)
Args:
project_dir: Root directory of the project
Returns:
The template content as a string, or None if no template is found.
"""
project_dir = Path(project_dir)
# Check for single template file
single_template = project_dir / ".github" / "PULL_REQUEST_TEMPLATE.md"
if single_template.is_file():
try:
content = single_template.read_text(encoding="utf-8")
if content.strip():
logger.info(f"Found PR template: {single_template}")
return content
except Exception as e:
logger.warning(f"Failed to read PR template {single_template}: {e}")
# Check for template directory (pick first .md file alphabetically)
template_dir = project_dir / ".github" / "PULL_REQUEST_TEMPLATE"
if template_dir.is_dir():
try:
md_files = sorted(template_dir.glob("*.md"))
if md_files:
content = md_files[0].read_text(encoding="utf-8")
if content.strip():
logger.info(f"Found PR template: {md_files[0]}")
return content
except Exception as e:
logger.warning(f"Failed to read PR template from {template_dir}: {e}")
logger.info("No GitHub PR template found in project")
return None
def _truncate_diff(diff_summary: str) -> str:
"""
Truncate a large diff to file-level summaries to stay within token limits.
If the diff is within MAX_DIFF_CHARS, return it unchanged.
Otherwise, extract only file-level change summaries (e.g. file names
with insertions/deletions counts) and discard line-level detail.
Args:
diff_summary: The full diff summary text
Returns:
The original or truncated diff summary.
"""
if len(diff_summary) <= MAX_DIFF_CHARS:
return diff_summary
lines = diff_summary.splitlines()
summary_lines: list[str] = []
summary_lines.append("(Diff truncated to file-level summaries due to size)")
summary_lines.append("")
for line in lines:
# Keep file-level summary lines (stat lines, file headers, etc.)
stripped = line.strip()
if (
stripped.startswith("diff --git")
or stripped.startswith("---")
or stripped.startswith("+++")
or "file changed" in stripped.lower()
or "files changed" in stripped.lower()
or "insertion" in stripped.lower()
or "deletion" in stripped.lower()
or stripped.startswith("rename")
or stripped.startswith("new file")
or stripped.startswith("deleted file")
or stripped.startswith("Binary files")
):
summary_lines.append(line)
# If we couldn't extract meaningful summaries, take the first chunk
if len(summary_lines) <= 2:
truncated = diff_summary[:MAX_DIFF_CHARS]
return truncated + "\n\n(... diff truncated due to size)"
return "\n".join(summary_lines)
def _strip_markdown_fences(content: str) -> str:
"""
Strip markdown code fences from the response if present.
The AI sometimes wraps the output in ```markdown ... ``` even when instructed
not to. This ensures the PR body renders correctly on GitHub.
Args:
content: The response content to clean
Returns:
The content with markdown fences stripped.
"""
result = content
# Strip opening fence (```markdown or just ```)
if result.startswith("```markdown"):
result = result[len("```markdown") :].lstrip("\n")
elif result.startswith("```md"):
result = result[len("```md") :].lstrip("\n")
elif result.startswith("```"):
result = result[3:].lstrip("\n")
# Strip closing fence
if result.endswith("```"):
result = result[:-3].rstrip("\n")
return result.strip()
def _build_prompt(
template_content: str,
diff_summary: str,
spec_overview: str,
commit_log: str,
branch_name: str,
target_branch: str,
) -> str:
"""
Build the prompt for the PR template filler agent.
Combines the system prompt context variables into a single message
that includes the template and all change context.
Args:
template_content: The PR template markdown
diff_summary: Git diff summary (possibly truncated)
spec_overview: Spec.md content or summary
commit_log: Git log of commits in the PR
branch_name: Source branch name
target_branch: Target branch name
Returns:
The assembled prompt string.
"""
return f"""Fill out the following GitHub PR template using the provided context.
Return ONLY the filled template markdown — no preamble, no explanation, no code fences.
## Checkbox Guidelines
IMPORTANT: Be accurate and honest about what has and hasn't been verified.
**Check these based on context (you can infer from the diff/spec):**
- Base Branch targeting — check based on target_branch value
- Type of Change (bug fix, feature, docs, refactor, test) — infer from diff and spec
- Area (Frontend, Backend, Fullstack) — infer from changed file paths
- Feature Toggle "N/A" — if the feature appears complete and not behind a flag
- Breaking Changes "No" — if changes appear backward compatible
**Leave UNCHECKED (these require human verification you cannot perform):**
- "I've tested my changes locally" — you have not tested anything
- "All CI checks pass" — CI has not run yet
- "Windows/macOS/Linux tested" — requires manual testing on each platform
- "All existing tests pass" — CI has not run yet
- "New features include test coverage" — unless test files are clearly visible in the diff
- "Bug fixes include regression tests" — unless test files are clearly visible in the diff
**For platform/code quality checkboxes:**
- "Used centralized platform/ module" — leave unchecked unless you can verify from the diff
- "No hardcoded paths" — leave unchecked unless you can verify from the diff
- "PR is small and focused (< 400 lines)" — check only if diff stats show < 400 lines changed
**For the "I've synced with develop branch" checkbox:**
- Leave unchecked — you cannot verify the sync status
## PR Template
{template_content}
## Change Context
### Branch Information
- **Source branch:** {branch_name}
- **Target branch:** {target_branch}
### Git Diff Summary
```
{diff_summary}
```
### Spec Overview
{spec_overview}
### Commit History
```
{commit_log}
```
Fill every section of the PR template. Follow the checkbox guidelines above carefully.
Output ONLY the completed template — no code fences, no preamble."""
def _load_spec_overview(spec_dir: Path) -> str:
"""
Load the spec.md content for context. Falls back to a brief note if unavailable.
Args:
spec_dir: Directory containing the spec files
Returns:
The spec content or a fallback message.
"""
spec_file = spec_dir / "spec.md"
if spec_file.is_file():
try:
content = spec_file.read_text(encoding="utf-8")
# Truncate very long specs to keep prompt manageable
if len(content) > 8000:
return content[:8000] + "\n\n(... spec truncated for brevity)"
return content
except Exception as e:
logger.warning(f"Failed to read spec.md: {e}")
return "(No spec overview available)"
async def run_pr_template_filler(
project_dir: Path,
spec_dir: Path,
model: str,
thinking_budget: int | None = None,
branch_name: str = "",
target_branch: str = "develop",
diff_summary: str = "",
commit_log: str = "",
verbose: bool = False,
) -> str | None:
"""
Run the PR template filler agent to generate a filled PR body.
Detects the project's PR template, gathers change context, and invokes
Claude to intelligently fill out the template sections.
Args:
project_dir: Root directory of the project
spec_dir: Directory containing the spec files
model: Claude model to use
thinking_budget: Max thinking tokens (None to disable extended thinking)
branch_name: Source branch name for the PR
target_branch: Target branch name for the PR
diff_summary: Git diff summary of changes
commit_log: Git log of commits included in the PR
verbose: Whether to show detailed output
Returns:
The filled template markdown string, or None if template detection fails
or the agent encounters an error.
"""
# Detect PR template
template_content = detect_pr_template(project_dir)
if template_content is None:
logger.info("No PR template detected — skipping template filler")
return None
# Load spec overview
spec_overview = _load_spec_overview(spec_dir)
# Truncate diff if too large
truncated_diff = _truncate_diff(diff_summary)
# Build the prompt
prompt = _build_prompt(
template_content=template_content,
diff_summary=truncated_diff,
spec_overview=spec_overview,
commit_log=commit_log,
branch_name=branch_name,
target_branch=target_branch,
)
# Initialize task logger
task_logger = get_task_logger(spec_dir)
if task_logger:
task_logger.start_phase(LogPhase.CODING, "PR template filling")
# Create client following the pattern from planner.py
client = create_client(
project_dir,
spec_dir,
model,
agent_type="pr_template_filler",
max_thinking_tokens=thinking_budget,
)
try:
async with client:
status, response, _ = await run_agent_session(
client, prompt, spec_dir, verbose, phase=LogPhase.CODING
)
if task_logger:
task_logger.end_phase(
LogPhase.CODING,
success=(status != "error"),
message="PR template filling completed",
)
if status == "error":
logger.error("PR template filler agent returned an error")
return None
# The agent should return only the filled template markdown
if response and response.strip():
result = _strip_markdown_fences(response.strip())
logger.info("PR template filled successfully")
return result
logger.warning("PR template filler returned empty response")
return None
except Exception as e:
logger.error(f"PR template filler error: {e}")
if task_logger:
task_logger.log_error(f"PR template filler error: {e}", LogPhase.CODING)
return None
File diff suppressed because it is too large Load Diff
-6
View File
@@ -263,12 +263,6 @@ AGENT_CONFIGS = {
"auto_claude_tools": [],
"thinking_default": "low",
},
"pr_template_filler": {
"tools": BASE_READ_TOOLS, # Read-only — reads diff, template, spec
"mcp_servers": [], # No MCP needed, context passed via prompt
"auto_claude_tools": [],
"thinking_default": "low", # Fast utility task for structured fill-in
},
"pr_reviewer": {
"tools": BASE_READ_TOOLS + WEB_TOOLS, # Read-only
"mcp_servers": ["context7"],
+8 -4
View File
@@ -56,10 +56,14 @@ def _apply_qa_update(
"ready_for_qa_revalidation": status == "fixes_applied",
}
# 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".
# 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"
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
+1 -8
View File
@@ -87,10 +87,7 @@ def handle_build_command(
debug_success,
)
from phase_config import get_phase_model
from prompts_pkg.prompts import (
get_base_branch_from_metadata,
get_use_local_branch_from_metadata,
)
from prompts_pkg.prompts import get_base_branch_from_metadata
from qa_loop import run_qa_validation_loop, should_run_qa
from .utils import print_banner, validate_environment
@@ -206,9 +203,6 @@ 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
@@ -219,7 +213,6 @@ 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:
-197
View File
@@ -536,175 +536,6 @@ def handle_cleanup_worktrees_command(project_dir: Path) -> None:
cleanup_all_worktrees(project_dir, confirm=True)
def _detect_conflict_scenario(
project_dir: Path,
conflicting_files: list[str],
spec_branch: str,
base_branch: str,
) -> dict:
"""
Analyze conflicting files to determine the conflict scenario.
This helps distinguish between:
- 'already_merged': Task changes already identical in target branch
- 'superseded': Target has newer version of same feature
- 'diverged': Standard diverged branches (AI can resolve)
- 'normal_conflict': Actual conflicting changes
Returns dict with:
- scenario: 'already_merged' | 'superseded' | 'diverged' | 'normal_conflict'
- already_merged_files: files identical in task and target
- details: additional context
"""
if not conflicting_files:
return {
"scenario": "normal_conflict",
"already_merged_files": [],
"details": "No conflicting files to analyze",
}
already_merged_files = []
superseded_files = []
diverged_files = []
try:
# Get the merge-base commit
merge_base_result = subprocess.run(
["git", "merge-base", base_branch, spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
)
if merge_base_result.returncode != 0:
debug_warning(
MODULE, "Could not find merge base for conflict scenario detection"
)
return {
"scenario": "normal_conflict",
"already_merged_files": [],
"details": "Could not determine merge base",
}
merge_base = merge_base_result.stdout.strip()
for file_path in conflicting_files:
try:
# Get content from spec branch (task's changes)
spec_content_result = subprocess.run(
["git", "show", f"{spec_branch}:{file_path}"],
cwd=project_dir,
capture_output=True,
text=True,
)
# Get content from base branch (target)
base_content_result = subprocess.run(
["git", "show", f"{base_branch}:{file_path}"],
cwd=project_dir,
capture_output=True,
text=True,
)
# Get content from merge-base (original state)
merge_base_content_result = subprocess.run(
["git", "show", f"{merge_base}:{file_path}"],
cwd=project_dir,
capture_output=True,
text=True,
)
# Check file existence in each ref
spec_exists = spec_content_result.returncode == 0
base_exists = base_content_result.returncode == 0
merge_base_exists = merge_base_content_result.returncode == 0
if spec_exists and base_exists:
spec_content = spec_content_result.stdout
base_content = base_content_result.stdout
# If contents are identical, the changes are already merged
if spec_content == base_content:
already_merged_files.append(file_path)
debug(
MODULE,
f"File {file_path}: already merged (identical content)",
)
elif merge_base_exists:
merge_base_content = merge_base_content_result.stdout
# If base has changed from merge_base but spec matches merge_base,
# the task's changes are superseded by newer changes
if spec_content == merge_base_content:
superseded_files.append(file_path)
debug(
MODULE,
f"File {file_path}: superseded (base has newer changes)",
)
else:
diverged_files.append(file_path)
debug(
MODULE,
f"File {file_path}: diverged (both branches modified)",
)
else:
diverged_files.append(file_path)
else:
diverged_files.append(file_path)
except Exception as e:
debug_warning(
MODULE, f"Error analyzing file {file_path} for scenario: {e}"
)
diverged_files.append(file_path)
# Determine overall scenario based on dominant pattern
total_files = len(conflicting_files)
if len(already_merged_files) == total_files:
scenario = "already_merged"
details = "All conflicting files have identical content in both branches"
elif len(already_merged_files) > total_files / 2:
scenario = "already_merged"
details = f"{len(already_merged_files)} of {total_files} files already have the same content"
elif len(superseded_files) == total_files:
scenario = "superseded"
details = "All task changes have been superseded by newer changes in the target branch"
elif len(superseded_files) > total_files / 2:
scenario = "superseded"
details = (
f"{len(superseded_files)} of {total_files} files have been superseded"
)
elif diverged_files:
scenario = "diverged"
details = f"{len(diverged_files)} files have diverged and need AI merge"
else:
scenario = "normal_conflict"
details = "Standard merge conflicts detected"
debug(
MODULE,
f"Conflict scenario: {scenario}",
already_merged=len(already_merged_files),
superseded=len(superseded_files),
diverged=len(diverged_files),
)
return {
"scenario": scenario,
"already_merged_files": already_merged_files,
"superseded_files": superseded_files,
"diverged_files": diverged_files,
"details": details,
}
except Exception as e:
debug_error(MODULE, f"Error detecting conflict scenario: {e}")
return {
"scenario": "normal_conflict",
"already_merged_files": [],
"superseded_files": [],
"diverged_files": [],
"details": f"Error during analysis: {e}",
}
def _check_git_merge_conflicts(
project_dir: Path, spec_name: str, base_branch: str | None = None
) -> dict:
@@ -1048,24 +879,6 @@ def handle_merge_preview_command(
f for f in git_conflicts.get("conflicting_files", []) if not is_lock_file(f)
]
# Detect conflict scenario (already_merged, superseded, diverged, normal_conflict)
# This helps the UI show appropriate messaging and actions
conflict_scenario = None
if non_lock_conflicting_files:
conflict_scenario = _detect_conflict_scenario(
project_dir,
non_lock_conflicting_files,
git_conflicts["spec_branch"],
git_conflicts["base_branch"],
)
debug(
MODULE,
f"Conflict scenario detected: {conflict_scenario.get('scenario')}",
already_merged_files=len(
conflict_scenario.get("already_merged_files", [])
),
)
# Use git diff file count as the authoritative totalFiles count
# The semantic tracker may not track all files (e.g., test files, config files)
# but we want to show the user all files that will be merged
@@ -1139,16 +952,6 @@ def handle_merge_preview_command(
# Path-mapped files that need AI merge due to renames
"pathMappedAIMerges": path_mapped_ai_merges,
"totalRenames": len(path_mappings),
# Conflict scenario detection for better UX messaging
"scenario": conflict_scenario.get("scenario")
if conflict_scenario
else None,
"alreadyMergedFiles": conflict_scenario.get("already_merged_files", [])
if conflict_scenario
else [],
"scenarioMessage": conflict_scenario.get("details")
if conflict_scenario
else None,
},
"summary": {
# Use git diff count, not semantic tracker count
-51
View File
@@ -948,57 +948,6 @@ def get_sdk_env_vars() -> dict[str, str]:
return env
def configure_sdk_authentication(config_dir: str | None = None) -> None:
"""
Configure SDK authentication based on environment variables.
Supports two authentication modes:
- API Profile mode (ANTHROPIC_BASE_URL set): uses ANTHROPIC_AUTH_TOKEN
- OAuth mode (default): uses CLAUDE_CODE_OAUTH_TOKEN
In API profile mode, explicitly removes CLAUDE_CODE_OAUTH_TOKEN from the
environment because the SDK gives OAuth priority over API keys when both
are present.
Args:
config_dir: Optional profile config directory for per-profile Keychain
lookup. When set, enables multi-profile token storage.
Raises:
ValueError: If required tokens are missing for the active mode.
- API profile mode: requires ANTHROPIC_AUTH_TOKEN
- OAuth mode: requires CLAUDE_CODE_OAUTH_TOKEN (from Keychain or env)
"""
api_profile_mode = bool(os.environ.get("ANTHROPIC_BASE_URL", "").strip())
if api_profile_mode:
# API profile mode: ensure ANTHROPIC_AUTH_TOKEN is present
if not os.environ.get("ANTHROPIC_AUTH_TOKEN"):
raise ValueError(
"API profile mode active (ANTHROPIC_BASE_URL is set) "
"but ANTHROPIC_AUTH_TOKEN is not set"
)
# Explicitly remove CLAUDE_CODE_OAUTH_TOKEN so SDK uses ANTHROPIC_AUTH_TOKEN
# SDK gives OAuth priority over API keys when both are present
os.environ.pop("CLAUDE_CODE_OAUTH_TOKEN", None)
logger.info("Using API profile authentication")
else:
# OAuth mode: require and validate OAuth token
# Get OAuth token - uses profile-specific Keychain lookup when config_dir is set
# This correctly reads from "Claude Code-credentials-{hash}" for non-default profiles
oauth_token = require_auth_token(config_dir)
# Validate token is not encrypted before passing to SDK
# Encrypted tokens (enc:...) should have been decrypted by require_auth_token()
# If we still have an encrypted token here, it means decryption failed or was skipped
validate_token_not_encrypted(oauth_token)
# Ensure SDK can access it via its expected env var
# This is required because the SDK doesn't know about per-profile Keychain naming
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
logger.info("Using OAuth authentication")
def ensure_claude_code_oauth_token() -> None:
"""
Ensure CLAUDE_CODE_OAUTH_TOKEN is set (for SDK compatibility).
+30 -3
View File
@@ -139,8 +139,9 @@ from agents.tools_pkg import (
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from claude_agent_sdk.types import HookMatcher
from core.auth import (
configure_sdk_authentication,
get_sdk_env_vars,
require_auth_token,
validate_token_not_encrypted,
)
from linear_updater import is_linear_enabled
from prompts_pkg.project_context import detect_project_capabilities, load_project_index
@@ -496,8 +497,34 @@ def create_client(
# CLAUDE_CONFIG_DIR enables per-profile Keychain entries with SHA256-hashed service names
config_dir = sdk_env.get("CLAUDE_CONFIG_DIR")
# Configure SDK authentication (OAuth or API profile mode)
configure_sdk_authentication(config_dir)
# Authentication: Support both OAuth and API Profile modes
# API Profile mode: ANTHROPIC_BASE_URL indicates custom endpoint (e.g., z.ai)
# OAuth mode: CLAUDE_CODE_OAUTH_TOKEN for Claude Code subscription
api_profile_mode = bool(os.environ.get("ANTHROPIC_BASE_URL"))
if api_profile_mode:
# API profile mode: ensure ANTHROPIC_AUTH_TOKEN is present
if not os.environ.get("ANTHROPIC_AUTH_TOKEN"):
raise ValueError(
"API profile mode active (ANTHROPIC_BASE_URL is set) "
"but ANTHROPIC_AUTH_TOKEN is not set"
)
# Do NOT set CLAUDE_CODE_OAUTH_TOKEN - SDK will use ANTHROPIC_AUTH_TOKEN
logger.info("Using API profile authentication")
else:
# OAuth mode: require and validate OAuth token
# Get OAuth token - uses profile-specific Keychain lookup when config_dir is set
# This correctly reads from "Claude Code-credentials-{hash}" for non-default profiles
oauth_token = require_auth_token(config_dir)
# Validate token is not encrypted before passing to SDK
# Encrypted tokens (enc:...) should have been decrypted by require_auth_token()
# If we still have an encrypted token here, it means decryption failed or was skipped
validate_token_not_encrypted(oauth_token)
# Ensure SDK can access it via its expected env var
# This is required because the SDK doesn't know about per-profile Keychain naming
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
if config_dir:
logger.info(f"Using CLAUDE_CONFIG_DIR for profile: {config_dir}")
-115
View File
@@ -1,115 +0,0 @@
#!/usr/bin/env python3
"""
Git Provider Detection
======================
Utility to detect git hosting provider (GitHub, GitLab, or unknown) from git remote URLs.
Supports both SSH and HTTPS remote formats, and self-hosted GitLab instances.
"""
import re
from pathlib import Path
from .git_executable import run_git
def detect_git_provider(project_dir: str | Path, remote_name: str | None = None) -> str:
"""Detect the git hosting provider from the git remote URL.
Args:
project_dir: Path to the git repository
remote_name: Name of the remote to check (defaults to "origin")
Returns:
'github' if GitHub remote detected
'gitlab' if GitLab remote detected (cloud or self-hosted)
'unknown' if no remote or unsupported provider
Examples:
>>> detect_git_provider('/path/to/repo')
'github' # for git@github.com:user/repo.git
'gitlab' # for git@gitlab.com:user/repo.git
'gitlab' # for https://gitlab.company.com/user/repo.git
'unknown' # for no remote or other providers
"""
try:
# Get the remote URL (use specified remote or default to origin)
remote = remote_name if remote_name else "origin"
result = run_git(
["remote", "get-url", remote],
cwd=project_dir,
timeout=5,
)
# If command failed or no output, return unknown
if result.returncode != 0 or not result.stdout.strip():
return "unknown"
remote_url = result.stdout.strip()
# Parse ssh:// URL format: ssh://[user@]host[:port]/path
ssh_url_match = re.match(r"^ssh://(?:[^@]+@)?([^:/]+)(?::\d+)?/", remote_url)
if ssh_url_match:
hostname = ssh_url_match.group(1)
return _classify_hostname(hostname)
# Parse HTTPS/HTTP format: https://host/path or http://host/path
# Must check before scp-like format to avoid matching "https" as hostname
https_match = re.match(r"^https?://([^/]+)/", remote_url)
if https_match:
hostname = https_match.group(1)
return _classify_hostname(hostname)
# Parse scp-like format: [user@]host:path (any username, not just 'git')
# This handles git@github.com:user/repo.git and similar formats
scp_match = re.match(r"^(?:[^@]+@)?([^:]+):", remote_url)
if scp_match:
hostname = scp_match.group(1)
# Exclude paths that look like Windows drives (e.g., C:)
if len(hostname) > 1:
return _classify_hostname(hostname)
# Unrecognized URL format
return "unknown"
except Exception:
# Any error (subprocess issues, etc.) -> unknown
return "unknown"
def _classify_hostname(hostname: str) -> str:
"""Classify a hostname as github, gitlab, or unknown.
Args:
hostname: The git remote hostname (e.g., 'github.com', 'gitlab.example.com')
Returns:
'github', 'gitlab', or 'unknown'
"""
hostname_lower = hostname.lower()
# Check for GitHub (cloud and self-hosted/enterprise)
# Match github.com, *.github.com, or domains where a segment is or starts with 'github'
hostname_parts = hostname_lower.split(".")
if (
hostname_lower == "github.com"
or hostname_lower.endswith(".github.com")
or any(
part == "github" or part.startswith("github-") for part in hostname_parts
)
):
return "github"
# Check for GitLab (cloud and self-hosted)
# Match gitlab.com, *.gitlab.com, or domains where a segment is or starts with 'gitlab'
if (
hostname_lower == "gitlab.com"
or hostname_lower.endswith(".gitlab.com")
or any(
part == "gitlab" or part.startswith("gitlab-") for part in hostname_parts
)
):
return "gitlab"
# Unknown provider
return "unknown"
-192
View File
@@ -1,192 +0,0 @@
#!/usr/bin/env python3
"""
GitLab CLI Executable Finder
============================
Utility to find the glab (GitLab CLI) executable, with platform-specific fallbacks.
"""
import os
import shutil
import subprocess
_cached_glab_path: str | None = None
def invalidate_glab_cache() -> None:
"""Invalidate the cached glab executable path.
Useful when glab may have been uninstalled, updated, or when
GITLAB_CLI_PATH environment variable has changed.
"""
global _cached_glab_path
_cached_glab_path = None
def _verify_glab_executable(path: str) -> bool:
"""Verify that a path is a valid glab executable by checking version.
Args:
path: Path to the potential glab executable
Returns:
True if the path points to a valid glab executable, False otherwise
"""
try:
result = subprocess.run(
[path, "--version"],
capture_output=True,
text=True,
encoding="utf-8",
timeout=5,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, OSError):
return False
def _run_where_command() -> str | None:
"""Run Windows 'where glab' command to find glab executable.
Returns:
First path found, or None if command failed
"""
try:
result = subprocess.run(
"where glab",
capture_output=True,
text=True,
encoding="utf-8",
timeout=5,
shell=True, # Required: 'where' command must be executed through shell on Windows
)
if result.returncode == 0 and result.stdout.strip():
found_path = result.stdout.strip().split("\n")[0].strip()
if (
found_path
and os.path.isfile(found_path)
and _verify_glab_executable(found_path)
):
return found_path
except (subprocess.TimeoutExpired, OSError):
# 'where' command failed or timed out - fall through to return None
pass
return None
def get_glab_executable() -> str | None:
"""Find the glab executable, with platform-specific fallbacks.
Returns the path to glab executable, or None if not found.
Priority order:
1. GITLAB_CLI_PATH env var (user-configured path from frontend)
2. shutil.which (if glab is in PATH)
3. Homebrew paths on macOS
4. Windows Program Files paths
5. Windows 'where' command
Caches the result after first successful find. Use invalidate_glab_cache()
to force re-detection (e.g., after glab installation/uninstallation).
"""
global _cached_glab_path
# Return cached result if available AND still exists
if _cached_glab_path is not None and os.path.isfile(_cached_glab_path):
return _cached_glab_path
_cached_glab_path = _find_glab_executable()
return _cached_glab_path
def _find_glab_executable() -> str | None:
"""Internal function to find glab executable."""
# 1. Check GITLAB_CLI_PATH env var (set by Electron frontend)
env_path = os.environ.get("GITLAB_CLI_PATH")
if env_path and os.path.isfile(env_path) and _verify_glab_executable(env_path):
return env_path
# 2. Try shutil.which (works if glab is in PATH)
glab_path = shutil.which("glab")
if glab_path and _verify_glab_executable(glab_path):
return glab_path
# 3. macOS-specific: check Homebrew paths
if os.name != "nt": # Unix-like systems (macOS, Linux)
homebrew_paths = [
"/opt/homebrew/bin/glab", # Apple Silicon
"/usr/local/bin/glab", # Intel Mac
"/home/linuxbrew/.linuxbrew/bin/glab", # Linux Homebrew
]
for path in homebrew_paths:
if os.path.isfile(path) and _verify_glab_executable(path):
return path
# 4. Windows-specific: check Program Files paths
# glab uses Inno Setup with DefaultDirName={autopf}\glab
if os.name == "nt":
windows_paths = [
os.path.expandvars(r"%PROGRAMFILES%\glab\glab.exe"),
os.path.expandvars(r"%PROGRAMFILES(X86)%\glab\glab.exe"),
os.path.expandvars(r"%LOCALAPPDATA%\Programs\glab\glab.exe"),
]
for path in windows_paths:
if os.path.isfile(path) and _verify_glab_executable(path):
return path
# 5. Try 'where' command with shell=True (more reliable on Windows)
return _run_where_command()
return None
def run_glab(
args: list[str],
cwd: str | None = None,
timeout: int = 60,
input_data: str | None = None,
) -> subprocess.CompletedProcess:
"""Run a glab command with proper executable finding.
Args:
args: glab command arguments (without 'glab' prefix)
cwd: Working directory for the command
timeout: Command timeout in seconds (default: 60)
input_data: Optional string data to pass to stdin
Returns:
CompletedProcess with command results.
"""
glab = get_glab_executable()
if not glab:
return subprocess.CompletedProcess(
args=["glab"] + args,
returncode=-1,
stdout="",
stderr="GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli",
)
try:
return subprocess.run(
[glab] + args,
cwd=cwd,
input=input_data,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(
args=[glab] + args,
returncode=-1,
stdout="",
stderr=f"Command timed out after {timeout} seconds",
)
except FileNotFoundError:
return subprocess.CompletedProcess(
args=[glab] + args,
returncode=-1,
stdout="",
stderr="GitLab CLI (glab) executable not found. Install from https://gitlab.com/gitlab-org/cli",
)
+1 -21
View File
@@ -23,9 +23,6 @@ class ExecutionPhase(str, Enum):
QA_FIXING = "qa_fixing"
COMPLETE = "complete"
FAILED = "failed"
# Pause states for intelligent error recovery
RATE_LIMIT_PAUSED = "rate_limit_paused"
AUTH_FAILURE_PAUSED = "auth_failure_paused"
def emit_phase(
@@ -34,19 +31,8 @@ def emit_phase(
*,
progress: int | None = None,
subtask: str | None = None,
reset_timestamp: int | None = None,
profile_id: str | None = None,
) -> None:
"""Emit structured phase event to stdout for frontend parsing.
Args:
phase: The execution phase (e.g., PLANNING, CODING, RATE_LIMIT_PAUSED)
message: Optional message describing the phase state
progress: Optional progress percentage (0-100)
subtask: Optional subtask identifier
reset_timestamp: Optional Unix timestamp for rate limit reset time
profile_id: Optional profile ID that triggered the pause
"""
"""Emit structured phase event to stdout for frontend parsing."""
phase_value = phase.value if isinstance(phase, ExecutionPhase) else phase
payload: dict[str, Any] = {
@@ -62,12 +48,6 @@ def emit_phase(
if subtask is not None:
payload["subtask"] = subtask
if reset_timestamp is not None:
payload["reset_timestamp"] = reset_timestamp
if profile_id is not None:
payload["profile_id"] = profile_id
try:
print(f"{PHASE_MARKER_PREFIX}{json.dumps(payload, default=str)}", flush=True)
except (OSError, UnicodeEncodeError) as e:
+14 -3
View File
@@ -28,8 +28,9 @@ from pathlib import Path
from agents.tools_pkg import get_agent_config, get_default_thinking_level
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from core.auth import (
configure_sdk_authentication,
get_sdk_env_vars,
require_auth_token,
validate_token_not_encrypted,
)
from core.platform import validate_cli_path
from phase_config import get_thinking_budget
@@ -79,8 +80,18 @@ def create_simple_client(
# CLAUDE_CONFIG_DIR enables per-profile Keychain entries with SHA256-hashed service names
config_dir = sdk_env.get("CLAUDE_CONFIG_DIR")
# Configure SDK authentication (OAuth or API profile mode)
configure_sdk_authentication(config_dir)
# Get OAuth token - uses profile-specific Keychain lookup when config_dir is set
# This correctly reads from "Claude Code-credentials-{hash}" for non-default profiles
oauth_token = require_auth_token(config_dir)
# Validate token is not encrypted before passing to SDK
# Encrypted tokens (enc:...) should have been decrypted by require_auth_token()
# If we still have an encrypted token here, it means decryption failed or was skipped
validate_token_not_encrypted(oauth_token)
# Ensure SDK can access it via its expected env var
# This is required because the SDK doesn't know about per-profile Keychain naming
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
# Get agent configuration (raises ValueError if unknown type)
config = get_agent_config(agent_type)
-101
View File
@@ -1,101 +0,0 @@
"""
Task event protocol for frontend XState synchronization.
Protocol: __TASK_EVENT__:{...}
"""
from __future__ import annotations
import json
import os
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4
TASK_EVENT_PREFIX = "__TASK_EVENT__:"
_DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true", "yes")
@dataclass
class TaskEventContext:
task_id: str
spec_id: str
project_id: str
sequence_start: int = 0
def _load_task_metadata(spec_dir: Path) -> dict:
metadata_path = spec_dir / "task_metadata.json"
if not metadata_path.exists():
return {}
try:
with open(metadata_path, encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return {}
def _load_last_sequence(spec_dir: Path) -> int:
plan_path = spec_dir / "implementation_plan.json"
if not plan_path.exists():
return 0
try:
with open(plan_path, encoding="utf-8") as f:
plan = json.load(f)
last_event = plan.get("lastEvent") or {}
seq = last_event.get("sequence")
if isinstance(seq, int) and seq >= 0:
return seq + 1
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return 0
return 0
def load_task_event_context(spec_dir: Path) -> TaskEventContext:
metadata = _load_task_metadata(spec_dir)
task_id = metadata.get("taskId") or metadata.get("task_id") or spec_dir.name
spec_id = metadata.get("specId") or metadata.get("spec_id") or spec_dir.name
project_id = metadata.get("projectId") or metadata.get("project_id") or ""
sequence_start = _load_last_sequence(spec_dir)
return TaskEventContext(
task_id=str(task_id),
spec_id=str(spec_id),
project_id=str(project_id),
sequence_start=sequence_start,
)
class TaskEventEmitter:
def __init__(self, context: TaskEventContext) -> None:
self._context = context
self._sequence = context.sequence_start
@classmethod
def from_spec_dir(cls, spec_dir: Path) -> TaskEventEmitter:
return cls(load_task_event_context(spec_dir))
def emit(self, event_type: str, payload: dict | None = None) -> None:
event = {
"type": event_type,
"taskId": self._context.task_id,
"specId": self._context.spec_id,
"projectId": self._context.project_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"eventId": str(uuid4()),
"sequence": self._sequence,
}
if payload:
event.update(payload)
try:
print(f"{TASK_EVENT_PREFIX}{json.dumps(event, default=str)}", flush=True)
self._sequence += 1
except (OSError, UnicodeEncodeError) as e:
if _DEBUG:
try:
sys.stderr.write(f"[task_event] emit failed: {e}\n")
sys.stderr.flush()
except (OSError, UnicodeEncodeError):
pass # Silent on complete I/O failure
+169 -196
View File
@@ -121,7 +121,6 @@ from merge import (
FileTimelineTracker,
MergeOrchestrator,
)
from merge.progress import MergeProgressCallback, MergeProgressStage, emit_progress
MODULE = "workspace"
@@ -146,26 +145,6 @@ MODULE = "workspace"
# - _heuristic_merge
def _create_merge_progress_callback() -> MergeProgressCallback | None:
"""
Create a progress callback for merge operations when running as a subprocess.
Returns emit_progress (writing JSON to stdout) only when stdout is piped
(i.e., running as a subprocess from the Electron frontend). Returns None
when running interactively in a terminal to avoid polluting CLI output.
This function must be called at runtime (not at import time) to ensure
sys.stdout state is accurate.
"""
import sys
# Only emit progress JSON when stdout is piped (subprocess mode).
# In interactive CLI mode (TTY), progress JSON would clutter the output.
if not sys.stdout.isatty():
return emit_progress
return None
def merge_existing_build(
project_dir: Path,
spec_name: str,
@@ -273,11 +252,10 @@ def merge_existing_build(
had_conflicts = stats.get("conflicts_resolved", 0) > 0
ai_assisted = stats.get("ai_assisted", 0) > 0
direct_copy = stats.get("direct_copy", False)
git_merge_used = stats.get("git_merge", False)
if had_conflicts or ai_assisted or direct_copy or git_merge_used:
# AI resolved conflicts, assisted with merges, git merge was used, or direct copy was used
# Changes are already written and staged - no need for additional git merge
if had_conflicts or ai_assisted or direct_copy:
# AI resolved conflicts, assisted with merges, or direct copy was used
# Changes are already written and staged - no need for git merge
_print_merge_success(
no_commit, stats, spec_name=spec_name, keep_worktree=True
)
@@ -424,20 +402,9 @@ def _try_smart_merge_inner(
no_commit=no_commit,
)
# Create progress callback for subprocess mode (Electron frontend).
# Only emits JSON to stdout when piped, not in interactive CLI.
progress_callback = _create_merge_progress_callback()
try:
print(muted(" Analyzing changes with intent-aware merge..."))
if progress_callback is not None:
progress_callback(
MergeProgressStage.ANALYZING,
0,
"Starting merge analysis",
)
# Capture worktree state in FileTimelineTracker before merge
try:
timeline_tracker = FileTimelineTracker(project_dir)
@@ -473,13 +440,6 @@ def _try_smart_merge_inner(
)
# Check for git-level conflicts first (branch divergence)
if progress_callback is not None:
progress_callback(
MergeProgressStage.DETECTING_CONFLICTS,
25,
"Checking for git-level conflicts",
)
debug(MODULE, "Checking for git-level conflicts")
git_conflicts = _check_git_conflicts(project_dir, spec_name)
@@ -532,11 +492,12 @@ def _try_smart_merge_inner(
# If rebase succeeded and now there are no conflicts,
# the diverged_but_no_conflicts path will handle the merge
else:
# Rebase failed (likely due to worktree lock) - continue with merge
# Git merge or AI resolver will handle it depending on conflict state
debug(
MODULE,
"Rebase skipped or failed, continuing with merge flow",
# Rebase failed - continue with conflict resolution as before
# The AI resolver will handle the conflicts
print(
warning(
" Rebase encountered issues, using AI conflict resolution..."
)
)
if git_conflicts.get("has_conflicts"):
@@ -557,18 +518,6 @@ def _try_smart_merge_inner(
num_conflicts=len(git_conflicts.get("conflicting_files", [])),
)
if progress_callback is not None:
progress_callback(
MergeProgressStage.RESOLVING,
50,
f"Resolving {len(git_conflicts.get('conflicting_files', []))} conflicting files with AI",
{
"conflicts_found": len(
git_conflicts.get("conflicting_files", [])
)
},
)
# Try to resolve git conflicts with AI
resolution_result = _resolve_git_conflicts_with_ai(
project_dir,
@@ -586,22 +535,6 @@ def _try_smart_merge_inner(
resolved_files=resolution_result.get("resolved_files", []),
stats=resolution_result.get("stats", {}),
)
if progress_callback is not None:
stats = resolution_result.get("stats", {})
original_conflict_count = len(
git_conflicts.get("conflicting_files", [])
)
progress_callback(
MergeProgressStage.COMPLETE,
100,
"Merge complete",
{
"conflicts_found": original_conflict_count,
"conflicts_resolved": stats.get("conflicts_resolved", 0),
},
)
return resolution_result
else:
# AI couldn't resolve all conflicts
@@ -614,26 +547,6 @@ def _try_smart_merge_inner(
resolved_files=resolution_result.get("resolved_files", []),
error=resolution_result.get("error"),
)
if progress_callback is not None:
original_conflict_count = len(
git_conflicts.get("conflicting_files", [])
)
remaining_count = len(
resolution_result.get("remaining_conflicts", [])
)
progress_callback(
MergeProgressStage.ERROR,
0,
"Some conflicts could not be resolved",
{
"conflicts_found": original_conflict_count,
"conflicts_resolved": original_conflict_count
- remaining_count,
"conflicts_remaining": remaining_count,
},
)
return {
"success": False,
"conflicts": resolution_result.get("remaining_conflicts", []),
@@ -642,81 +555,148 @@ def _try_smart_merge_inner(
"error": resolution_result.get("error"),
}
# Check if branches diverged but no actual conflicts (use git merge)
# Check if branches diverged but no actual conflicts (can do direct copy)
if git_conflicts.get("diverged_but_no_conflicts"):
debug(MODULE, "Branches diverged but no conflicts - using git merge")
debug(MODULE, "Branches diverged but no conflicts - doing direct file copy")
print(muted(" Branches diverged but no conflicts detected"))
print(muted(" Using git merge to combine changes..."))
print(muted(" Copying changed files directly from worktree..."))
# Get changed files from spec branch
spec_branch = f"auto-claude/{spec_name}"
base_branch = git_conflicts.get("base_branch", "main")
# Use git merge --no-commit to combine changes from both branches
# Since merge-tree confirmed no conflicts, this should succeed cleanly
merge_result = run_git(
["merge", "--no-commit", "--no-ff", spec_branch],
# Get merge-base for diff
merge_base_result = run_git(
["merge-base", base_branch, spec_branch],
cwd=project_dir,
)
merge_base = (
merge_base_result.stdout.strip()
if merge_base_result.returncode == 0
else None
)
if merge_result.returncode == 0:
# Merge succeeded - get list of files that were merged
# Use git diff --cached to see what's staged
diff_result = run_git(
["diff", "--cached", "--name-only"],
cwd=project_dir,
)
merged_files = [
f.strip()
for f in diff_result.stdout.splitlines()
if f.strip() and not _is_auto_claude_file(f.strip())
]
debug_success(
MODULE,
"Git merge succeeded",
merged_files_count=len(merged_files),
if merge_base:
# Get list of changed files in spec branch
changed_files = _get_changed_files_from_branch(
project_dir, merge_base, spec_branch
)
for file_path in merged_files:
print(success(f"{file_path}"))
resolved_files = []
skipped_files = [] # Track files that failed to copy
files_to_stage = []
for file_path, status in changed_files:
if _is_auto_claude_file(file_path):
continue
if progress_callback is not None:
progress_callback(
MergeProgressStage.COMPLETE,
100,
f"Git merge complete ({len(merged_files)} files)",
try:
target_path = project_dir / file_path
if status == "D":
# Deleted in worktree
if target_path.exists():
target_path.unlink()
files_to_stage.append(file_path)
resolved_files.append(file_path)
print(success(f"{file_path} (deleted)"))
else:
# New or modified - copy from spec branch
target_path.parent.mkdir(parents=True, exist_ok=True)
if _is_binary_file(file_path):
binary_content = _get_binary_file_content_from_ref(
project_dir, spec_branch, file_path
)
if binary_content is not None:
target_path.write_bytes(binary_content)
files_to_stage.append(file_path)
resolved_files.append(file_path)
status_label = (
"new file" if status == "A" else "updated"
)
print(
success(f"{file_path} ({status_label})")
)
else:
skipped_files.append(file_path)
debug_warning(
MODULE,
f"Could not retrieve binary content for {file_path}",
)
else:
content = _get_file_content_from_ref(
project_dir, spec_branch, file_path
)
if content is not None:
target_path.write_text(content, encoding="utf-8")
files_to_stage.append(file_path)
resolved_files.append(file_path)
status_label = (
"new file" if status == "A" else "updated"
)
print(
success(f"{file_path} ({status_label})")
)
else:
skipped_files.append(file_path)
debug_warning(
MODULE,
f"Could not retrieve content for {file_path}",
)
except Exception as e:
skipped_files.append(file_path)
debug_warning(MODULE, f"Could not copy {file_path}: {e}")
# Stage all files in a single git add call for efficiency
if files_to_stage:
add_result = run_git(
["add"] + files_to_stage,
cwd=project_dir,
)
if add_result.returncode != 0:
debug_warning(
MODULE,
f"Failed to stage files for direct copy: {add_result.stderr}",
)
# Return failure - files were written but not staged
return {
"success": False,
"error": f"Failed to stage files: {add_result.stderr}",
"resolved_files": [],
}
return {
"success": True,
"resolved_files": merged_files,
# Build result - check for skipped files to detect partial merges
result = {
"success": len(skipped_files) == 0,
"resolved_files": resolved_files,
"stats": {
"files_merged": len(merged_files),
"files_merged": len(resolved_files),
"conflicts_resolved": 0,
"ai_assisted": 0,
"auto_merged": len(merged_files),
"git_merge": True, # Flag indicating git merge was used
"auto_merged": len(resolved_files),
"direct_copy": True, # Flag indicating direct copy was used
"skipped_count": len(skipped_files),
},
}
if skipped_files:
result["skipped_files"] = skipped_files
result["partial_success"] = len(resolved_files) > 0
print()
print(
warning(
f"{len(skipped_files)} file(s) could not be retrieved:"
)
)
for skipped_file in skipped_files:
print(muted(f" - {skipped_file}"))
print(muted(" These files may need manual review."))
return result
else:
# Merge failed unexpectedly - abort and fall back to semantic analysis
# merge-base failed - branches may not share history
debug_warning(
MODULE,
"Git merge failed unexpectedly despite no conflicts detected",
stderr=merge_result.stderr[:500] if merge_result.stderr else "",
)
# Abort the merge to restore clean state
abort_result = run_git(["merge", "--abort"], cwd=project_dir)
if abort_result.returncode != 0:
debug_error(
MODULE,
"Failed to abort merge - repo may be in inconsistent state",
stderr=abort_result.stderr,
)
return None # Trigger fallback to avoid operating on inconsistent state
print(
warning(
" Git merge failed unexpectedly, falling back to semantic analysis..."
)
"Could not find merge-base between branches - falling back to semantic analysis",
)
# No git conflicts - proceed with semantic analysis
@@ -745,14 +725,6 @@ def _try_smart_merge_inner(
# All conflicts can be auto-merged or no conflicts
print(muted(" All changes compatible, proceeding with merge..."))
if progress_callback is not None:
progress_callback(
MergeProgressStage.COMPLETE,
100,
f"Analysis complete ({files_to_merge} files compatible)",
)
return {
"success": True,
"stats": {
@@ -765,13 +737,6 @@ def _try_smart_merge_inner(
# If smart merge fails, fall back to git
import traceback
if progress_callback is not None:
progress_callback(
MergeProgressStage.ERROR,
0,
f"Smart merge error: {e}",
)
print(muted(f" Smart merge error: {e}"))
traceback.print_exc()
return None
@@ -783,11 +748,14 @@ def _rebase_spec_branch(
base_branch: str,
) -> bool:
"""
Attempt to rebase the spec branch onto the latest base branch.
Rebase the spec branch onto the latest base branch.
NOTE: This will fail if the spec branch is checked out in a worktree,
which is the normal case. The caller should handle failure gracefully
by falling back to git merge or AI conflict resolution.
This performs an automatic rebase of the spec branch onto the current
base branch (main/develop) to bring it up to date before merging.
If conflicts occur during rebase, the function aborts and returns False
so that the caller can fall back to AI conflict resolution.
The function preserves the current HEAD by restoring it after completion.
Args:
project_dir: The project directory
@@ -796,36 +764,22 @@ def _rebase_spec_branch(
Returns:
True if rebase succeeded cleanly or branch was already up-to-date,
False if rebase failed (worktree lock, conflicts, or other errors)
False if rebase failed due to conflicts or other errors (aborted, no ref movement)
"""
spec_branch = f"auto-claude/{spec_name}"
debug(
MODULE,
"Attempting to rebase spec branch",
"Rebasing spec branch",
spec_branch=spec_branch,
base_branch=base_branch,
)
# Check if spec branch is used by a worktree (common case)
# In this case, we can't checkout/rebase from the main repo
worktree_list_result = run_git(["worktree", "list", "--porcelain"], cwd=project_dir)
if worktree_list_result.returncode == 0:
# Check if spec_branch is in use by a worktree
output = worktree_list_result.stdout
if f"branch refs/heads/{spec_branch}" in output:
debug(
MODULE,
"Spec branch is checked out in a worktree - skipping rebase",
spec_branch=spec_branch,
)
# This is expected - return False to let caller use git merge instead
return False
# Save original branch to restore after rebase
# Save original branch to restore after rebase (HIGH: prevents leaving repo on spec branch)
original_branch_result = run_git(
["rev-parse", "--abbrev-ref", "HEAD"], cwd=project_dir
)
# Check returncode and validate stdout before using original_branch
if original_branch_result.returncode != 0:
debug_error(
MODULE,
@@ -841,6 +795,7 @@ def _rebase_spec_branch(
)
return False
# Save current state for recovery
# Get the current commit of spec_branch before rebase
before_commit_result = run_git(["rev-parse", spec_branch], cwd=project_dir)
if before_commit_result.returncode != 0:
@@ -849,6 +804,8 @@ def _rebase_spec_branch(
"Could not get spec branch commit before rebase",
stderr=before_commit_result.stderr,
)
# Restore original branch before returning
run_git(["checkout", original_branch], cwd=project_dir)
return False
before_commit = before_commit_result.stdout.strip()
@@ -856,18 +813,22 @@ def _rebase_spec_branch(
print(muted(f" Rebasing {spec_branch} onto {base_branch}..."))
try:
# Try to checkout the spec branch
# Perform the rebase using safe/standard invocation:
# 1. Checkout the spec branch first
# 2. Run standard rebase (no strategy options - let conflicts stop the rebase)
# If conflicts occur, we'll abort and let AI handle them during merge
checkout_result = run_git(["checkout", spec_branch], cwd=project_dir)
if checkout_result.returncode != 0:
# Checkout failed - likely due to worktree lock
debug(
debug_error(
MODULE,
"Could not checkout spec branch for rebase (likely worktree lock)",
stderr=checkout_result.stderr[:200] if checkout_result.stderr else "",
"Could not checkout spec branch for rebase",
stderr=checkout_result.stderr,
)
return False
# Run standard rebase
# Run standard rebase - will stop on conflicts so we can detect them
# Git syntax: git rebase [options] <upstream>
# where <upstream> is the branch to rebase onto
rebase_result = run_git(
["rebase", base_branch],
cwd=project_dir,
@@ -877,6 +838,9 @@ def _rebase_spec_branch(
# Rebase failed - check if it was due to conflicts
status_result = run_git(["status", "--porcelain"], cwd=project_dir)
# MEDIUM: Properly parse git status output for conflict markers
# Git status --porcelain uses two-character status codes:
# UU = both modified, AA = both added, DD = both deleted, etc.
has_unmerged = any(
line[:2] in ("UU", "AA", "DD", "AU", "UA", "DU", "UD")
for line in status_result.stdout.splitlines()
@@ -884,6 +848,7 @@ def _rebase_spec_branch(
)
# Abort the rebase to return to clean state
# NEW-002: If abort fails, immediately return False (repo in bad state)
abort_result = run_git(["rebase", "--abort"], cwd=project_dir)
if abort_result.returncode != 0:
debug_error(
@@ -891,16 +856,19 @@ def _rebase_spec_branch(
"Failed to abort rebase - repo may be in inconsistent state",
stderr=abort_result.stderr,
)
return False
return False # Abort failed - cannot safely continue
if has_unmerged:
# Rebase failed due to conflicts - we aborted, so no ref movement happened
debug_warning(
MODULE,
"Rebase encountered conflicts - aborted, will use alternative merge",
"Rebase encountered conflicts - aborted, will use AI conflict resolution",
stderr=rebase_result.stderr[:200] if rebase_result.stderr else "",
)
# Return False since we aborted - no rebase occurred, caller should use AI
return False
# Other error (not conflict-related)
debug_error(
MODULE,
"Rebase failed with unexpected error",
@@ -914,7 +882,9 @@ def _rebase_spec_branch(
if after_commit_result.returncode == 0:
after_commit_hash = after_commit_result.stdout.strip()
# Verify the branch actually moved (commit changed)
if before_commit == after_commit_hash:
# MEDIUM: Branch already up-to-date is a success condition, not failure
debug(
MODULE,
"Branch already up-to-date, no rebase needed",
@@ -934,7 +904,8 @@ def _rebase_spec_branch(
debug_error(MODULE, "Could not verify spec branch commit after rebase")
return False
finally:
# Always restore original branch
# HIGH: Always restore original branch, even on error/exception
# NEW-001: Log restoration failure (cannot modify return from finally block)
if original_branch:
restore_result = run_git(["checkout", original_branch], cwd=project_dir)
if restore_result.returncode != 0:
@@ -943,6 +914,8 @@ def _rebase_spec_branch(
f"Failed to restore original branch '{original_branch}'",
stderr=restore_result.stderr,
)
# Note: Cannot modify return value from finally block,
# but restoration failure is rare and non-critical (user can manually switch back)
def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
+1 -5
View File
@@ -325,7 +325,6 @@ 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.
@@ -338,7 +337,6 @@ 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)
@@ -359,9 +357,7 @@ 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, use_local_branch=use_local_branch
)
manager = WorktreeManager(project_dir, base_branch=base_branch)
manager.setup()
# Get or create worktree for THIS SPECIFIC SPEC
+28 -457
View File
@@ -15,8 +15,6 @@ This allows:
"""
import asyncio
import json
import logging
import os
import re
import shutil
@@ -30,13 +28,8 @@ from typing import TypedDict, TypeVar
from core.gh_executable import get_gh_executable, invalidate_gh_cache
from core.git_executable import get_git_executable, get_isolated_git_env, run_git
from core.git_provider import detect_git_provider
from core.glab_executable import get_glab_executable, invalidate_glab_cache
from core.model_config import get_utility_model_config
from debug import debug_warning
logger = logging.getLogger(__name__)
T = TypeVar("T")
@@ -143,7 +136,6 @@ class PushAndCreatePRResult(TypedDict, total=False):
pushed: bool
remote: str
branch: str
provider: str # 'github', 'gitlab', or 'unknown'
pr_url: str | None # None when PR was created but URL couldn't be extracted
already_exists: bool
error: str
@@ -183,18 +175,12 @@ class WorktreeManager:
# Timeout constants for subprocess operations
GIT_PUSH_TIMEOUT = 120 # 2 minutes for git push (network operations)
CLI_TIMEOUT = 60 # 1 minute for CLI commands (gh/glab)
CLI_QUERY_TIMEOUT = 30 # 30 seconds for CLI queries (gh/glab)
GH_CLI_TIMEOUT = 60 # 1 minute for gh CLI commands
GH_QUERY_TIMEOUT = 30 # 30 seconds for gh CLI queries
def __init__(
self,
project_dir: Path,
base_branch: str | None = None,
use_local_branch: bool = False,
):
def __init__(self, project_dir: Path, base_branch: str | None = None):
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()
@@ -701,23 +687,18 @@ 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
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}")
# 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}")
else:
# 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}"
)
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(
@@ -1211,22 +1192,8 @@ class WorktreeManager:
target = target_branch or self.base_branch
pr_title = title or f"auto-claude: {spec_name}"
# Try AI-powered PR body from project's PR template, fall back to spec summary
pr_body: str | None = None
try:
diff_summary, commit_log = self._gather_pr_context(spec_name, target)
pr_body = self._try_ai_pr_body(
spec_name=spec_name,
target_branch=target,
branch_name=info.branch,
diff_summary=diff_summary,
commit_log=commit_log,
)
except Exception as e:
logger.warning(f"AI PR body generation encountered an error: {e}")
if not pr_body:
pr_body = self._extract_spec_summary(spec_name)
# Get PR body from spec.md if available
pr_body = self._extract_spec_summary(spec_name)
# Find gh executable before attempting PR creation
gh_executable = get_gh_executable()
@@ -1269,7 +1236,7 @@ class WorktreeManager:
text=True,
encoding="utf-8",
errors="replace",
timeout=self.CLI_TIMEOUT,
timeout=self.GH_CLI_TIMEOUT,
env=get_isolated_git_env(),
)
@@ -1345,328 +1312,9 @@ class WorktreeManager:
invalidate_gh_cache()
return PullRequestResult(
success=False,
error="GitHub CLI (gh) not found. Install from https://cli.github.com/",
error="gh CLI not found. Install from https://cli.github.com/",
)
def create_merge_request(
self,
spec_name: str,
target_branch: str | None = None,
title: str | None = None,
draft: bool = False,
) -> PullRequestResult:
"""
Create a GitLab merge request for a spec's branch using glab CLI with retry logic.
Args:
spec_name: The spec folder name
target_branch: Target branch for MR (defaults to base_branch)
title: MR title (defaults to spec name)
draft: Whether to create as draft MR
Returns:
PullRequestResult with keys:
- success: bool
- pr_url: str (if created)
- already_exists: bool (if MR already exists)
- error: str (if failed)
"""
info = self.get_worktree_info(spec_name)
if not info:
return PullRequestResult(
success=False,
error=f"No worktree found for spec: {spec_name}",
)
target = target_branch or self.base_branch
mr_title = title or f"auto-claude: {spec_name}"
# Get MR body from spec.md if available
mr_body = self._extract_spec_summary(spec_name)
# Find glab executable before attempting MR creation
glab_executable = get_glab_executable()
if not glab_executable:
return PullRequestResult(
success=False,
error="GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli",
)
# Build glab mr create command
glab_args = [
glab_executable,
"mr",
"create",
"--target-branch",
target,
"--source-branch",
info.branch,
"--title",
mr_title,
"--description",
mr_body,
]
if draft:
glab_args.append("--draft")
def is_mr_retryable(stderr: str) -> bool:
"""Check if MR creation error is retryable (network or HTTP 5xx)."""
return _is_retryable_network_error(stderr) or _is_retryable_http_error(
stderr
)
def do_create_mr() -> tuple[bool, PullRequestResult | None, str]:
"""Execute MR creation for retry wrapper."""
try:
result = subprocess.run(
glab_args,
cwd=info.path,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=self.CLI_TIMEOUT,
env=get_isolated_git_env(),
)
# Check for "already exists" case (success, no retry needed)
if result.returncode != 0 and "already exists" in result.stderr.lower():
existing_url = self._get_existing_mr_url(spec_name, target)
result_dict = PullRequestResult(
success=True,
pr_url=existing_url,
already_exists=True,
)
if existing_url is None:
result_dict["message"] = (
"MR already exists but URL could not be retrieved"
)
return (True, result_dict, "")
if result.returncode == 0:
# Extract MR URL from output
mr_url: str | None = result.stdout.strip()
if not mr_url.startswith("http"):
# Try to find URL in output
# GitLab URL pattern: matches any HTTPS URL with /merge_requests/<number> or /-/merge_requests/<number> path
match = re.search(
r"https://[^\s]+(?:/merge_requests/|/-/merge_requests/)\d+",
result.stdout,
)
if match:
mr_url = match.group(0)
else:
# Invalid output - no valid URL found
mr_url = None
return (
True,
PullRequestResult(
success=True,
pr_url=mr_url,
already_exists=False,
),
"",
)
return (False, None, result.stderr)
except FileNotFoundError:
# glab CLI not installed - not retryable, raise to exit retry loop
raise
max_retries = 3
try:
result, last_error = _with_retry(
operation=do_create_mr,
max_retries=max_retries,
is_retryable=is_mr_retryable,
)
if result:
return result
# Handle timeout error message
if last_error == "Operation timed out":
return PullRequestResult(
success=False,
error=f"MR creation timed out after {max_retries} attempts.",
)
return PullRequestResult(
success=False,
error=f"Failed to create MR: {last_error}",
)
except FileNotFoundError:
# Cached glab path became invalid - clear cache so next call re-discovers
invalidate_glab_cache()
return PullRequestResult(
success=False,
error="GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli",
)
def _gather_pr_context(self, spec_name: str, target_branch: str) -> tuple[str, str]:
"""
Gather diff summary and commit log for PR template filling.
Args:
spec_name: The spec folder name
target_branch: The target branch for the PR
Returns:
Tuple of (diff_summary, commit_log)
"""
worktree_path = self.get_worktree_path(spec_name)
info = self.get_worktree_info(spec_name)
branch = info.branch if info else self.get_branch_name(spec_name)
# Get diff summary (stat for overview)
diff_result = self._run_git(
["diff", "--stat", f"{target_branch}...{branch}"],
cwd=worktree_path,
timeout=30,
)
diff_summary = diff_result.stdout.strip() if diff_result.returncode == 0 else ""
# Get shortstat for quick summary
shortstat_result = self._run_git(
["diff", "--shortstat", f"{target_branch}...{branch}"],
cwd=worktree_path,
timeout=30,
)
if shortstat_result.returncode == 0 and shortstat_result.stdout.strip():
diff_summary += "\n\n" + shortstat_result.stdout.strip()
# Get actual code changes (patch format) for better AI context
# Truncate to 30k chars to avoid token limits while still providing meaningful context
patch_result = self._run_git(
["diff", "-p", "--stat-width=999", f"{target_branch}...{branch}"],
cwd=worktree_path,
timeout=30,
)
if patch_result.returncode == 0 and patch_result.stdout.strip():
patch_content = patch_result.stdout.strip()
MAX_DIFF_CHARS = 30_000
if len(patch_content) > MAX_DIFF_CHARS:
# Truncate patch and add notice
truncated_patch = patch_content[:MAX_DIFF_CHARS]
diff_summary += (
"\n\n" + truncated_patch + "\n\n(... diff truncated due to size)"
)
else:
diff_summary += "\n\n" + patch_content
# Get commit log
log_result = self._run_git(
[
"log",
"--oneline",
"--no-merges",
f"{target_branch}..{branch}",
],
cwd=worktree_path,
timeout=30,
)
commit_log = log_result.stdout.strip() if log_result.returncode == 0 else ""
return diff_summary, commit_log
def _try_ai_pr_body(
self,
spec_name: str,
target_branch: str,
branch_name: str,
diff_summary: str,
commit_log: str,
) -> str | None:
"""
Attempt to generate a PR body using the AI template filler agent.
Runs the async agent synchronously with a 30-second timeout.
Returns None on any failure so the caller can fall back gracefully.
Args:
spec_name: The spec folder name
target_branch: The target branch for the PR
branch_name: The source branch name
diff_summary: Git diff summary of changes
commit_log: Git log of commits
Returns:
The AI-generated PR body string, or None if unavailable.
"""
try:
from agents.pr_template_filler import (
detect_pr_template,
run_pr_template_filler,
)
except ImportError:
logger.warning(
"PR template filler module not available, skipping AI PR body"
)
return None
# Check if a PR template exists before doing any heavy lifting
template = detect_pr_template(self.project_dir)
if template is None:
return None
# Resolve spec directory
spec_dir = self.project_dir / ".auto-claude" / "specs" / spec_name
if not spec_dir.is_dir():
# Try worktree-local spec path
worktree_path = self.get_worktree_path(spec_name)
spec_dir = worktree_path / ".auto-claude" / "specs" / spec_name
if not spec_dir.is_dir():
logger.warning("Spec directory not found for AI PR body generation")
return None
# Get model configuration from environment (respects user settings)
model, thinking_budget = get_utility_model_config()
async def _run_with_timeout() -> str | None:
try:
return await asyncio.wait_for(
run_pr_template_filler(
project_dir=self.project_dir,
spec_dir=spec_dir,
model=model,
thinking_budget=thinking_budget,
branch_name=branch_name,
target_branch=target_branch,
diff_summary=diff_summary,
commit_log=commit_log,
verbose=False,
),
timeout=30.0,
)
except asyncio.TimeoutError:
logger.warning("PR template filler timed out after 30s")
return None
try:
# Check if there's already a running event loop
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
# We're already inside an async context — run in a new thread
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(asyncio.run, _run_with_timeout())
return future.result(timeout=35)
else:
return asyncio.run(_run_with_timeout())
except Exception as e:
logger.warning(f"AI PR body generation failed: {e}")
return None
def _extract_spec_summary(self, spec_name: str) -> str:
"""Extract a summary from spec.md for PR body."""
worktree_path = self.get_worktree_path(spec_name)
@@ -1741,7 +1389,7 @@ class WorktreeManager:
text=True,
encoding="utf-8",
errors="replace",
timeout=self.CLI_QUERY_TIMEOUT,
timeout=self.GH_QUERY_TIMEOUT,
env=get_isolated_git_env(),
)
if result.returncode == 0:
@@ -1760,57 +1408,6 @@ class WorktreeManager:
return None
def _get_existing_mr_url(self, spec_name: str, target_branch: str) -> str | None:
"""Get the URL of an existing MR for this branch."""
info = self.get_worktree_info(spec_name)
if not info:
return None
glab_executable = get_glab_executable()
if not glab_executable:
# glab CLI not found - return None and let caller handle it
return None
try:
result = subprocess.run(
[
glab_executable,
"mr",
"view",
info.branch,
"--output",
"json",
],
cwd=info.path,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=self.CLI_QUERY_TIMEOUT,
env=get_isolated_git_env(),
)
if result.returncode == 0 and result.stdout.strip():
# Parse JSON output to extract web_url (glab uses snake_case)
try:
data = json.loads(result.stdout)
return data.get("web_url")
except json.JSONDecodeError:
# If JSON parsing fails, return None
pass
except (
subprocess.TimeoutExpired,
FileNotFoundError,
subprocess.SubprocessError,
) as e:
# Silently ignore errors when fetching existing MR URL - this is a best-effort
# lookup that may fail due to network issues, missing glab CLI, or auth problems.
# Returning None allows the caller to handle missing URLs gracefully.
if isinstance(e, FileNotFoundError):
invalidate_glab_cache()
debug_warning("worktree", f"Could not get existing MR URL: {e}")
return None
def push_and_create_pr(
self,
spec_name: str,
@@ -1820,14 +1417,13 @@ class WorktreeManager:
force_push: bool = False,
) -> PushAndCreatePRResult:
"""
Push branch and create a pull request/merge request in one operation.
Automatically detects git provider (GitHub or GitLab) and routes to the appropriate CLI.
Push branch and create a pull request in one operation.
Args:
spec_name: The spec folder name
target_branch: Target branch for PR/MR (defaults to base_branch)
title: PR/MR title (defaults to spec name)
draft: Whether to create as draft PR/MR
target_branch: Target branch for PR (defaults to base_branch)
title: PR title (defaults to spec name)
draft: Whether to create as draft PR
force_push: Whether to force push the branch
Returns:
@@ -1835,8 +1431,7 @@ class WorktreeManager:
- success: bool
- pr_url: str (if created)
- pushed: bool (if push succeeded)
- provider: str ('github', 'gitlab', or 'unknown')
- already_exists: bool (if PR/MR already exists)
- already_exists: bool (if PR already exists)
- error: str (if failed)
"""
# Step 1: Push the branch
@@ -1850,44 +1445,20 @@ class WorktreeManager:
error=push_result.get("error", "Push failed"),
)
# Step 2: Detect git provider (use the remote that was pushed to)
provider = detect_git_provider(
self.project_dir, remote_name=push_result.get("remote")
# Step 2: Create the PR
pr_result = self.create_pull_request(
spec_name=spec_name,
target_branch=target_branch,
title=title,
draft=draft,
)
# Step 3: Create the PR/MR based on provider
if provider == "github":
pr_result = self.create_pull_request(
spec_name=spec_name,
target_branch=target_branch,
title=title,
draft=draft,
)
elif provider == "gitlab":
pr_result = self.create_merge_request(
spec_name=spec_name,
target_branch=target_branch,
title=title,
draft=draft,
)
else:
# Unknown provider
return PushAndCreatePRResult(
success=False,
pushed=True,
remote=push_result.get("remote"),
branch=push_result.get("branch"),
provider=provider,
error="Unable to determine git hosting provider. Supported: GitHub, GitLab.",
)
# Combine results
return PushAndCreatePRResult(
success=pr_result.get("success", False),
pushed=True,
remote=push_result.get("remote"),
branch=push_result.get("branch"),
provider=provider,
pr_url=pr_result.get("pr_url"),
already_exists=pr_result.get("already_exists", False),
error=pr_result.get("error"),
-5
View File
@@ -91,14 +91,11 @@ 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:
@@ -187,13 +184,11 @@ 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:
+6 -36
View File
@@ -28,7 +28,6 @@ from .types import IdeationPhaseResult
# Configuration
MAX_RETRIES = 3
IDEATION_TIMEOUT_SECONDS = 5 * 60 # 5 minutes max for all ideation types
class IdeationOrchestrator:
@@ -174,45 +173,16 @@ class IdeationOrchestrator:
"progress",
)
# 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
)
# Create tasks for all enabled types
ideation_tasks = [
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 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
]
# Run all ideation types concurrently
ideation_results = await asyncio.gather(*ideation_tasks, return_exceptions=True)
# Process results
for i, result in enumerate(ideation_results):
+6 -2
View File
@@ -12,8 +12,12 @@ The refactored code is now organized as:
- graphiti/search.py - Semantic search logic
- graphiti/schema.py - Graph schema definitions
Import from this module:
from integrations.graphiti.memory import GraphitiMemory, is_graphiti_enabled, GroupIdMode
This facade ensures existing imports continue to work:
from graphiti_memory import GraphitiMemory, is_graphiti_enabled
New code should prefer importing from the graphiti package:
from graphiti import GraphitiMemory
from graphiti.schema import GroupIdMode
For detailed documentation on the memory system architecture and usage,
see graphiti/graphiti.py.
@@ -62,7 +62,7 @@ async def get_graph_hints(
try:
from pathlib import Path
from integrations.graphiti.memory import GraphitiMemory, GroupIdMode
from graphiti_memory import GraphitiMemory, GroupIdMode
# Determine project directory from project_id or use current dir
project_dir = Path.cwd()
+2 -2
View File
@@ -17,7 +17,7 @@ from core.sentry import capture_exception
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from integrations.graphiti.memory import GraphitiMemory
from graphiti_memory import GraphitiMemory
def is_graphiti_memory_enabled() -> bool:
@@ -60,7 +60,7 @@ async def get_graphiti_memory(
return None
try:
from integrations.graphiti.memory import GraphitiMemory, GroupIdMode
from graphiti_memory import GraphitiMemory, GroupIdMode
if project_dir is None:
project_dir = spec_dir.parent.parent
+1 -20
View File
@@ -17,7 +17,6 @@ import logging
from .ai_resolver import AIResolver
from .auto_merger import AutoMerger, MergeContext
from .file_merger import apply_ai_merge, extract_location_content
from .progress import MergeProgressCallback, MergeProgressStage
from .types import (
ConflictRegion,
ConflictSeverity,
@@ -61,7 +60,6 @@ class ConflictResolver:
baseline_content: str,
task_snapshots: list[TaskSnapshot],
conflicts: list[ConflictRegion],
progress_callback: MergeProgressCallback | None = None,
) -> MergeResult:
"""
Resolve conflicts using AutoMerger and AIResolver.
@@ -71,8 +69,6 @@ class ConflictResolver:
baseline_content: Original file content
task_snapshots: Snapshots from all tasks modifying this file
conflicts: List of detected conflicts
progress_callback: Optional callback for emitting per-conflict
resolution progress with details about current file and conflict count
Returns:
MergeResult with resolution details
@@ -82,23 +78,8 @@ class ConflictResolver:
remaining: list[ConflictRegion] = []
ai_calls = 0
tokens_used = 0
total_conflicts = len(conflicts)
for idx, conflict in enumerate(conflicts):
if progress_callback:
# Emit per-conflict progress within the resolving stage (50-75%)
# Calculate progress after processing (idx + 1) to reach 75% on last conflict
conflict_percent = 50 + int(((idx + 1) / max(total_conflicts, 1)) * 25)
progress_callback(
stage=MergeProgressStage.RESOLVING,
percent=conflict_percent,
message=f"Resolving conflict {idx + 1}/{total_conflicts} in {file_path}",
details={
"current_file": file_path,
"conflicts_found": total_conflicts,
"conflicts_resolved": len(resolved),
},
)
for conflict in conflicts:
# Try auto-merge first
if conflict.can_auto_merge and conflict.merge_strategy:
context = MergeContext(
-13
View File
@@ -18,7 +18,6 @@ import logging
from .conflict_detector import ConflictDetector
from .conflict_resolver import ConflictResolver
from .file_merger import apply_single_task_changes, combine_non_conflicting_changes
from .progress import MergeProgressCallback, MergeProgressStage
from .types import (
ChangeType,
FileAnalysis,
@@ -58,7 +57,6 @@ class MergePipeline:
file_path: str,
baseline_content: str,
task_snapshots: list[TaskSnapshot],
progress_callback: MergeProgressCallback | None = None,
) -> MergeResult:
"""
Merge changes from multiple tasks for a single file.
@@ -67,8 +65,6 @@ class MergePipeline:
file_path: Path to the file
baseline_content: Original baseline content
task_snapshots: Snapshots from tasks that modified this file
progress_callback: Optional callback for emitting per-file progress
within the 'resolving' stage (50-75% range)
Returns:
MergeResult with merged content or conflict info
@@ -76,14 +72,6 @@ class MergePipeline:
task_ids = [s.task_id for s in task_snapshots]
logger.info(f"Merging {file_path} with {len(task_snapshots)} task(s)")
if progress_callback:
progress_callback(
stage=MergeProgressStage.RESOLVING,
percent=50,
message=f"Merging file: {file_path}",
details={"current_file": file_path},
)
# If only one task modified the file, no conflict possible
if len(task_snapshots) == 1:
snapshot = task_snapshots[0]
@@ -131,7 +119,6 @@ class MergePipeline:
baseline_content=baseline_content,
task_snapshots=task_snapshots,
conflicts=conflicts,
progress_callback=progress_callback,
)
def _build_task_analyses(
+4 -168
View File
@@ -33,7 +33,6 @@ from .merge_pipeline import MergePipeline
# Re-export models for backwards compatibility
from .models import MergeReport, MergeStats, TaskMergeRequest
from .progress import MergeProgressCallback, MergeProgressStage
from .semantic_analyzer import SemanticAnalyzer
from .types import (
ConflictRegion,
@@ -261,7 +260,6 @@ class MergeOrchestrator:
task_id: str,
worktree_path: Path | None = None,
target_branch: str = "main",
progress_callback: MergeProgressCallback | None = None,
) -> MergeReport:
"""
Merge a single task's changes into the target branch.
@@ -270,8 +268,6 @@ class MergeOrchestrator:
task_id: The task identifier
worktree_path: Path to the task's worktree (auto-detected if not provided)
target_branch: Branch to merge into
progress_callback: Optional callback for progress updates.
Called with (stage, percent, message, details) at key pipeline stages.
Returns:
MergeReport with results
@@ -288,20 +284,7 @@ class MergeOrchestrator:
report = MergeReport(started_at=datetime.now(), tasks_merged=[task_id])
start_time = datetime.now()
def _emit(
stage: MergeProgressStage,
percent: int,
message: str,
details: dict[str, Any] | None = None,
) -> None:
"""Emit progress if a callback is provided."""
if progress_callback is not None:
progress_callback(stage, percent, message, details)
try:
# --- ANALYZING stage (0-25%) ---
_emit(MergeProgressStage.ANALYZING, 0, "Starting merge analysis")
# Find worktree if not provided
if worktree_path is None:
debug_detailed(MODULE, "Auto-detecting worktree path...")
@@ -310,23 +293,16 @@ class MergeOrchestrator:
debug_error(MODULE, f"Could not find worktree for task {task_id}")
report.success = False
report.error = f"Could not find worktree for task {task_id}"
_emit(
MergeProgressStage.ERROR,
0,
f"Could not find worktree for task {task_id}",
)
return report
debug_detailed(MODULE, f"Found worktree: {worktree_path}")
# Ensure evolution data is up to date
_emit(MergeProgressStage.ANALYZING, 5, "Loading file evolution data")
debug(MODULE, "Refreshing evolution data from git...")
self.evolution_tracker.refresh_from_git(
task_id, worktree_path, target_branch=target_branch
)
# Get files modified by this task
_emit(MergeProgressStage.ANALYZING, 15, "Running semantic analysis")
modifications = self.evolution_tracker.get_task_modifications(task_id)
debug(
MODULE,
@@ -336,39 +312,11 @@ class MergeOrchestrator:
if not modifications:
debug_warning(MODULE, f"No modifications found for task {task_id}")
logger.info(f"No modifications found for task {task_id}")
_emit(
MergeProgressStage.COMPLETE,
100,
"No modifications found",
)
report.completed_at = datetime.now()
return report
_emit(
MergeProgressStage.ANALYZING,
25,
f"Found {len(modifications)} modified files",
)
# --- DETECTING_CONFLICTS stage (25-50%) ---
_emit(
MergeProgressStage.DETECTING_CONFLICTS,
25,
"Detecting conflicts",
)
# --- RESOLVING stage (50-75%) ---
total_files = len(modifications)
for idx, (file_path, snapshot) in enumerate(modifications):
# Calculate progress after processing (idx + 1) to reach 75% on last file
file_percent = 50 + int(((idx + 1) / max(total_files, 1)) * 25)
_emit(
MergeProgressStage.RESOLVING,
file_percent,
f"Merging file {idx + 1}/{total_files}",
{"current_file": file_path},
)
# Process each modified file
for file_path, snapshot in modifications:
debug_detailed(
MODULE,
f"Processing file: {file_path}",
@@ -401,31 +349,13 @@ class MergeOrchestrator:
file=file_path,
)
# --- VALIDATING stage (75-100%) ---
_emit(
MergeProgressStage.VALIDATING,
75,
"Validating merge results",
{
"conflicts_found": report.stats.conflicts_detected,
"conflicts_resolved": report.stats.conflicts_auto_resolved,
},
)
report.success = report.stats.files_failed == 0
_emit(
MergeProgressStage.VALIDATING,
90,
"Validation complete",
)
except Exception as e:
debug_error(MODULE, f"Merge failed for task {task_id}", error=str(e))
logger.exception(f"Merge failed for task {task_id}")
report.success = False
report.error = str(e)
_emit(MergeProgressStage.ERROR, 0, f"Merge failed: {e}")
report.completed_at = datetime.now()
report.stats.duration_seconds = (
@@ -436,18 +366,6 @@ class MergeOrchestrator:
if not self.dry_run:
self._save_report(report, task_id)
# --- COMPLETE stage (100%) ---
if report.success:
_emit(
MergeProgressStage.COMPLETE,
100,
f"Merge complete for {task_id}",
{
"conflicts_found": report.stats.conflicts_detected,
"conflicts_resolved": report.stats.conflicts_auto_resolved,
},
)
debug_success(
MODULE,
f"Merge complete for {task_id}",
@@ -464,7 +382,6 @@ class MergeOrchestrator:
self,
requests: list[TaskMergeRequest],
target_branch: str = "main",
progress_callback: MergeProgressCallback | None = None,
) -> MergeReport:
"""
Merge multiple tasks' changes.
@@ -475,8 +392,6 @@ class MergeOrchestrator:
Args:
requests: List of merge requests (one per task)
target_branch: Branch to merge into
progress_callback: Optional callback for progress updates.
Called with (stage, percent, message, details) at key pipeline stages.
Returns:
MergeReport with combined results
@@ -487,33 +402,11 @@ class MergeOrchestrator:
)
start_time = datetime.now()
def _emit(
stage: MergeProgressStage,
percent: int,
message: str,
details: dict[str, Any] | None = None,
) -> None:
"""Emit progress if a callback is provided."""
if progress_callback is not None:
progress_callback(stage, percent, message, details)
try:
# --- ANALYZING stage (0-25%) ---
_emit(
MergeProgressStage.ANALYZING,
0,
f"Starting merge analysis for {len(requests)} tasks",
)
# Sort by priority (higher first)
requests = sorted(requests, key=lambda r: -r.priority)
# Refresh evolution data for all tasks
_emit(
MergeProgressStage.ANALYZING,
5,
"Loading file evolution data",
)
for request in requests:
if request.worktree_path and request.worktree_path.exists():
self.evolution_tracker.refresh_from_git(
@@ -523,38 +416,11 @@ class MergeOrchestrator:
)
# Find all files modified by any task
_emit(
MergeProgressStage.ANALYZING,
15,
"Running semantic analysis",
)
task_ids = [r.task_id for r in requests]
file_tasks = self.evolution_tracker.get_files_modified_by_tasks(task_ids)
_emit(
MergeProgressStage.ANALYZING,
25,
f"Found {len(file_tasks)} files to merge",
)
# --- DETECTING_CONFLICTS stage (25-50%) ---
_emit(
MergeProgressStage.DETECTING_CONFLICTS,
25,
"Detecting conflicts across tasks",
)
# --- RESOLVING stage (50-75%) ---
total_files = len(file_tasks)
for idx, (file_path, modifying_tasks) in enumerate(file_tasks.items()):
file_percent = 50 + int((idx / max(total_files, 1)) * 25)
_emit(
MergeProgressStage.RESOLVING,
file_percent,
f"Merging file {idx + 1}/{total_files}",
{"current_file": file_path},
)
# Process each file
for file_path, modifying_tasks in file_tasks.items():
# Get snapshots from all tasks that modified this file
evolution = self.evolution_tracker.get_file_evolution(file_path)
if not evolution:
@@ -600,25 +466,8 @@ class MergeOrchestrator:
report.file_results[file_path] = result
self._update_stats(report.stats, result)
# --- VALIDATING stage (75-100%) ---
_emit(
MergeProgressStage.VALIDATING,
75,
"Validating merge results",
{
"conflicts_found": report.stats.conflicts_detected,
"conflicts_resolved": report.stats.conflicts_auto_resolved,
},
)
report.success = report.stats.files_failed == 0
_emit(
MergeProgressStage.VALIDATING,
90,
"Validation complete",
)
except Exception as e:
debug_error(
MODULE,
@@ -629,7 +478,6 @@ class MergeOrchestrator:
logger.exception("Merge failed")
report.success = False
report.error = str(e)
_emit(MergeProgressStage.ERROR, 0, f"Merge failed: {e}")
report.completed_at = datetime.now()
report.stats.duration_seconds = (
@@ -641,18 +489,6 @@ class MergeOrchestrator:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
self._save_report(report, f"multi_{timestamp}")
# --- COMPLETE stage (100%) ---
if report.success:
_emit(
MergeProgressStage.COMPLETE,
100,
f"Merge complete for {len(requests)} tasks",
{
"conflicts_found": report.stats.conflicts_detected,
"conflicts_resolved": report.stats.conflicts_auto_resolved,
},
)
return report
def _merge_file(
-105
View File
@@ -1,105 +0,0 @@
"""
Merge Progress Emission
=======================
Structured progress event emission for the merge pipeline.
This module provides the progress reporting infrastructure used by the
merge orchestrator to communicate real-time status updates to the
Electron frontend via stdout JSON lines.
Progress events are emitted as JSON lines to stdout with type='progress',
allowing the frontend to parse them separately from the final merge result.
Components:
- MergeProgressStage: Enum of pipeline stages
- MergeProgressCallback: Protocol for type-safe callback threading
- emit_progress: Function to emit structured progress events to stdout
"""
from __future__ import annotations
import json
from enum import Enum
from typing import Any, Protocol
class MergeProgressStage(Enum):
"""
Stages of the merge pipeline.
Each stage corresponds to a phase of the merge process and maps
to a percentage range for progress reporting:
- ANALYZING: 0-25% — Loading file evolution, running semantic analysis
- DETECTING_CONFLICTS: 25-50% — Conflict detection and compatibility checks
- RESOLVING: 50-75% — Auto-merge and AI resolution of conflicts
- VALIDATING: 75-100% — Final validation of merged results
- COMPLETE: 100% — Merge finished successfully
- ERROR: N/A — Merge failed with an error
"""
ANALYZING = "analyzing"
DETECTING_CONFLICTS = "detecting_conflicts"
RESOLVING = "resolving"
VALIDATING = "validating"
COMPLETE = "complete"
ERROR = "error"
class MergeProgressCallback(Protocol):
"""
Protocol for type-safe progress callback threading.
Implementations receive structured progress updates from the merge
pipeline stages and can forward them to any output channel.
Args:
stage: Current pipeline stage
percent: Progress percentage (0-100)
message: Human-readable status message
details: Optional additional context (conflicts_found, conflicts_resolved, current_file)
"""
def __call__(
self,
stage: MergeProgressStage,
percent: int,
message: str,
details: dict[str, Any] | None = None,
) -> None: ...
def emit_progress(
stage: MergeProgressStage,
percent: int,
message: str,
details: dict[str, Any] | None = None,
) -> None:
"""
Emit a progress event as a JSON line to stdout.
The Electron main process parses these JSON lines from the merge
subprocess stdout and forwards them to the renderer via IPC.
Args:
stage: Current pipeline stage
percent: Progress percentage (0-100), clamped to valid range
message: Human-readable status message
details: Optional dict with additional context. Supported keys:
- conflicts_found (int): Number of conflicts detected
- conflicts_resolved (int): Number of conflicts resolved so far
- current_file (str): File currently being processed
"""
percent = max(0, min(100, percent))
event: dict[str, Any] = {
"type": "progress",
"stage": stage.value,
"percent": percent,
"message": message,
}
if details:
event["details"] = details
print(json.dumps(event), flush=True)
+60 -44
View File
@@ -13,58 +13,15 @@ environment at the start of each prompt in the "YOUR ENVIRONMENT" section. Pay c
- **Working Directory**: This is your root - all paths are relative to here
- **Spec Location**: Where your spec files live (usually `./auto-claude/specs/{spec-name}/`)
- **Isolation Mode**: If present, you are in an isolated worktree (see below)
**RULES:**
1. ALWAYS use relative paths starting with `./`
2. NEVER use absolute paths (like `/Users/...` or `/e/projects/...`)
2. NEVER use absolute paths (like `/Users/...`)
3. NEVER assume paths exist - check with `ls` first
4. If a file doesn't exist where expected, check the spec location from YOUR ENVIRONMENT section
---
## ⛔ WORKTREE ISOLATION (When Applicable)
If your environment shows **"Isolation Mode: WORKTREE"**, you are working in an **isolated git worktree**.
This is a complete copy of the project created for safe, isolated development.
### Critical Rules for Worktree Mode:
1. **NEVER navigate to the parent project path** shown in "FORBIDDEN PATH"
- If you see `cd /path/to/main/project` in your context, DO NOT run it
- The parent project is OFF LIMITS
2. **All files exist locally via relative paths**
- `./prod/...` ✅ CORRECT
- `/path/to/main/project/prod/...` ❌ WRONG (escapes isolation)
3. **Git commits in the wrong location = disaster**
- Commits made after escaping go to the WRONG branch
- This defeats the entire isolation system
### Why You Might Be Tempted to Escape:
You may see absolute paths like `/e/projects/myapp/prod/src/file.ts` in:
- `spec.md` (file references)
- `context.json` (discovered files)
- Error messages
**DO NOT** `cd` to these paths. Instead, convert them to relative paths:
- `/e/projects/myapp/prod/src/file.ts``./prod/src/file.ts`
### Quick Check:
```bash
# Verify you're still in the worktree
pwd
# Should show: .../.auto-claude/worktrees/tasks/{spec-name}/
# Or (legacy): .../.worktrees/{spec-name}/
# Or (PR review): .../.auto-claude/github/pr/worktrees/{pr-number}/
# NOT: /path/to/main/project
```
---
## 🚨 CRITICAL: PATH CONFUSION PREVENTION 🚨
**THE #1 BUG IN MONOREPOS: Doubled paths after `cd` commands**
@@ -127,6 +84,65 @@ git add [verified-path]
---
## 🚨 CRITICAL: WORKTREE ISOLATION 🚨
**You may be in an ISOLATED GIT WORKTREE environment.**
Check the "YOUR ENVIRONMENT" section at the top of this prompt. If you see an
**"ISOLATED WORKTREE - CRITICAL"** section, you are in a worktree.
### What is a Worktree?
A worktree is a **complete copy of the project** isolated from the main project.
This allows safe development without affecting the main branch.
### Worktree Rules (CRITICAL)
**If you are in a worktree, the environment section will show:**
* **YOUR LOCATION:** The path to your isolated worktree
* **FORBIDDEN:** The parent project path you must NEVER `cd` to
**CRITICAL RULES:**
* **NEVER** `cd` to the forbidden parent path
* **NEVER** use `cd ../..` to escape the worktree
* **STAY** within your working directory at all times
* **ALL** file operations use paths relative to your current location
### Why This Matters
Escaping the worktree causes:
* ❌ Git commits going to the wrong branch
* ❌ Files created/modified in the wrong location
* ❌ Breaking worktree isolation guarantees
* ❌ Losing the safety of isolated development
### How to Stay Safe
**Before ANY `cd` command:**
```bash
# 1. Check where you are
pwd
# 2. Verify the target is within your worktree
# If pwd shows: /path/to/.auto-claude/worktrees/tasks/spec-name/
# Then: cd ./apps/backend ✅ SAFE
# But: cd /path/to/parent/project ❌ FORBIDDEN - ESCAPES ISOLATION
# 3. When in doubt, don't use cd at all
# Use relative paths from your current directory instead
git add ./apps/backend/file.py # Works from anywhere in worktree
```
### The Golden Rule in Worktrees
**If you're in a worktree, pretend the parent project doesn't exist.**
Everything you need is in your worktree, accessible via relative paths.
---
## STEP 1: GET YOUR BEARINGS (MANDATORY)
First, check your environment. The prompt should tell you your working directory and spec location.
@@ -2,17 +2,6 @@
You are an expert PR reviewer orchestrating a comprehensive, parallel code review. Your role is to analyze the PR, delegate to specialized review agents, and synthesize their findings into a final verdict.
## CRITICAL: Tool Execution Strategy
**IMPORTANT: Execute tool calls ONE AT A TIME, waiting for each result before making the next call.**
When you need to use multiple tools (Read, Grep, Glob, Task):
- ✅ Make ONE tool call, wait for the result
- ✅ Process the result, then make the NEXT tool call
- ❌ Do NOT make multiple tool calls in a single response
**Why this matters:** Parallel tool execution can cause API errors when some tools fail while others succeed. Sequential execution ensures reliable operation and proper error handling.
## Core Principle
**YOU decide which agents to invoke based on YOUR analysis of the PR.** There are no programmatic rules - you evaluate the PR's content, complexity, and risk areas, then delegate to the appropriate specialists.
@@ -1,138 +0,0 @@
# PR Template Filler Agent
## Your Role
You are an expert developer filling out a GitHub Pull Request template. You receive the repository's PR template along with comprehensive context about the changes — git diff summary, spec overview, commit history, and branch information. Your job is to produce a complete, accurate PR body that matches the template structure exactly, with every section filled intelligently and every relevant checkbox checked.
## Input Context
You will receive:
1. **PR Template** — The repository's `.github/PULL_REQUEST_TEMPLATE.md` content
2. **Git Diff Summary** — A summary of all code changes (files changed, insertions, deletions)
3. **Spec Overview** — The specification document describing the feature/fix being implemented
4. **Commit History** — The list of commits included in this PR
5. **Branch Context** — Source branch name, target branch name
## Methodology
### Step 1: Understand the Changes
Before filling anything:
1. **Read the spec overview** to understand the purpose and scope of the work
2. **Analyze the diff summary** to identify what files changed and what kind of changes were made
3. **Review the commit history** to understand the progression of work
4. **Note the branch names** to infer the PR target and type of change
### Step 2: Fill Every Section
For each section in the template:
1. **Identify the section type** — Is it a description field, a checkbox list, a free-text area, or a conditional section?
2. **Select the appropriate content** based on the change context
3. **Be specific and accurate** — Reference actual files, components, and behaviors from the diff
4. **Never leave a section empty** — If a section is not applicable, explicitly state "N/A" or "Not applicable"
### Step 3: Check Appropriate Checkboxes
For checkbox lists (`- [ ]` items):
1. **Check boxes that apply** by changing `- [ ]` to `- [x]`
2. **Leave unchecked** boxes that don't apply
3. **Base decisions on evidence** from the diff and spec, not assumptions
4. **When uncertain**, leave unchecked rather than incorrectly checking
### Step 4: Validate Output
Before returning:
1. **Verify markdown structure** matches the template exactly (same headings, same order)
2. **Ensure no template placeholders remain** (no `<!-- comments -->` left unfilled where content is expected)
3. **Check that descriptions are concise** but informative (2-3 sentences for summaries)
4. **Confirm all checkboxes reflect reality** based on the provided context
## Section-Specific Guidelines
### Description Sections
- Write 2-3 clear sentences explaining what the PR does and why
- Reference the spec or task if available
- Focus on the "what" and "why", not implementation details
### Type of Change
- Determine from the spec and diff whether this is a bug fix, feature, refactor, docs, or test change
- Check exactly one type unless the PR genuinely spans multiple types
- Use the spec's `workflow_type` field as a strong signal
### Area / Service
- Analyze which directories were modified in the diff
- `frontend` = changes in `apps/frontend/`
- `backend` = changes in `apps/backend/`
- `fullstack` = changes in both
### Related Issues
- Extract issue numbers from branch names (e.g., `feature/123-description``#123`)
- Extract from spec metadata if available
- Use `Closes #N` format for issues that will be closed by this PR
### Checklists
- **Testing checklists**: Check items that the commit history and diff evidence support
- **Platform checklists**: Check platforms that CI covers; note if manual testing is needed
- **Code quality checklists**: Check if the diff shows adherence to the principles mentioned
### AI Disclosure
- Always check the AI disclosure box — this PR is generated by Auto Claude
- Set tool to "Auto Claude (Claude Agent SDK)"
- Set testing level based on whether QA was run (check spec context for QA status)
- Always check "I understand what this PR does" — the AI agent analyzed the changes
### Screenshots
- If the diff includes UI changes (frontend components, styles), note that screenshots should be added
- If no UI changes, write "N/A - No UI changes" or remove the section if the template allows
### Breaking Changes
- Analyze the diff for API changes, removed exports, changed interfaces, or modified database schemas
- If no breaking changes are evident, mark as "No"
- If breaking changes exist, describe what breaks and suggest migration steps
### Feature Toggle
- Check the spec for mentions of feature flags, localStorage flags, or environment variables
- If the feature is complete and ready, check "N/A - Feature is complete and ready for all users"
## Output Format
Return **only** the filled PR template as valid markdown. Do not include any preamble, explanation, or wrapper — just the completed template content ready to be used as a GitHub PR body.
## Quality Standards
1. **Accuracy over completeness** — It's better to leave a checkbox unchecked than to incorrectly check it
2. **Evidence-based** — Every filled section should be traceable to the provided context
3. **Professional tone** — Write as a senior developer would in a real PR
4. **Concise but informative** — Don't pad sections with filler text
5. **Valid markdown** — The output must render correctly on GitHub
## Anti-Patterns to Avoid
### DO NOT:
- **Invent information** not present in the provided context
- **Leave template placeholders** like `<!-- What does this PR do? -->` without replacing them with actual content
- **Check every checkbox** — only check those supported by evidence
- **Write vague descriptions** like "This PR makes some changes" — be specific
- **Add sections** not present in the original template
- **Remove sections** from the original template — fill or mark as N/A
- **Hallucinate file names** or components not mentioned in the diff
- **Guess issue numbers** — only reference issues you can confirm from the branch name or spec
---
Remember: Your output becomes the PR body on GitHub. It should be professional, accurate, and immediately useful for reviewers. Every section should help a reviewer understand what changed, why it changed, and what to look for during review.
+1 -1
View File
@@ -159,7 +159,7 @@ This allows safe development without affecting the main branch.
**If you are in a worktree, the environment section will show:**
* **YOUR LOCATION:** The path to your isolated worktree
* **FORBIDDEN PATH:** The parent project path you must NEVER `cd` to
* **FORBIDDEN:** The parent project path you must NEVER `cd` to
**CRITICAL RULES:**
* **NEVER** `cd` to the forbidden parent path
+52 -97
View File
@@ -13,103 +13,40 @@ This approach:
"""
import json
import re
from pathlib import Path
# Worktree path patterns for detection
# Matches paths like: .auto-claude/worktrees/tasks/{spec-name}/
WORKTREE_PATH_PATTERNS = [
r"[/\\]\.auto-claude[/\\]worktrees[/\\]tasks[/\\]",
r"[/\\]\.auto-claude[/\\]github[/\\]pr[/\\]worktrees[/\\]", # PR review worktrees
r"[/\\]\.worktrees[/\\]", # Legacy worktree location
]
def detect_worktree_isolation(project_dir: Path) -> tuple[bool, Path | None]:
def detect_worktree_mode(spec_dir: Path) -> tuple[bool, str | None]:
"""
Detect if the project_dir is inside an isolated worktree.
When running in a worktree, the agent should NOT escape to the parent project.
This function detects worktree mode and extracts the forbidden parent path.
Detect if running in isolated worktree mode.
Args:
project_dir: The working directory for the AI
spec_dir: Absolute path to spec directory
Returns:
Tuple of (is_worktree, parent_project_path)
- is_worktree: True if running in an isolated worktree
- parent_project_path: The forbidden parent project path (None if not in worktree)
(is_worktree, forbidden_parent_path) tuple:
- is_worktree: True if running in a worktree
- forbidden_parent_path: The parent project path to forbid, or None
"""
# Resolve the path first for consistent matching across platforms
# This handles Windows drive letters, symlinks, and relative paths
resolved_dir = project_dir.resolve()
project_str = str(resolved_dir)
# Check if spec_dir contains worktree path patterns
# Normalize path separators to forward slashes for consistent matching
spec_str = str(spec_dir).replace("\\", "/")
for pattern in WORKTREE_PATH_PATTERNS:
match = re.search(pattern, project_str)
if match:
# Extract the parent project path (everything before the worktree marker)
parent_path = project_str[: match.start()]
# Handle edge case where worktree is at filesystem root
if not parent_path:
parent_path = resolved_dir.anchor
return True, Path(parent_path)
# New worktree location: .auto-claude/worktrees/tasks/{spec-name}/
new_worktree_marker = "/.auto-claude/worktrees/tasks/"
if new_worktree_marker in spec_str:
parent_path = spec_str.split(new_worktree_marker, 1)[0]
return True, parent_path
# Legacy worktree location: .worktrees/{spec-name}/
legacy_worktree_marker = "/.worktrees/"
if legacy_worktree_marker in spec_str:
parent_path = spec_str.split(legacy_worktree_marker, 1)[0]
return True, parent_path
return False, None
def generate_worktree_isolation_warning(
project_dir: Path, parent_project_path: Path
) -> str:
"""
Generate the worktree isolation warning section for prompts.
This warning explicitly tells the agent that it's in an isolated worktree
and must NOT escape to the parent project directory.
Args:
project_dir: The worktree directory (agent's working directory)
parent_project_path: The forbidden parent project path
Returns:
Markdown string with isolation warning
"""
return f"""## ⛔ ISOLATED WORKTREE - CRITICAL
You are in an **ISOLATED GIT WORKTREE** - a complete copy of the project for safe development.
**YOUR LOCATION:** `{project_dir}`
**FORBIDDEN PATH:** `{parent_project_path}`
### Rules:
1. **NEVER** use `cd {parent_project_path}` or any path starting with `{parent_project_path}`
2. **NEVER** use absolute paths that reference the parent project
3. **ALL** project files exist HERE via relative paths
### Why This Matters:
- Git commits made in the parent project go to the WRONG branch
- File changes in the parent project escape isolation
- This defeats the entire purpose of safe, isolated development
### Correct Usage:
```bash
# ✅ CORRECT - Use relative paths from your worktree
./prod/src/file.ts
./apps/frontend/src/component.tsx
# ❌ WRONG - These escape isolation!
cd {parent_project_path}
{parent_project_path}/prod/src/file.ts
```
If you see absolute paths in spec.md or context.json that reference `{parent_project_path}`,
convert them to relative paths from YOUR current location.
---
"""
def get_relative_spec_path(spec_dir: Path, project_dir: Path) -> str:
"""
Get the spec directory path relative to the project/working directory.
@@ -138,7 +75,6 @@ def generate_environment_context(project_dir: Path, spec_dir: Path) -> str:
Generate environment context header for prompts.
This explicitly tells the AI where it is working, preventing path confusion.
When running in a worktree, includes an isolation warning to prevent escaping.
Args:
project_dir: The working directory for the AI
@@ -149,21 +85,14 @@ def generate_environment_context(project_dir: Path, spec_dir: Path) -> str:
"""
relative_spec = get_relative_spec_path(spec_dir, project_dir)
# Check if we're in an isolated worktree
is_worktree, parent_project_path = detect_worktree_isolation(project_dir)
# Detect worktree mode and get forbidden parent path
is_worktree, forbidden_parent = detect_worktree_mode(spec_dir)
# Start with worktree isolation warning if applicable
sections = []
if is_worktree and parent_project_path:
sections.append(
generate_worktree_isolation_warning(project_dir, parent_project_path)
)
sections.append(f"""## YOUR ENVIRONMENT
# Build the environment context
context = f"""## YOUR ENVIRONMENT
**Working Directory:** `{project_dir}`
**Spec Location:** `{relative_spec}/`
{"**Isolation Mode:** WORKTREE (changes are isolated from main project)" if is_worktree else ""}
Your filesystem is restricted to your working directory. All file paths should be
relative to this location. Do NOT use absolute paths.
@@ -181,9 +110,35 @@ coder prompt for detailed examples.
---
""")
"""
return "".join(sections)
# Add worktree isolation warning if in worktree mode
if is_worktree and forbidden_parent:
context += f"""## 🚨 ISOLATED WORKTREE - CRITICAL
You are in an **ISOLATED GIT WORKTREE** - a complete copy of the project.
**YOUR LOCATION:** `{project_dir}`
**FORBIDDEN:** Do NOT use `cd {forbidden_parent}` or `cd ../..` - this **ESCAPES ISOLATION**
All project files exist HERE via relative paths from your working directory.
**CRITICAL RULES:**
* **NEVER** `cd {forbidden_parent}` or any path traversal outside your worktree
* **STAY** within your working directory at all times
* **ALL** file operations use paths relative to your current location
* Before any `cd` command, run `pwd` and verify the target is within your worktree
**VIOLATION WARNING:** Escaping the worktree will cause:
* Git commits going to wrong branch
* Files created/modified in the wrong location
* Breaking worktree isolation guarantees
---
"""
return context
def generate_subtask_prompt(
-25
View File
@@ -81,31 +81,6 @@ 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
+531 -603
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,7 +1,7 @@
# Auto-Build Framework Dependencies
# SDK 0.1.25+ required for improved tool use concurrency handling
# Earlier versions had 400 errors when tool_use blocks had partial failures
claude-agent-sdk>=0.1.25
# SDK 0.1.22+ required for custom subagent support (bundles CLI 2.1.19+)
# Earlier versions bundled CLI 2.1.9 which didn't properly register --agents flag
claude-agent-sdk>=0.1.22
python-dotenv>=1.0.0
# TOML parsing fallback for Python < 3.11
+8 -176
View File
@@ -10,8 +10,6 @@ Key Features:
- Skips re-reviewing bot commits
- Implements "cooling off" period to prevent rapid re-reviews
- Tracks reviewed commits to avoid duplicate reviews
- In-progress tracking to prevent concurrent reviews
- Stale review detection with automatic cleanup
Usage:
detector = BotDetector(bot_token="ghp_...")
@@ -22,16 +20,8 @@ Usage:
print(f"Skipping PR: {reason}")
return
# Mark review as started (prevents concurrent reviews)
detector.mark_review_started(pr_number)
# Perform review...
# After successful review, mark as reviewed
detector.mark_reviewed(pr_number, head_sha)
# Or if review failed:
detector.mark_review_finished(pr_number, success=False)
"""
from __future__ import annotations
@@ -65,15 +55,11 @@ class BotDetectionState:
# PR number -> last review timestamp (ISO format)
last_review_times: dict[int, str] = field(default_factory=dict)
# PR number -> in-progress review start time (ISO format)
in_progress_reviews: dict[int, str] = field(default_factory=dict)
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
"reviewed_commits": self.reviewed_commits,
"last_review_times": self.last_review_times,
"in_progress_reviews": self.in_progress_reviews,
}
@classmethod
@@ -82,7 +68,6 @@ class BotDetectionState:
return cls(
reviewed_commits=data.get("reviewed_commits", {}),
last_review_times=data.get("last_review_times", {}),
in_progress_reviews=data.get("in_progress_reviews", {}),
)
def save(self, state_dir: Path) -> None:
@@ -119,16 +104,11 @@ class BotDetector:
- 1-minute cooling off period between reviews of same PR (for testing)
- Tracks reviewed commit SHAs to avoid duplicate reviews
- Identifies bot user from token to skip bot-authored content
- In-progress tracking to prevent concurrent reviews
- Stale review detection (30-minute timeout)
"""
# Cooling off period in minutes (reduced to 1 for testing large PRs)
COOLING_OFF_MINUTES = 1
# Timeout for in-progress reviews in minutes (after this, review is considered stale/crashed)
IN_PROGRESS_TIMEOUT_MINUTES = 30
def __init__(
self,
state_dir: Path,
@@ -321,104 +301,6 @@ class BotDetector:
reviewed = self.state.reviewed_commits.get(str(pr_number), [])
return commit_sha in reviewed
def is_review_in_progress(self, pr_number: int) -> tuple[bool, str]:
"""
Check if a review is currently in progress for this PR.
Also detects stale reviews (started > IN_PROGRESS_TIMEOUT_MINUTES ago).
Args:
pr_number: The PR number
Returns:
Tuple of (is_in_progress, reason_message)
"""
pr_key = str(pr_number)
start_time_str = self.state.in_progress_reviews.get(pr_key)
if not start_time_str:
return False, ""
try:
start_time = datetime.fromisoformat(start_time_str)
time_elapsed = datetime.now() - start_time
# Check if review is stale (timeout exceeded)
if time_elapsed > timedelta(minutes=self.IN_PROGRESS_TIMEOUT_MINUTES):
# Mark as stale and clear the in-progress state
print(
f"[BotDetector] Review for PR #{pr_number} is stale "
f"(started {int(time_elapsed.total_seconds() / 60)}m ago, "
f"timeout: {self.IN_PROGRESS_TIMEOUT_MINUTES}m) - clearing in-progress state",
file=sys.stderr,
)
self.mark_review_finished(pr_number, success=False)
return False, ""
# Review is actively in progress
minutes_elapsed = int(time_elapsed.total_seconds() / 60)
reason = f"Review already in progress (started {minutes_elapsed}m ago)"
print(f"[BotDetector] PR #{pr_number}: {reason}", file=sys.stderr)
return True, reason
except (ValueError, TypeError) as e:
print(
f"[BotDetector] Error parsing in-progress start time: {e}",
file=sys.stderr,
)
# Clear invalid state
self.mark_review_finished(pr_number, success=False)
return False, ""
def mark_review_started(self, pr_number: int) -> None:
"""
Mark a review as started for this PR.
This should be called when beginning a review to prevent concurrent reviews.
Args:
pr_number: The PR number
"""
pr_key = str(pr_number)
# Record start time
self.state.in_progress_reviews[pr_key] = datetime.now().isoformat()
# Save state
self.state.save(self.state_dir)
logger.info(f"[BotDetector] Marked PR #{pr_number} review as started")
print(f"[BotDetector] Started review for PR #{pr_number}", file=sys.stderr)
def mark_review_finished(self, pr_number: int, success: bool = True) -> None:
"""
Mark a review as finished for this PR.
This clears the in-progress state. Should be called when review completes
(successfully or with error) or when detected as stale.
Args:
pr_number: The PR number
success: Whether the review completed successfully
"""
pr_key = str(pr_number)
# Clear in-progress state
if pr_key in self.state.in_progress_reviews:
del self.state.in_progress_reviews[pr_key]
# Save state
self.state.save(self.state_dir)
status = "successfully" if success else "with error/timeout"
logger.info(
f"[BotDetector] Marked PR #{pr_number} review as finished ({status})"
)
print(
f"[BotDetector] Finished review for PR #{pr_number} ({status})",
file=sys.stderr,
)
def should_skip_pr_review(
self,
pr_number: int,
@@ -453,19 +335,13 @@ class BotDetector:
print(f"[BotDetector] SKIP PR #{pr_number}: {reason}")
return True, reason
# Check 3: Is a review already in progress?
is_in_progress, reason = self.is_review_in_progress(pr_number)
if is_in_progress:
print(f"[BotDetector] SKIP PR #{pr_number}: {reason}")
return True, reason
# Check 4: Are we in the cooling off period?
# Check 3: Are we in the cooling off period?
is_cooling, reason = self.is_within_cooling_off(pr_number)
if is_cooling:
print(f"[BotDetector] SKIP PR #{pr_number}: {reason}")
return True, reason
# Check 5: Have we already reviewed this exact commit?
# Check 4: Have we already reviewed this exact commit?
head_sha = self.get_last_commit_sha(commits) if commits else None
if head_sha and self.has_reviewed_commit(pr_number, head_sha):
reason = f"Already reviewed commit {head_sha[:8]}"
@@ -481,7 +357,6 @@ class BotDetector:
Mark a PR as reviewed at a specific commit.
This should be called after successfully posting a review.
Also clears the in-progress state.
Args:
pr_number: The PR number
@@ -499,10 +374,6 @@ class BotDetector:
# Update last review time
self.state.last_review_times[pr_key] = datetime.now().isoformat()
# Clear in-progress state
if pr_key in self.state.in_progress_reviews:
del self.state.in_progress_reviews[pr_key]
# Save state
self.state.save(self.state_dir)
@@ -526,9 +397,6 @@ class BotDetector:
if pr_key in self.state.last_review_times:
del self.state.last_review_times[pr_key]
if pr_key in self.state.in_progress_reviews:
del self.state.in_progress_reviews[pr_key]
self.state.save(self.state_dir)
print(f"[BotDetector] Cleared state for PR #{pr_number}")
@@ -544,16 +412,13 @@ class BotDetector:
total_reviews = sum(
len(commits) for commits in self.state.reviewed_commits.values()
)
in_progress_count = len(self.state.in_progress_reviews)
return {
"bot_username": self.bot_username,
"review_own_prs": self.review_own_prs,
"total_prs_tracked": total_prs,
"total_reviews_performed": total_reviews,
"in_progress_reviews": in_progress_count,
"cooling_off_minutes": self.COOLING_OFF_MINUTES,
"in_progress_timeout_minutes": self.IN_PROGRESS_TIMEOUT_MINUTES,
}
def cleanup_stale_prs(self, max_age_days: int = 30) -> int:
@@ -563,9 +428,6 @@ class BotDetector:
This prevents unbounded growth of the state file by cleaning up
entries for PRs that are likely closed/merged.
Also cleans up stale in-progress reviews (reviews that have been
in progress for longer than IN_PROGRESS_TIMEOUT_MINUTES).
Args:
max_age_days: Remove PRs not reviewed in this many days (default: 30)
@@ -573,13 +435,8 @@ class BotDetector:
Number of PRs cleaned up
"""
cutoff = datetime.now() - timedelta(days=max_age_days)
in_progress_cutoff = datetime.now() - timedelta(
minutes=self.IN_PROGRESS_TIMEOUT_MINUTES
)
prs_to_remove: list[str] = []
stale_in_progress: list[str] = []
# Find stale reviewed PRs
for pr_key, last_review_str in self.state.last_review_times.items():
try:
last_review = datetime.fromisoformat(last_review_str)
@@ -589,43 +446,18 @@ class BotDetector:
# Invalid timestamp - mark for removal
prs_to_remove.append(pr_key)
# Find stale in-progress reviews
for pr_key, start_time_str in self.state.in_progress_reviews.items():
try:
start_time = datetime.fromisoformat(start_time_str)
if start_time < in_progress_cutoff:
stale_in_progress.append(pr_key)
except (ValueError, TypeError):
# Invalid timestamp - mark for removal
stale_in_progress.append(pr_key)
# Remove stale PRs
for pr_key in prs_to_remove:
if pr_key in self.state.reviewed_commits:
del self.state.reviewed_commits[pr_key]
if pr_key in self.state.last_review_times:
del self.state.last_review_times[pr_key]
if pr_key in self.state.in_progress_reviews:
del self.state.in_progress_reviews[pr_key]
# Remove stale in-progress reviews
for pr_key in stale_in_progress:
if pr_key in self.state.in_progress_reviews:
del self.state.in_progress_reviews[pr_key]
total_cleaned = len(prs_to_remove) + len(stale_in_progress)
if total_cleaned > 0:
if prs_to_remove:
self.state.save(self.state_dir)
if prs_to_remove:
print(
f"[BotDetector] Cleaned up {len(prs_to_remove)} stale PRs "
f"(older than {max_age_days} days)"
)
if stale_in_progress:
print(
f"[BotDetector] Cleaned up {len(stale_in_progress)} stale in-progress reviews "
f"(older than {self.IN_PROGRESS_TIMEOUT_MINUTES} minutes)"
)
print(
f"[BotDetector] Cleaned up {len(prs_to_remove)} stale PRs "
f"(older than {max_age_days} days)"
)
return total_cleaned
return len(prs_to_remove)
+226 -19
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import ast
import asyncio
import json
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
@@ -824,11 +825,41 @@ class PRContextGatherer:
"""
Find files related to the changes.
DEPRECATED: LLM agents now discover related files themselves using Read, Grep, and Glob tools.
This method returns an empty list - agents have domain expertise to find what's relevant.
This includes:
- Test files for changed source files
- Imported modules and dependencies
- Configuration files in the same directory
- Related type definition files
- Reverse dependencies (files that import changed files)
"""
# Return empty list - LLM agents will discover files via their tools
return []
related = set()
for changed_file in changed_files:
path = Path(changed_file.path)
# Find test files
related.update(self._find_test_files(path))
# Find imported files (for supported languages)
if path.suffix in [".ts", ".tsx", ".js", ".jsx", ".py"]:
related.update(self._find_imports(changed_file.content, path))
# Find config files in same directory
related.update(self._find_config_files(path.parent))
# Find type definition files
if path.suffix in [".ts", ".tsx"]:
related.update(self._find_type_definitions(path))
# Find reverse dependencies (files that import this file)
related.update(self._find_dependents(changed_file.path))
# Remove files that are already in changed_files
changed_paths = {cf.path for cf in changed_files}
related = {r for r in related if r not in changed_paths}
# Use smart prioritization with increased limit (50 instead of 20)
return self._prioritize_related_files(related, limit=50)
def _find_test_files(self, source_path: Path) -> set[str]:
"""Find test files related to a source file."""
@@ -1040,35 +1071,170 @@ class PRContextGatherer:
"""
Find files that import the given file (reverse dependencies).
DEPRECATED: LLM agents now discover reverse dependencies themselves using Grep and Read tools.
Returns empty set - agents can search the codebase with their domain expertise.
Uses pure Python to search for import statements referencing this file.
Cross-platform compatible (Windows, macOS, Linux).
Limited to prevent performance issues on large codebases.
Args:
file_path: Path of the file to find dependents for
max_results: Maximum number of dependents to return
Returns:
Empty set - LLM agents will discover dependents via Grep tool.
Set of file paths that import this file.
"""
# Return empty set - LLM agents will use Grep to find importers when needed
return set()
dependents: set[str] = set()
path_obj = Path(file_path)
stem = path_obj.stem # e.g., 'helpers' from 'utils/helpers.ts'
# NOTE: We no longer skip generic filenames like "utils", "types", "index".
# The LLM-driven exploration system decides what's relevant based on the PR context.
# Widely-used utilities are often the MOST important files to track dependents for.
# Build regex patterns and file extensions based on file type
pattern = None
file_extensions = []
if path_obj.suffix in [".ts", ".tsx", ".js", ".jsx"]:
# Match various import styles for JS/TS
# from './helpers', from '../utils/helpers', from '@/utils/helpers'
# Escape stem for regex safety
escaped_stem = re.escape(stem)
pattern = re.compile(rf"['\"].*{escaped_stem}['\"]")
file_extensions = [".ts", ".tsx", ".js", ".jsx"]
elif path_obj.suffix == ".py":
# Match Python imports: from .helpers import, import helpers
escaped_stem = re.escape(stem)
pattern = re.compile(rf"(from.*{escaped_stem}|import.*{escaped_stem})")
file_extensions = [".py"]
else:
return dependents
# Directories to exclude
exclude_dirs = {
"node_modules",
".git",
"dist",
"build",
"__pycache__",
".venv",
"venv",
}
# Walk the project directory
project_path = Path(self.project_dir)
files_checked = 0
max_files_to_check = 2000 # Prevent infinite scanning on large codebases
try:
for root, dirs, files in os.walk(project_path):
# Modify dirs in-place to exclude certain directories
dirs[:] = [d for d in dirs if d not in exclude_dirs]
for filename in files:
# Check if we've hit the file limit
if files_checked >= max_files_to_check:
safe_print(
f"[Context] File scan limit ({max_files_to_check}) reached for {file_path}. "
f"Found {len(dependents)} dependents. "
f"LLM agents can explore additional callers if needed via Read/Grep tools."
)
return dependents
# Check if file has the right extension
if not any(filename.endswith(ext) for ext in file_extensions):
continue
file_full_path = Path(root) / filename
files_checked += 1
# Get relative path from project root
try:
relative_path = file_full_path.relative_to(project_path)
relative_path_str = str(relative_path).replace("\\", "/")
# Don't include the file itself
if relative_path_str == file_path:
continue
# Search for the pattern in the file
try:
with open(
file_full_path, encoding="utf-8", errors="ignore"
) as f:
content = f.read()
if pattern.search(content):
dependents.add(relative_path_str)
if len(dependents) >= max_results:
return dependents
except (OSError, UnicodeDecodeError):
# Skip files that can't be read
continue
except ValueError:
# File is not relative to project_path, skip it
continue
except Exception as e:
safe_print(f"[Context] Error finding dependents: {e}")
return dependents
def _prioritize_related_files(self, files: set[str], limit: int = 50) -> list[str]:
"""
Prioritize related files by relevance.
DEPRECATED: LLM agents now prioritize exploration based on their domain expertise.
Returns empty list since _find_related_files no longer populates files.
Priority order:
1. Test files (most important for review context)
2. Type definition files (.d.ts)
3. Configuration files
4. Direct imports/dependents
5. Other files
Args:
files: Set of file paths to prioritize
limit: Maximum number of files to return
Returns:
Empty list - LLM agents handle prioritization via their tools.
List of files sorted by priority, limited to `limit`.
"""
# Return empty list - LLM agents will prioritize exploration themselves
return []
test_files = []
type_files = []
config_files = []
other_files = []
for f in files:
path = Path(f)
name_lower = path.name.lower()
# Test files
if (
".test." in name_lower
or ".spec." in name_lower
or name_lower.startswith("test_")
or name_lower.endswith("_test.py")
or "__tests__" in f
):
test_files.append(f)
# Type definition files
elif name_lower.endswith(".d.ts") or "types" in name_lower:
type_files.append(f)
# Config files
elif name_lower in [
n.lower() for n in CONFIG_FILE_NAMES
] or name_lower.endswith((".config.js", ".config.ts", "rc", "rc.json")):
config_files.append(f)
else:
other_files.append(f)
# Sort within each category alphabetically for consistency, then combine
prioritized = (
sorted(test_files)
+ sorted(type_files)
+ sorted(config_files)
+ sorted(other_files)
)
return prioritized[:limit]
def _load_json_safe(self, filename: str) -> dict | None:
"""
@@ -1296,18 +1462,59 @@ class PRContextGatherer:
"""
Find files related to the changes using a specific project root.
DEPRECATED: LLM agents now discover related files themselves using Read, Grep, and Glob tools.
This method returns an empty list - agents have domain expertise to find what's relevant.
This static method allows finding related files AFTER a worktree
has been created, ensuring files exist in the worktree filesystem.
Args:
changed_files: List of changed files from the PR
project_root: Path to search for related files (e.g., worktree path)
Returns:
Empty list - LLM agents will discover files via their tools.
List of related file paths (relative to project root)
"""
# Return empty list - LLM agents will discover files via their tools
return []
related: set[str] = set()
for changed_file in changed_files:
path = Path(changed_file.path)
# Find test files
test_patterns = [
# Jest/Vitest patterns
path.parent / f"{path.stem}.test{path.suffix}",
path.parent / f"{path.stem}.spec{path.suffix}",
path.parent / "__tests__" / f"{path.name}",
# Python patterns
path.parent / f"test_{path.stem}.py",
path.parent / f"{path.stem}_test.py",
# Go patterns
path.parent / f"{path.stem}_test.go",
]
for test_path in test_patterns:
full_path = project_root / test_path
if full_path.exists() and full_path.is_file():
related.add(str(test_path))
# Find config files in same directory
for name in CONFIG_FILE_NAMES:
config_path = path.parent / name
full_path = project_root / config_path
if full_path.exists() and full_path.is_file():
related.add(str(config_path))
# Find type definition files
if path.suffix in [".ts", ".tsx"]:
type_def = path.parent / f"{path.stem}.d.ts"
full_path = project_root / type_def
if full_path.exists() and full_path.is_file():
related.add(str(type_def))
# Remove files that are already in changed_files
changed_paths = {cf.path for cf in changed_files}
related = {r for r in related if r not in changed_paths}
# Limit to 50 most relevant files (increased from 20)
return sorted(related)[:50]
class FollowupContextGatherer:
+1 -28
View File
@@ -396,15 +396,9 @@ class GitHubOrchestrator:
# No existing review found, create skip result
return await self._create_skip_result(pr_number, skip_reason)
else:
# For other skip reasons (bot-authored, cooling off, in-progress), create a skip result
# For other skip reasons (bot-authored, cooling off), create a skip result
return await self._create_skip_result(pr_number, skip_reason)
# Mark review as started (prevents concurrent reviews)
self.bot_detector.mark_review_started(pr_number)
safe_print(
f"[BOT DETECTION] Marked PR #{pr_number} review as started", flush=True
)
self._report_progress(
"analyzing", 30, "Running multi-pass review...", pr_number=pr_number
)
@@ -578,13 +572,6 @@ class GitHubOrchestrator:
except Exception as e:
import traceback
# Mark review as finished with error
self.bot_detector.mark_review_finished(pr_number, success=False)
safe_print(
f"[BOT DETECTION] Marked PR #{pr_number} review as finished (error)",
flush=True,
)
# Log full exception details for debugging
error_details = f"{type(e).__name__}: {e}"
full_traceback = traceback.format_exc()
@@ -647,13 +634,6 @@ class GitHubOrchestrator:
pr_number=pr_number,
)
# Mark review as started (prevents concurrent reviews)
self.bot_detector.mark_review_started(pr_number)
safe_print(
f"[BOT DETECTION] Marked PR #{pr_number} follow-up review as started",
flush=True,
)
try:
# Import here to avoid circular imports at module level
try:
@@ -976,13 +956,6 @@ class GitHubOrchestrator:
return result
except Exception as e:
# Mark review as finished with error
self.bot_detector.mark_review_finished(pr_number, success=False)
safe_print(
f"[BOT DETECTION] Marked PR #{pr_number} follow-up review as finished (error)",
flush=True,
)
result = PRReviewResult(
pr_number=pr_number,
repo=self.config.repo,
@@ -17,24 +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
# 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
from claude_agent_sdk import AgentDefinition
try:
from ...core.client import create_client
from ...phase_config import get_thinking_budget, resolve_model_id
from ..context_gatherer import PRContext, _validate_git_ref
from ..context_gatherer import PRContext, PRContextGatherer, _validate_git_ref
from ..gh_client import GHClient
from ..models import (
BRANCH_BEHIND_BLOCKER_MSG,
@@ -53,11 +48,10 @@ try:
AgentAgreement,
FindingValidationResponse,
ParallelOrchestratorResponse,
SpecialistResponse,
)
from .sdk_utils import process_sdk_stream
except (ImportError, ValueError, SystemError):
from context_gatherer import PRContext, _validate_git_ref
from context_gatherer import PRContext, PRContextGatherer, _validate_git_ref
from core.client import create_client
from gh_client import GHClient
from models import (
@@ -78,56 +72,10 @@ 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
@@ -307,8 +255,9 @@ class ParallelOrchestratorReviewer:
"Security specialist. Use for OWASP Top 10, authentication, "
"injection, cryptographic issues, and sensitive data exposure. "
"Invoke when PR touches auth, API endpoints, user input, database queries, "
"or file operations. Use Read, Grep, and Glob tools to explore related files, "
"callers, and tests as needed."
"or file operations. IMPORTANT: Also check related files listed in the "
"PR context - callers may be affected by security changes, and tests "
"should verify security behavior."
),
prompt=with_working_dir(
security_prompt, "You are a security expert. Find vulnerabilities."
@@ -320,8 +269,9 @@ class ParallelOrchestratorReviewer:
description=(
"Code quality expert. Use for complexity, duplication, error handling, "
"maintainability, and pattern adherence. Invoke when PR has complex logic, "
"large functions, or significant business logic changes. Use Grep to search "
"for similar patterns across the codebase for consistency checks."
"large functions, or significant business logic changes. IMPORTANT: Check "
"related files for pattern consistency - if a pattern is changed, similar "
"code elsewhere should be updated too."
),
prompt=with_working_dir(
quality_prompt,
@@ -335,7 +285,8 @@ class ParallelOrchestratorReviewer:
"Logic and correctness specialist. Use for algorithm verification, "
"edge cases, state management, and race conditions. Invoke when PR has "
"algorithmic changes, data transformations, concurrent operations, or bug fixes. "
"Use Grep to find callers and dependents that may be affected by logic changes."
"IMPORTANT: Check callers and dependents in related files - logic changes "
"may break assumptions made by code that uses this file."
),
prompt=with_working_dir(
logic_prompt, "You are a logic expert. Find correctness issues."
@@ -348,7 +299,8 @@ class ParallelOrchestratorReviewer:
"Codebase consistency expert. Use for naming conventions, ecosystem fit, "
"architectural alignment, and avoiding reinvention. Invoke when PR introduces "
"new patterns, large additions, or code that might duplicate existing functionality. "
"Use Grep and Glob to explore existing patterns and conventions in the codebase."
"IMPORTANT: Use related files to understand existing patterns - new code "
"should match established conventions in the codebase."
),
prompt=with_working_dir(
codebase_fit_prompt,
@@ -377,7 +329,7 @@ class ParallelOrchestratorReviewer:
"Reads the ACTUAL CODE at the finding location with fresh eyes. "
"CRITICAL: Invoke for ALL findings after specialist agents complete. "
"Can confirm findings as valid OR dismiss them as false positives. "
"Use Read, Grep, and Glob to check for mitigations the original agent missed."
"Check related files for mitigations the original agent missed."
),
prompt=with_working_dir(
validator_prompt, "You validate whether findings are real issues."
@@ -387,304 +339,6 @@ 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
@@ -743,10 +397,80 @@ Found {len(context.ai_bot_comments)} comments from AI tools.
{chr(10).join(commits_list)}
"""
# Removed: Related files and import graph sections
# LLM agents now discover relevant files themselves via Read, Grep, Glob tools
# Build related files section (CONTEXT-02)
related_files_section = ""
if context.related_files:
# Categorize by type
tests = [
f
for f in context.related_files
if ".test." in f
or "_test." in f
or f.startswith("test")
or "/tests/" in f
or "\\tests\\" in f
]
deps = [f for f in context.related_files if f not in tests]
# Limit to avoid context overflow
tests = tests[:15]
deps = deps[:15]
tests_str = ", ".join(f"`{t}`" for t in tests) if tests else "None found"
deps_str = ", ".join(f"`{d}`" for d in deps) if deps else "None found"
related_files_section = f"""
### Related Files to Investigate
These files are related to the changes (imports, tests, dependents). **Pass relevant files to specialists when delegating.**
**Tests** ({len(tests)} files): {tests_str}
**Dependencies/Callers** ({len(deps)} files): {deps_str}
**When delegating to specialists, include relevant files:**
- **security-reviewer**: Mention files that handle the same data flow
- **logic-reviewer**: Mention callers that depend on changed function signatures
- **quality-reviewer**: Mention files with similar patterns for consistency check
- **codebase-fit-reviewer**: Mention existing implementations of similar features
Example delegation: "Review the auth changes in login.ts. Also check auth_middleware.ts and auth.test.ts which use this module."
"""
# Build import graph summary (CONTEXT-03)
import_graph_section = ""
import_entries = []
changed_paths = {f.path for f in context.changed_files}
for file in context.changed_files[:10]: # Limit to 10 files
# Find what this file imports (look for related files it references)
imports_this = [
r
for r in context.related_files
if r in (file.content or "") and r not in changed_paths
][:5]
# Find what imports this file (reverse deps in related_files)
# Match by filename stem to catch imports without extension
file_stem = file.path.split("/")[-1].split(".")[0]
imported_by = [
r
for r in context.related_files
if file_stem in r and r not in changed_paths
][:5]
if imports_this or imported_by:
entry = f"**{file.path}**"
if imports_this:
entry += f"\n - Imports: {', '.join(imports_this)}"
if imported_by:
entry += f"\n - Imported by: {', '.join(imported_by)}"
import_entries.append(entry)
if import_entries:
import_graph_section = f"""
### Import Relationships
How the changed files connect to the codebase:
{chr(10).join(import_entries[:20])}
"""
pr_context = f"""
---
@@ -1066,9 +790,30 @@ The SDK will run invoked agents in parallel automatically.
else self.project_dir
)
# Removed: Related files rescanning
# LLM agents now discover relevant files themselves via Read, Grep, Glob tools
# No need to pre-scan the codebase programmatically
# Rescan for related files using the worktree/project root
# This fixes the issue where related files were 0 because context gathering
# happened BEFORE the worktree was created (PR files didn't exist locally)
if context.changed_files:
new_related_files = PRContextGatherer.find_related_files_for_root(
context.changed_files,
project_root,
)
# Always log rescan result (not gated by DEBUG_MODE)
if new_related_files:
context.related_files = new_related_files
safe_print(
f"[PRReview] Rescanned in worktree: found {len(new_related_files)} related files"
)
else:
safe_print(
f"[PRReview] Rescanned in worktree: found 0 related files "
f"(initial scan found {len(context.related_files)})"
)
# 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
@@ -1082,39 +827,48 @@ 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,
"Running specialist agents in parallel...",
"Orchestrator delegating to specialist agents...",
pr_number=context.pr_number,
)
# =================================================================
# 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
# =================================================================
# Run orchestrator session using shared SDK stream processor
async with client:
await client.query(prompt)
# 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,
)
safe_print(
f"[ParallelOrchestrator] Running orchestrator ({model})...",
flush=True,
)
# Log results
logger.info(
f"[ParallelOrchestrator] Parallel specialists complete: "
f"{len(findings)} findings from {len(agents_invoked)} agents"
)
# 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,
)
# Check for stream processing errors
if stream_result.get("error"):
logger.error(
f"[ParallelOrchestrator] SDK stream failed: {stream_result['error']}"
)
raise RuntimeError(
f"SDK stream processing failed: {stream_result['error']}"
)
result_text = stream_result["result_text"]
structured_output = stream_result["structured_output"]
agents_invoked = stream_result["agents_invoked"]
msg_count = stream_result["msg_count"]
self._report_progress(
"finalizing",
@@ -1123,9 +877,20 @@ The SDK will run invoked agents in parallel automatically.
pr_number=context.pr_number,
)
# Log completion with agent info
# 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}"
)
safe_print(
f"[ParallelOrchestrator] Complete. Agents invoked: {agents_invoked}",
f"[ParallelOrchestrator] Complete. Agents invoked: {final_agents}",
flush=True,
)
@@ -1228,7 +993,7 @@ The SDK will run invoked agents in parallel automatically.
verdict_reasoning=verdict_reasoning,
blockers=blockers,
findings=unique_findings,
agents_invoked=agents_invoked,
agents_invoked=final_agents,
)
# Map verdict to overall_status
@@ -1662,10 +1427,6 @@ The SDK will run invoked agents in parallel automatically.
if not findings:
return []
# Retry configuration for API errors
MAX_VALIDATION_RETRIES = 2
VALIDATOR_MAX_MESSAGES = 200 # Lower limit for validator (simpler task)
# Build validation prompt with all findings
findings_json = []
for f in findings:
@@ -1705,101 +1466,47 @@ For EACH finding above:
model_shorthand = self.config.model or "sonnet"
model = resolve_model_id(model_shorthand)
# Retry loop for transient API errors
last_error = None
for attempt in range(MAX_VALIDATION_RETRIES + 1):
if attempt > 0:
logger.info(
f"[PRReview] Validation retry {attempt}/{MAX_VALIDATION_RETRIES}"
)
safe_print(
f"[FindingValidator] Retry attempt {attempt}/{MAX_VALIDATION_RETRIES}"
)
# Create validator client (inherits worktree filesystem access)
try:
validator_client = create_client(
project_dir=worktree_path,
spec_dir=self.github_dir,
model=model,
agent_type="pr_finding_validator",
max_thinking_tokens=get_thinking_budget("medium"),
output_format={
"type": "json_schema",
"schema": FindingValidationResponse.model_json_schema(),
},
)
except Exception as e:
logger.error(f"[PRReview] Failed to create validator client: {e}")
# Fail-safe: return original findings
return findings
# Create validator client (inherits worktree filesystem access)
try:
validator_client = create_client(
project_dir=worktree_path,
spec_dir=self.github_dir,
# Run validation
try:
async with validator_client:
await validator_client.query(prompt)
stream_result = await process_sdk_stream(
client=validator_client,
context_name="FindingValidator",
model=model,
agent_type="pr_finding_validator",
max_thinking_tokens=get_thinking_budget("medium"),
output_format={
"type": "json_schema",
"schema": FindingValidationResponse.model_json_schema(),
},
system_prompt=prompt,
)
except Exception as e:
logger.error(f"[PRReview] Failed to create validator client: {e}")
last_error = e
continue # Try again
# Run validation
try:
async with validator_client:
await validator_client.query(prompt)
stream_result = await process_sdk_stream(
client=validator_client,
context_name="FindingValidator",
model=model,
system_prompt=prompt,
max_messages=VALIDATOR_MAX_MESSAGES,
if stream_result.get("error"):
logger.error(
f"[PRReview] Validation failed: {stream_result['error']}"
)
# Fail-safe: return original findings
return findings
error = stream_result.get("error")
if error:
# Check for specific error types that warrant retry
error_str = str(error).lower()
is_retryable = (
"400" in error_str
or "concurrency" in error_str
or "circuit breaker" in error_str
or "tool_use" in error_str
)
structured_output = stream_result.get("structured_output")
if is_retryable and attempt < MAX_VALIDATION_RETRIES:
logger.warning(
f"[PRReview] Retryable validation error: {error}"
)
last_error = Exception(error)
continue # Retry
logger.error(f"[PRReview] Validation failed: {error}")
# Fail-safe: return original findings
return findings
structured_output = stream_result.get("structured_output")
# Success - break out of retry loop
if structured_output:
break
except Exception as e:
error_str = str(e).lower()
is_retryable = (
"400" in error_str
or "concurrency" in error_str
or "rate" in error_str
)
if is_retryable and attempt < MAX_VALIDATION_RETRIES:
logger.warning(f"[PRReview] Retryable stream error: {e}")
last_error = e
continue # Retry
logger.error(f"[PRReview] Validation stream error: {e}")
# Fail-safe: return original findings
return findings
else:
# All retries exhausted
logger.error(
f"[PRReview] Validation failed after {MAX_VALIDATION_RETRIES} retries. "
f"Last error: {last_error}"
)
safe_print(
f"[FindingValidator] ERROR: Validation failed after {MAX_VALIDATION_RETRIES} retries"
)
except Exception as e:
logger.error(f"[PRReview] Validation stream error: {e}")
# Fail-safe: return original findings
return findings
@@ -139,9 +139,18 @@ class PRReviewEngine:
files_list.append(f"- ... and {len(context.changed_files) - 20} more files")
files_str = "\n".join(files_list)
# Removed: Related files section
# LLM agents now discover relevant files themselves via Read, Grep, Glob tools
# NEW: Format related files (imports, tests, etc.)
related_files_str = ""
if context.related_files:
related_files_list = [f"- `{f}`" for f in context.related_files[:10]]
if len(context.related_files) > 10:
related_files_list.append(
f"- ... and {len(context.related_files) - 10} more"
)
related_files_str = f"""
### Related Files (imports, tests, configs)
{chr(10).join(related_files_list)}
"""
# NEW: Format commits for context
commits_str = ""
@@ -537,51 +537,6 @@ 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."""
+256 -80
View File
@@ -25,6 +25,243 @@ logger = logging.getLogger(__name__)
# Check if debug mode is enabled
DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes")
# ── TEMPORARY: Per-PR full agent communication logger (v2) ────────────
# Writes every message to .auto-claude/github/pr/debug_logs/<context>_<ts>.log
# Remove after measurement phase is complete.
import datetime as _dt
import json as _json
from pathlib import Path as _Path
# Derive project root dynamically from this file's location
# sdk_utils.py is at: apps/backend/runners/github/services/sdk_utils.py
# So project root is 5 levels up
_PROJECT_ROOT = _Path(__file__).resolve().parent.parent.parent.parent.parent
_PR_LOG_DIR = _PROJECT_ROOT / ".auto-claude" / "github" / "pr" / "debug_logs"
class _PRDebugLogger:
"""Writes full agent communication to a log file for review.
Improvements (v2):
- System prompt and agent definitions logged at session start
- No truncation on thinking, text, tool input, or tool results
- No duplicate logging (single structured dump per message)
- Empty/whitespace content shown via repr()
- Agent attribution via subagent_tool_ids mapping
"""
def __init__(self, context_name: str, model: str | None = None):
self._f = None
self._subagent_tool_ids: dict[str, str] = {} # tool_id -> agent_name
try:
_PR_LOG_DIR.mkdir(parents=True, exist_ok=True)
ts = _dt.datetime.now().strftime("%Y%m%d_%H%M%S")
self.path = _PR_LOG_DIR / f"{context_name}_{ts}.log"
self._f = open(self.path, "w", encoding="utf-8")
self._write(
f"=== {context_name} Session Started at {_dt.datetime.now().isoformat()} ==="
)
if model:
self._write(f"Model: {model}")
self._write("")
except OSError as e:
# Failed to create directory or open file - logging disabled
logger.warning(f"PR debug logger disabled: {e}")
self.path = None
def _write(self, text: str):
# Skip logging if file handle was not created successfully
if self._f is None:
return
try:
self._f.write(text + "\n")
self._f.flush()
except (OSError, ValueError) as e:
# File write failed (file closed, disk full, etc.) - disable logging
logger.warning(f"PR debug logger write failed: {e}")
self._f = None
# ── Session preamble loggers ──────────────────────────────────────
def log_system_prompt(self, prompt: str):
"""Log the full system prompt (no truncation)."""
self._write(f"\n{'#' * 80}")
self._write("# SYSTEM PROMPT (full orchestrator instructions + PR context)")
self._write(f"# Length: {len(prompt)} chars")
self._write(f"{'#' * 80}")
self._write(prompt)
self._write(f"{'#' * 80}\n")
def log_agent_definitions(self, agents: dict):
"""Log all specialist agent definitions (prompts, tools, descriptions)."""
self._write(f"\n{'#' * 80}")
self._write(f"# AGENT DEFINITIONS ({len(agents)} specialists)")
self._write(f"{'#' * 80}")
for name, defn in agents.items():
self._write(f"\n--- Agent: {name} ---")
self._write(f" description: {getattr(defn, 'description', 'N/A')}")
self._write(f" model: {getattr(defn, 'model', 'N/A')}")
self._write(f" tools: {getattr(defn, 'tools', 'N/A')}")
prompt = getattr(defn, "prompt", "")
self._write(f" prompt ({len(prompt)} chars):")
self._write(prompt)
self._write(f"{'#' * 80}\n")
# ── Agent attribution ─────────────────────────────────────────────
def set_subagent_mapping(self, mapping: dict[str, str]):
"""Update the tool_id -> agent_name mapping for attribution."""
self._subagent_tool_ids = mapping
def _get_agent_label(self, tool_id: str) -> str:
"""Return agent label if this tool_id belongs to a known subagent."""
agent = self._subagent_tool_ids.get(tool_id)
return f" [Agent:{agent}]" if agent else ""
# ── Per-message logger (single structured dump) ───────────────────
def log_message(self, msg_count: int, msg_type: str, msg: object):
self._write(f"\n{'=' * 80}")
self._write(f"--- Message #{msg_count} [{msg_type}] ---")
self._write(f"{'=' * 80}")
self._dump_raw(msg)
def _dump_raw(self, msg: object, indent: int = 0):
"""Dump full raw message content recursively — NO truncation."""
prefix = " " * indent
# Content blocks
if hasattr(msg, "content"):
content = msg.content
if isinstance(content, list):
self._write(f"{prefix}[content] ({len(content)} blocks):")
for i, block in enumerate(content):
block_type = type(block).__name__
self._write(f"{prefix} [{i}] {block_type}:")
self._dump_block(block, indent + 2)
elif isinstance(content, str):
if not content or content.isspace():
self._write(
f"{prefix}[content] (string, {len(content)} chars): {repr(content)}"
)
else:
self._write(f"{prefix}[content] (string, {len(content)} chars):")
self._write(content)
else:
self._write(f"{prefix}[content] ({type(content).__name__}):")
self._write(f"{prefix} {str(content)}")
# Role / type
if hasattr(msg, "role"):
self._write(f"{prefix}[role] {msg.role}")
if hasattr(msg, "type") and not hasattr(msg, "content"):
self._write(f"{prefix}[type] {msg.type}")
# Structured output
if hasattr(msg, "structured_output") and msg.structured_output:
self._write(f"{prefix}[structured_output]:")
try:
self._write(_json.dumps(msg.structured_output, indent=2, default=str))
except Exception:
self._write(f"{prefix} {str(msg.structured_output)}")
# Result message fields
if hasattr(msg, "subtype"):
self._write(f"{prefix}[subtype] {msg.subtype}")
if hasattr(msg, "is_error"):
self._write(f"{prefix}[is_error] {msg.is_error}")
if hasattr(msg, "duration_ms"):
self._write(f"{prefix}[duration_ms] {msg.duration_ms}")
if hasattr(msg, "session_id"):
self._write(f"{prefix}[session_id] {msg.session_id}")
# Catch-all for messages without content blocks
for attr in ("text", "thinking", "name", "id", "input", "tool_use_id"):
if hasattr(msg, attr) and not hasattr(msg, "content"):
val = getattr(msg, attr)
if val is not None:
self._write(f"{prefix}[{attr}] {str(val)}")
def _dump_block(self, block: object, indent: int = 0):
"""Dump a single content block — NO truncation."""
prefix = " " * indent
block_type = getattr(block, "type", type(block).__name__)
if block_type in ("text", "TextBlock") and hasattr(block, "text"):
text = block.text
if not text or text.isspace():
self._write(f"{prefix}[text] ({len(text)} chars): {repr(text)}")
else:
self._write(f"{prefix}[text] ({len(text)} chars):")
self._write(text)
elif block_type in ("thinking", "ThinkingBlock") and hasattr(block, "thinking"):
text = block.thinking or getattr(block, "text", "")
self._write(f"{prefix}[thinking] ({len(text)} chars):")
self._write(text)
elif block_type in ("tool_use", "ToolUseBlock"):
tool_name = getattr(block, "name", "unknown")
tool_id = getattr(block, "id", "unknown")
tool_input = getattr(block, "input", {})
agent_label = self._get_agent_label(tool_id)
self._write(f"{prefix}[tool_use] {tool_name} (id={tool_id}){agent_label}")
try:
self._write(_json.dumps(tool_input, indent=2, default=str))
except Exception:
self._write(str(tool_input))
elif block_type in ("tool_result", "ToolResultBlock"):
tool_id = getattr(block, "tool_use_id", "unknown")
is_error = getattr(block, "is_error", False)
result = getattr(block, "content", "")
if isinstance(result, list):
result = " ".join(str(getattr(c, "text", c)) for c in result)
status = "ERROR" if is_error else "OK"
agent_label = self._get_agent_label(tool_id)
self._write(
f"{prefix}[tool_result] (tool_id={tool_id}) {status}{agent_label}"
)
self._write(str(result))
else:
# Unknown block type — dump everything we can
self._write(f"{prefix}[{block_type}] (raw dump):")
for attr in dir(block):
if not attr.startswith("_"):
try:
val = getattr(block, attr)
if not callable(val):
self._write(f"{prefix} {attr}: {str(val)}")
except Exception:
pass
# ── Structured output (standalone, for final result) ──────────────
def log_structured_output(self, output: dict):
self._write("[STRUCTURED_OUTPUT]")
try:
self._write(_json.dumps(output, indent=2, default=str))
except Exception:
self._write(str(output))
# ── Session close ─────────────────────────────────────────────────
def close(self, summary: dict):
self._write("\n=== Session Ended ===")
self._write(f"Messages: {summary.get('msg_count', '?')}")
self._write(f"Agents invoked: {summary.get('agents_invoked', [])}")
self._write(f"Error: {summary.get('error')}")
self._write(f"Log file: {self.path}")
if self._f is not None:
try:
self._f.close()
except OSError as e:
logger.warning(f"PR debug logger close failed: {e}")
# ── END TEMPORARY ──────────────────────────────────────────────────────
def _short_model_name(model: str | None) -> str:
"""Convert full model name to a short display name for logs.
@@ -124,49 +361,6 @@ def _get_tool_detail(tool_name: str, tool_input: dict[str, Any]) -> str:
return f"Using tool: {tool_name}"
# Circuit breaker threshold - abort if message count exceeds this
# Prevents runaway retry loops from consuming unbounded resources
MAX_MESSAGE_COUNT = 500
def _is_tool_concurrency_error(text: str) -> bool:
"""
Detect the specific tool use concurrency error pattern.
This error occurs when Claude makes multiple parallel tool_use blocks
and some fail, corrupting the tool_use/tool_result message pairing.
Args:
text: Text to check for error pattern
Returns:
True if this is the tool concurrency error, False otherwise
"""
text_lower = text.lower()
# Check for the specific error message pattern
# Pattern 1: Explicit concurrency or tool_use errors with 400
has_400 = "400" in text_lower
has_tool = "tool" in text_lower
if has_400 and has_tool:
# Look for specific keywords indicating tool concurrency issues
error_keywords = [
"concurrency",
"tool_use",
"tool use",
"tool_result",
"tool result",
]
if any(keyword in text_lower for keyword in error_keywords):
return True
# Pattern 2: API error with 400 and tool mention
if "api error" in text_lower and has_400 and has_tool:
return True
return False
async def process_sdk_stream(
client: Any,
on_thinking: Callable[[str], None] | None = None,
@@ -176,10 +370,8 @@ async def process_sdk_stream(
on_structured_output: Callable[[dict[str, Any]], None] | None = None,
context_name: str = "SDK",
model: str | None = None,
max_messages: int | None = None,
# Deprecated parameters (kept for backwards compatibility, no longer used)
system_prompt: str | None = None, # noqa: ARG001
agent_definitions: dict | None = None, # noqa: ARG001
system_prompt: str | None = None,
agent_definitions: dict | None = None,
) -> dict[str, Any]:
"""
Process SDK response stream with customizable callbacks.
@@ -200,7 +392,8 @@ async def process_sdk_stream(
on_structured_output: Callback for structured output - receives dict
context_name: Name for logging (e.g., "ParallelOrchestrator", "ParallelFollowup")
model: Model name for logging (e.g., "claude-sonnet-4-5-20250929")
max_messages: Optional override for max message count circuit breaker (default: MAX_MESSAGE_COUNT)
system_prompt: Full system prompt sent to the agent (logged at session start)
agent_definitions: Dict of agent name -> AgentDefinition (logged at session start)
Returns:
Dictionary with:
@@ -219,11 +412,15 @@ async def process_sdk_stream(
# Track subagent tool IDs to log their results
subagent_tool_ids: dict[str, str] = {} # tool_id -> agent_name
completed_agent_tool_ids: set[str] = set() # tool_ids of completed agents
# Track tool concurrency errors for retry logic
detected_concurrency_error = False
# Circuit breaker: max messages before aborting
message_limit = max_messages if max_messages is not None else MAX_MESSAGE_COUNT
# TEMPORARY: per-PR debug file logger
_dbg = _PRDebugLogger(context_name, model=model)
# Log session preamble: system prompt and agent definitions
if system_prompt:
_dbg.log_system_prompt(system_prompt)
if agent_definitions:
_dbg.log_agent_definitions(agent_definitions)
safe_print(f"[{context_name}] Processing SDK stream...")
if DEBUG_MODE:
@@ -238,17 +435,7 @@ async def process_sdk_stream(
try:
msg_type = type(msg).__name__
msg_count += 1
# CIRCUIT BREAKER: Abort if message count exceeds threshold
# This prevents runaway retry loops (e.g., 400 errors causing infinite retries)
if msg_count > message_limit:
stream_error = (
f"Circuit breaker triggered: message count ({msg_count}) "
f"exceeded limit ({message_limit}). Possible retry loop detected."
)
logger.error(f"[{context_name}] {stream_error}")
safe_print(f"[{context_name}] ERROR: {stream_error}")
break
_dbg.log_message(msg_count, msg_type, msg)
# Log progress periodically so user knows AI is working
if msg_count - last_progress_log >= PROGRESS_LOG_INTERVAL:
@@ -317,6 +504,7 @@ async def process_sdk_stream(
agents_invoked.append(agent_name)
# Track this tool ID to log its result later
subagent_tool_ids[tool_id] = agent_name
_dbg.set_subagent_mapping(subagent_tool_ids)
# Log with model info if available
model_info = f" [{_short_model_name(model)}]" if model else ""
safe_print(
@@ -423,15 +611,6 @@ async def process_sdk_stream(
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
result_text += block.text
# Check for tool concurrency error pattern in text output
if _is_tool_concurrency_error(block.text):
detected_concurrency_error = True
logger.warning(
f"[{context_name}] Detected tool use concurrency error in response"
)
safe_print(
f"[{context_name}] WARNING: Tool concurrency error detected"
)
# Always print text content preview (not just in DEBUG_MODE)
text_preview = block.text[:500].replace("\n", " ").strip()
if text_preview:
@@ -476,6 +655,7 @@ async def process_sdk_stream(
# Only capture if we don't already have it (avoid duplicates)
if structured_output is None:
structured_output = msg.structured_output
_dbg.log_structured_output(msg.structured_output)
safe_print(f"[{context_name}] Received structured output")
if on_structured_output:
on_structured_output(msg.structured_output)
@@ -548,14 +728,7 @@ async def process_sdk_stream(
safe_print(f"[{context_name}] Session ended. Total messages: {msg_count}")
# Set error flag if tool concurrency error was detected
if detected_concurrency_error and not stream_error:
stream_error = "tool_use_concurrency_error"
logger.warning(
f"[{context_name}] Tool use concurrency error detected - caller should retry"
)
return {
result = {
"result_text": result_text,
"structured_output": structured_output,
"agents_invoked": agents_invoked,
@@ -563,3 +736,6 @@ async def process_sdk_stream(
"subagent_tool_ids": subagent_tool_ids,
"error": stream_error,
}
_dbg.close(result)
safe_print(f"[{context_name}] Full debug log: {_dbg.path}")
return result
@@ -532,176 +532,5 @@ class TestGhExecutableDetection:
mock_run.assert_not_called()
class TestInProgressTracking:
"""Test in-progress review tracking."""
def test_mark_review_started(self, mock_bot_detector, temp_state_dir):
"""Test marking review as started."""
mock_bot_detector.mark_review_started(123)
# Check state
assert "123" in mock_bot_detector.state.in_progress_reviews
start_time_str = mock_bot_detector.state.in_progress_reviews["123"]
start_time = datetime.fromisoformat(start_time_str)
# Should be very recent (within last 5 seconds)
time_diff = datetime.now() - start_time
assert time_diff.total_seconds() < 5
# Check persistence
loaded = BotDetectionState.load(temp_state_dir)
assert "123" in loaded.in_progress_reviews
def test_mark_review_finished_success(self, mock_bot_detector, temp_state_dir):
"""Test marking review as finished successfully."""
mock_bot_detector.mark_review_started(123)
assert "123" in mock_bot_detector.state.in_progress_reviews
mock_bot_detector.mark_review_finished(123, success=True)
# In-progress state should be cleared
assert "123" not in mock_bot_detector.state.in_progress_reviews
# Check persistence
loaded = BotDetectionState.load(temp_state_dir)
assert "123" not in loaded.in_progress_reviews
def test_mark_review_finished_error(self, mock_bot_detector):
"""Test marking review as finished with error."""
mock_bot_detector.mark_review_started(123)
mock_bot_detector.mark_review_finished(123, success=False)
# In-progress state should be cleared
assert "123" not in mock_bot_detector.state.in_progress_reviews
def test_is_review_in_progress_active(self, mock_bot_detector):
"""Test detecting active in-progress review."""
mock_bot_detector.mark_review_started(123)
is_in_progress, reason = mock_bot_detector.is_review_in_progress(123)
assert is_in_progress is True
assert "already in progress" in reason.lower()
def test_is_review_in_progress_not_started(self, mock_bot_detector):
"""Test checking in-progress when review not started."""
is_in_progress, reason = mock_bot_detector.is_review_in_progress(999)
assert is_in_progress is False
assert reason == ""
def test_is_review_in_progress_stale(self, mock_bot_detector):
"""Test detecting stale in-progress review."""
# Set review start time to 31 minutes ago (past timeout)
stale_time = datetime.now() - timedelta(minutes=31)
mock_bot_detector.state.in_progress_reviews["123"] = stale_time.isoformat()
is_in_progress, reason = mock_bot_detector.is_review_in_progress(123)
# Should detect as stale and clear it
assert is_in_progress is False
assert reason == ""
# Should be removed from state
assert "123" not in mock_bot_detector.state.in_progress_reviews
def test_is_review_in_progress_invalid_timestamp(self, mock_bot_detector):
"""Test handling invalid timestamp in in-progress state."""
mock_bot_detector.state.in_progress_reviews["123"] = "invalid-timestamp"
is_in_progress, reason = mock_bot_detector.is_review_in_progress(123)
# Should clear invalid state
assert is_in_progress is False
assert reason == ""
assert "123" not in mock_bot_detector.state.in_progress_reviews
def test_should_skip_review_in_progress(self, mock_bot_detector):
"""Test skipping PR when review is in progress."""
mock_bot_detector.mark_review_started(123)
pr_data = {"author": {"login": "alice"}}
commits = [{"author": {"login": "alice"}, "oid": "abc123"}]
should_skip, reason = mock_bot_detector.should_skip_pr_review(
pr_number=123,
pr_data=pr_data,
commits=commits,
)
assert should_skip is True
assert "already in progress" in reason.lower()
def test_mark_reviewed_clears_in_progress(self, mock_bot_detector):
"""Test that mark_reviewed also clears in-progress state."""
mock_bot_detector.mark_review_started(123)
assert "123" in mock_bot_detector.state.in_progress_reviews
mock_bot_detector.mark_reviewed(123, "abc123")
# In-progress should be cleared
assert "123" not in mock_bot_detector.state.in_progress_reviews
# Reviewed state should be set
assert "123" in mock_bot_detector.state.reviewed_commits
assert "abc123" in mock_bot_detector.state.reviewed_commits["123"]
def test_clear_pr_state_clears_in_progress(self, mock_bot_detector):
"""Test that clear_pr_state also clears in-progress state."""
mock_bot_detector.mark_review_started(123)
mock_bot_detector.mark_reviewed(123, "abc123")
assert (
"123" in mock_bot_detector.state.in_progress_reviews or True
) # May be cleared by mark_reviewed
assert "123" in mock_bot_detector.state.reviewed_commits
# Start another review
mock_bot_detector.mark_review_started(123)
assert "123" in mock_bot_detector.state.in_progress_reviews
mock_bot_detector.clear_pr_state(123)
# Everything should be cleared
assert "123" not in mock_bot_detector.state.in_progress_reviews
assert "123" not in mock_bot_detector.state.reviewed_commits
assert "123" not in mock_bot_detector.state.last_review_times
def test_get_stats_includes_in_progress(self, mock_bot_detector):
"""Test that get_stats includes in-progress count."""
mock_bot_detector.mark_review_started(123)
mock_bot_detector.mark_review_started(456)
mock_bot_detector.mark_reviewed(789, "abc123")
stats = mock_bot_detector.get_stats()
assert stats["in_progress_reviews"] == 2
assert stats["total_prs_tracked"] == 1 # Only 789 is tracked as reviewed
assert stats["in_progress_timeout_minutes"] == 30
def test_cleanup_stale_prs_removes_stale_in_progress(self, mock_bot_detector):
"""Test that cleanup_stale_prs removes stale in-progress reviews."""
# Add a stale in-progress review (32 minutes ago)
stale_time = datetime.now() - timedelta(minutes=32)
mock_bot_detector.state.in_progress_reviews["123"] = stale_time.isoformat()
# Add an active in-progress review (5 minutes ago)
active_time = datetime.now() - timedelta(minutes=5)
mock_bot_detector.state.in_progress_reviews["456"] = active_time.isoformat()
# Add a stale reviewed PR (40 days ago)
stale_review_time = datetime.now() - timedelta(days=40)
mock_bot_detector.state.reviewed_commits["789"] = ["abc123"]
mock_bot_detector.state.last_review_times["789"] = stale_review_time.isoformat()
cleaned = mock_bot_detector.cleanup_stale_prs(max_age_days=30)
# Should remove stale in-progress and stale reviewed PR
assert cleaned == 2 # 1 stale in-progress + 1 stale reviewed
assert "123" not in mock_bot_detector.state.in_progress_reviews
assert (
"456" in mock_bot_detector.state.in_progress_reviews
) # Active one remains
assert "789" not in mock_bot_detector.state.reviewed_commits
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+3 -42
View File
@@ -46,7 +46,6 @@ if sys.version_info < (3, 10): # noqa: UP036
import asyncio
import io
import json
import os
import subprocess
from pathlib import Path
@@ -253,17 +252,9 @@ Examples:
# Find project root (look for auto-claude folder)
project_dir = args.project_dir
# Auto-detect if running from within auto-claude/apps/backend/ source directory.
# This must be specific: check for run.py FILE (not dir) AND core/client.py to confirm
# we're in the actual backend source tree, not just a project named "auto-claude".
run_py_path = project_dir / "run.py"
if (
project_dir.name == "auto-claude"
and run_py_path.exists()
and run_py_path.is_file()
and (project_dir / "core" / "client.py").exists()
):
# Running from within auto-claude/apps/backend/ source directory, go up 1 level
# Auto-detect if running from within auto-claude directory (the source code)
if project_dir.name == "auto-claude" and (project_dir / "run.py").exists():
# Running from within auto-claude/ source directory, go up 1 level
project_dir = project_dir.parent
elif not (project_dir / ".auto-claude").exists():
# No .auto-claude folder found - try to find project root
@@ -360,36 +351,6 @@ Examples:
"--auto-continue", # Non-interactive mode for chained execution
]
# Bypass approval re-validation when all conditions are met:
# 1. Spec was auto-approved (no human review required)
# 2. Spec creation succeeded (we're past the success check above)
# 3. No review-before-coding gate was requested
# This prevents hash mismatch failures when spec files are
# touched between auto-approval and run.py startup.
if args.auto_approve:
# Default to requiring review (fail-closed) - only skip if explicitly disabled
require_review = True
task_meta_path = orchestrator.spec_dir / "task_metadata.json"
if task_meta_path.exists():
try:
with open(task_meta_path, encoding="utf-8") as f:
task_meta = json.load(f)
require_review = task_meta.get(
"requireReviewBeforeCoding", False
)
except (json.JSONDecodeError, OSError) as e:
# On parse error, keep require_review=True (fail-closed)
debug(
"spec_runner",
f"Failed to parse task_metadata.json, not adding --force: {e}",
)
if not require_review:
run_cmd.append("--force")
debug(
"spec_runner",
"Adding --force: auto-approved, no review required, spec completed",
)
# Pass base branch if specified (for worktree creation)
if args.base_branch:
run_cmd.extend(["--base-branch", args.base_branch])
+10 -28
View File
@@ -38,7 +38,7 @@ async def bash_security_hook(
context: Optional context
Returns:
Empty dict to allow, or hookSpecificOutput with permissionDecision "deny" to block
Empty dict to allow, or {"decision": "block", "reason": "..."} to block
"""
if input_data.get("tool_name") != "Bash":
return {}
@@ -49,21 +49,15 @@ async def bash_security_hook(
# Check if tool_input is None (malformed tool call)
if tool_input is None:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Bash tool_input is None - malformed tool call from SDK",
}
"decision": "block",
"reason": "Bash tool_input is None - malformed tool call from SDK",
}
# Check if tool_input is a dict
if not isinstance(tool_input, dict):
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"Bash tool_input must be dict, got {type(tool_input).__name__}",
}
"decision": "block",
"reason": f"Bash tool_input must be dict, got {type(tool_input).__name__}",
}
# Now safe to access command
@@ -103,11 +97,8 @@ async def bash_security_hook(
if not commands:
# Could not parse - fail safe by blocking
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"Could not parse command for security validation: {command}",
}
"decision": "block",
"reason": f"Could not parse command for security validation: {command}",
}
# Split into segments for per-command validation
@@ -123,11 +114,8 @@ async def bash_security_hook(
if not is_allowed:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
"decision": "block",
"reason": reason,
}
# Additional validation for sensitive commands
@@ -139,13 +127,7 @@ async def bash_security_hook(
validator = VALIDATORS[cmd]
allowed, reason = validator(cmd_segment)
if not allowed:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}
return {"decision": "block", "reason": reason}
return {}
@@ -10,7 +10,6 @@ from collections.abc import Callable
from pathlib import Path
from analysis.analyzers import analyze_project
from core.task_event import TaskEventEmitter
from core.workspace.models import SpecNumberLock
from phase_config import get_thinking_budget
from prompts_pkg.project_context import should_refresh_project_index
@@ -235,7 +234,6 @@ class SpecOrchestrator:
# Initialize task logger for planning phase
task_logger = get_task_logger(self.spec_dir)
task_logger.start_phase(LogPhase.PLANNING, "Starting spec creation process")
TaskEventEmitter.from_spec_dir(self.spec_dir).emit("PLANNING_STARTED")
print(
box(
@@ -410,28 +408,6 @@ class SpecOrchestrator:
LogPhase.PLANNING, success=True, message="Spec creation complete"
)
# Load task metadata to check requireReviewBeforeCoding setting
task_metadata_file = self.spec_dir / "task_metadata.json"
require_review_before_coding = False
if task_metadata_file.exists():
with open(task_metadata_file, encoding="utf-8") as f:
task_metadata = json.load(f)
require_review_before_coding = task_metadata.get(
"requireReviewBeforeCoding", False
)
# Emit PLANNING_COMPLETE event for XState machine transition
# This signals the frontend that spec creation is done
task_emitter = TaskEventEmitter.from_spec_dir(self.spec_dir)
task_emitter.emit(
"PLANNING_COMPLETE",
{
"hasSubtasks": False, # Spec creation doesn't have subtasks yet
"subtaskCount": 0,
"requireReviewBeforeCoding": require_review_before_coding,
},
)
# === HUMAN REVIEW CHECKPOINT ===
return self._run_review_checkpoint(auto_approve)
-172
View File
@@ -1,172 +0,0 @@
# XState Task State Machine Migration - Summary
**Issue:** #1338
**PR:** #1575
**Date:** 2026-01-28
**Branch:** fix/1524-xstate-clean
## Overview
Migrated task status management from scattered decision logic across multiple handler files to a centralized XState v5 state machine. This eliminates race conditions, inconsistent status updates, and makes the task lifecycle formally defined and testable.
## Critical Dependencies & Blockers
### 1. Windows Credential Manager Fix (Required for Testing)
**PR:** #1569 - fix(windows): fix Windows Credential Manager authentication
**Issue:** #1525
This PR includes changes that depend on the Windows authentication fix. We could not complete end-to-end testing without this fix in place. If a different solution is implemented for #1525, we can remove these changes and resubmit.
### 2. spec_runner.py Project Detection Fix
**Issue:** #1570 - spec_runner.py incorrectly detects auto-claude project as source directory
We encountered and fixed this bug during development as it was blocking our test workflow. The fix is included in this PR.
## Implementation Phases
| Phase | Description | Status |
|-------|-------------|--------|
| Phase 1 | Create XState machine definition (task-machine.ts) | ✅ Complete |
| Phase 2 | Create TaskStateManager singleton wrapper | ✅ Complete |
| Phase 3 | Integrate into agent-events-handlers.ts | ⏸️ Partially done |
| Phase 4 | Remove legacy TaskStateMachine class | ❌ Not started |
### Why We Stopped at Phase 2
The original scope was to introduce XState as the new state management approach. Full integration (Phase 3-4) requires:
- Extensive refactoring of agent-events-handlers.ts to remove all legacy decision logic
- Removing the old TaskStateMachine class entirely
- Migration of all status persistence to go through XState
We delivered Phases 1-2 to establish the foundation. The current state has both systems running in parallel with XState as primary:
- **XState is primary:** When TaskStateManager returns a valid state transition, that decision is used
- **Legacy as fallback:** The old TaskStateMachine logic only applies when XState doesn't produce a decision
- **Safe rollback:** If XState causes issues, the legacy system is still present and can take over
This dual-system approach allows:
- Validation that XState produces correct state transitions in production
- Safe rollback if issues arise
- Incremental adoption path for Phase 3-4
## What Changed
### Before (Old Architecture)
- Status decisions scattered across agent-events-handlers.ts, execution-handlers.ts, worktree-handlers.ts
- `validateStatusTransition()` function with complex conditional logic
- TaskStateMachine class that was essentially an event emitter wrapper
- Multiple places persisting status to implementation_plan.json
- Race conditions possible when multiple handlers tried to update status
### After (New Architecture)
- **Single source of truth:** TaskStateManager (XState-based singleton)
- **Formal state machine:** taskMachine with explicit states and transitions
- **Centralized persistence:** Status written to JSON from one place
- **Testable:** Unit tests verify all state transitions
- **Observable:** XState actors can be inspected/visualized
## State Machine States
```
backlog → planning → coding → qa_review → qa_fixing → human_review → done
↘ plan_review ↗ ↓
error
```
| State | Maps to Legacy Status | reviewReason |
|-------|----------------------|--------------|
| backlog | backlog | - |
| planning | in_progress | - |
| coding | in_progress | - |
| plan_review | human_review | plan_review |
| qa_review | ai_review | - |
| qa_fixing | ai_review | - |
| human_review | human_review | completed or stopped |
| creating_pr | human_review | completed |
| pr_created | pr_created | - |
| error | human_review | errors |
| done | done | - |
## Key Files
| File | Purpose |
|------|---------|
| `apps/frontend/src/shared/state-machines/task-machine.ts` | XState machine definition |
| `apps/frontend/src/main/task-state-manager.ts` | Singleton service wrapping XState actors |
| `apps/frontend/src/shared/state-machines/__tests__/task-machine.test.ts` | State machine unit tests (35 tests) |
| `apps/frontend/src/main/__tests__/task-state-manager.test.ts` | Manager service unit tests (20 tests) |
| `apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts` | Refactored to call TaskStateManager |
## Events
The state machine responds to these events:
| Event | Triggered By |
|-------|-------------|
| PLANNING_STARTED | Execution progress phase=planning |
| PLANNING_COMPLETE | Execution progress moving past planning |
| PLAN_APPROVED | User clicks "Proceed to Coding" from plan_review |
| CODING_STARTED | Execution progress phase=coding |
| QA_STARTED | Execution progress phase=qa_review |
| QA_PASSED | Execution progress phase=complete |
| QA_FAILED | Execution progress phase=qa_fixing |
| PROCESS_EXITED | Agent process exit event |
| USER_STOPPED | User clicks stop |
| USER_RESUMED | User resumes task |
| MARK_DONE | User marks task as done |
| CREATE_PR | User initiates PR creation |
| PR_CREATED | PR successfully created |
## Rollback Plan
If issues arise post-merge:
1. **Quick rollback:** `git revert <merge-commit>`
2. **Restore point:** Commit 3e5f004a has old code intact
3. **Legacy persistence still works:** implementation_plan.json continues to store status
## Testing
| Test Suite | Result |
|------------|--------|
| Frontend unit tests | ✅ 2579 passed |
| TypeScript strict mode | ✅ Pass |
| Biome lint | ✅ Pass |
| XState machine tests | ✅ 35 passed |
| TaskStateManager tests | ✅ 20 passed |
| Python backend tests | ✅ Pass |
## Session Fixes (2026-01-28)
### Fixed Issues
1. **Badge showing "Needs Review" instead of "Complete"** - Added `effectiveReviewReason` logic in TaskCard.tsx that sets 'completed' when phase === 'complete'
2. **Task showing "Incomplete" badge for plan_review** - Added 'plan_review' to exclusion list in `isIncompleteHumanReview`
3. **Missing "Proceed to Coding" button** - Restored in WorkspaceMessages.tsx for plan_review flow
4. **Wrong XState event for plan_review → coding** - Fixed to send PLAN_APPROVED instead of PLANNING_STARTED when starting from plan_review state
5. **Stuck detection logic** - Reverted useTaskDetail.ts to simpler logic from working branch (only skip 'planning' phase, 2s timeout)
## Outstanding Items (Requires PM Input)
### 1. Future: Subtask XState Migration
- **Issue:** `subtask.status` is checked directly in UI code
- **Recommendation:** Should be managed by state machine for consistency
- **Status:** Out of scope for current PR, document for future work
## Future Improvements
- Add @stately-ai/inspect for runtime devtools
- **Subtask state management** - Track individual subtask states within the machine using XState parallel states
- Add more granular QA states (qa_round_1, qa_round_2, etc.)
- Complete Phase 3-4: Full integration and removal of legacy TaskStateMachine class
## Visualization
The state machine can be visualized at [Stately.ai Editor](https://stately.ai/editor):
1. Paste the contents of task-machine.ts
2. Click "Visualize" to see the state diagram
+1 -7
View File
@@ -1,10 +1,6 @@
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
import { config as dotenvConfig } from 'dotenv';
// Load .env file for build-time constants (Sentry DSN, etc.)
dotenvConfig({ path: resolve(__dirname, '.env') });
/**
* Sentry configuration embedded at build time.
@@ -47,9 +43,7 @@ export default defineConfig({
'debug',
'ms',
// Minimatch for glob pattern matching in worktree handlers
'minimatch',
// XState for task state machine
'xstate'
'minimatch'
]
})],
build: {
+10 -37
View File
@@ -1,6 +1,6 @@
{
"name": "auto-claude-ui",
"version": "2.7.6-beta.2",
"version": "2.7.5",
"type": "module",
"description": "Desktop UI for Auto Claude autonomous coding framework",
"homepage": "https://github.com/AndyMik90/Auto-Claude",
@@ -36,8 +36,6 @@
"package:win": "node scripts/package-with-python.cjs --win",
"package:linux": "node scripts/package-with-python.cjs --linux",
"package:flatpak": "node scripts/package-with-python.cjs --linux flatpak",
"verify:linux": "node scripts/verify-linux-packages.cjs dist",
"test:verify-linux": "node --test scripts/verify-linux-packages.test.mjs",
"start:packaged:mac": "open dist/mac-arm64/Auto-Claude.app || open dist/mac/Auto-Claude.app",
"start:packaged:win": "start \"\" \"dist\\win-unpacked\\Auto-Claude.exe\"",
"start:packaged:linux": "./dist/linux-unpacked/auto-claude",
@@ -96,13 +94,10 @@
"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",
"uuid": "^13.0.0",
"xstate": "^5.26.0",
"zod": "^4.2.1",
"zustand": "^5.0.9"
},
@@ -170,6 +165,14 @@
"from": "resources/icon.ico",
"to": "icon.ico"
},
{
"from": "python-runtime/${os}-${arch}/python",
"to": "python"
},
{
"from": "python-runtime/${os}-${arch}/site-packages",
"to": "python-site-packages"
},
{
"from": "../backend",
"to": "backend",
@@ -199,16 +202,6 @@
"target": [
"dmg",
"zip"
],
"extraResources": [
{
"from": "python-runtime/${os}-${arch}/python",
"to": "python"
},
{
"from": "python-runtime/${os}-${arch}/site-packages",
"to": "python-site-packages"
}
]
},
"win": {
@@ -216,16 +209,6 @@
"target": [
"nsis",
"zip"
],
"extraResources": [
{
"from": "python-runtime/${os}-${arch}/python",
"to": "python"
},
{
"from": "python-runtime/${os}-${arch}/site-packages",
"to": "python-site-packages"
}
]
},
"linux": {
@@ -235,17 +218,7 @@
"deb",
"flatpak"
],
"category": "Development",
"extraResources": [
{
"from": "python-runtime/${os}-${arch}/python",
"to": "python"
},
{
"from": "python-runtime/${os}-${arch}/site-packages",
"to": "python-site-packages"
}
]
"category": "Development"
},
"flatpak": {
"runtime": "org.freedesktop.Platform",
@@ -1,406 +0,0 @@
#!/usr/bin/env node
/**
* Verify Linux package contents to ensure alignment between AppImage, deb, and Flatpak.
*
* This script extracts and inspects each Linux package format to verify that critical
* files (Python binary, backend code, Python packages) are present and correctly bundled.
*
* Usage: node scripts/verify-linux-packages.cjs [dist-dir]
*/
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
// Critical Python packages that must be present
const CRITICAL_PACKAGES = [
'secretstorage', // Linux OAuth token storage
'pydantic_core',
'claude_agent_sdk',
'dotenv',
];
// Minimum expected Flatpak file size (50 MB)
// Flatpak files are large OCI archives; anything smaller is suspicious
// Based on observed minimum sizes of valid builds
const FLATPAK_MIN_SIZE_MB = 50;
// Colors for terminal output
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
};
function log(message, color = colors.reset) {
console.log(`${color}${message}${colors.reset}`);
}
function logSuccess(message) {
log(`${message}`, colors.green);
}
function logError(message) {
log(`${message}`, colors.red);
}
function logWarning(message) {
log(`${message}`, colors.yellow);
}
function logInfo(message) {
log(` ${message}`, colors.cyan);
}
/**
* Check if a command exists
* Uses 'which' directly without shell interpolation to prevent command injection
*/
function commandExists(cmd) {
const result = spawnSync('which', [cmd], { stdio: 'ignore' });
return result.status === 0;
}
/**
* Find all Linux packages in the dist directory
*/
function findPackages(distDir) {
const packages = {
appImage: null,
deb: null,
flatpak: null,
};
if (!fs.existsSync(distDir)) {
logError(`Distribution directory not found: ${distDir}`);
return packages;
}
const files = fs.readdirSync(distDir);
for (const file of files) {
const fullPath = path.join(distDir, file);
if (file.endsWith('.AppImage')) {
if (!packages.appImage) {
packages.appImage = fullPath;
} else {
logWarning(`Multiple AppImage files found, using first: ${path.basename(packages.appImage)}`);
}
} else if (file.endsWith('.deb')) {
if (!packages.deb) {
packages.deb = fullPath;
} else {
logWarning(`Multiple deb files found, using first: ${path.basename(packages.deb)}`);
}
} else if (file.endsWith('.flatpak')) {
if (!packages.flatpak) {
packages.flatpak = fullPath;
} else {
logWarning(`Multiple Flatpak files found, using first: ${path.basename(packages.flatpak)}`);
}
}
}
return packages;
}
/**
* Common file list verification logic
* @param {string[]} files - List of files from package
* @param {string} packageType - Type of package (for error messages)
* @returns {Object} Verification result with verified flag and issues array
*
* File formats:
* - AppImage (bsdtar): './resources/python', './resources/backend/file.py'
* - deb (dpkg-deb -c): 'resources/python', 'resources/backend/file.py' (in last column)
*/
function verifyFileList(files, packageType) {
const issues = [];
// Normalize paths by removing trailing slashes (archive tools commonly add these)
const normalizePath = (p) => p.replace(/\/+$/, '');
// Check for Python binary directory
// AppImage: './resources/python' or './resources/python/' (with trailing slash)
// deb: 'resources/python' or 'resources/python/' (with trailing slash)
// Must NOT match 'resources/python-site-packages'
const pythonBinFound = files.some((f) => {
const normalized = normalizePath(f);
return (
(normalized === './resources/python' ||
normalized === 'resources/python' ||
normalized.endsWith('/resources/python')) &&
!f.includes('python-site-packages')
);
});
if (!pythonBinFound) {
issues.push(`Python binary directory not found in ${packageType}`);
}
// Check for backend directory (must be under resources/)
const backendFound = files.some((f) => {
const normalized = normalizePath(f);
return (
f.includes('./resources/backend/') ||
f.includes('resources/backend/') ||
normalized === './resources/backend' ||
normalized === 'resources/backend'
);
});
if (!backendFound) {
issues.push(`Backend directory not found in ${packageType}`);
}
// Check for critical Python packages (must be under python-site-packages/)
for (const pkg of CRITICAL_PACKAGES) {
// Match: './resources/python-site-packages/secretstorage/__init__.py'
// Match: 'resources/python-site-packages/secretstorage/__init__.py'
// Don't match: '/some/other/path/secretstorage/'
const found = files.some(
(f) => f.includes(`python-site-packages/${pkg}/`) || f.includes(`python-site-packages/${pkg}.`),
);
if (!found) {
issues.push(`Python package not found: ${pkg}`);
}
}
return {
verified: issues.length === 0,
issues,
fileCount: files.filter((f) => f.trim()).length,
};
}
/**
* Verify AppImage contents using bsdtar (libarchive)
*/
function verifyAppImage(appImagePath) {
logInfo(`Verifying AppImage: ${path.basename(appImagePath)}`);
// Check if bsdtar is available
if (!commandExists('bsdtar')) {
logWarning('bsdtar not found. Install with: sudo apt-get install libarchive-tools');
logWarning('Skipping AppImage verification');
return { verified: false, reason: 'bsdtar not available', critical: true };
}
// Extract file list from AppImage using bsdtar
const result = spawnSync('bsdtar', ['-t', '-f', appImagePath], {
stdio: 'pipe',
encoding: 'utf-8',
maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large file listings
});
// Check for spawn errors (e.g., permission denied, memory issues)
if (result.error) {
logError(`Failed to execute bsdtar: ${result.error.message}`);
return { verified: false, reason: `Command execution failed: ${result.error.message}` };
}
if (result.status !== 0) {
logError(`Failed to read AppImage: ${result.stderr}`);
return { verified: false, reason: 'Failed to extract file list' };
}
const files = result.stdout.split('\n');
return verifyFileList(files, 'AppImage');
}
/**
* Verify deb package contents
*/
function verifyDeb(debPath) {
logInfo(`Verifying deb package: ${path.basename(debPath)}`);
// Check if dpkg is available
if (!commandExists('dpkg-deb')) {
logWarning('dpkg-deb not found. Skipping deb verification');
return { verified: false, reason: 'dpkg-deb not available', critical: true };
}
// List contents of deb package
const result = spawnSync('dpkg-deb', ['-c', debPath], {
stdio: 'pipe',
encoding: 'utf-8',
maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large file listings
});
// Check for spawn errors (e.g., permission denied, memory issues)
if (result.error) {
logError(`Failed to execute dpkg-deb: ${result.error.message}`);
return { verified: false, reason: `Command execution failed: ${result.error.message}` };
}
if (result.status !== 0) {
logError(`Failed to read deb package: ${result.stderr}`);
return { verified: false, reason: 'Failed to extract file list' };
}
const files = result.stdout.split('\n');
return verifyFileList(files, 'deb package');
}
/**
* Verify Flatpak package contents
* Note: Flatpak is more complex to inspect, so we do basic validation
*/
function verifyFlatpak(flatpakPath) {
logInfo(`Verifying Flatpak package: ${path.basename(flatpakPath)}`);
const issues = [];
// Check if flatpak command is available for detailed validation
const hasFlatpakCli = commandExists('flatpak');
if (!hasFlatpakCli) {
logWarning('flatpak command not found. Skipping detailed Flatpak verification');
// Continue with basic file existence/size checks
}
// Check if file exists and is not empty
if (!fs.existsSync(flatpakPath)) {
return { verified: false, issues: ['Flatpak file does not exist'] };
}
const stats = fs.statSync(flatpakPath);
if (stats.size === 0) {
return { verified: false, issues: ['Flatpak file is empty'] };
}
// Flatpak files are large OCI archives, so we just verify file size and basic structure
// Detailed content inspection would require mounting or extracting the flatpak
if (stats.size < FLATPAK_MIN_SIZE_MB * 1024 * 1024) {
// Less than minimum size is suspicious
issues.push(
`Flatpak file seems too small (${(stats.size / 1024 / 1024).toFixed(2)} MB, expected at least ${FLATPAK_MIN_SIZE_MB} MB)`,
);
}
return {
verified: issues.length === 0,
issues,
size: stats.size,
};
}
/**
* Main verification function
*/
function main() {
const distDir = process.argv[2] || path.join(__dirname, '..', 'dist');
log('\n=== Linux Package Verification ===\n', colors.blue);
logInfo(`Distribution directory: ${distDir}\n`);
const packages = findPackages(distDir);
// Report found packages
if (packages.appImage) {
logSuccess(`Found AppImage: ${path.basename(packages.appImage)}`);
} else {
logWarning('No AppImage found');
}
if (packages.deb) {
logSuccess(`Found deb: ${path.basename(packages.deb)}`);
} else {
logWarning('No deb package found');
}
if (packages.flatpak) {
logSuccess(`Found Flatpak: ${path.basename(packages.flatpak)}`);
} else {
logWarning('No Flatpak package found');
}
if (!packages.appImage && !packages.deb && !packages.flatpak) {
logError('\nNo Linux packages found to verify!');
process.exit(1);
}
log('');
// Verify each package
const results = {};
if (packages.appImage) {
results.appImage = verifyAppImage(packages.appImage);
}
if (packages.deb) {
results.deb = verifyDeb(packages.deb);
}
if (packages.flatpak) {
results.flatpak = verifyFlatpak(packages.flatpak);
}
// Print results
log('\n=== Verification Results ===\n', colors.blue);
let hasFailures = false;
let hasCriticalSkips = false;
for (const [type, result] of Object.entries(results)) {
if (result.reason) {
if (result.critical) {
logError(`${type}: CRITICAL - SKIPPED (${result.reason})`);
hasCriticalSkips = true;
} else {
logWarning(`${type}: SKIPPED (${result.reason})`);
}
} else if (result.verified) {
logSuccess(`${type}: VERIFIED`);
if (result.fileCount) {
logInfo(` Files: ${result.fileCount}`);
}
if (result.size) {
logInfo(` Size: ${(result.size / 1024 / 1024).toFixed(2)} MB`);
}
} else {
logError(`${type}: FAILED`);
hasFailures = true;
for (const issue of result.issues || []) {
logError(` - ${issue}`);
}
}
}
log('');
if (hasFailures || hasCriticalSkips) {
logError('\n=== VERIFICATION FAILED ===\n');
if (hasFailures) {
log('Some packages are missing critical files. This will cause runtime errors.\n', colors.red);
}
if (hasCriticalSkips) {
log('Some packages could not be verified due to missing required tools.\n', colors.red);
log('Install required tools:\n', colors.red);
log(' - bsdtar: sudo apt-get install libarchive-tools\n', colors.red);
log(' - dpkg-deb: sudo apt-get install dpkg\n', colors.red);
}
process.exit(1);
} else {
logSuccess('\n=== ALL PACKAGES VERIFIED ===\n');
log('All Linux packages contain the required files.\n', colors.green);
process.exit(0);
}
}
// Only run main if this file is executed directly (not imported)
if (require.main === module) {
main();
}
// Export for testing
module.exports = {
CRITICAL_PACKAGES,
findPackages,
verifyFileList,
verifyAppImage,
verifyDeb,
verifyFlatpak,
};
@@ -1,533 +0,0 @@
/**
* Tests for verify-linux-packages.cjs
*
* These tests cover the core logic by calling the actual exported functions.
*/
import { describe, it, mock } from 'node:test';
import assert from 'node:assert';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const require = createRequire(import.meta.url);
// Get child_process and save original spawnSync
const childProcess = require('child_process');
const originalSpawnSync = childProcess.spawnSync;
// Helper to reload the verification module with a mocked spawnSync
function loadWithMockedSpawnSync(mockFn) {
// Set the mock before requiring
childProcess.spawnSync = mockFn;
// Clear the module cache
delete require.cache[require.resolve('./verify-linux-packages.cjs')];
// Re-require the module
return require('./verify-linux-packages.cjs');
}
function restoreSpawnSync() {
childProcess.spawnSync = originalSpawnSync;
delete require.cache[require.resolve('./verify-linux-packages.cjs')];
}
// Load the module normally for tests that don't need spawnSync mocking
const {
CRITICAL_PACKAGES,
findPackages,
verifyFileList,
verifyFlatpak,
} = require('./verify-linux-packages.cjs');
describe('verify-linux-packages', () => {
describe('package finding logic', () => {
it('should identify all three Linux package types', () => {
// Test that findPackages correctly identifies .AppImage, .deb, and .flatpak files
const mockFiles = [
'Auto-Claude-2.7.5-linux-x86_64.AppImage',
'auto-claude_2.7.5_amd64.deb',
'com.autoclaude.ui_2.7.5_linux_x86_64.flatpak',
'latest-mac.yml',
'latest.yml',
];
const distDir = '/test/dist';
// Mock fs.existsSync to return true (directory exists)
const existsSync = mock.method(fs, 'existsSync', mock.fn(() => true));
// Mock fs.readdirSync to return our test files
const readdirSync = mock.method(fs, 'readdirSync', mock.fn(() => mockFiles));
try {
const result = findPackages(distDir);
// Verify the expected results
assert.equal(result.appImage, '/test/dist/Auto-Claude-2.7.5-linux-x86_64.AppImage');
assert.equal(result.deb, '/test/dist/auto-claude_2.7.5_amd64.deb');
assert.equal(result.flatpak, '/test/dist/com.autoclaude.ui_2.7.5_linux_x86_64.flatpak');
} finally {
existsSync.mock.restore();
readdirSync.mock.restore();
}
});
it('should handle missing packages gracefully', () => {
// Test behavior when packages are missing
const mockFiles = ['latest-mac.yml', 'latest.yml'];
const distDir = '/test/dist';
const existsSync = mock.method(fs, 'existsSync', mock.fn(() => true));
const readdirSync = mock.method(fs, 'readdirSync', mock.fn(() => mockFiles));
try {
const result = findPackages(distDir);
assert.equal(result.appImage, null);
assert.equal(result.deb, null);
assert.equal(result.flatpak, null);
} finally {
existsSync.mock.restore();
readdirSync.mock.restore();
}
});
it('should handle missing dist directory', () => {
// Test behavior when dist directory doesn't exist
const distDir = '/test/dist';
const existsSync = mock.method(fs, 'existsSync', mock.fn(() => false));
try {
const result = findPackages(distDir);
// Should return empty packages object without error
assert.equal(result.appImage, null);
assert.equal(result.deb, null);
assert.equal(result.flatpak, null);
} finally {
existsSync.mock.restore();
}
});
it('should warn about duplicate packages', () => {
// Test behavior when multiple packages of same type exist
const mockFiles = [
'Auto-Claude-2.7.5-linux-x86_64.AppImage',
'Auto-Claude-2.7.5-linux-x86_64.AppImage', // Duplicate
'auto-claude_2.7.5_amd64.deb',
'auto-claude_2.7.5_amd64.deb', // Duplicate
'com.autoclaude.ui_2.7.5_linux_x86_64.flatpak',
];
const distDir = '/test/dist';
const existsSync = mock.method(fs, 'existsSync', mock.fn(() => true));
const readdirSync = mock.method(fs, 'readdirSync', mock.fn(() => mockFiles));
try {
const result = findPackages(distDir);
// Should still find packages, using first occurrence
assert.equal(result.appImage, '/test/dist/Auto-Claude-2.7.5-linux-x86_64.AppImage');
assert.equal(result.deb, '/test/dist/auto-claude_2.7.5_amd64.deb');
assert.equal(result.flatpak, '/test/dist/com.autoclaude.ui_2.7.5_linux_x86_64.flatpak');
} finally {
existsSync.mock.restore();
readdirSync.mock.restore();
}
});
});
describe('critical packages list', () => {
it('should contain all required Linux packages', () => {
assert.ok(CRITICAL_PACKAGES.includes('secretstorage'), 'secretstorage must be present for Linux OAuth');
assert.ok(CRITICAL_PACKAGES.includes('pydantic_core'), 'pydantic_core must be present');
assert.ok(CRITICAL_PACKAGES.includes('claude_agent_sdk'), 'claude_agent_sdk must be present');
assert.ok(CRITICAL_PACKAGES.includes('dotenv'), 'dotenv must be present');
});
});
describe('file content verification logic', () => {
it('should detect Python binary in file list', () => {
// AppImage format uses './' prefix
const mockFiles = [
'usr/bin/auto-claude',
'./resources/python',
'./resources/backend/core/client.py',
'./resources/python-site-packages/secretstorage/__init__.py',
'./resources/python-site-packages/pydantic_core/__init__.py',
'./resources/python-site-packages/claude_agent_sdk/__init__.py',
'./resources/python-site-packages/dotenv/__init__.py',
];
const result = verifyFileList(mockFiles, 'test-package');
assert.ok(result.verified, 'Should detect Python binary directory');
assert.equal(result.issues.length, 0);
});
it('should detect Python binary with trailing slashes', () => {
// Archive tools like bsdtar/dpkg-deb commonly output directories with trailing slashes
const mockFiles = [
'usr/bin/auto-claude',
'./resources/python/', // Trailing slash
'resources/backend/', // Trailing slash
'./resources/python-site-packages/secretstorage/__init__.py',
'./resources/python-site-packages/pydantic_core/__init__.py',
'./resources/python-site-packages/claude_agent_sdk/__init__.py',
'./resources/python-site-packages/dotenv/__init__.py',
];
const result = verifyFileList(mockFiles, 'test-package');
assert.ok(result.verified, 'Should detect Python binary directory with trailing slash');
assert.equal(result.issues.length, 0);
});
it('should detect backend directory in file list', () => {
const mockFiles = [
'usr/bin/auto-claude',
'./resources/python',
'./resources/backend/core/client.py',
'./resources/python-site-packages/secretstorage/__init__.py',
'./resources/python-site-packages/pydantic_core/__init__.py',
'./resources/python-site-packages/claude_agent_sdk/__init__.py',
'./resources/python-site-packages/dotenv/__init__.py',
];
const result = verifyFileList(mockFiles, 'test-package');
assert.ok(result.verified, 'Should detect backend directory');
assert.equal(result.issues.length, 0);
});
it('should detect critical Python packages', () => {
const mockFiles = [
'usr/bin/auto-claude',
'./resources/python',
'./resources/backend/core/client.py',
'./resources/python-site-packages/secretstorage/__init__.py',
'./resources/python-site-packages/pydantic_core/__init__.py',
'./resources/python-site-packages/claude_agent_sdk/__init__.py',
'./resources/python-site-packages/dotenv/__init__.py',
];
const result = verifyFileList(mockFiles, 'test-package');
assert.ok(result.verified, 'Should detect all critical packages');
assert.equal(result.issues.length, 0);
});
it('should report missing packages', () => {
const mockFiles = [
'usr/bin/auto-claude',
'./resources/python',
'./resources/backend/core/client.py',
'./resources/python-site-packages/dotenv/__init__.py',
];
const result = verifyFileList(mockFiles, 'test-package');
assert.ok(!result.verified, 'Should fail verification');
assert.ok(result.issues.includes('Python package not found: secretstorage'));
assert.ok(result.issues.includes('Python package not found: pydantic_core'));
assert.ok(result.issues.includes('Python package not found: claude_agent_sdk'));
assert.ok(!result.issues.some((i) => i.includes('dotenv')));
});
it('should not match python-site-packages when looking for python binary', () => {
const mockFiles = [
'usr/bin/auto-claude',
'./resources/python-site-packages/secretstorage/__init__.py',
'./resources/python-site-packages/pydantic_core/__init__.py',
'./resources/python-site-packages/claude_agent_sdk/__init__.py',
'./resources/python-site-packages/dotenv/__init__.py',
// Note: NO './resources/python' entry
];
const result = verifyFileList(mockFiles, 'test-package');
assert.ok(!result.verified, 'Should fail verification');
assert.ok(result.issues.some((i) => i.includes('Python binary directory not found')));
});
it('should not match unrelated paths when looking for packages', () => {
const mockFiles = [
'usr/bin/auto-claude',
'./resources/python',
'./resources/backend/core/client.py',
// These paths end with package names but are NOT under python-site-packages
'./some/other/path/secretstorage/file.txt',
'./unrelated/dotenv/config',
'./another/pydantic_core/standalone/__init__.py',
];
const result = verifyFileList(mockFiles, 'test-package');
assert.ok(!result.verified, 'Should fail verification');
assert.ok(result.issues.some((i) => i.includes('Python package not found: secretstorage')));
});
});
describe('Flatpak file validation', () => {
it('should reject empty Flatpak files', () => {
const flatpakPath = '/test/app.flatpak';
const mockStat = { size: 0 };
// Mock fs.existsSync to return true
const existsSync = mock.method(fs, 'existsSync', mock.fn(() => true));
// Mock fs.statSync to return empty file stats
const statSync = mock.method(fs, 'statSync', mock.fn(() => mockStat));
try {
const result = verifyFlatpak(flatpakPath);
assert.ok(!result.verified, 'Should reject empty Flatpak files');
assert.ok(result.issues.includes('Flatpak file is empty'));
} finally {
existsSync.mock.restore();
statSync.mock.restore();
}
});
it('should warn about suspiciously small Flatpak files', () => {
const flatpakPath = '/test/app.flatpak';
const mockStat = { size: 10 * 1024 * 1024 }; // 10 MB
const existsSync = mock.method(fs, 'existsSync', mock.fn(() => true));
const statSync = mock.method(fs, 'statSync', mock.fn(() => mockStat));
try {
const result = verifyFlatpak(flatpakPath);
assert.ok(!result.verified, 'Should fail verification for too-small files');
assert.ok(result.issues.some((i) => i.includes('too small')));
} finally {
existsSync.mock.restore();
statSync.mock.restore();
}
});
it('should accept reasonable Flatpak file sizes', () => {
const flatpakPath = '/test/app.flatpak';
const mockStat = { size: 133 * 1024 * 1024 }; // 133 MB (typical size)
const existsSync = mock.method(fs, 'existsSync', mock.fn(() => true));
const statSync = mock.method(fs, 'statSync', mock.fn(() => mockStat));
try {
const result = verifyFlatpak(flatpakPath);
assert.ok(result.verified, 'Should accept reasonable Flatpak file sizes');
assert.equal(result.issues.length, 0);
} finally {
existsSync.mock.restore();
statSync.mock.restore();
}
});
it('should handle non-existent Flatpak files', () => {
const flatpakPath = '/test/nonexistent.flatpak';
const existsSync = mock.method(fs, 'existsSync', mock.fn(() => false));
try {
const result = verifyFlatpak(flatpakPath);
assert.ok(!result.verified, 'Should reject non-existent Flatpak files');
assert.ok(result.issues.includes('Flatpak file does not exist'));
} finally {
existsSync.mock.restore();
}
});
});
describe('AppImage verification', () => {
const appImagePath = '/test/Auto-Claude-2.7.5-linux-x86_64.AppImage';
it('should successfully verify valid AppImage', () => {
const mockFiles = [
'./resources/python',
'./resources/backend/core/client.py',
'./resources/python-site-packages/secretstorage/__init__.py',
'./resources/python-site-packages/pydantic_core/__init__.py',
'./resources/python-site-packages/claude_agent_sdk/__init__.py',
'./resources/python-site-packages/dotenv/__init__.py',
];
const mockFn = (cmd, args) => {
if (cmd === 'which' && args[0] === 'bsdtar') {
return { status: 0, stdout: '/usr/bin/bsdtar', stderr: '' };
}
if (cmd === 'bsdtar') {
return { status: 0, stdout: mockFiles.join('\n'), stderr: '', error: undefined };
}
return { status: 1, stderr: 'Unknown command' };
};
const { verifyAppImage } = loadWithMockedSpawnSync(mockFn);
const result = verifyAppImage(appImagePath);
restoreSpawnSync();
assert.ok(result.verified, 'Should verify valid AppImage');
assert.equal(result.issues.length, 0);
assert.equal(result.fileCount, mockFiles.length);
});
it('should handle spawn errors (OS-level failures)', () => {
const spawnError = new Error('EACCES: permission denied');
const mockFn = (cmd) => {
if (cmd === 'which') {
return { status: 0, stdout: '/usr/bin/bsdtar', stderr: '', error: undefined };
}
if (cmd === 'bsdtar') {
return { status: null, stdout: '', stderr: '', error: spawnError };
}
return { status: 1, stderr: 'Unknown command' };
};
const { verifyAppImage } = loadWithMockedSpawnSync(mockFn);
const result = verifyAppImage(appImagePath);
restoreSpawnSync();
assert.ok(!result.verified, 'Should fail on spawn error');
assert.ok(result.reason.includes('Command execution failed'));
assert.ok(result.reason.includes('permission denied'));
});
it('should handle non-zero exit status from bsdtar', () => {
const mockFn = (cmd) => {
if (cmd === 'which') {
return { status: 0, stdout: '/usr/bin/bsdtar', stderr: '', error: undefined };
}
if (cmd === 'bsdtar') {
return { status: 1, stdout: '', stderr: 'bsdtar: Error: Not an AppImage file', error: undefined };
}
return { status: 1, stderr: 'Unknown command' };
};
const { verifyAppImage } = loadWithMockedSpawnSync(mockFn);
const result = verifyAppImage(appImagePath);
restoreSpawnSync();
assert.ok(!result.verified, 'Should fail on non-zero status');
assert.equal(result.reason, 'Failed to extract file list');
});
it('should handle missing bsdtar tool', () => {
const mockFn = (cmd) => {
if (cmd === 'which') {
return { status: 1, stdout: '', stderr: '', error: undefined };
}
return { status: 1, stderr: 'Unknown command' };
};
const { verifyAppImage } = loadWithMockedSpawnSync(mockFn);
const result = verifyAppImage(appImagePath);
restoreSpawnSync();
assert.ok(!result.verified, 'Should fail when bsdtar is missing');
assert.equal(result.reason, 'bsdtar not available');
assert.ok(result.critical, 'Should be marked as critical');
});
});
describe('deb package verification', () => {
const debPath = '/test/auto-claude_2.7.5_amd64.deb';
it('should successfully verify valid deb package', () => {
const mockFiles = [
'drwxr-xr-x root/root 0 2025-01-01 00:00 ./resources/python',
'-rw-r--r-- root/root 1234 2025-01-01 00:00 ./resources/backend/core/client.py',
'-rw-r--r-- root/root 567 2025-01-01 00:00 ./resources/python-site-packages/secretstorage/__init__.py',
'-rw-r--r-- root/root 789 2025-01-01 00:00 ./resources/python-site-packages/pydantic_core/__init__.py',
'-rw-r--r-- root/root 456 2025-01-01 00:00 ./resources/python-site-packages/claude_agent_sdk/__init__.py',
'-rw-r--r-- root/root 321 2025-01-01 00:00 ./resources/python-site-packages/dotenv/__init__.py',
];
const mockFn = (cmd, args) => {
if (cmd === 'which' && args[0] === 'dpkg-deb') {
return { status: 0, stdout: '/usr/bin/dpkg-deb', stderr: '', error: undefined };
}
if (cmd === 'dpkg-deb') {
return { status: 0, stdout: mockFiles.join('\n'), stderr: '', error: undefined };
}
return { status: 1, stderr: 'Unknown command' };
};
const { verifyDeb } = loadWithMockedSpawnSync(mockFn);
const result = verifyDeb(debPath);
restoreSpawnSync();
assert.ok(result.verified, 'Should verify valid deb package');
assert.equal(result.issues.length, 0);
assert.equal(result.fileCount, mockFiles.length);
});
it('should handle spawn errors (OS-level failures)', () => {
const spawnError = new Error('ENOMEM: Cannot allocate memory');
const mockFn = (cmd) => {
if (cmd === 'which') {
return { status: 0, stdout: '/usr/bin/dpkg-deb', stderr: '', error: undefined };
}
if (cmd === 'dpkg-deb') {
return { status: null, stdout: '', stderr: '', error: spawnError };
}
return { status: 1, stderr: 'Unknown command' };
};
const { verifyDeb } = loadWithMockedSpawnSync(mockFn);
const result = verifyDeb(debPath);
restoreSpawnSync();
assert.ok(!result.verified, 'Should fail on spawn error');
assert.ok(result.reason.includes('Command execution failed'));
assert.ok(result.reason.includes('Cannot allocate memory'));
});
it('should handle non-zero exit status from dpkg-deb', () => {
const mockFn = (cmd) => {
if (cmd === 'which') {
return { status: 0, stdout: '/usr/bin/dpkg-deb', stderr: '', error: undefined };
}
if (cmd === 'dpkg-deb') {
return { status: 2, stdout: '', stderr: 'dpkg-deb: error: cannot read archive', error: undefined };
}
return { status: 1, stderr: 'Unknown command' };
};
const { verifyDeb } = loadWithMockedSpawnSync(mockFn);
const result = verifyDeb(debPath);
restoreSpawnSync();
assert.ok(!result.verified, 'Should fail on non-zero status');
assert.equal(result.reason, 'Failed to extract file list');
});
it('should handle missing dpkg-deb tool', () => {
const mockFn = (cmd) => {
if (cmd === 'which') {
return { status: 1, stdout: '', stderr: '', error: undefined };
}
return { status: 1, stderr: 'Unknown command' };
};
const { verifyDeb } = loadWithMockedSpawnSync(mockFn);
const result = verifyDeb(debPath);
restoreSpawnSync();
assert.ok(!result.verified, 'Should fail when dpkg-deb is missing');
assert.equal(result.reason, 'dpkg-deb not available');
assert.ok(result.critical, 'Should be marked as critical');
});
});
});
@@ -396,7 +396,7 @@ describe('E2E Smoke Tests', () => {
statusHandler({}, 'task-001', 'in_progress');
}
expect(statusCallback).toHaveBeenCalledWith('task-001', 'in_progress', undefined, undefined);
expect(statusCallback).toHaveBeenCalledWith('task-001', 'in_progress', undefined);
// Cleanup listeners
cleanupProgress();
@@ -656,7 +656,6 @@ describe('E2E Smoke Tests', () => {
index + 1,
'task-001',
status,
undefined,
undefined
);
});
@@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdirSync, rmSync, existsSync, mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import path from 'path';
import type { ClaudeProfile, IPCResult, } from '../../shared/types';
import type { ClaudeProfile, IPCResult, TerminalCreateOptions } from '../../shared/types';
// Test directories - use secure temp directory with random suffix
let TEST_DIR: string;
@@ -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', undefined);
expect(logHandler).toHaveBeenCalledWith('task-1', 'Test log output\n');
}, 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', undefined);
expect(logHandler).toHaveBeenCalledWith('task-1', 'Progress: 50%\n');
}, 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, process type, and optional projectId
expect(exitHandler).toHaveBeenCalledWith('task-1', 0, expect.any(String), undefined);
// Exit event includes taskId, exit code, and process type
expect(exitHandler).toHaveBeenCalledWith('task-1', 0, expect.any(String));
}, 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', undefined);
expect(errorHandler).toHaveBeenCalledWith('task-1', 'Spawn failed');
}, 30000); // Increase timeout for Windows CI (dynamic imports are slow)
it('should kill task and remove from tracking', async () => {
@@ -395,15 +395,16 @@ describe('Subprocess Spawn Integration', () => {
expect(manager.getRunningTasks()).toHaveLength(2);
}, { timeout: 5000 });
// Wait for both spawn promises to fully resolve — this ensures the exit
// handlers are attached to mockProcess. A single setImmediate is NOT enough
// on Windows CI because spawnProcess has async operations (getAPIProfileEnv,
// getRecoveryCoordinator) between addProcess and the .on('exit') listener.
// Waiting for the promises guarantees spawnProcess has completed fully.
await Promise.allSettled([promise1, promise2]);
// Wait for spawn to complete (ensures exit handlers are attached)
await new Promise(resolve => setImmediate(resolve));
// Both tasks share the same mockProcess, so one emit fires both exit handlers
// Emit exit events - both tasks share the same mockProcess, so both handlers fire
// We emit twice to ensure both task handlers receive exit events
mockProcess.emit('exit', 0);
mockProcess.emit('exit', 0);
// Wait for promises to settle
await Promise.allSettled([promise1, promise2]);
// Wait for tasks to be removed from tracking (cleanup may be async)
await vi.waitFor(() => {
@@ -241,12 +241,12 @@ describe('Task Lifecycle Integration', () => {
)?.[1];
if (eventHandler) {
eventHandler({}, 'task-001', 'spec_complete', undefined, undefined);
eventHandler({}, 'task-001', 'spec_complete');
}
// Verify callback was invoked with correct parameters (taskId, status, projectId, reviewReason)
// Note: projectId/reviewReason are optional and undefined when not provided
expect(callback).toHaveBeenCalledWith('task-001', 'spec_complete', undefined, undefined);
// Verify callback was invoked with correct parameters (taskId, status, projectId)
// Note: projectId is optional and undefined when not provided
expect(callback).toHaveBeenCalledWith('task-001', 'spec_complete', undefined);
});
it('should emit task:progress event with updated plan during spec creation', async () => {
@@ -379,4 +379,4 @@ describe('Task Lifecycle Integration', () => {
});
});
});
});
@@ -69,7 +69,7 @@ vi.mock('child_process', () => {
// so when tests call vi.mocked(execFileSync).mockReturnValue(), it affects execSync too
const sharedSyncMock = vi.fn();
const mockExecFile = vi.fn((_cmd: unknown, _args: unknown, _options: unknown, callback: unknown) => {
const mockExecFile = vi.fn((cmd: unknown, args: unknown, options: unknown, callback: unknown) => {
// Return a minimal ChildProcess-like object
const childProcess = {
stdout: { on: vi.fn() },
@@ -86,7 +86,7 @@ const mockExecFile = vi.fn((_cmd: unknown, _args: unknown, _options: unknown, ca
return childProcess as unknown as import('child_process').ChildProcess;
});
const mockExec = vi.fn((_cmd: unknown, _options: unknown, callback: unknown) => {
const mockExec = vi.fn((cmd: unknown, options: unknown, callback: unknown) => {
// Return a minimal ChildProcess-like object
const childProcess = {
stdout: { on: vi.fn() },
@@ -61,17 +61,15 @@ import path from 'path';
import { isValidConfigDir } from '../utils/config-path-validator';
describe('isValidConfigDir - Security Validation', () => {
let _originalHomedir: string;
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
let originalHomedir: string;
let consoleWarnSpy: any;
beforeEach(() => {
// Store original homedir for restoration
_originalHomedir = os.homedir();
originalHomedir = os.homedir();
// Spy on console.warn to suppress warning output during tests
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {
/* intentionally empty - suppress console output during tests */
});
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
@@ -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", "dark");
expect(data).toHaveProperty("theme", "system");
});
});
@@ -689,8 +689,7 @@ describe("IPC Handlers", { timeout: 30000 }, () => {
"task:statusChange",
"task-1",
"human_review",
expect.any(String), // projectId for multi-project filtering
"errors"
expect.any(String) // projectId for multi-project filtering
);
});
});
@@ -292,7 +292,6 @@ describe('ProjectStore', () => {
feature: 'Test Feature',
workflow_type: 'feature',
services_involved: [],
status: 'in_progress',
phases: [
{
phase: 1,
@@ -339,7 +338,6 @@ describe('ProjectStore', () => {
feature: 'Pending Feature',
workflow_type: 'feature',
services_involved: [],
status: 'backlog',
phases: [
{
phase: 1,
@@ -379,7 +377,6 @@ describe('ProjectStore', () => {
feature: 'Complete Feature',
workflow_type: 'feature',
services_involved: [],
status: 'ai_review',
phases: [
{
phase: 1,
@@ -411,7 +408,7 @@ describe('ProjectStore', () => {
expect(tasks[0].status).toBe('ai_review');
});
it('should determine status as human_review when plan status is human_review', async () => {
it('should determine status as human_review when QA report rejected', async () => {
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '004-rejected');
mkdirSync(specsDir, { recursive: true });
@@ -419,8 +416,6 @@ describe('ProjectStore', () => {
feature: 'Rejected Feature',
workflow_type: 'feature',
services_involved: [],
status: 'human_review',
reviewReason: 'qa_rejected',
phases: [
{
phase: 1,
@@ -442,6 +437,11 @@ describe('ProjectStore', () => {
JSON.stringify(plan)
);
writeFileSync(
path.join(specsDir, 'qa_report.md'),
'# QA Report\n\nStatus: REJECTED\n'
);
const { ProjectStore } = await import('../project-store');
const store = new ProjectStore();
@@ -451,7 +451,8 @@ describe('ProjectStore', () => {
expect(tasks[0].status).toBe('human_review');
});
it('should determine reviewReason from plan when status is human_review', async () => {
it('should determine status as human_review when QA report approved', async () => {
// QA approval moves task to human_review (user needs to review before marking done)
const specsDir = path.join(TEST_PROJECT_PATH, '.auto-claude', 'specs', '005-approved');
mkdirSync(specsDir, { recursive: true });
@@ -459,8 +460,6 @@ describe('ProjectStore', () => {
feature: 'Approved Feature',
workflow_type: 'feature',
services_involved: [],
status: 'human_review',
reviewReason: 'completed',
phases: [
{
phase: 1,
@@ -482,6 +481,11 @@ describe('ProjectStore', () => {
JSON.stringify(plan)
);
writeFileSync(
path.join(specsDir, 'qa_report.md'),
'# QA Report\n\nStatus: APPROVED\n'
);
const { ProjectStore } = await import('../project-store');
const store = new ProjectStore();
@@ -93,7 +93,7 @@ describe('PythonEnvManager', () => {
const sitePackagesPath = 'C:\\test\\site-packages';
// Access private property for testing
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
(manager as any).sitePackagesPath = sitePackagesPath;
const env = manager.getPythonEnv();
@@ -106,7 +106,7 @@ describe('PythonEnvManager', () => {
const sitePackagesPath = 'C:\\test\\site-packages';
// Access private property for testing
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
(manager as any).sitePackagesPath = sitePackagesPath;
const env = manager.getPythonEnv();
@@ -125,7 +125,7 @@ describe('PythonEnvManager', () => {
const sitePackagesPath = '/test/site-packages';
// Access private property for testing
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
(manager as any).sitePackagesPath = sitePackagesPath;
const env = manager.getPythonEnv();
@@ -144,7 +144,7 @@ describe('PythonEnvManager', () => {
const sitePackagesPath = 'C:\\test\\site-packages';
// Access private property for testing
(manager as unknown as { sitePackagesPath: string }).sitePackagesPath = sitePackagesPath;
(manager as any).sitePackagesPath = sitePackagesPath;
// Save and clear existing PATH, then set lowercase 'Path'
// This simulates a Windows environment where the system has 'Path' instead of 'PATH'
@@ -507,7 +507,7 @@ describe('Rate Limit Edge Cases', () => {
];
for (const msg of falsePositives) {
const _result = detectRateLimit(msg);
const result = detectRateLimit(msg);
// Note: Some may still match secondary indicators - that's intentional
// The primary pattern should NOT match these
const primaryPattern = /Limit reached\s*[·•]\s*resets/i;
@@ -609,392 +609,3 @@ Please authenticate and try again.`;
});
});
});
describe('Billing Failure Detection', () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('detectBillingFailure - spec appendix messages', () => {
it('should detect "Your credit balance is too low to access the Anthropic API"', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const result = detectBillingFailure('Your credit balance is too low to access the Anthropic API');
expect(result.isBillingFailure).toBe(true);
expect(result.failureType).toBe('insufficient_credits');
expect(result.message).toBeDefined();
});
it('should detect "insufficient credits"', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const result = detectBillingFailure('insufficient credits');
expect(result.isBillingFailure).toBe(true);
expect(result.failureType).toBe('insufficient_credits');
});
it('should detect "billing error"', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const result = detectBillingFailure('billing error');
expect(result.isBillingFailure).toBe(true);
expect(result.failureType).toBe('payment_required');
});
it('should detect "extra_usage exceeded"', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const result = detectBillingFailure('extra_usage exceeded');
expect(result.isBillingFailure).toBe(true);
expect(result.failureType).toBe('insufficient_credits');
});
it('should detect standalone "extra_usage"', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const result = detectBillingFailure('extra_usage');
expect(result.isBillingFailure).toBe(true);
});
it('should detect "payment required"', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const result = detectBillingFailure('payment required');
expect(result.isBillingFailure).toBe(true);
expect(result.failureType).toBe('payment_required');
});
it('should detect "subscription expired"', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const result = detectBillingFailure('subscription expired');
expect(result.isBillingFailure).toBe(true);
expect(result.failureType).toBe('subscription_inactive');
});
it('should detect credit balance variations', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const messages = [
'credit balance is insufficient',
'credit balance is empty',
'credit balance is zero',
'credit balance is exhausted',
'credit balance is too low',
];
for (const msg of messages) {
const result = detectBillingFailure(msg);
expect(result.isBillingFailure).toBe(true);
}
});
});
describe('detectBillingFailure - negative cases', () => {
it('should return false for normal output', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const normalMessages = [
'Task completed successfully',
'Processing 100 files',
'Build succeeded',
'Deploying to production',
'',
];
for (const msg of normalMessages) {
const result = detectBillingFailure(msg);
expect(result.isBillingFailure).toBe(false);
}
});
it('should NOT detect rate limit errors as billing failures', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const rateLimitMessages = [
'Limit reached · resets Dec 17 at 6am',
'rate limit exceeded',
'too many requests',
];
for (const msg of rateLimitMessages) {
const result = detectBillingFailure(msg);
expect(result.isBillingFailure).toBe(false);
}
});
it('should NOT detect auth errors as billing failures', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const authMessages = [
'authentication required',
'not authenticated',
'unauthorized',
'invalid token',
'session expired',
];
for (const msg of authMessages) {
const result = detectBillingFailure(msg);
expect(result.isBillingFailure).toBe(false);
}
});
it('should NOT match "line 402" as billing failure (false positive check)', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const falsePositives = [
'line 402',
'Processing line 402 of 1000',
'Found 4020 records',
'Error on line 402',
'The user has 402 files',
];
for (const msg of falsePositives) {
const result = detectBillingFailure(msg);
expect(result.isBillingFailure).toBe(false);
}
});
});
describe('detectBillingFailure - 402 with proper context', () => {
it('should detect "HTTP 402 Payment Required"', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const result = detectBillingFailure('HTTP 402 Payment Required');
expect(result.isBillingFailure).toBe(true);
});
it('should detect "API Error: 402"', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const result = detectBillingFailure('API Error: 402');
expect(result.isBillingFailure).toBe(true);
});
it('should detect "status: 402"', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const result = detectBillingFailure('status: 402');
expect(result.isBillingFailure).toBe(true);
});
it('should detect "error 402"', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const result = detectBillingFailure('error 402');
expect(result.isBillingFailure).toBe(true);
});
it('should detect "402 payment required" (lowercase)', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const result = detectBillingFailure('402 payment required');
expect(result.isBillingFailure).toBe(true);
});
});
describe('isBillingFailureError', () => {
it('should return true for billing failure errors', async () => {
const { isBillingFailureError } = await import('../rate-limit-detector');
expect(isBillingFailureError('credit balance is too low')).toBe(true);
expect(isBillingFailureError('insufficient credits')).toBe(true);
expect(isBillingFailureError('billing error')).toBe(true);
expect(isBillingFailureError('extra_usage exceeded')).toBe(true);
});
it('should return false for non-billing errors', async () => {
const { isBillingFailureError } = await import('../rate-limit-detector');
expect(isBillingFailureError('Task completed')).toBe(false);
expect(isBillingFailureError('')).toBe(false);
expect(isBillingFailureError('rate limit exceeded')).toBe(false);
});
});
describe('classifyBillingFailureType via detectBillingFailure', () => {
it('should classify credit-related failures as insufficient_credits', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const creditMessages = [
'Your credit balance is too low',
'insufficient credits',
'out of credits',
'extra_usage exceeded',
];
for (const msg of creditMessages) {
const result = detectBillingFailure(msg);
expect(result.isBillingFailure).toBe(true);
expect(result.failureType).toBe('insufficient_credits');
}
});
it('should classify payment failures as payment_required', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const paymentMessages = [
'payment required',
'billing error',
'billing failure',
];
for (const msg of paymentMessages) {
const result = detectBillingFailure(msg);
expect(result.isBillingFailure).toBe(true);
expect(result.failureType).toBe('payment_required');
}
});
it('should classify subscription failures as subscription_inactive', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const subscriptionMessages = [
'subscription expired',
'subscription inactive',
'subscription cancelled',
'account suspended',
];
for (const msg of subscriptionMessages) {
const result = detectBillingFailure(msg);
expect(result.isBillingFailure).toBe(true);
expect(result.failureType).toBe('subscription_inactive');
}
});
});
describe('detectBillingFailure - result structure', () => {
it('should include profile ID in result', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const result = detectBillingFailure('credit balance is too low', 'custom-profile');
expect(result.isBillingFailure).toBe(true);
expect(result.profileId).toBe('custom-profile');
});
it('should use active profile ID when not specified', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const result = detectBillingFailure('credit balance is too low');
expect(result.isBillingFailure).toBe(true);
expect(result.profileId).toBe('test-profile-id');
});
it('should include original error in result', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const output = 'Error: credit balance is too low to access the API';
const result = detectBillingFailure(output);
expect(result.isBillingFailure).toBe(true);
expect(result.originalError).toBe(output);
});
it('should include user-friendly message', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const result = detectBillingFailure('credit balance is too low');
expect(result.isBillingFailure).toBe(true);
expect(result.message).toContain('credit');
expect(result.message).toContain('Settings');
});
});
describe('cross-detection tests', () => {
it('should not detect billing errors as auth failures', async () => {
const { detectAuthFailure } = await import('../rate-limit-detector');
const billingMessages = [
'credit balance is too low',
'insufficient credits',
'billing error',
'extra_usage exceeded',
'payment required',
];
for (const msg of billingMessages) {
const result = detectAuthFailure(msg);
expect(result.isAuthFailure).toBe(false);
}
});
it('should not detect billing errors as rate limits', async () => {
const { detectRateLimit } = await import('../rate-limit-detector');
const billingMessages = [
'credit balance is too low',
'insufficient credits',
'billing error',
'extra_usage exceeded',
];
for (const msg of billingMessages) {
const result = detectRateLimit(msg);
expect(result.isRateLimited).toBe(false);
}
});
});
describe('edge cases', () => {
it('should handle case-insensitive billing messages', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const testCases = [
'CREDIT BALANCE IS TOO LOW',
'Credit Balance Is Too Low',
'BILLING ERROR',
'Insufficient Credits',
'EXTRA_USAGE EXCEEDED',
];
for (const output of testCases) {
const result = detectBillingFailure(output);
expect(result.isBillingFailure).toBe(true);
}
});
it('should handle multiline output with billing failure', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const output = `Starting API call...
Processing request...
Error: Your credit balance is too low to access the Anthropic API
Please add credits to continue.`;
const result = detectBillingFailure(output);
expect(result.isBillingFailure).toBe(true);
});
it('should handle JSON error responses with billing failure', async () => {
const { detectBillingFailure } = await import('../rate-limit-detector');
const output = '{"type":"billing_error","message":"Insufficient credits"}';
const result = detectBillingFailure(output);
expect(result.isBillingFailure).toBe(true);
});
});
});
@@ -243,31 +243,24 @@ describe('SETTINGS_CLAUDE_CODE_GET_ONBOARDING_STATUS handler', () => {
// Get the mocked functions (vitest stores the implementation)
const { existsSync, readFileSync } = await import('fs');
// Cast to vi.Mock for accessing mock methods
type MockFn = ReturnType<typeof vi.fn>;
const existsSyncMock = existsSync as unknown as MockFn;
const readFileSyncMock = readFileSync as unknown as MockFn;
// Save original mock implementations to restore after test
type ExistsSyncFn = (path: string) => boolean;
type ReadFileSyncFn = (path: string) => string;
const originalExistsSync: ExistsSyncFn = (existsSyncMock.getMockImplementation() as ExistsSyncFn | undefined) ?? (() => false);
const originalReadFileSync: ReadFileSyncFn = (readFileSyncMock.getMockImplementation() as ReadFileSyncFn | undefined) ?? (() => '');
const originalExistsSync = (existsSync as any).getMockImplementation();
const originalReadFileSync = (readFileSync as any).getMockImplementation();
// Override existsSync to make file appear to exist
existsSyncMock.mockImplementation((path: string) => {
(existsSync as any).mockImplementation((path: string) => {
if (path === claudeJsonPath) {
return true; // File appears to exist
}
return originalExistsSync(path);
return originalExistsSync ? originalExistsSync(path) : false;
});
// Override readFileSync to throw error for our specific file
readFileSyncMock.mockImplementation((path: string) => {
(readFileSync as any).mockImplementation((path: string) => {
if (path === claudeJsonPath) {
throw new Error('EACCES: permission denied, open \'' + path + '\'');
}
return originalReadFileSync(path);
return originalReadFileSync ? originalReadFileSync(path) : '';
});
const result = await onboardingStatusHandler({}, null) as {
@@ -279,8 +272,8 @@ describe('SETTINGS_CLAUDE_CODE_GET_ONBOARDING_STATUS handler', () => {
expect(result.data?.hasCompletedOnboarding).toBe(false);
// Restore original mocks
existsSyncMock.mockImplementation(originalExistsSync);
readFileSyncMock.mockImplementation(originalReadFileSync);
(existsSync as any).mockImplementation(originalExistsSync);
(readFileSync as any).mockImplementation(originalReadFileSync);
});
});
});
@@ -1,521 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { TaskStateManager } from '../task-state-manager';
import type { Task, Project } from '../../shared/types';
// Mock dependencies
vi.mock('../ipc-handlers/utils', () => ({
safeSendToRenderer: vi.fn()
}));
vi.mock('../ipc-handlers/task/plan-file-utils', () => ({
getPlanPath: vi.fn(() => '/mock/path/implementation_plan.json'),
persistPlanStatusAndReasonSync: vi.fn()
}));
vi.mock('../worktree-paths', () => ({
findTaskWorktree: vi.fn(() => null)
}));
vi.mock('fs', () => ({
existsSync: vi.fn(() => false)
}));
// Create mock task and project
function createMockTask(overrides: Partial<Task> = {}): Task {
return {
id: 'test-task-id',
specId: '001-test-spec',
projectId: 'test-project-id',
title: 'Test Task',
description: 'Test description',
status: 'backlog',
subtasks: [],
logs: [],
createdAt: new Date(),
updatedAt: new Date(),
...overrides
};
}
function createMockProject(overrides: Partial<Project> = {}): Project {
return {
id: 'test-project-id',
name: 'Test Project',
path: '/mock/project/path',
createdAt: new Date().toISOString(),
lastOpenedAt: new Date().toISOString(),
...overrides
} as Project;
}
describe('TaskStateManager', () => {
let manager: TaskStateManager;
let mockTask: Task;
let mockProject: Project;
beforeEach(() => {
manager = new TaskStateManager();
mockTask = createMockTask();
mockProject = createMockProject();
vi.clearAllMocks();
});
afterEach(() => {
manager.clearAllTasks();
});
describe('handleTaskEvent', () => {
it('should accept events with increasing sequence numbers', () => {
const event1 = {
type: 'PLANNING_STARTED',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-1',
sequence: 0
};
const event2 = {
type: 'PLANNING_COMPLETE',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-2',
sequence: 1,
hasSubtasks: true,
subtaskCount: 1,
requireReviewBeforeCoding: false
};
const result1 = manager.handleTaskEvent(mockTask.id, event1, mockTask, mockProject);
const result2 = manager.handleTaskEvent(mockTask.id, event2, mockTask, mockProject);
expect(result1).toBe(true);
expect(result2).toBe(true);
});
it('should reject events with stale sequence numbers', () => {
const event1 = {
type: 'PLANNING_STARTED',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-1',
sequence: 5
};
const event2 = {
type: 'PLANNING_COMPLETE',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-2',
sequence: 3, // Older than event1
hasSubtasks: true,
subtaskCount: 1,
requireReviewBeforeCoding: false
};
const result1 = manager.handleTaskEvent(mockTask.id, event1, mockTask, mockProject);
const result2 = manager.handleTaskEvent(mockTask.id, event2, mockTask, mockProject);
expect(result1).toBe(true);
expect(result2).toBe(false); // Should be rejected
});
it('should accept events with equal sequence numbers (edge case)', () => {
// This handles reload scenarios where we might see the same sequence
const event1 = {
type: 'PLANNING_STARTED',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-1',
sequence: 5
};
const event2 = {
type: 'PLANNING_COMPLETE',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-2',
sequence: 5, // Same as event1
hasSubtasks: true,
subtaskCount: 1,
requireReviewBeforeCoding: false
};
const result1 = manager.handleTaskEvent(mockTask.id, event1, mockTask, mockProject);
const result2 = manager.handleTaskEvent(mockTask.id, event2, mockTask, mockProject);
expect(result1).toBe(true);
expect(result2).toBe(true); // Should be accepted (>= comparison)
});
});
describe('handleUiEvent', () => {
it('should send PLAN_APPROVED event correctly', () => {
// First, set up the task in plan_review state
const planningEvent = {
type: 'PLANNING_STARTED',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-1',
sequence: 0
};
const planCompleteEvent = {
type: 'PLANNING_COMPLETE',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-2',
sequence: 1,
hasSubtasks: true,
subtaskCount: 1,
requireReviewBeforeCoding: true // This will cause plan_review state
};
manager.handleTaskEvent(mockTask.id, planningEvent, mockTask, mockProject);
manager.handleTaskEvent(mockTask.id, planCompleteEvent, mockTask, mockProject);
// Now send PLAN_APPROVED
manager.handleUiEvent(mockTask.id, { type: 'PLAN_APPROVED' }, mockTask, mockProject);
// The actor should now be in 'coding' state
// We can't easily check the state directly, but we can verify no errors occurred
});
it('should send USER_STOPPED event correctly', () => {
const event = {
type: 'PLANNING_STARTED',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-1',
sequence: 0
};
manager.handleTaskEvent(mockTask.id, event, mockTask, mockProject);
manager.handleUiEvent(mockTask.id, { type: 'USER_STOPPED', hasPlan: false }, mockTask, mockProject);
// Should not throw
});
});
describe('handleManualStatusChange', () => {
it('should handle done status', () => {
const result = manager.handleManualStatusChange(mockTask.id, 'done', mockTask, mockProject);
expect(result).toBe(true);
});
it('should handle pr_created status', () => {
const taskWithPrUrl = createMockTask({ metadata: { prUrl: 'https://github.com/test/pr/1' } });
const result = manager.handleManualStatusChange(mockTask.id, 'pr_created', taskWithPrUrl, mockProject);
expect(result).toBe(true);
});
it('should handle in_progress status with plan_review', () => {
const taskInPlanReview = createMockTask({
status: 'human_review',
reviewReason: 'plan_review'
});
const result = manager.handleManualStatusChange(mockTask.id, 'in_progress', taskInPlanReview, mockProject);
expect(result).toBe(true);
});
it('should handle in_progress status without plan_review', () => {
const stoppedTask = createMockTask({
status: 'human_review',
reviewReason: 'stopped'
});
const result = manager.handleManualStatusChange(mockTask.id, 'in_progress', stoppedTask, mockProject);
expect(result).toBe(true);
});
it('should handle backlog status', () => {
const result = manager.handleManualStatusChange(mockTask.id, 'backlog', mockTask, mockProject);
expect(result).toBe(true);
});
it('should handle human_review status (stage-only merge keeps task in review)', () => {
const taskInReview = createMockTask({
status: 'human_review',
reviewReason: 'completed'
});
const result = manager.handleManualStatusChange(mockTask.id, 'human_review', taskInReview, mockProject);
expect(result).toBe(true);
});
it('should handle human_review with default reviewReason when task has none', () => {
const taskNoReason = createMockTask({
status: 'human_review'
// no reviewReason set
});
const result = manager.handleManualStatusChange(mockTask.id, 'human_review', taskNoReason, mockProject);
expect(result).toBe(true);
});
it('should return false for unhandled status', () => {
const result = manager.handleManualStatusChange(mockTask.id, 'ai_review', mockTask, mockProject);
expect(result).toBe(false);
});
});
describe('sequence management', () => {
it('should set and get last sequence', () => {
manager.setLastSequence(mockTask.id, 42);
expect(manager.getLastSequence(mockTask.id)).toBe(42);
});
it('should return undefined for unknown task', () => {
expect(manager.getLastSequence('unknown-task')).toBeUndefined();
});
});
describe('clearTask', () => {
it('should clear task state', () => {
// Set up some state
manager.setLastSequence(mockTask.id, 10);
const event = {
type: 'PLANNING_STARTED',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-1',
sequence: 0
};
manager.handleTaskEvent(mockTask.id, event, mockTask, mockProject);
// Clear
manager.clearTask(mockTask.id);
// Verify cleared
expect(manager.getLastSequence(mockTask.id)).toBeUndefined();
});
});
describe('clearAllTasks', () => {
it('should clear all task state', () => {
// Set up state for multiple tasks
manager.setLastSequence('task-1', 10);
manager.setLastSequence('task-2', 20);
const event1 = {
type: 'PLANNING_STARTED',
taskId: 'task-1',
specId: '001',
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-1',
sequence: 0
};
const event2 = {
type: 'PLANNING_STARTED',
taskId: 'task-2',
specId: '002',
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-2',
sequence: 0
};
manager.handleTaskEvent('task-1', event1, createMockTask({ id: 'task-1' }), mockProject);
manager.handleTaskEvent('task-2', event2, createMockTask({ id: 'task-2' }), mockProject);
// Clear all
manager.clearAllTasks();
// Verify actors and state are cleared, but sequence tracking is preserved
// (to prevent duplicate event processing during refresh window)
expect(manager.getLastSequence('task-1')).toBe(10);
expect(manager.getLastSequence('task-2')).toBe(20);
});
});
describe('handleProcessExited', () => {
it('should NOT mark as error if terminal event was already seen', () => {
// First send a terminal event (like QA_PASSED)
const qaPassedEvent = {
type: 'QA_PASSED',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-1',
sequence: 0,
iteration: 1,
testsRun: {}
};
manager.handleTaskEvent(mockTask.id, qaPassedEvent, mockTask, mockProject);
// Now process exits - this should NOT trigger error state
manager.handleProcessExited(mockTask.id, 0, mockTask, mockProject);
// Should not throw and should not transition to error
// (We can't easily verify the state, but the important thing is no crash)
});
it('should mark as error on unexpected exit when no terminal event seen', () => {
// Start a task
const planningEvent = {
type: 'PLANNING_STARTED',
taskId: mockTask.id,
specId: mockTask.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-1',
sequence: 0
};
manager.handleTaskEvent(mockTask.id, planningEvent, mockTask, mockProject);
// Process exits unexpectedly (no terminal event seen)
manager.handleProcessExited(mockTask.id, 1, mockTask, mockProject);
// 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', () => {
it('should restore actor state from task with in_progress status', () => {
const taskInProgress = createMockTask({
status: 'in_progress',
executionProgress: { phase: 'coding', phaseProgress: 50, overallProgress: 50 }
});
const event = {
type: 'QA_STARTED',
taskId: taskInProgress.id,
specId: taskInProgress.specId,
projectId: mockProject.id,
timestamp: new Date().toISOString(),
eventId: 'evt-1',
sequence: 0,
iteration: 1,
maxIterations: 3
};
// This should create an actor restored to 'coding' state, then transition to 'qa_review'
manager.handleTaskEvent(taskInProgress.id, event, taskInProgress, mockProject);
// No error should occur
});
it('should restore actor state from task with human_review/plan_review', () => {
const taskInPlanReview = createMockTask({
status: 'human_review',
reviewReason: 'plan_review'
});
// Actor should be created in plan_review state
manager.handleUiEvent(taskInPlanReview.id, { type: 'PLAN_APPROVED' }, taskInPlanReview, mockProject);
// Should transition from plan_review to coding without error
});
it('should restore actor state from task with error status', () => {
const taskInError = createMockTask({
status: 'error',
reviewReason: 'errors'
});
// Actor should be created in error state
manager.handleUiEvent(taskInError.id, { type: 'USER_RESUMED' }, taskInError, mockProject);
// Should transition from error to coding without error
});
});
});
+5 -15
View File
@@ -178,9 +178,7 @@ describe("safeSendToRenderer", () => {
describe("error handling - non-disposal errors", () => {
it("catches other errors and returns false", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
/* intentionally empty - suppress console output during tests */
});
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockSend.mockImplementation(() => {
throw new Error("Some other IPC error");
@@ -218,9 +216,7 @@ describe("safeSendToRenderer", () => {
});
it("logs console.warn only once for multiple consecutive calls to same channel", () => {
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
/* intentionally empty - suppress console output during tests */
});
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
mockWindow = {
isDestroyed: vi.fn(() => true),
@@ -246,9 +242,7 @@ describe("safeSendToRenderer", () => {
});
it("logs console.warn separately for different channels", () => {
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
/* intentionally empty - suppress console output during tests */
});
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
mockWindow = {
isDestroyed: vi.fn(() => true),
@@ -313,9 +307,7 @@ describe("safeSendToRenderer", () => {
describe("warning pruning logic - 100-entry hard cap", () => {
it("enforces 100-entry cap by removing oldest entries when exceeded", async () => {
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
/* intentionally empty - suppress console output during tests */
});
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
mockWindow = {
isDestroyed: vi.fn(() => true),
@@ -348,9 +340,7 @@ describe("safeSendToRenderer", () => {
});
it("handles many unique channels without throwing errors", async () => {
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
/* intentionally empty - suppress console output during tests */
});
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
mockWindow = {
isDestroyed: vi.fn(() => true),
+4 -38
View File
@@ -3,7 +3,6 @@ import { parsePhaseEvent } from './phase-event-parser';
import {
wouldPhaseRegress,
isTerminalPhase,
isPausePhase,
isValidExecutionPhase,
type ExecutionPhase
} from '../../shared/constants/phase-protocol';
@@ -14,48 +13,18 @@ export class AgentEvents {
log: string,
currentPhase: ExecutionProgressData['phase'],
isSpecRunner: boolean
): {
phase: ExecutionProgressData['phase'];
message?: string;
currentSubtask?: string;
resetTimestamp?: number;
profileId?: string;
} | null {
): { phase: ExecutionProgressData['phase']; message?: string; currentSubtask?: string } | null {
const structuredEvent = parsePhaseEvent(log);
if (structuredEvent) {
// structuredEvent.phase is validated as BackendPhase (via Zod schema),
// which is a subset of ExecutionPhase, so this assertion is safe
const result: {
phase: ExecutionProgressData['phase'];
message?: string;
currentSubtask?: string;
resetTimestamp?: number;
profileId?: string;
} = {
phase: structuredEvent.phase as ExecutionPhase,
return {
phase: structuredEvent.phase as ExecutionProgressData['phase'],
message: structuredEvent.message,
currentSubtask: structuredEvent.subtask
};
// Include pause phase metadata if present
if (structuredEvent.reset_timestamp !== undefined) {
result.resetTimestamp = structuredEvent.reset_timestamp;
}
if (structuredEvent.profile_id !== undefined) {
result.profileId = structuredEvent.profile_id;
}
return result;
}
// Terminal states can't be changed by fallback matching
if (isTerminalPhase(currentPhase)) {
return null;
}
// Pause phases should only be changed by structured events
// Don't allow fallback text matching to transition out of pause phases
if (isPausePhase(currentPhase)) {
if (isTerminalPhase(currentPhase as ExecutionPhase)) {
return null;
}
@@ -74,7 +43,6 @@ export class AgentEvents {
const lowerLog = log.toLowerCase();
// Spec runner phase detection (all part of "planning")
// IMPORTANT: Spec runner should NEVER transition to coding/qa phases via fallback matching
if (isSpecRunner) {
if (lowerLog.includes('discovering') || lowerLog.includes('discovery')) {
return { phase: 'planning', message: 'Discovering project context...' };
@@ -91,8 +59,6 @@ export class AgentEvents {
if (lowerLog.includes('spec complete') || lowerLog.includes('specification complete')) {
return { phase: 'planning', message: 'Specification complete' };
}
// Spec runner: don't fall through to run.py patterns (would incorrectly detect coding phase)
return null;
}
// Run.py phase detection
+16 -95
View File
@@ -6,8 +6,6 @@ import { AgentEvents } from './agent-events';
import { AgentProcessManager } from './agent-process';
import { AgentQueueManager } from './agent-queue';
import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager';
import type { ClaudeProfileManager } from '../claude-profile-manager';
import { getOperationRegistry } from '../claude-profile/operation-registry';
import {
SpecCreationMetadata,
TaskExecutionOptions,
@@ -34,9 +32,6 @@ 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() {
@@ -56,40 +51,26 @@ export class AgentManager extends EventEmitter {
});
// Listen for task completion to clean up context (prevent memory leak)
this.on('exit', (taskId: string, code: number | null, _processType?: string, _projectId?: string) => {
this.on('exit', (taskId: string, code: number | null) => {
// 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
@@ -103,46 +84,6 @@ export class AgentManager extends EventEmitter {
this.processManager.configure(pythonPath, autoBuildSourcePath);
}
/**
* Register a task with the unified OperationRegistry for proactive swap support.
* Extracted helper to avoid code duplication between spec creation and task execution.
* @private
*/
private registerTaskWithOperationRegistry(
taskId: string,
operationType: 'spec-creation' | 'task-execution',
metadata: Record<string, unknown>
): void {
const profileManager = getClaudeProfileManager();
const activeProfile = profileManager.getActiveProfile();
if (!activeProfile) {
return;
}
// Keep internal state tracking for backward compatibility
this.assignProfileToTask(taskId, activeProfile.id, activeProfile.name, 'proactive');
// Register with unified registry for proactive swap
// Note: We don't provide a stopFn because restartTask() already handles stopping
// the task internally via killTask() before restarting. Providing a separate
// stopFn would cause a redundant double-kill during profile swaps.
const operationRegistry = getOperationRegistry();
operationRegistry.registerOperation(
taskId,
operationType,
activeProfile.id,
activeProfile.name,
(newProfileId: string) => this.restartTask(taskId, newProfileId),
{ metadata }
);
console.log('[AgentManager] Task registered with OperationRegistry:', {
taskId,
profileId: activeProfile.id,
profileName: activeProfile.name,
type: operationType
});
}
/**
* Start spec creation process
*/
@@ -152,12 +93,11 @@ export class AgentManager extends EventEmitter {
taskDescription: string,
specDir?: string,
metadata?: SpecCreationMetadata,
baseBranch?: string,
projectId?: string
baseBranch?: string
): Promise<void> {
// Pre-flight auth check: Verify active profile has valid authentication
// Ensure profile manager is initialized to prevent race condition
let profileManager: ClaudeProfileManager;
let profileManager;
try {
profileManager = await initializeClaudeProfileManager();
} catch (error) {
@@ -233,13 +173,10 @@ export class AgentManager extends EventEmitter {
}
// Store context for potential restart
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch, projectId);
// Register with unified OperationRegistry for proactive swap support
this.registerTaskWithOperationRegistry(taskId, 'spec-creation', { projectPath, taskDescription, specDir });
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch);
// Note: This is spec-creation but it chains to task-execution via run.py
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId);
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
}
/**
@@ -249,12 +186,11 @@ export class AgentManager extends EventEmitter {
taskId: string,
projectPath: string,
specId: string,
options: TaskExecutionOptions = {},
projectId?: string
options: TaskExecutionOptions = {}
): Promise<void> {
// Pre-flight auth check: Verify active profile has valid authentication
// Ensure profile manager is initialized to prevent race condition
let profileManager: ClaudeProfileManager;
let profileManager;
try {
profileManager = await initializeClaudeProfileManager();
} catch (error) {
@@ -315,12 +251,9 @@ 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, undefined, undefined, undefined, undefined, projectId);
this.storeTaskContext(taskId, projectPath, specId, options, false);
// Register with unified OperationRegistry for proactive swap support
this.registerTaskWithOperationRegistry(taskId, 'task-execution', { projectPath, specId, options });
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution', projectId);
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
}
/**
@@ -329,8 +262,7 @@ export class AgentManager extends EventEmitter {
async startQAProcess(
taskId: string,
projectPath: string,
specId: string,
projectId?: string
specId: string
): Promise<void> {
// Ensure Python environment is ready before spawning process (prevents exit code 127 race condition)
const pythonStatus = await this.processManager.ensurePythonEnvReady('AgentManager');
@@ -358,7 +290,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', projectId);
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process');
}
/**
@@ -455,14 +387,11 @@ export class AgentManager extends EventEmitter {
taskDescription?: string,
specDir?: string,
metadata?: SpecCreationMetadata,
baseBranch?: string,
projectId?: string
baseBranch?: 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,
@@ -473,9 +402,7 @@ export class AgentManager extends EventEmitter {
specDir,
metadata,
baseBranch,
swapCount, // Preserve existing count instead of resetting
projectId,
generation, // Incremented to prevent stale exit cleanup
swapCount // Preserve existing count instead of resetting
});
}
@@ -531,18 +458,13 @@ 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.projectId
context.baseBranch
);
} else {
console.log('[AgentManager] Restarting as task execution');
@@ -550,8 +472,7 @@ export class AgentManager extends EventEmitter {
taskId,
context.projectPath,
context.specId,
context.options,
context.projectId
context.options
);
}
}, 500);
@@ -13,7 +13,7 @@ function createMockProcess() {
return {
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn((event: string, callback: (code: number) => void) => {
on: vi.fn((event: string, callback: any) => {
if (event === 'exit') {
// Simulate immediate exit with code 0
setTimeout(() => callback(0), 10);
+20 -105
View File
@@ -12,7 +12,6 @@ import { AgentState } from './agent-state';
import { AgentEvents } from './agent-events';
import { ProcessType, ExecutionProgressData } from './types';
import type { CompletablePhase } from '../../shared/constants/phase-protocol';
import { parseTaskEvent } from './task-event-parser';
import { detectRateLimit, createSDKRateLimitInfo, getBestAvailableProfileEnv, detectAuthFailure } from '../rate-limit-detector';
import { getAPIProfileEnv } from '../services/profile';
import { projectStore } from '../project-store';
@@ -30,7 +29,7 @@ import { killProcessGracefully, isWindows } from '../platform';
/**
* Type for supported CLI tools
*/
type CliTool = 'claude' | 'gh' | 'glab';
type CliTool = 'claude' | 'gh';
/**
* Mapping of CLI tools to their environment variable names
@@ -38,8 +37,7 @@ type CliTool = 'claude' | 'gh' | 'glab';
*/
const CLI_TOOL_ENV_MAP: Readonly<Record<CliTool, string>> = {
claude: 'CLAUDE_CLI_PATH',
gh: 'GITHUB_CLI_PATH',
glab: 'GITLAB_CLI_PATH'
gh: 'GITHUB_CLI_PATH'
} as const;
@@ -203,14 +201,12 @@ export class AgentProcessManager {
// Detect and pass CLI tool paths to Python backend
const claudeCliEnv = this.detectAndSetCliPath('claude');
const ghCliEnv = this.detectAndSetCliPath('gh');
const glabCliEnv = this.detectAndSetCliPath('glab');
return {
...augmentedEnv,
...gitBashEnv,
...claudeCliEnv,
...ghCliEnv,
...glabCliEnv,
...extraEnv,
...profileEnv,
PYTHONUNBUFFERED: '1',
@@ -281,11 +277,7 @@ export class AgentProcessManager {
} : 'NONE');
if (!bestProfile) {
// Single account case: let backend handle with intelligent pause
// Don't show manual modal - backend will pause intelligently and resume when ready
console.log('[AgentProcess] No alternative profile - backend will handle with intelligent pause');
// Return false to let handleProcessFailure emit sdk-rate-limit event
// The frontend can then show appropriate UI (e.g., "Paused until X time")
console.log('[AgentProcess] No alternative profile available - falling back to manual modal');
return false;
}
@@ -310,84 +302,19 @@ export class AgentProcessManager {
console.log('[AgentProcess] No rate limit detected - checking for auth failure');
const authFailureDetection = detectAuthFailure(allOutput);
if (!authFailureDetection.isAuthFailure) {
console.log('[AgentProcess] Process failed but no rate limit or auth failure detected');
return false;
}
console.log('[AgentProcess] Auth failure detected:', authFailureDetection);
// Try auto-swap if enabled
const wasHandled = this.handleAuthFailureWithAutoSwap(taskId, authFailureDetection);
if (!wasHandled) {
// Fall back to UI notification
if (authFailureDetection.isAuthFailure) {
console.log('[AgentProcess] Auth failure detected:', authFailureDetection);
this.emitter.emit('auth-failure', taskId, {
profileId: authFailureDetection.profileId,
failureType: authFailureDetection.failureType,
message: authFailureDetection.message,
originalError: authFailureDetection.originalError
});
return true;
}
return true;
}
/**
* Attempt to auto-swap to another profile on authentication failure.
* Only works when autoSwitchOnAuthFailure is enabled and an alternative
* authenticated profile is available.
*/
private handleAuthFailureWithAutoSwap(
taskId: string,
authFailureDetection: ReturnType<typeof detectAuthFailure>
): boolean {
const profileManager = getClaudeProfileManager();
const autoSwitchSettings = profileManager.getAutoSwitchSettings();
console.log('[AgentProcess] Auth failure auto-switch settings:', {
enabled: autoSwitchSettings.enabled,
autoSwitchOnAuthFailure: autoSwitchSettings.autoSwitchOnAuthFailure
});
// Check if auto-switch on auth failure is enabled
if (!autoSwitchSettings.enabled || !autoSwitchSettings.autoSwitchOnAuthFailure) {
console.log('[AgentProcess] Auth failure auto-switch disabled - falling back to UI');
return false;
}
const currentProfileId = authFailureDetection.profileId;
const bestProfile = profileManager.getBestAvailableProfile(currentProfileId);
console.log('[AgentProcess] Best available profile for auth failure swap:', bestProfile ? {
id: bestProfile.id,
name: bestProfile.name,
isAuthenticated: bestProfile.isAuthenticated
} : 'NONE');
// Verify the best profile is actually authenticated
if (!bestProfile || !bestProfile.isAuthenticated) {
console.log('[AgentProcess] No authenticated alternative profile - falling back to UI');
return false;
}
console.log('[AgentProcess] AUTH-FAILURE AUTO-SWAP:', currentProfileId, '->', bestProfile.id);
profileManager.setActiveProfile(bestProfile.id);
// Emit auth-failure event with swap metadata for UI notification
this.emitter.emit('auth-failure', taskId, {
profileId: authFailureDetection.profileId,
failureType: authFailureDetection.failureType,
message: authFailureDetection.message,
originalError: authFailureDetection.originalError,
wasAutoSwapped: true,
swappedToProfile: { id: bestProfile.id, name: bestProfile.name }
});
// Reuse existing restart event
console.log('[AgentProcess] Emitting auto-swap-restart-task event for auth failure:', taskId);
this.emitter.emit('auto-swap-restart-task', taskId, bestProfile.id);
return true;
console.log('[AgentProcess] Process failed but no rate limit or auth failure detected');
return false;
}
/**
@@ -579,8 +506,7 @@ export class AgentProcessManager {
cwd: string,
args: string[],
extraEnv: Record<string, string> = {},
processType: ProcessType = 'task-execution',
projectId?: string
processType: ProcessType = 'task-execution'
): Promise<void> {
const isSpecRunner = processType === 'spec-creation';
this.killProcess(taskId);
@@ -632,7 +558,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), projectId);
this.emitter.emit('error', taskId, err instanceof Error ? err.message : String(err));
throw err;
}
@@ -679,7 +605,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() ?? '');
@@ -691,17 +617,6 @@ export class AgentProcessManager {
console.log(`[PhaseDebug:${taskId}] Found marker in line: "${line.substring(0, 200)}"`);
}
// Log all task event markers for debugging
if (line.includes('__TASK_EVENT__')) {
console.log(`[AgentProcess:${taskId}] Found __TASK_EVENT__ marker in line:`, line.substring(0, 300));
}
const taskEvent = parseTaskEvent(line);
if (taskEvent) {
console.log(`[AgentProcess:${taskId}] Parsed task event:`, taskEvent.type, taskEvent);
this.emitter.emit('task-event', taskId, taskEvent, projectId);
}
const phaseUpdate = this.events.parseExecutionPhase(line, currentPhase, isSpecRunner);
if (isDebug && hasMarker) {
@@ -759,7 +674,7 @@ export class AgentProcessManager {
message: lastMessage,
sequenceNumber: ++sequenceNumber,
completedPhases: [...completedPhases]
}, projectId);
});
}
};
@@ -779,7 +694,7 @@ export class AgentProcessManager {
for (const line of lines) {
if (line.trim()) {
this.emitter.emit('log', taskId, line + '\n', projectId);
this.emitter.emit('log', taskId, line + '\n');
processLog(line);
if (isDebug) {
console.log(`[Agent:${taskId}] ${line}`);
@@ -800,11 +715,11 @@ export class AgentProcessManager {
childProcess.on('exit', (code: number | null) => {
if (stdoutBuffer.trim()) {
this.emitter.emit('log', taskId, stdoutBuffer + '\n', projectId);
this.emitter.emit('log', taskId, stdoutBuffer + '\n');
processLog(stdoutBuffer);
}
if (stderrBuffer.trim()) {
this.emitter.emit('log', taskId, stderrBuffer + '\n', projectId);
this.emitter.emit('log', taskId, stderrBuffer + '\n');
processLog(stderrBuffer);
}
@@ -819,7 +734,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, projectId);
this.emitter.emit('exit', taskId, code, processType);
return;
}
}
@@ -832,10 +747,10 @@ export class AgentProcessManager {
message: `Process exited with code ${code}`,
sequenceNumber: ++sequenceNumber,
completedPhases: [...completedPhases]
}, projectId);
});
}
this.emitter.emit('exit', taskId, code, processType, projectId);
this.emitter.emit('exit', taskId, code, processType);
});
// Handle process error
@@ -850,9 +765,9 @@ export class AgentProcessManager {
message: `Error: ${err.message}`,
sequenceNumber: ++sequenceNumber,
completedPhases: [...completedPhases]
}, projectId);
});
this.emitter.emit('error', taskId, err.message, projectId);
this.emitter.emit('error', taskId, err.message);
});
}
+1 -6
View File
@@ -417,12 +417,7 @@ export class AgentQueueManager {
// Track completed types for progress calculation
const completedTypes = new Set<string>();
// 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
const totalTypes = 7; // Default all types
// Handle stdout - explicitly decode as UTF-8 for cross-platform Unicode support
childProcess.stdout?.on('data', (data: Buffer) => {
@@ -24,24 +24,9 @@ describe('AgentState - Queue Routing', () => {
it('should group tasks by profile', () => {
// Add mock processes
state.addProcess('task-1', {
taskId: 'task-1',
process: { pid: 1001 } as unknown as import('child_process').ChildProcess,
startedAt: new Date(),
spawnId: 1
});
state.addProcess('task-2', {
taskId: 'task-2',
process: { pid: 1002 } as unknown as import('child_process').ChildProcess,
startedAt: new Date(),
spawnId: 2
});
state.addProcess('task-3', {
taskId: 'task-3',
process: { pid: 1003 } as unknown as import('child_process').ChildProcess,
startedAt: new Date(),
spawnId: 3
});
state.addProcess('task-1', { pid: 1001 } as any);
state.addProcess('task-2', { pid: 1002 } as any);
state.addProcess('task-3', { pid: 1003 } as any);
// Assign profiles
state.assignProfileToTask('task-1', 'profile-1', 'Profile 1', 'proactive');
@@ -57,12 +42,7 @@ describe('AgentState - Queue Routing', () => {
it('should use default profile for unassigned tasks', () => {
// Add process without profile assignment
state.addProcess('task-1', {
taskId: 'task-1',
process: { pid: 1001 } as unknown as import('child_process').ChildProcess,
startedAt: new Date(),
spawnId: 1
});
state.addProcess('task-1', { pid: 1001 } as any);
const result = state.getRunningTasksByProfile();
@@ -14,9 +14,6 @@ export interface PhaseParseResult<TPhase extends string = string> {
message?: string;
currentSubtask?: string;
progress?: number;
// Pause phase metadata
resetTimestamp?: number; // Unix timestamp for rate limit reset
profileId?: string; // Profile that hit the limit
}
/**
@@ -9,7 +9,6 @@ import { BasePhaseParser, type PhaseParseResult, type PhaseParserContext } from
import {
EXECUTION_PHASES,
TERMINAL_PHASES,
isPausePhase,
type ExecutionPhase
} from '../../../shared/constants/phase-protocol';
import { parsePhaseEvent } from '../phase-event-parser';
@@ -41,21 +40,11 @@ export class ExecutionPhaseParser extends BasePhaseParser<ExecutionPhase> {
// 1. Try structured event first (authoritative source)
const structuredEvent = parsePhaseEvent(log);
if (structuredEvent) {
const result: PhaseParseResult<ExecutionPhase> = {
return {
phase: structuredEvent.phase as ExecutionPhase,
message: structuredEvent.message,
currentSubtask: structuredEvent.subtask
};
// Include pause phase metadata if present
if (structuredEvent.reset_timestamp !== undefined) {
result.resetTimestamp = structuredEvent.reset_timestamp;
}
if (structuredEvent.profile_id !== undefined) {
result.profileId = structuredEvent.profile_id;
}
return result;
}
// 2. Terminal states can't be changed by fallback matching
@@ -63,13 +52,7 @@ export class ExecutionPhaseParser extends BasePhaseParser<ExecutionPhase> {
return null;
}
// 3. Pause phases should only be changed by structured events
// Don't allow fallback text matching to transition out of pause phases
if (isPausePhase(context.currentPhase)) {
return null;
}
// 4. Fall back to text pattern matching
// 3. Fall back to text pattern matching
return this.parseFallbackPatterns(log, context);
}
@@ -7,10 +7,7 @@ export const PhaseEventSchema = z.object({
phase: BackendPhaseSchema,
message: z.string().default(''),
progress: z.number().int().min(0).max(100).optional(),
subtask: z.string().optional(),
// Pause phase metadata
reset_timestamp: z.number().int().optional(), // Unix timestamp for rate limit reset
profile_id: z.string().optional() // Profile that hit the limit
subtask: z.string().optional()
});
export type PhaseEventPayload = z.infer<typeof PhaseEventSchema>;
@@ -1,120 +0,0 @@
/**
* Structured task event parser for Python -> TypeScript protocol.
* Protocol: __TASK_EVENT__:{...}
*/
import { validateTaskEvent, type TaskEventPayload } from './task-event-schema';
export const TASK_EVENT_PREFIX = '__TASK_EVENT__:';
const DEBUG = process.env.DEBUG?.toLowerCase() === 'true' || process.env.DEBUG === '1';
export type TaskEvent = TaskEventPayload;
export function parseTaskEvent(line: string): TaskEventPayload | null {
const markerIndex = line.indexOf(TASK_EVENT_PREFIX);
if (markerIndex === -1) {
return null;
}
if (DEBUG) {
console.log('[task-event-parser] Found marker at index', markerIndex, 'in line:', line.substring(0, 200));
}
const rawJsonStr = line.slice(markerIndex + TASK_EVENT_PREFIX.length).trim();
if (!rawJsonStr) {
if (DEBUG) {
console.log('[task-event-parser] Empty JSON string after marker');
}
return null;
}
const jsonStr = extractJsonObject(rawJsonStr);
if (!jsonStr) {
if (DEBUG) {
console.log('[task-event-parser] Could not extract JSON object from:', rawJsonStr.substring(0, 200));
}
return null;
}
if (DEBUG) {
console.log('[task-event-parser] Attempting to parse JSON:', jsonStr.substring(0, 200));
}
try {
const rawPayload = JSON.parse(jsonStr) as unknown;
const result = validateTaskEvent(rawPayload);
if (!result.success) {
if (DEBUG) {
console.log('[task-event-parser] Validation failed:', result.error.format());
}
return null;
}
if (DEBUG) {
console.log('[task-event-parser] Successfully parsed event:', result.data);
}
return result.data;
} catch (e) {
if (DEBUG) {
console.log('[task-event-parser] JSON parse FAILED for:', jsonStr);
console.log('[task-event-parser] Error:', e);
}
return null;
}
}
export function hasTaskMarker(line: string): boolean {
return line.includes(TASK_EVENT_PREFIX);
}
/**
* Extract a JSON object from a string that may have trailing garbage.
* Finds the matching closing brace for the first opening brace.
*/
function extractJsonObject(str: string): string | null {
const firstBrace = str.indexOf('{');
if (firstBrace === -1) {
return null;
}
let depth = 0;
let inString = false;
let isEscaped = false;
for (let i = firstBrace; i < str.length; i++) {
const char = str[i];
if (isEscaped) {
isEscaped = false;
continue;
}
if (char === '\\' && inString) {
isEscaped = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (inString) {
continue;
}
if (char === '{') {
depth++;
} else if (char === '}') {
depth--;
if (depth === 0) {
return str.slice(firstBrace, i + 1);
}
}
}
return null;
}
@@ -1,33 +0,0 @@
import { z } from 'zod';
export const TaskEventSchema = z.object({
type: z.string(),
taskId: z.string(),
specId: z.string(),
projectId: z.string(),
timestamp: z.string(),
eventId: z.string(),
sequence: z.number().int().min(0)
}).passthrough();
export type TaskEventPayload = z.infer<typeof TaskEventSchema>;
export interface ValidationResult {
success: true;
data: TaskEventPayload;
}
export interface ValidationError {
success: false;
error: z.ZodError;
}
export type ParseResult = ValidationResult | ValidationError;
export function validateTaskEvent(data: unknown): ParseResult {
const result = TaskEventSchema.safeParse(data);
if (result.success) {
return { success: true, data: result.data as TaskEventPayload };
}
return { success: false, error: result.error };
}
+7 -10
View File
@@ -1,6 +1,6 @@
import { ChildProcess } from 'child_process';
import type { CompletablePhase, ExecutionPhase } from '../../shared/constants/phase-protocol';
import type { TaskEventPayload } from './task-event-schema';
import type { IdeationConfig } from '../../shared/types';
import type { CompletablePhase } from '../../shared/constants/phase-protocol';
/**
* Agent-specific types for process and state management
@@ -18,7 +18,7 @@ export interface AgentProcess {
}
export interface ExecutionProgressData {
phase: ExecutionPhase;
phase: 'idle' | 'planning' | 'coding' | 'qa_review' | 'qa_fixing' | 'complete' | 'failed';
phaseProgress: number;
overallProgress: number;
currentSubtask?: string;
@@ -30,11 +30,10 @@ export interface ExecutionProgressData {
export type ProcessType = 'spec-creation' | 'task-execution' | 'qa-process';
export interface AgentManagerEvents {
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;
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;
}
// IdeationConfig now imported from shared types to maintain consistency
@@ -49,7 +48,6 @@ 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,7 +71,6 @@ 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 {
-45
View File
@@ -1,45 +0,0 @@
/**
* App language tracking module for main process.
*
* Tracks the user's in-app language setting (not OS locale) for use in
* main process code that needs localized strings (e.g., context menus).
*
* Updated via IPC when user changes language in settings.
*/
import { app } from 'electron';
// Current app language, defaults to 'en'
// Updated via setAppLanguage() when renderer notifies of language change
let currentAppLanguage = 'en';
/**
* Get the current app language.
* Falls back to 'en' if not set.
*/
export function getAppLanguage(): string {
return currentAppLanguage;
}
/**
* Set the current app language.
* Called by IPC handler when renderer changes language.
*/
export function setAppLanguage(language: string): void {
currentAppLanguage = language;
}
/**
* Initialize app language from OS locale as a starting point.
* The renderer will update this once i18n initializes.
*/
export function initAppLanguage(): void {
try {
// app.getLocale() may not be available in test environments
const osLocale = app?.getLocale?.() || 'en';
// Extract base language (e.g., 'en-US' -> 'en')
currentAppLanguage = osLocale.split('-')[0] || 'en';
} catch {
currentAppLanguage = 'en';
}
}
+12 -58
View File
@@ -17,8 +17,6 @@
* - APP_UPDATE_ERROR: Error during update process
*/
import { accessSync, constants as fsConstants } from 'fs';
import path from 'path';
import { autoUpdater } from 'electron-updater';
import type { UpdateInfo } from 'electron-updater';
import { app, net } from 'electron';
@@ -26,7 +24,6 @@ import type { BrowserWindow } from 'electron';
import { IPC_CHANNELS } from '../shared/constants';
import type { AppUpdateInfo } from '../shared/types';
import { compareVersions } from './updater/version-manager';
import { isMacOS } from './platform';
// GitHub repo info for API calls
const GITHUB_OWNER = 'AndyMik90';
@@ -94,13 +91,10 @@ function formatReleaseNotes(releaseNotes: UpdateInfo['releaseNotes']): string |
*/
export function setUpdateChannel(channel: UpdateChannel): void {
autoUpdater.channel = channel;
// Enable pre-release scanning when beta channel is selected
// This allows electron-updater to find beta releases on GitHub
autoUpdater.allowPrerelease = channel === 'beta';
// Clear any downloaded update info when channel changes to prevent showing
// an Install button for an update from a different channel
downloadedUpdateInfo = null;
console.warn(`[app-updater] Update channel set to: ${channel}, allowPrerelease: ${autoUpdater.allowPrerelease}`);
console.warn(`[app-updater] Update channel set to: ${channel}`);
}
// Enable more verbose logging in debug mode
@@ -193,7 +187,8 @@ export function initializeAppUpdater(window: BrowserWindow, betaUpdates = false)
console.error('[app-updater] Update error:', error);
if (mainWindow) {
mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_ERROR, {
message: error.message
message: error.message,
stack: error.stack
});
}
});
@@ -298,57 +293,13 @@ export async function downloadUpdate(): Promise<void> {
}
}
/**
* Check if the app is running from a read-only volume (e.g., DMG on macOS)
* Returns true if the app cannot be updated in place
*/
function isRunningFromReadOnlyVolume(): boolean {
if (!isMacOS()) {
return false;
}
const appPath = app.getAppPath();
// Check if the filesystem is read-only by testing write access.
// We don't use a /Volumes/ prefix check because writable external drives
// (USB, external SSDs) are also mounted under /Volumes/ on macOS.
try {
// Navigate from app.asar to the Contents/ directory (app.asar -> Resources -> Contents)
const contentsPath = path.resolve(appPath, '..', '..');
// Try to check if we can write to the app bundle's parent directory
accessSync(path.dirname(contentsPath), fsConstants.W_OK);
return false;
} catch (error: unknown) {
// Only treat as read-only if the filesystem itself is read-only (EROFS).
// Permission errors (EACCES) in managed/enterprise environments should not
// block updates — the updater may still have elevated access.
const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined;
return code === 'EROFS';
}
}
/**
* Quit and install update
* Called from IPC handler when user confirms installation
* Returns false if running from a read-only volume (update cannot proceed)
*/
export function quitAndInstall(): boolean {
// Check if running from read-only volume before attempting install
if (isRunningFromReadOnlyVolume()) {
console.warn('[app-updater] Cannot install: running from read-only volume');
if (mainWindow) {
mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_READONLY_VOLUME, {
appPath: app.getAppPath()
});
}
return false;
}
export function quitAndInstall(): void {
console.warn('[app-updater] Quitting and installing update');
autoUpdater.quitAndInstall(false, true);
return true;
}
/**
@@ -451,7 +402,7 @@ async function fetchLatestStableRelease(): Promise<AppUpdateInfo | null> {
const version = latestStable.tag_name.replace(/^v/, '');
// Sanitize version string for logging (remove control characters and limit length)
// biome-ignore lint/suspicious/noControlCharactersInRegex: Intentionally matching control chars for sanitization
// eslint-disable-next-line no-control-regex
const safeVersion = String(version).replace(/[\x00-\x1f\x7f]/g, '').slice(0, 50);
console.warn('[app-updater] Found latest stable release:', safeVersion);
@@ -532,8 +483,11 @@ export async function setUpdateChannelWithDowngradeCheck(
channel: UpdateChannel,
triggerDowngradeCheck = false
): Promise<AppUpdateInfo | null> {
// Use the shared channel-setting function to avoid code duplication
setUpdateChannel(channel);
autoUpdater.channel = channel;
// Clear any downloaded update info when channel changes to prevent showing
// an Install button for an update from a different channel
downloadedUpdateInfo = null;
console.warn(`[app-updater] Update channel set to: ${channel}`);
// If switching to stable and downgrade check requested, look for stable version
if (channel === 'latest' && triggerDowngradeCheck) {
@@ -555,8 +509,8 @@ export async function setUpdateChannelWithDowngradeCheck(
* Uses electron-updater with allowDowngrade enabled to download older stable versions
*/
export async function downloadStableVersion(): Promise<void> {
// Switch to stable channel (resets allowPrerelease and clears downloadedUpdateInfo)
setUpdateChannel('latest');
// Switch to stable channel
autoUpdater.channel = 'latest';
// Enable downgrade to allow downloading older versions (e.g., stable when on beta)
autoUpdater.allowDowngrade = true;
console.warn('[app-updater] Downloading stable version (allowDowngrade=true)...');
@@ -44,6 +44,7 @@ export class ChangelogService extends EventEmitter {
private _pythonPath: string | null = null;
private claudePath: string;
private autoBuildSourcePath: string = '';
private cachedEnv: Record<string, string> | null = null;
private debugEnabled: boolean | null = null;
private generator: ChangelogGenerator | null = null;
private versionSuggester: VersionSuggester | null = null;
@@ -453,7 +454,7 @@ export class ChangelogService extends EventEmitter {
}
const parts = currentVersion.split('.').map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) {
if (parts.length !== 3 || parts.some(isNaN)) {
return '1.0.0';
}
@@ -490,7 +491,7 @@ export class ChangelogService extends EventEmitter {
* Suggest version using AI analysis of git commits
*/
async suggestVersionFromCommits(
_projectPath: string,
projectPath: string,
commits: import('../../shared/types').GitCommit[],
currentVersion?: string
): Promise<{ version: string; reason: string }> {
@@ -501,7 +502,7 @@ export class ChangelogService extends EventEmitter {
}
const parts = currentVersion.split('.').map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) {
if (parts.length !== 3 || parts.some(isNaN)) {
return { version: '1.0.0', reason: 'Invalid current version, resetting to 1.0.0' };
}
@@ -198,6 +198,7 @@ except Exception as e:
case 'minor':
newVersion = `${major}.${minor + 1}.0`;
break;
case 'patch':
default:
newVersion = `${major}.${minor}.${patch + 1}`;
break;
@@ -32,6 +32,7 @@ import {
clearRateLimitEvents as clearRateLimitEventsImpl
} from './claude-profile/rate-limit-manager';
import {
loadProfileStore,
loadProfileStoreAsync,
saveProfileStore,
ProfileStoreData,
@@ -42,7 +43,7 @@ import {
shouldProactivelySwitch as shouldProactivelySwitchImpl,
getProfilesSortedByAvailability as getProfilesSortedByAvailabilityImpl
} from './claude-profile/profile-scorer';
import { getCredentialsFromKeychain, normalizeWindowsPath, updateProfileSubscriptionMetadata } from './claude-profile/credential-utils';
import { getCredentialsFromKeychain } from './claude-profile/credential-utils';
import {
CLAUDE_PROFILES_DIR,
generateProfileId as generateProfileIdImpl,
@@ -95,10 +96,6 @@ 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;
}
@@ -136,58 +133,6 @@ 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
*/
@@ -195,6 +140,31 @@ export class ClaudeProfileManager {
return this.initialized;
}
/**
* Load profiles from disk
*/
private load(): ProfileStoreData {
const loadedData = loadProfileStore(this.storePath);
if (loadedData) {
if (process.env.DEBUG === 'true') {
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
*
@@ -528,12 +498,9 @@ export class ClaudeProfileManager {
// This prevents interference with external Claude Code CLI usage
if (profile?.configDir) {
// Expand ~ to home directory for the environment variable
const expandedConfigDir = normalizeWindowsPath(
profile.configDir.startsWith('~')
? profile.configDir.replace(/^~/, homedir())
: profile.configDir
);
const expandedConfigDir = profile.configDir.startsWith('~')
? profile.configDir.replace(/^~/, homedir())
: profile.configDir;
env.CLAUDE_CONFIG_DIR = expandedConfigDir;
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir);
@@ -752,11 +719,9 @@ export class ClaudeProfileManager {
}
// Expand ~ to home directory for the environment variable
const expandedConfigDir = normalizeWindowsPath(
profile.configDir.startsWith('~')
? profile.configDir.replace(/^~/, require('os').homedir())
: profile.configDir
);
const expandedConfigDir = profile.configDir.startsWith('~')
? profile.configDir.replace(/^~/, require('os').homedir())
: profile.configDir;
if (process.env.DEBUG === 'true') {
console.warn('[ClaudeProfileManager] getProfileEnv:', {
@@ -1,673 +0,0 @@
/**
* Unit tests for OperationRegistry
*
* Tests cover:
* - Singleton pattern
* - Operation registration/unregistration
* - Profile-based querying
* - Summary generation
* - Operation restart functionality
* - Event emissions
* - Edge cases
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
getOperationRegistry,
resetOperationRegistry,
type OperationType,
} from '../operation-registry';
describe('OperationRegistry', () => {
beforeEach(() => {
// Reset registry before each test
resetOperationRegistry();
});
afterEach(() => {
// Clean up after each test
resetOperationRegistry();
});
describe('Singleton Pattern', () => {
it('should return the same instance on multiple calls', () => {
const instance1 = getOperationRegistry();
const instance2 = getOperationRegistry();
expect(instance1).toBe(instance2);
});
it('should create new instance after reset', () => {
const instance1 = getOperationRegistry();
resetOperationRegistry();
const instance2 = getOperationRegistry();
expect(instance1).not.toBe(instance2);
});
it('should clear all operations on reset', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation(
'op1',
'spec-creation',
'profile1',
'Profile 1',
mockRestart
);
expect(registry.getOperationCount()).toBe(1);
resetOperationRegistry();
const newRegistry = getOperationRegistry();
expect(newRegistry.getOperationCount()).toBe(0);
});
});
describe('registerOperation', () => {
it('should register a basic operation', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation(
'op1',
'spec-creation',
'profile1',
'Profile 1',
mockRestart
);
const operation = registry.getOperation('op1');
expect(operation).toBeDefined();
expect(operation?.id).toBe('op1');
expect(operation?.type).toBe('spec-creation');
expect(operation?.profileId).toBe('profile1');
expect(operation?.profileName).toBe('Profile 1');
expect(operation?.restartFn).toBe(mockRestart);
expect(operation?.startedAt).toBeInstanceOf(Date);
});
it('should register operation with optional stopFn', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
const mockStop = vi.fn();
registry.registerOperation(
'op1',
'pr-review',
'profile1',
'Profile 1',
mockRestart,
{ stopFn: mockStop }
);
const operation = registry.getOperation('op1');
expect(operation?.stopFn).toBe(mockStop);
});
it('should register operation with metadata', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
const metadata = { projectId: 'proj1', prNumber: 123 };
registry.registerOperation(
'op1',
'pr-review',
'profile1',
'Profile 1',
mockRestart,
{ metadata }
);
const operation = registry.getOperation('op1');
expect(operation?.metadata).toEqual(metadata);
});
it('should emit operation-registered event', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
const eventListener = vi.fn();
registry.on('operation-registered', eventListener);
registry.registerOperation(
'op1',
'task-execution',
'profile1',
'Profile 1',
mockRestart
);
expect(eventListener).toHaveBeenCalledTimes(1);
const emittedOperation = eventListener.mock.calls[0][0];
expect(emittedOperation.id).toBe('op1');
expect(emittedOperation.type).toBe('task-execution');
});
it('should increment operation count', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
expect(registry.getOperationCount()).toBe(0);
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
expect(registry.getOperationCount()).toBe(1);
registry.registerOperation('op2', 'roadmap', 'profile1', 'Profile 1', mockRestart);
expect(registry.getOperationCount()).toBe(2);
});
it('should allow registering multiple operation types', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
const types: OperationType[] = [
'spec-creation',
'task-execution',
'pr-review',
'mr-review',
'insights',
'roadmap',
'changelog',
'ideation',
'triage',
'other',
];
types.forEach((type, index) => {
registry.registerOperation(
`op${index}`,
type,
'profile1',
'Profile 1',
mockRestart
);
});
expect(registry.getOperationCount()).toBe(types.length);
types.forEach((type, index) => {
const op = registry.getOperation(`op${index}`);
expect(op?.type).toBe(type);
});
});
});
describe('unregisterOperation', () => {
it('should unregister an existing operation', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
expect(registry.getOperation('op1')).toBeDefined();
registry.unregisterOperation('op1');
expect(registry.getOperation('op1')).toBeUndefined();
expect(registry.getOperationCount()).toBe(0);
});
it('should emit operation-unregistered event', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
const eventListener = vi.fn();
registry.on('operation-unregistered', eventListener);
registry.registerOperation('op1', 'task-execution', 'profile1', 'Profile 1', mockRestart);
registry.unregisterOperation('op1');
expect(eventListener).toHaveBeenCalledTimes(1);
expect(eventListener).toHaveBeenCalledWith('op1', 'task-execution');
});
it('should handle unregistering non-existent operation gracefully', () => {
const registry = getOperationRegistry();
const eventListener = vi.fn();
registry.on('operation-unregistered', eventListener);
// Should not throw
expect(() => registry.unregisterOperation('non-existent')).not.toThrow();
// Should not emit event for non-existent operation
expect(eventListener).not.toHaveBeenCalled();
});
it('should decrement operation count', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op2', 'roadmap', 'profile1', 'Profile 1', mockRestart);
expect(registry.getOperationCount()).toBe(2);
registry.unregisterOperation('op1');
expect(registry.getOperationCount()).toBe(1);
registry.unregisterOperation('op2');
expect(registry.getOperationCount()).toBe(0);
});
});
describe('getOperation', () => {
it('should retrieve operation by id', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'pr-review', 'profile1', 'Profile 1', mockRestart);
const operation = registry.getOperation('op1');
expect(operation).toBeDefined();
expect(operation?.id).toBe('op1');
});
it('should return undefined for non-existent operation', () => {
const registry = getOperationRegistry();
const operation = registry.getOperation('non-existent');
expect(operation).toBeUndefined();
});
});
describe('getOperationsByProfile', () => {
it('should return operations for a specific profile', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
const profile1Ops = registry.getOperationsByProfile('profile1');
expect(profile1Ops).toHaveLength(2);
expect(profile1Ops.map(op => op.id)).toEqual(['op1', 'op2']);
const profile2Ops = registry.getOperationsByProfile('profile2');
expect(profile2Ops).toHaveLength(1);
expect(profile2Ops[0].id).toBe('op3');
});
it('should return empty array for profile with no operations', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
const profile2Ops = registry.getOperationsByProfile('profile2');
expect(profile2Ops).toEqual([]);
});
});
describe('getAllOperationsByProfile', () => {
it('should return all operations grouped by profile', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
registry.registerOperation('op4', 'roadmap', 'profile3', 'Profile 3', mockRestart);
const allOps = registry.getAllOperationsByProfile();
expect(Object.keys(allOps)).toEqual(['profile1', 'profile2', 'profile3']);
expect(allOps['profile1']).toHaveLength(2);
expect(allOps['profile2']).toHaveLength(1);
expect(allOps['profile3']).toHaveLength(1);
});
it('should return empty object when no operations', () => {
const registry = getOperationRegistry();
const allOps = registry.getAllOperationsByProfile();
expect(allOps).toEqual({});
});
});
describe('getSummary', () => {
it('should return correct summary with no operations', () => {
const registry = getOperationRegistry();
const summary = registry.getSummary();
expect(summary.totalRunning).toBe(0);
expect(summary.byProfile).toEqual({});
expect(summary.byType).toEqual({});
});
it('should count operations by profile', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
const summary = registry.getSummary();
expect(summary.totalRunning).toBe(3);
expect(summary.byProfile['profile1']).toEqual(['op1', 'op2']);
expect(summary.byProfile['profile2']).toEqual(['op3']);
});
it('should count operations by type', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op2', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op3', 'pr-review', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op4', 'insights', 'profile2', 'Profile 2', mockRestart);
const summary = registry.getSummary();
expect(summary.byType['spec-creation']).toBe(2);
expect(summary.byType['pr-review']).toBe(1);
expect(summary.byType['insights']).toBe(1);
});
it('should return complete summary with multiple profiles and types', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
registry.registerOperation('op4', 'insights', 'profile2', 'Profile 2', mockRestart);
registry.registerOperation('op5', 'roadmap', 'profile3', 'Profile 3', mockRestart);
const summary = registry.getSummary();
expect(summary.totalRunning).toBe(5);
expect(Object.keys(summary.byProfile)).toHaveLength(3);
expect(Object.keys(summary.byType)).toHaveLength(5);
});
});
describe('restartOperationsOnProfile', () => {
it('should restart all operations on a profile', async () => {
const registry = getOperationRegistry();
const mockRestart1 = vi.fn().mockResolvedValue(true);
const mockRestart2 = vi.fn().mockResolvedValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
const count = await registry.restartOperationsOnProfile(
'profile1',
'profile2',
'Profile 2'
);
expect(count).toBe(2);
expect(mockRestart1).toHaveBeenCalledWith('profile2');
expect(mockRestart2).toHaveBeenCalledWith('profile2');
// Verify profile was updated
const op1 = registry.getOperation('op1');
const op2 = registry.getOperation('op2');
expect(op1?.profileId).toBe('profile2');
expect(op1?.profileName).toBe('Profile 2');
expect(op2?.profileId).toBe('profile2');
expect(op2?.profileName).toBe('Profile 2');
});
it('should call stopFn before restart if provided', async () => {
const registry = getOperationRegistry();
const mockStop = vi.fn().mockResolvedValue(undefined);
const mockRestart = vi.fn().mockResolvedValue(true);
registry.registerOperation(
'op1',
'pr-review',
'profile1',
'Profile 1',
mockRestart,
{ stopFn: mockStop }
);
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
expect(mockStop).toHaveBeenCalledTimes(1);
expect(mockRestart).toHaveBeenCalledWith('profile2');
// Ensure stopFn was called before restartFn
expect(mockStop.mock.invocationCallOrder[0]).toBeLessThan(
mockRestart.mock.invocationCallOrder[0]
);
});
it('should return 0 when no operations on profile', async () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockResolvedValue(true);
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
const count = await registry.restartOperationsOnProfile(
'profile2',
'profile3',
'Profile 3'
);
expect(count).toBe(0);
expect(mockRestart).not.toHaveBeenCalled();
});
it('should handle restart failure gracefully', async () => {
const registry = getOperationRegistry();
const mockRestart1 = vi.fn().mockResolvedValue(true);
const mockRestart2 = vi.fn().mockResolvedValue(false); // Fails
const mockRestart3 = vi.fn().mockRejectedValue(new Error('Restart error')); // Throws
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
registry.registerOperation('op3', 'pr-review', 'profile1', 'Profile 1', mockRestart3);
const count = await registry.restartOperationsOnProfile(
'profile1',
'profile2',
'Profile 2'
);
// Only op1 succeeded
expect(count).toBe(1);
// op1 should have updated profile
const op1 = registry.getOperation('op1');
expect(op1?.profileId).toBe('profile2');
// op2 and op3 should still have old profile
const op2 = registry.getOperation('op2');
const op3 = registry.getOperation('op3');
expect(op2?.profileId).toBe('profile1');
expect(op3?.profileId).toBe('profile1');
});
it('should emit operation-restarted event for each successful restart', async () => {
const registry = getOperationRegistry();
const mockRestart1 = vi.fn().mockResolvedValue(true);
const mockRestart2 = vi.fn().mockResolvedValue(true);
const eventListener = vi.fn();
registry.on('operation-restarted', eventListener);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
expect(eventListener).toHaveBeenCalledTimes(2);
expect(eventListener).toHaveBeenCalledWith('op1', 'profile1', 'profile2');
expect(eventListener).toHaveBeenCalledWith('op2', 'profile1', 'profile2');
});
it('should emit operations-restarted event after restart', async () => {
const registry = getOperationRegistry();
const mockRestart1 = vi.fn().mockResolvedValue(true);
const mockRestart2 = vi.fn().mockResolvedValue(true);
const eventListener = vi.fn();
registry.on('operations-restarted', eventListener);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart2);
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
expect(eventListener).toHaveBeenCalledTimes(1);
expect(eventListener).toHaveBeenCalledWith(2, 'profile1', 'profile2');
});
it('should not emit operations-restarted event if no restarts succeeded', async () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockResolvedValue(false);
const eventListener = vi.fn();
registry.on('operations-restarted', eventListener);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
expect(eventListener).not.toHaveBeenCalled();
});
it('should handle synchronous restart functions', async () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true); // Synchronous return
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
const count = await registry.restartOperationsOnProfile(
'profile1',
'profile2',
'Profile 2'
);
expect(count).toBe(1);
expect(mockRestart).toHaveBeenCalledWith('profile2');
const op1 = registry.getOperation('op1');
expect(op1?.profileId).toBe('profile2');
});
});
describe('updateOperationProfile', () => {
it('should update profile for existing operation', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
registry.updateOperationProfile('op1', 'profile2', 'Profile 2');
const operation = registry.getOperation('op1');
expect(operation?.profileId).toBe('profile2');
expect(operation?.profileName).toBe('Profile 2');
});
it('should handle updating non-existent operation gracefully', () => {
const registry = getOperationRegistry();
// Should not throw
expect(() =>
registry.updateOperationProfile('non-existent', 'profile2', 'Profile 2')
).not.toThrow();
});
});
describe('clear', () => {
it('should clear all operations', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op2', 'task-execution', 'profile1', 'Profile 1', mockRestart);
registry.registerOperation('op3', 'pr-review', 'profile2', 'Profile 2', mockRestart);
expect(registry.getOperationCount()).toBe(3);
registry.clear();
expect(registry.getOperationCount()).toBe(0);
expect(registry.getSummary().totalRunning).toBe(0);
});
});
describe('Edge Cases', () => {
it('should handle registering operation with same id (overwrites)', () => {
const registry = getOperationRegistry();
const mockRestart1 = vi.fn().mockReturnValue(true);
const mockRestart2 = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'spec-creation', 'profile1', 'Profile 1', mockRestart1);
registry.registerOperation('op1', 'task-execution', 'profile2', 'Profile 2', mockRestart2);
const operation = registry.getOperation('op1');
expect(operation?.type).toBe('task-execution');
expect(operation?.profileId).toBe('profile2');
expect(registry.getOperationCount()).toBe(1);
});
it('should handle multiple unregisters of same operation', () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockReturnValue(true);
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
registry.unregisterOperation('op1');
expect(registry.getOperationCount()).toBe(0);
// Second unregister should not throw or cause issues
registry.unregisterOperation('op1');
expect(registry.getOperationCount()).toBe(0);
});
it('should handle restart with no operations gracefully', async () => {
const registry = getOperationRegistry();
const count = await registry.restartOperationsOnProfile(
'profile1',
'profile2',
'Profile 2'
);
expect(count).toBe(0);
});
it('should preserve operation metadata through restart', async () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockResolvedValue(true);
const metadata = { projectId: 'proj1', prNumber: 123 };
registry.registerOperation(
'op1',
'pr-review',
'profile1',
'Profile 1',
mockRestart,
{ metadata }
);
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
const operation = registry.getOperation('op1');
expect(operation?.metadata).toEqual(metadata);
});
it('should preserve startedAt timestamp through restart', async () => {
const registry = getOperationRegistry();
const mockRestart = vi.fn().mockResolvedValue(true);
registry.registerOperation('op1', 'insights', 'profile1', 'Profile 1', mockRestart);
const originalOp = registry.getOperation('op1');
const originalStartTime = originalOp?.startedAt;
await registry.restartOperationsOnProfile('profile1', 'profile2', 'Profile 2');
const updatedOp = registry.getOperation('op1');
expect(updatedOp?.startedAt).toBe(originalStartTime);
});
});
});
@@ -34,7 +34,6 @@ import {
getKeychainServiceName,
getWindowsCredentialTarget,
getCredentialsFromKeychain,
getFullCredentialsFromKeychain,
getCredentials,
clearKeychainCache,
clearCredentialCache,
@@ -359,24 +358,19 @@ describe('credential-utils', () => {
vi.mocked(homedir).mockReturnValue('C:\\Users\\TestUser');
});
it('should return null when PowerShell not found and no credentials file exists', () => {
// Neither PowerShell nor credentials file exists
it('should return null when PowerShell not found', () => {
vi.mocked(existsSync).mockReturnValue(false);
const result = getCredentialsFromKeychain();
expect(result.token).toBeNull();
expect(result.email).toBeNull();
// No error because file fallback returns null gracefully when file doesn't exist
expect(result.error).toBe('PowerShell not found');
});
it('should return credentials from Windows Credential Manager when file is empty', () => {
// Mock PowerShell path found, but credentials file doesn't exist
vi.mocked(existsSync).mockImplementation((path: unknown) => {
const pathStr = String(path);
// PowerShell exists, but credentials file doesn't
return pathStr.includes('PowerShell') || pathStr.includes('powershell');
});
it('should return credentials from Windows Credential Manager', () => {
// Mock PowerShell path found
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-windows-token-789',
@@ -390,33 +384,9 @@ describe('credential-utils', () => {
expect(result.email).toBe('windows@example.com');
});
it('should fall back to file when Credential Manager returns empty', () => {
// Mock PowerShell exists but returns empty (no credential in Credential Manager)
// Mock file exists with valid credentials
it('should return null when credential not found', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(execFileSync).mockReturnValue(''); // Credential Manager empty
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-file-fallback-token',
email: 'file@example.com',
},
}));
const result = getCredentialsFromKeychain();
expect(result.token).toBe('sk-ant-file-fallback-token');
expect(result.email).toBe('file@example.com');
});
it('should return null when both Credential Manager and file have no credentials', () => {
// Mock PowerShell exists but returns empty
// Mock credentials file doesn't exist
vi.mocked(existsSync).mockImplementation((path: unknown) => {
const pathStr = String(path);
// PowerShell exists, but credentials file doesn't
return pathStr.includes('PowerShell') || pathStr.includes('powershell');
});
vi.mocked(execFileSync).mockReturnValue(''); // Credential Manager empty
vi.mocked(execFileSync).mockReturnValue('');
const result = getCredentialsFromKeychain();
@@ -424,137 +394,14 @@ describe('credential-utils', () => {
expect(result.email).toBeNull();
});
it('should handle invalid JSON from Credential Manager by falling back to file', () => {
it('should handle invalid JSON from Credential Manager', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(execFileSync).mockReturnValue('invalid json'); // Invalid JSON from Credential Manager
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-file-token-after-cm-failure',
email: 'fallback@example.com',
},
}));
vi.mocked(execFileSync).mockReturnValue('invalid json');
const result = getCredentialsFromKeychain();
// Should fall back to file and get valid credentials
expect(result.token).toBe('sk-ant-file-token-after-cm-failure');
expect(result.email).toBe('fallback@example.com');
});
it('should prefer file credentials when both sources have tokens', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-windows-file-token',
email: 'windowsfile@example.com',
},
}));
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-credman-token',
email: 'credman@example.com',
},
}));
const result = getCredentialsFromKeychain();
// Should prefer file since Claude CLI writes there after login
expect(result.token).toBe('sk-ant-windows-file-token');
expect(result.email).toBe('windowsfile@example.com');
});
});
describe('getFullCredentialsFromKeychain (Windows)', () => {
beforeEach(() => {
vi.mocked(isMacOS).mockReturnValue(false);
vi.mocked(isWindows).mockReturnValue(true);
vi.mocked(isLinux).mockReturnValue(false);
vi.mocked(homedir).mockReturnValue('C:\\Users\\TestUser');
clearCredentialCache();
});
it('should return full credentials from file when available', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-full-creds-token',
refreshToken: 'refresh-token-123',
expiresAt: 1700000000000,
email: 'full@example.com',
scopes: ['user:read', 'user:write'],
},
}));
vi.mocked(execFileSync).mockReturnValue(''); // Credential Manager empty
const result = getFullCredentialsFromKeychain();
expect(result.token).toBe('sk-ant-full-creds-token');
expect(result.refreshToken).toBe('refresh-token-123');
expect(result.expiresAt).toBe(1700000000000);
expect(result.email).toBe('full@example.com');
expect(result.scopes).toEqual(['user:read', 'user:write']);
});
it('should return credentials from Credential Manager when file is empty', () => {
vi.mocked(existsSync).mockImplementation((path: unknown) => {
const pathStr = String(path);
return pathStr.includes('PowerShell') || pathStr.includes('powershell');
});
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-credman-full-token',
refreshToken: 'credman-refresh',
expiresAt: 1700000000000,
email: 'credman@example.com',
},
}));
const result = getFullCredentialsFromKeychain();
expect(result.token).toBe('sk-ant-credman-full-token');
expect(result.refreshToken).toBe('credman-refresh');
expect(result.email).toBe('credman@example.com');
});
it('should prefer file credentials when both sources have tokens (consistent with basic API)', () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-file-full-token',
refreshToken: 'file-refresh',
expiresAt: 1700000000000,
email: 'file@example.com',
},
}));
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({
claudeAiOauth: {
accessToken: 'sk-ant-credman-full-token',
refreshToken: 'credman-refresh',
expiresAt: 1800000000000, // Later expiry
email: 'credman@example.com',
},
}));
const result = getFullCredentialsFromKeychain();
// Should prefer file since Claude CLI writes there after login
// This is consistent with getCredentialsFromKeychain behavior
expect(result.token).toBe('sk-ant-file-full-token');
expect(result.refreshToken).toBe('file-refresh');
expect(result.email).toBe('file@example.com');
});
it('should return null when both sources have no credentials', () => {
vi.mocked(existsSync).mockImplementation((path: unknown) => {
const pathStr = String(path);
return pathStr.includes('PowerShell') || pathStr.includes('powershell');
});
vi.mocked(execFileSync).mockReturnValue('');
const result = getFullCredentialsFromKeychain();
expect(result.token).toBeNull();
expect(result.refreshToken).toBeNull();
expect(result.email).toBeNull();
});
});
File diff suppressed because it is too large Load Diff
@@ -1,497 +0,0 @@
/**
* Unified registry for ALL Claude Agent SDK operations.
*
* This is the single source of truth for tracking running operations that use
* Claude profiles. It enables:
* 1. Proactive account swapping - restart operations on a different profile
* 2. Rate limit recovery - know which operations to restart after auth refresh
* 3. Usage attribution - track which profile is being used by which operation
*
* Operations include:
* - Autonomous tasks (spec creation, task execution)
* - GitHub PR reviews
* - GitLab MR reviews
* - Insights analysis
* - Roadmap generation
* - Changelog generation
* - Any other Claude SDK subprocess
*/
import { EventEmitter } from 'events';
/**
* Types of operations that use Claude SDK
*/
export type OperationType =
| 'spec-creation'
| 'task-execution'
| 'pr-review'
| 'mr-review'
| 'insights'
| 'roadmap'
| 'changelog'
| 'ideation'
| 'triage'
| 'other';
/**
* Registered operation entry
*
* IMPORTANT: Object reference stability during restarts
* =====================================================
* When an operation is restarted via restartFn, the restartFn implementation may
* choose to re-register the operation (creating a new RegisteredOperation object)
* OR update the existing one. Either approach is valid:
*
* 1. RE-REGISTRATION (AgentManager pattern):
* - restartFn calls registerOperation() which replaces the Map entry
* - Creates a new RegisteredOperation object with fresh closures
* - Previous object references become stale and should not be used
* - Callers MUST call getOperation(id) again to get the fresh reference
*
* 2. IN-PLACE UPDATE (alternative pattern):
* - restartFn updates internal state but doesn't re-register
* - Object reference remains valid
* - Registry calls updateOperationProfile() to sync profileId
*
* BEST PRACTICE for consumers:
* - Don't hold long-lived references to RegisteredOperation objects
* - Always use getOperation(id) to get current state
* - Subscribe to 'operation-restarted' events to know when to refresh
* - If you must hold a reference, listen for 'operation-restarted' and refresh it
*/
export interface RegisteredOperation {
/** Unique operation ID */
id: string;
/** Type of operation */
type: OperationType;
/** Profile ID currently being used */
profileId: string;
/** Profile name for logging */
profileName: string;
/** When the operation started */
startedAt: Date;
/** Optional metadata (project ID, PR number, etc.) */
metadata?: Record<string, unknown>;
/**
* Function to restart this operation with a new profile.
* Returns true if restart was initiated successfully.
* The registry will update the profileId after successful restart.
*
* IMPORTANT: This function may re-register the operation (creating a new object)
* or update in-place. Callers should use getOperation(id) after restart to get
* the current reference.
*/
restartFn: (newProfileId: string) => boolean | Promise<boolean>;
/**
* Optional function to stop the operation.
* Called before restart if provided.
*/
stopFn?: () => void | Promise<void>;
}
/**
* Events emitted by the operation registry
*
* NOTE: This interface is defined for documentation purposes only. It describes the event types
* that ClaudeOperationRegistry can emit, but is not currently enforced at the type system level.
* EventEmitter uses runtime event names, so type-safe event binding would require additional
* type assertion infrastructure. This interface serves as documentation for consumers of the
* operation registry to know which events are available and their callback signatures.
*/
export interface OperationRegistryEvents {
'operation-registered': (operation: RegisteredOperation) => void;
'operation-unregistered': (operationId: string, type: OperationType) => void;
'operation-restarted': (operationId: string, oldProfileId: string, newProfileId: string) => void;
'operations-restarted': (count: number, oldProfileId: string, newProfileId: string) => void;
'operation-profile-updated': (operationId: string, oldProfileId: string, newProfileId: string) => void;
}
/**
* Singleton registry for Claude SDK operations
*
* CONSUMER GUIDELINES: Object Reference Stability
* ================================================
* Operations may be restarted during profile swaps. When this happens:
*
* 1. The operation's restartFn is called with a new profileId
* 2. The restartFn may choose to:
* a) Re-register the operation (creates new RegisteredOperation object), OR
* b) Update internal state without re-registering (keeps same object)
*
* 3. Either pattern is valid, but has implications for consumers:
* - Pattern (a): Previous object references become stale
* - Pattern (b): Object references remain valid
*
* BEST PRACTICES for consumers:
* - Don't hold long-lived references to RegisteredOperation objects
* - Always use getOperation(id) to get current state when needed
* - Subscribe to 'operation-restarted' events to know when state may have changed
* - Use hasOperation(id) to verify an operation is still registered
*
* EXAMPLE: Safely working with operation references
* ```typescript
* const registry = getOperationRegistry();
*
* // Initial fetch
* let operation = registry.getOperation('task-123');
*
* // Listen for restarts
* registry.onOperationRestarted((operationId, oldProfileId, newProfileId) => {
* if (operationId === 'task-123') {
* // Refresh reference after restart
* operation = registry.getOperation('task-123');
* console.log('Operation restarted with new profile:', newProfileId);
* }
* });
*
* // When accessing operation state later, prefer fresh fetch:
* const currentOp = registry.getOperation('task-123');
* if (currentOp) {
* console.log('Current profile:', currentOp.profileId);
* }
* ```
*/
class ClaudeOperationRegistry extends EventEmitter {
private operations: Map<string, RegisteredOperation> = new Map();
private debugMode: boolean;
constructor() {
super();
this.debugMode = process.env.DEBUG === 'true';
}
private debugLog(...args: unknown[]): void {
if (this.debugMode) {
console.log('[OperationRegistry]', ...args);
}
}
/**
* Register a new operation
*/
registerOperation(
id: string,
type: OperationType,
profileId: string,
profileName: string,
restartFn: RegisteredOperation['restartFn'],
options?: {
stopFn?: RegisteredOperation['stopFn'];
metadata?: Record<string, unknown>;
}
): void {
const operation: RegisteredOperation = {
id,
type,
profileId,
profileName,
startedAt: new Date(),
restartFn,
stopFn: options?.stopFn,
metadata: options?.metadata,
};
this.operations.set(id, operation);
this.debugLog('Operation registered:', {
id,
type,
profileId,
profileName,
metadata: options?.metadata,
});
this.emit('operation-registered', operation);
}
/**
* Unregister an operation (when it completes or is cancelled)
*/
unregisterOperation(id: string): void {
const operation = this.operations.get(id);
if (operation) {
this.operations.delete(id);
this.debugLog('Operation unregistered:', { id, type: operation.type });
this.emit('operation-unregistered', id, operation.type);
}
}
/**
* Get all operations running on a specific profile
*/
getOperationsByProfile(profileId: string): RegisteredOperation[] {
const result: RegisteredOperation[] = [];
for (const op of this.operations.values()) {
if (op.profileId === profileId) {
result.push(op);
}
}
return result;
}
/**
* Get all running operations grouped by profile
*/
getAllOperationsByProfile(): Record<string, RegisteredOperation[]> {
const result: Record<string, RegisteredOperation[]> = {};
for (const op of this.operations.values()) {
if (!result[op.profileId]) {
result[op.profileId] = [];
}
result[op.profileId].push(op);
}
return result;
}
/**
* Get operation by ID
*
* IMPORTANT: Always call this method to get the current operation state.
* Don't hold long-lived references to RegisteredOperation objects, as they
* may become stale after a restart. Instead, call getOperation(id) whenever
* you need current state, or subscribe to 'operation-restarted' events.
*/
getOperation(id: string): RegisteredOperation | undefined {
return this.operations.get(id);
}
/**
* Check if an operation exists and is currently registered.
* Use this to verify an operation reference is still valid.
*
* @param id - Operation ID to check
* @returns true if operation exists in registry, false otherwise
*/
hasOperation(id: string): boolean {
return this.operations.has(id);
}
/**
* Get count of running operations
*/
getOperationCount(): number {
return this.operations.size;
}
/**
* Get summary of running operations for logging
*/
getSummary(): {
totalRunning: number;
byProfile: Record<string, string[]>;
byType: Record<OperationType, number>;
} {
const byProfile: Record<string, string[]> = {};
const byType: Record<string, number> = {};
for (const op of this.operations.values()) {
// By profile
if (!byProfile[op.profileId]) {
byProfile[op.profileId] = [];
}
byProfile[op.profileId].push(op.id);
// By type
byType[op.type] = (byType[op.type] || 0) + 1;
}
return {
totalRunning: this.operations.size,
byProfile,
byType: byType as Record<OperationType, number>,
};
}
/**
* Restart all operations running on a specific profile with a new profile.
* This is called by UsageMonitor during proactive swaps.
*
* IMPORTANT: Object reference stability after restart
* ====================================================
* When operations are restarted, their restartFn implementations may:
* 1. Re-register the operation (AgentManager pattern) - creates new object
* 2. Update in-place (alternative pattern) - keeps same object
*
* For consumers holding operation references:
* - Your reference may become stale if the operation re-registers
* - Always call getOperation(id) after this method to get fresh reference
* - Or subscribe to 'operation-restarted' events and refresh on each event
*
* This method emits:
* - 'operation-restarted' for each successful restart (use this to refresh refs)
* - 'operations-restarted' once with total count
* - 'operation-profile-updated' for each profile update
*
* @param oldProfileId - Profile ID to migrate away from
* @param newProfileId - Profile ID to migrate to
* @param newProfileName - Profile name for logging
* @returns Number of operations that were restarted
*/
async restartOperationsOnProfile(
oldProfileId: string,
newProfileId: string,
newProfileName: string
): Promise<number> {
const operations = this.getOperationsByProfile(oldProfileId);
if (operations.length === 0) {
this.debugLog('No operations to restart on profile:', oldProfileId);
return 0;
}
console.log('[OperationRegistry] Restarting', operations.length, 'operations:', {
from: oldProfileId,
to: newProfileId,
operations: operations.map(op => ({ id: op.id, type: op.type })),
});
let restartedCount = 0;
for (const op of operations) {
try {
// Stop the operation first if a stop function is provided
if (op.stopFn) {
this.debugLog('Stopping operation before restart:', op.id);
await op.stopFn();
}
// Call the restart function
this.debugLog('Restarting operation:', op.id, 'with profile:', newProfileId);
const success = await op.restartFn(newProfileId);
if (success) {
restartedCount++;
// Update the profile for operations that weren't re-registered during restart.
// For AgentManager tasks, restartFn may create a NEW object in the Map,
// in which case this update is harmless (updates the new reference).
// For other operations, this ensures the profile is properly updated.
this.updateOperationProfile(op.id, newProfileId, newProfileName);
// Re-fetch from Map to get the current object (restartFn may have
// re-registered the operation with a new object)
const currentOp = this.operations.get(op.id);
console.log('[OperationRegistry] Operation restarted successfully:', {
id: op.id,
type: currentOp?.type ?? op.type,
newProfile: newProfileName,
});
this.emit('operation-restarted', op.id, oldProfileId, newProfileId);
} else {
console.warn('[OperationRegistry] Operation restart returned false:', op.id);
}
} catch (error) {
console.error('[OperationRegistry] Failed to restart operation:', op.id, error);
}
}
if (restartedCount > 0) {
this.emit('operations-restarted', restartedCount, oldProfileId, newProfileId);
}
console.log('[OperationRegistry] Restart complete:', {
total: operations.length,
succeeded: restartedCount,
failed: operations.length - restartedCount,
});
return restartedCount;
}
/**
* Update the profile assignment for an operation (e.g., after restart)
*/
updateOperationProfile(id: string, newProfileId: string, newProfileName: string): void {
const operation = this.operations.get(id);
if (operation) {
const oldProfileId = operation.profileId;
operation.profileId = newProfileId;
operation.profileName = newProfileName;
this.debugLog('Operation profile updated:', {
id,
from: oldProfileId,
to: newProfileId,
});
this.emit('operation-profile-updated', id, oldProfileId, newProfileId);
}
}
/**
* Clear all registered operations (for testing or cleanup)
*/
clear(): void {
this.operations.clear();
this.debugLog('All operations cleared');
}
/**
* Type-safe event subscription: operation-registered
* Subscribe to operation registration events
*/
onOperationRegistered(callback: (operation: RegisteredOperation) => void): () => void {
this.on('operation-registered', callback);
return () => this.off('operation-registered', callback);
}
/**
* Type-safe event subscription: operation-unregistered
* Subscribe to operation unregistration events
*/
onOperationUnregistered(callback: (operationId: string, type: OperationType) => void): () => void {
this.on('operation-unregistered', callback);
return () => this.off('operation-unregistered', callback);
}
/**
* Type-safe event subscription: operation-restarted
* Subscribe to individual operation restart events
*/
onOperationRestarted(callback: (operationId: string, oldProfileId: string, newProfileId: string) => void): () => void {
this.on('operation-restarted', callback);
return () => this.off('operation-restarted', callback);
}
/**
* Type-safe event subscription: operations-restarted
* Subscribe to batch operation restart events
*/
onOperationsRestarted(callback: (count: number, oldProfileId: string, newProfileId: string) => void): () => void {
this.on('operations-restarted', callback);
return () => this.off('operations-restarted', callback);
}
/**
* Type-safe event subscription: operation-profile-updated
* Subscribe to operation profile update events
*/
onOperationProfileUpdated(callback: (operationId: string, oldProfileId: string, newProfileId: string) => void): () => void {
this.on('operation-profile-updated', callback);
return () => this.off('operation-profile-updated', callback);
}
}
// Singleton instance
let registryInstance: ClaudeOperationRegistry | null = null;
/**
* Get the singleton ClaudeOperationRegistry instance
*/
export function getOperationRegistry(): ClaudeOperationRegistry {
if (!registryInstance) {
registryInstance = new ClaudeOperationRegistry();
}
return registryInstance;
}
/**
* Reset the registry (for testing)
*/
export function resetOperationRegistry(): void {
if (registryInstance) {
registryInstance.clear();
registryInstance.removeAllListeners();
}
registryInstance = null;
}
@@ -26,7 +26,6 @@ export const DEFAULT_AUTO_SWITCH_SETTINGS: ClaudeAutoSwitchSettings = {
sessionThreshold: 95, // Consider switching at 95% session usage
weeklyThreshold: 99, // Consider switching at 99% weekly usage
autoSwitchOnRateLimit: false, // Prompt user by default
autoSwitchOnAuthFailure: false, // Prompt user by default on auth failures
usageCheckInterval: 30000 // Check every 30s when enabled (0 = disabled)
};
@@ -207,7 +207,7 @@ export function hasValidToken(profile: ClaudeProfile): boolean {
* Expand ~ in path to home directory
*/
export function expandHomePath(path: string): string {
if (path?.startsWith('~')) {
if (path && path.startsWith('~')) {
const home = homedir();
return path.replace(/^~/, home);
}
@@ -12,6 +12,7 @@ import {
refreshOAuthToken,
ensureValidToken,
reactiveTokenRefresh,
type TokenRefreshResult
} from './token-refresh';
// Mock credential-utils
@@ -19,7 +19,6 @@ import { detectProvider as sharedDetectProvider, type ApiProvider } from '../../
import { getCredentialsFromKeychain, clearKeychainCache } from './credential-utils';
import { reactiveTokenRefresh, ensureValidToken } from './token-refresh';
import { isProfileRateLimited } from './rate-limit-manager';
import { getOperationRegistry } from './operation-registry';
// Re-export for backward compatibility
export type { ApiProvider };
@@ -776,7 +775,7 @@ export class UsageMonitor extends EventEmitter {
const activeProfile = profilesFile.profiles.find(
(p) => p.id === profilesFile.activeProfileId
);
if (activeProfile?.apiKey) {
if (activeProfile && activeProfile.apiKey) {
this.debugLog('[UsageMonitor:TRACE] Using API profile credential: ' + activeProfile.name);
return activeProfile.apiKey;
}
@@ -1334,7 +1333,7 @@ export class UsageMonitor extends EventEmitter {
let baseUrl: string;
let provider: ApiProvider;
if (activeProfile?.isAPIProfile) {
if (activeProfile && activeProfile.isAPIProfile) {
// Use the pre-determined profile to avoid race conditions
// Trust the activeProfile data and use baseUrl directly
baseUrl = activeProfile.baseUrl;
@@ -1348,7 +1347,7 @@ export class UsageMonitor extends EventEmitter {
const profilesFile = await loadProfilesFile();
apiProfile = profilesFile.profiles.find(p => p.id === profileId);
if (apiProfile?.apiKey) {
if (apiProfile && apiProfile.apiKey) {
// API profile found
baseUrl = apiProfile.baseUrl;
provider = detectProvider(baseUrl);
@@ -2008,46 +2007,6 @@ export class UsageMonitor extends EventEmitter {
limitType
});
// PROACTIVE OPERATION RESTART: Stop and restart all running Claude SDK operations with new profile credentials
// This includes autonomous tasks, PR reviews, insights, roadmap, etc.
// Claude Agent SDK sessions maintain state independently of auth tokens, so no progress is lost
const operationRegistry = getOperationRegistry();
const operationSummary = operationRegistry.getSummary();
const operationIdsOnOldProfile = operationSummary.byProfile[currentProfileId] || [];
// Always log running operations info for debugging
console.log('[UsageMonitor] PROACTIVE-SWAP: Checking running operations:', {
oldProfileId: currentProfileId,
newProfileId: bestAccount.id,
totalRunning: operationSummary.totalRunning,
byProfile: operationSummary.byProfile,
byType: operationSummary.byType,
operationIdsOnOldProfile: operationIdsOnOldProfile
});
if (operationIdsOnOldProfile.length > 0) {
console.log('[UsageMonitor] PROACTIVE-SWAP: Found', operationIdsOnOldProfile.length, 'operations to restart:', operationIdsOnOldProfile);
// Restart all operations on the old profile with the new profile
const restartedCount = await operationRegistry.restartOperationsOnProfile(
currentProfileId,
bestAccount.id,
bestAccount.name
);
// Emit event for tracking/logging
this.emit('proactive-operations-restarted', {
fromProfile: { id: currentProfileId, name: fromProfileName },
toProfile: { id: bestAccount.id, name: bestAccount.name },
operationIds: operationIdsOnOldProfile,
restartedCount,
limitType,
timestamp: new Date()
});
} else {
console.log('[UsageMonitor] PROACTIVE-SWAP: No operations running on old profile', currentProfileId, '- swap complete without restart');
}
// Note: Don't immediately check new profile - let normal interval handle it
// This prevents cascading swaps if multiple profiles are near limits
}
+1 -266
View File
@@ -46,7 +46,6 @@ import {
getWindowsExecutablePaths,
getWindowsExecutablePathsAsync,
WINDOWS_GIT_PATHS,
WINDOWS_GLAB_PATHS,
findWindowsExecutableViaWhere,
findWindowsExecutableViaWhereAsync,
isSecurePath,
@@ -55,7 +54,7 @@ import {
/**
* Supported CLI tools managed by this system
*/
export type CLITool = 'python' | 'git' | 'gh' | 'glab' | 'claude';
export type CLITool = 'python' | 'git' | 'gh' | 'claude';
/**
* User configuration for CLI tool paths
@@ -65,7 +64,6 @@ export interface ToolConfig {
pythonPath?: string;
gitPath?: string;
githubCLIPath?: string;
gitlabCLIPath?: string;
claudePath?: string;
}
@@ -372,8 +370,6 @@ class CLIToolManager {
return this.detectGit();
case 'gh':
return this.detectGitHubCLI();
case 'glab':
return this.detectGitLabCLI();
case 'claude':
return this.detectClaude();
default:
@@ -723,105 +719,6 @@ class CLIToolManager {
};
}
/**
* Detect GitLab CLI with multi-level priority
*
* Priority order:
* 1. User configuration (if valid for current platform)
* 2. Homebrew glab (macOS)
* 3. System PATH
* 4. Windows Program Files
*
* @returns Detection result for GitLab CLI
*/
private detectGitLabCLI(): ToolDetectionResult {
// 1. User configuration
if (this.userConfig.gitlabCLIPath) {
// Check if path is from wrong platform (e.g., Windows path on macOS)
if (isWrongPlatformPath(this.userConfig.gitlabCLIPath)) {
console.warn(
`[GitLab CLI] User-configured path is from different platform, ignoring: ${this.userConfig.gitlabCLIPath}`
);
} else {
const validation = this.validateGitLabCLI(this.userConfig.gitlabCLIPath);
if (validation.valid) {
return {
found: true,
path: this.userConfig.gitlabCLIPath,
version: validation.version,
source: 'user-config',
message: `Using user-configured GitLab CLI: ${this.userConfig.gitlabCLIPath}`,
};
}
console.warn(
`[GitLab CLI] User-configured path invalid: ${validation.message}`
);
}
}
// 2. Homebrew (macOS)
if (isMacOS()) {
const homebrewPaths = [
'/opt/homebrew/bin/glab', // Apple Silicon
'/usr/local/bin/glab', // Intel Mac
];
for (const glabPath of homebrewPaths) {
if (existsSync(glabPath)) {
const validation = this.validateGitLabCLI(glabPath);
if (validation.valid) {
return {
found: true,
path: glabPath,
version: validation.version,
source: 'homebrew',
message: `Using Homebrew GitLab CLI: ${glabPath}`,
};
}
}
}
}
// 3. System PATH (augmented)
const glabPath = findExecutable('glab');
if (glabPath) {
const validation = this.validateGitLabCLI(glabPath);
if (validation.valid) {
return {
found: true,
path: glabPath,
version: validation.version,
source: 'system-path',
message: `Using system GitLab CLI: ${glabPath}`,
};
}
}
// 4. Windows Program Files
if (isWindows()) {
const windowsPaths = getWindowsExecutablePaths(WINDOWS_GLAB_PATHS, '[GitLab CLI]');
for (const glabPath of windowsPaths) {
const validation = this.validateGitLabCLI(glabPath);
if (validation.valid) {
return {
found: true,
path: glabPath,
version: validation.version,
source: 'system-path',
message: `Using Windows GitLab CLI: ${glabPath}`,
};
}
}
}
// 5. Not found
return {
found: false,
source: 'fallback',
message: 'GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli',
};
}
/**
* Detect Claude CLI with multi-level priority
*
@@ -949,7 +846,6 @@ class CLIToolManager {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
env: getAugmentedEnv(),
}).trim();
const match = version.match(/Python (\d+\.\d+\.\d+)/);
@@ -1000,7 +896,6 @@ class CLIToolManager {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
env: getAugmentedEnv(),
}).trim();
const match = version.match(/git version (\d+\.\d+\.\d+)/);
@@ -1031,7 +926,6 @@ class CLIToolManager {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
env: getAugmentedEnv(),
}).trim();
const match = version.match(/gh version (\d+\.\d+\.\d+)/);
@@ -1050,38 +944,6 @@ class CLIToolManager {
}
}
/**
* Validate GitLab CLI availability and version
*
* @param glabCmd - The GitLab CLI command to validate
* @returns Validation result with version information
*/
private validateGitLabCLI(glabCmd: string): ToolValidation {
try {
const version = execFileSync(glabCmd, ['--version'], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
env: getAugmentedEnv(),
}).trim();
// glab version output format: "glab X.Y.Z (hash)" - note: no "version" word
const match = version.match(/glab\s+(\d+\.\d+\.\d+)/);
const versionStr = match ? match[1] : version.split('\n')[0];
return {
valid: true,
version: versionStr,
message: `GitLab CLI ${versionStr} is available`,
};
} catch (error) {
return {
valid: false,
message: `Failed to validate GitLab CLI: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* Validate Claude CLI availability and version
*
@@ -1235,8 +1097,6 @@ class CLIToolManager {
return this.detectGitAsync();
case 'gh':
return this.detectGitHubCLIAsync();
case 'glab':
return this.detectGitLabCLIAsync();
default:
return {
found: false,
@@ -1438,39 +1298,6 @@ class CLIToolManager {
}
}
/**
* Validate GitLab CLI availability and version asynchronously (non-blocking)
*
* @param glabCmd - The GitLab CLI command to validate
* @returns Promise resolving to validation result
*/
private async validateGitLabCLIAsync(glabCmd: string): Promise<ToolValidation> {
try {
const { stdout } = await execFileAsync(glabCmd, ['--version'], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
env: await getAugmentedEnvAsync(),
});
const version = stdout.trim();
// glab version output format: "glab X.Y.Z (hash)" - note: no "version" word
const match = version.match(/glab\s+(\d+\.\d+\.\d+)/);
const versionStr = match ? match[1] : version.split('\n')[0];
return {
valid: true,
version: versionStr,
message: `GitLab CLI ${versionStr} is available`,
};
} catch (error) {
return {
valid: false,
message: `Failed to validate GitLab CLI: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* Detect Claude CLI asynchronously (non-blocking)
*
@@ -1897,98 +1724,6 @@ class CLIToolManager {
};
}
/**
* Detect GitLab CLI asynchronously (non-blocking)
*
* Same detection logic as detectGitLabCLI but uses async validation.
*
* @returns Promise resolving to detection result
*/
private async detectGitLabCLIAsync(): Promise<ToolDetectionResult> {
// 1. User configuration
if (this.userConfig.gitlabCLIPath) {
if (isWrongPlatformPath(this.userConfig.gitlabCLIPath)) {
console.warn(
`[GitLab CLI] User-configured path is from different platform, ignoring: ${this.userConfig.gitlabCLIPath}`
);
} else {
const validation = await this.validateGitLabCLIAsync(this.userConfig.gitlabCLIPath);
if (validation.valid) {
return {
found: true,
path: this.userConfig.gitlabCLIPath,
version: validation.version,
source: 'user-config',
message: `Using user-configured GitLab CLI: ${this.userConfig.gitlabCLIPath}`,
};
}
console.warn(`[GitLab CLI] User-configured path invalid: ${validation.message}`);
}
}
// 2. Homebrew (macOS)
if (isMacOS()) {
const homebrewPaths = [
'/opt/homebrew/bin/glab',
'/usr/local/bin/glab',
];
for (const glabPath of homebrewPaths) {
if (await existsAsync(glabPath)) {
const validation = await this.validateGitLabCLIAsync(glabPath);
if (validation.valid) {
return {
found: true,
path: glabPath,
version: validation.version,
source: 'homebrew',
message: `Using Homebrew GitLab CLI: ${glabPath}`,
};
}
}
}
}
// 3. System PATH (augmented)
const glabPath = await findExecutableAsync('glab');
if (glabPath) {
const validation = await this.validateGitLabCLIAsync(glabPath);
if (validation.valid) {
return {
found: true,
path: glabPath,
version: validation.version,
source: 'system-path',
message: `Using system GitLab CLI: ${glabPath}`,
};
}
}
// 4. Windows Program Files
if (isWindows()) {
const windowsPaths = await getWindowsExecutablePathsAsync(WINDOWS_GLAB_PATHS, '[GitLab CLI]');
for (const winGlabPath of windowsPaths) {
const validation = await this.validateGitLabCLIAsync(winGlabPath);
if (validation.valid) {
return {
found: true,
path: winGlabPath,
version: validation.version,
source: 'system-path',
message: `Using Windows GitLab CLI: ${winGlabPath}`,
};
}
}
}
// 5. Not found
return {
found: false,
source: 'fallback',
message: 'GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli',
};
}
/**
* Get bundled Python path for packaged apps
*
+1 -1
View File
@@ -95,7 +95,7 @@ function getNpmGlobalPrefix(): string | null {
const normalizedPath = path.normalize(binPath);
return fs.existsSync(normalizedPath) ? normalizedPath : null;
} catch (_error) {
} catch (error) {
// Fallback for Windows: try default npm global location when npm.cmd is not in PATH
// This happens when the packaged app launches from GUI without full shell environment
if (isWindows()) {

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