From 02c757f70302dd8cf94ccafa3bf40e74213c9806 Mon Sep 17 00:00:00 2001 From: CD Date: Sat, 7 Mar 2026 10:58:10 +0900 Subject: [PATCH] =?UTF-8?q?fix(bridge):=20=EC=B1=84=EB=84=90=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=83=9D=EC=84=B1=20race=20condition=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20+=20AG-=20=EC=A0=91=EB=91=90=EC=82=AC=20+=20metadat?= =?UTF-8?q?a=20=EB=AA=A8=EB=8B=88=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 163 ++++++++++++++++++++++++++++++++++++----------------- config.py | 7 ++- main.py | 7 ++- watcher.py | 4 +- 4 files changed, 123 insertions(+), 58 deletions(-) diff --git a/bot.py b/bot.py index 6ef8fe6..c261632 100644 --- a/bot.py +++ b/bot.py @@ -33,26 +33,37 @@ def detect_project_name(conv_dir: Path) -> str: """Extract a human-readable project name from conversation artifacts. Strategy: - 1. Check task.md first line for a title (# Title) - 2. Check implementation_plan.md first line + 1. Check task.md first heading for a title + 2. Check implementation_plan.md first heading 3. Check any .metadata.json for summary keywords 4. Fallback to short conversation ID + + Output format: lowercase_with_underscores (e.g. 'gravity_control') """ short_id = conv_dir.name[:8] + def _sanitize(raw: str) -> str: + """Convert raw title to Discord-friendly channel name part.""" + # Remove common suffixes + for suffix in ["Task Tracker", "— Task Tracker", "태스크", "구현 계획"]: + raw = raw.replace(suffix, "") + raw = raw.strip(" —-–") + # Keep only alphanumeric, Korean, spaces, hyphens + raw = re.sub(r'[^a-zA-Z0-9가-힣\s\-]', '', raw) + # Convert to underscore format + raw = re.sub(r'[\s\-]+', '_', raw).strip('_').lower() + return raw[:30] if raw else "" + # Try task.md title task_file = conv_dir / "task.md" if task_file.exists(): try: first_lines = task_file.read_text(encoding="utf-8").splitlines()[:5] for line in first_lines: - match = re.match(r'^#\s+(.+?)[\s—\-]+', line) + match = re.match(r'^#\s+(.+)', line) if match: - name = match.group(1).strip() - # Sanitize for Discord channel name - name = re.sub(r'[^a-zA-Z0-9가-힣\s\-]', '', name) - name = re.sub(r'\s+', '-', name).lower()[:30] - if name: + name = _sanitize(match.group(1)) + if name and name != "task": return name except (OSError, UnicodeDecodeError): pass @@ -63,26 +74,23 @@ def detect_project_name(conv_dir: Path) -> str: try: first_lines = plan_file.read_text(encoding="utf-8").splitlines()[:5] for line in first_lines: - match = re.match(r'^#\s+(.+?)[\s—\-]+', line) + match = re.match(r'^#\s+(.+)', line) if match: - name = match.group(1).strip() - name = re.sub(r'[^a-zA-Z0-9가-힣\s\-]', '', name) - name = re.sub(r'\s+', '-', name).lower()[:30] + name = _sanitize(match.group(1)) if name: return name except (OSError, UnicodeDecodeError): pass - # Try metadata summary + # Try metadata summary (first 2 words) for meta_file in conv_dir.glob("*.metadata.json"): try: meta = json.loads(meta_file.read_text(encoding="utf-8")) summary = meta.get("summary", "") - # Extract first meaningful noun/phrase - words = summary.split()[:3] + words = summary.split()[:2] if words: - name = "-".join(words).lower() - name = re.sub(r'[^a-z0-9가-힣\-]', '', name)[:30] + name = "_".join(words).lower() + name = re.sub(r'[^a-z0-9가-힣_]', '', name)[:30] if name: return name except (OSError, json.JSONDecodeError): @@ -123,6 +131,8 @@ class GravityBot(commands.Bot): self.session_status_messages: dict[str, int] = {} # conversation_id -> project name self.session_names: dict[str, str] = {} + # Locks to prevent duplicate channel creation + self._channel_locks: dict[str, asyncio.Lock] = {} # Category for session channels self.session_category: discord.CategoryChannel | None = None # Guild reference @@ -184,49 +194,58 @@ class GravityBot(commands.Bot): async def _ensure_channel( self, conversation_id: str, project_name: str ) -> discord.TextChannel: - """Get or create a Discord channel for a session.""" - # Check if channel already exists + """Get or create a Discord channel for a session (thread-safe).""" + # Fast path: already mapped if conversation_id in self.session_channels: return self.session_channels[conversation_id] - channel_name = f"{Config.CHANNEL_PREFIX}-{project_name}" + # Get or create a lock for this conversation + if conversation_id not in self._channel_locks: + self._channel_locks[conversation_id] = asyncio.Lock() - # Check if channel already exists in category (from previous run) - if self.session_category: - for ch in self.session_category.text_channels: - if ch.topic and conversation_id in ch.topic: - self.session_channels[conversation_id] = ch - self.session_names[conversation_id] = project_name - logger.info(f"Reconnected to existing channel #{ch.name}") - return ch + async with self._channel_locks[conversation_id]: + # Double-check after acquiring lock + if conversation_id in self.session_channels: + return self.session_channels[conversation_id] - # Create new channel - try: - channel = await self.guild.create_text_channel( - name=channel_name, - category=self.session_category, - topic=f"Antigravity Session: {conversation_id}", - ) - self.session_channels[conversation_id] = channel - self.session_names[conversation_id] = project_name - logger.info(f"Created channel #{channel_name}") + channel_name = f"{Config.CHANNEL_PREFIX}-{project_name}" - # Welcome embed - embed = discord.Embed( - title=f"🚀 {project_name}", - description=( - f"Antigravity 세션 연결됨\n" - f"Session: `{conversation_id}`" - ), - color=discord.Color.blue(), - timestamp=datetime.now(timezone.utc), - ) - await channel.send(embed=embed) - return channel + # Check if channel already exists in category (from previous run) + if self.session_category: + for ch in self.session_category.text_channels: + if ch.topic and conversation_id in ch.topic: + self.session_channels[conversation_id] = ch + self.session_names[conversation_id] = project_name + logger.info(f"Reconnected to existing channel #{ch.name}") + return ch - except discord.errors.Forbidden: - logger.error(f"No permission to create channel: {channel_name}") - return None + # Create new channel + try: + channel = await self.guild.create_text_channel( + name=channel_name, + category=self.session_category, + topic=f"Antigravity Session: {conversation_id}", + ) + self.session_channels[conversation_id] = channel + self.session_names[conversation_id] = project_name + logger.info(f"Created channel #{channel_name}") + + # Welcome embed + embed = discord.Embed( + title=f"🚀 {project_name}", + description=( + f"Antigravity 세션 연결됨\n" + f"Session: `{conversation_id}`" + ), + color=discord.Color.blue(), + timestamp=datetime.now(timezone.utc), + ) + await channel.send(embed=embed) + return channel + + except discord.errors.Forbidden: + logger.error(f"No permission to create channel: {channel_name}") + return None async def _process_events(self): """Main event processing loop.""" @@ -259,6 +278,8 @@ class GravityBot(commands.Bot): if channel: if event.file_name == "task.md": await self._send_task_update(channel, event) + elif event.file_name.endswith(".metadata.json"): + await self._send_metadata_update(channel, event) else: await self._send_artifact_content(channel, event) @@ -311,6 +332,42 @@ class GravityBot(commands.Bot): await channel.send(chunk) await asyncio.sleep(0.5) + async def _send_metadata_update( + self, channel: discord.TextChannel, event: BrainEvent + ): + """Send artifact metadata summary changes as compact embed.""" + try: + meta = json.loads(event.content) + except json.JSONDecodeError: + return + + summary = meta.get("summary", "") + artifact_type = meta.get("artifactType", "") + updated_at = meta.get("updatedAt", "") + + if not summary: + return + + # Map artifact types to emoji + type_emoji = { + "ARTIFACT_TYPE_TASK": "📋", + "ARTIFACT_TYPE_IMPLEMENTATION_PLAN": "📐", + "ARTIFACT_TYPE_WALKTHROUGH": "📝", + "ARTIFACT_TYPE_OTHER": "📄", + } + emoji = type_emoji.get(artifact_type, "📄") + + # Artifact name from filename (strip .metadata.json) + artifact_name = event.file_name.replace(".metadata.json", "") + + embed = discord.Embed( + description=f"{emoji} **{artifact_name}** 업데이트\n\n{summary[:500]}", + color=discord.Color.dark_teal(), + timestamp=datetime.now(timezone.utc), + ) + + await channel.send(embed=embed) + @tasks.loop(minutes=5) async def session_cleanup_loop(self): """Periodically check for inactive sessions and archive their channels.""" diff --git a/config.py b/config.py index f828266..e5d7cb4 100644 --- a/config.py +++ b/config.py @@ -34,12 +34,17 @@ class Config: "walkthrough.md", } + # Also monitor these patterns (matched by suffix) + WATCHED_SUFFIXES: set = { + ".metadata.json", # artifact summary changes + } + # Discord message limits DISCORD_MSG_LIMIT: int = 2000 DISCORD_EMBED_DESC_LIMIT: int = 4096 # Channel naming - CHANNEL_PREFIX: str = "gravity" + CHANNEL_PREFIX: str = "AG" @classmethod def validate(cls) -> list[str]: diff --git a/main.py b/main.py index 2a9c8e4..7c30e11 100644 --- a/main.py +++ b/main.py @@ -4,21 +4,22 @@ Entry point that runs the brain watcher and Discord bot together. """ import asyncio +import io import logging -import signal import sys from config import Config from watcher import BrainWatcher from bot import GravityBot -# Logging setup +# Logging setup (UTF-8 forced for Windows cp949 compatibility) +_utf8_stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S", handlers=[ - logging.StreamHandler(sys.stdout), + logging.StreamHandler(_utf8_stdout), logging.FileHandler("gravity_control.log", encoding="utf-8"), ], ) diff --git a/watcher.py b/watcher.py index 05e8975..29987eb 100644 --- a/watcher.py +++ b/watcher.py @@ -118,7 +118,9 @@ class BrainEventHandler(FileSystemEventHandler): file_name = path.name if file_name not in Config.WATCHED_FILES: - return + # Check suffix patterns + if not any(file_name.endswith(s) for s in Config.WATCHED_SUFFIXES): + return if self._should_debounce(str(path)): return