fix: address second round of code review feedback

Backend improvements:
- Create shared validation utility for project name validation
- Add asyncio.Lock to prevent concurrent _query_claude calls
- Fix _create_features_bulk: use flush() for IDs, add rollback on error
- Use unique temp settings file instead of overwriting .claude_settings.json
- Remove exception details from error messages (security)

Frontend improvements:
- Memoize onError callback in ExpandProjectChat for stable dependencies
- Add timeout to start() checkAndSend loop to prevent infinite retries
- Add manuallyDisconnectedRef to prevent reconnection after explicit disconnect
- Clear pending reconnect timeout in disconnect()

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Dan Gentry
2026-01-09 23:57:50 -05:00
parent 75f2bf2a10
commit cdcbd11272
7 changed files with 106 additions and 53 deletions

View File

@@ -6,11 +6,13 @@ Manages interactive project expansion conversation with Claude.
Uses the expand-project.md skill to help users add features to existing projects.
"""
import asyncio
import json
import logging
import re
import shutil
import threading
import uuid
from datetime import datetime
from pathlib import Path
from typing import AsyncGenerator, Optional
@@ -68,6 +70,7 @@ class ExpandChatSession:
self.features_created: int = 0
self.created_feature_ids: list[int] = []
self._settings_file: Optional[Path] = None
self._query_lock = asyncio.Lock()
async def close(self) -> None:
"""Clean up resources and close the Claude client."""
@@ -117,7 +120,16 @@ class ExpandChatSession:
except UnicodeDecodeError:
skill_content = skill_path.read_text(encoding="utf-8", errors="replace")
# Create security settings file
# Find and validate Claude CLI before creating temp files
system_cli = shutil.which("claude")
if not system_cli:
yield {
"type": "error",
"content": "Claude CLI not found. Please install Claude Code."
}
return
# Create temporary security settings file (unique per session to avoid conflicts)
security_settings = {
"sandbox": {"enabled": True},
"permissions": {
@@ -128,23 +140,16 @@ class ExpandChatSession:
],
},
}
settings_file = self.project_dir / ".claude_settings.json"
settings_file = self.project_dir / f".claude_settings.expand.{uuid.uuid4().hex}.json"
self._settings_file = settings_file
with open(settings_file, "w") as f:
with open(settings_file, "w", encoding="utf-8") as f:
json.dump(security_settings, f, indent=2)
# Replace $ARGUMENTS with absolute project path
project_path = str(self.project_dir.resolve())
system_prompt = skill_content.replace("$ARGUMENTS", project_path)
# Find and validate Claude CLI
system_cli = shutil.which("claude")
if not system_cli:
yield {
"type": "error",
"content": "Claude CLI not found. Please install Claude Code."
}
return
# Create Claude SDK client
try:
self.client = ClaudeSDKClient(
options=ClaudeAgentOptions(
@@ -167,20 +172,21 @@ class ExpandChatSession:
logger.exception("Failed to create Claude client")
yield {
"type": "error",
"content": f"Failed to initialize Claude: {str(e)}"
"content": "Failed to initialize Claude"
}
return
# Start the conversation
try:
async for chunk in self._query_claude("Begin the project expansion process."):
yield chunk
async with self._query_lock:
async for chunk in self._query_claude("Begin the project expansion process."):
yield chunk
yield {"type": "response_done"}
except Exception as e:
logger.exception("Failed to start expand chat")
yield {
"type": "error",
"content": f"Failed to start conversation: {str(e)}"
"content": "Failed to start conversation"
}
async def send_message(
@@ -218,14 +224,16 @@ class ExpandChatSession:
})
try:
async for chunk in self._query_claude(user_message, attachments):
yield chunk
# Use lock to prevent concurrent queries from corrupting the response stream
async with self._query_lock:
async for chunk in self._query_claude(user_message, attachments):
yield chunk
yield {"type": "response_done"}
except Exception as e:
logger.exception("Error during Claude query")
yield {
"type": "error",
"content": f"Error: {str(e)}"
"content": "Error while processing message"
}
async def _query_claude(
@@ -340,6 +348,10 @@ class ExpandChatSession:
Returns:
List of created feature dictionaries with IDs
Note:
Uses flush() to get IDs immediately without re-querying by priority range,
which could pick up rows from concurrent writers.
"""
# Import database classes
import sys
@@ -358,7 +370,7 @@ class ExpandChatSession:
max_priority_feature = session.query(Feature).order_by(Feature.priority.desc()).first()
current_priority = (max_priority_feature.priority + 1) if max_priority_feature else 1
created_features = []
created_rows: list = []
for f in features:
db_feature = Feature(
@@ -370,24 +382,28 @@ class ExpandChatSession:
passes=False,
)
session.add(db_feature)
created_rows.append(db_feature)
current_priority += 1
session.commit()
# Flush to get IDs without relying on priority range query
session.flush()
# Re-query to get the created features with IDs
start_priority = current_priority - len(features)
for db_feature in session.query(Feature).filter(
Feature.priority >= start_priority,
Feature.priority < current_priority
).order_by(Feature.priority).all():
created_features.append({
# Build result from the flushed objects (IDs are now populated)
created_features = [
{
"id": db_feature.id,
"name": db_feature.name,
"category": db_feature.category,
})
}
for db_feature in created_rows
]
session.commit()
return created_features
except Exception:
session.rollback()
raise
finally:
session.close()