fix(bridge): 채널 중복 생성 race condition 수정 + AG- 접두사 + metadata 모니터링

This commit is contained in:
2026-03-07 10:58:10 +09:00
parent ba8454c2e1
commit 02c757f703
4 changed files with 123 additions and 58 deletions

95
bot.py
View File

@@ -33,26 +33,37 @@ def detect_project_name(conv_dir: Path) -> str:
"""Extract a human-readable project name from conversation artifacts. """Extract a human-readable project name from conversation artifacts.
Strategy: Strategy:
1. Check task.md first line for a title (# Title) 1. Check task.md first heading for a title
2. Check implementation_plan.md first line 2. Check implementation_plan.md first heading
3. Check any .metadata.json for summary keywords 3. Check any .metadata.json for summary keywords
4. Fallback to short conversation ID 4. Fallback to short conversation ID
Output format: lowercase_with_underscores (e.g. 'gravity_control')
""" """
short_id = conv_dir.name[:8] 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 # Try task.md title
task_file = conv_dir / "task.md" task_file = conv_dir / "task.md"
if task_file.exists(): if task_file.exists():
try: try:
first_lines = task_file.read_text(encoding="utf-8").splitlines()[:5] first_lines = task_file.read_text(encoding="utf-8").splitlines()[:5]
for line in first_lines: for line in first_lines:
match = re.match(r'^#\s+(.+?)[\s—\-]+', line) match = re.match(r'^#\s+(.+)', line)
if match: if match:
name = match.group(1).strip() name = _sanitize(match.group(1))
# Sanitize for Discord channel name if name and name != "task":
name = re.sub(r'[^a-zA-Z0-9가-힣\s\-]', '', name)
name = re.sub(r'\s+', '-', name).lower()[:30]
if name:
return name return name
except (OSError, UnicodeDecodeError): except (OSError, UnicodeDecodeError):
pass pass
@@ -63,26 +74,23 @@ def detect_project_name(conv_dir: Path) -> str:
try: try:
first_lines = plan_file.read_text(encoding="utf-8").splitlines()[:5] first_lines = plan_file.read_text(encoding="utf-8").splitlines()[:5]
for line in first_lines: for line in first_lines:
match = re.match(r'^#\s+(.+?)[\s—\-]+', line) match = re.match(r'^#\s+(.+)', line)
if match: if match:
name = match.group(1).strip() name = _sanitize(match.group(1))
name = re.sub(r'[^a-zA-Z0-9가-힣\s\-]', '', name)
name = re.sub(r'\s+', '-', name).lower()[:30]
if name: if name:
return name return name
except (OSError, UnicodeDecodeError): except (OSError, UnicodeDecodeError):
pass pass
# Try metadata summary # Try metadata summary (first 2 words)
for meta_file in conv_dir.glob("*.metadata.json"): for meta_file in conv_dir.glob("*.metadata.json"):
try: try:
meta = json.loads(meta_file.read_text(encoding="utf-8")) meta = json.loads(meta_file.read_text(encoding="utf-8"))
summary = meta.get("summary", "") summary = meta.get("summary", "")
# Extract first meaningful noun/phrase words = summary.split()[:2]
words = summary.split()[:3]
if words: if words:
name = "-".join(words).lower() name = "_".join(words).lower()
name = re.sub(r'[^a-z0-9가-힣\-]', '', name)[:30] name = re.sub(r'[^a-z0-9가-힣_]', '', name)[:30]
if name: if name:
return name return name
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
@@ -123,6 +131,8 @@ class GravityBot(commands.Bot):
self.session_status_messages: dict[str, int] = {} self.session_status_messages: dict[str, int] = {}
# conversation_id -> project name # conversation_id -> project name
self.session_names: dict[str, str] = {} self.session_names: dict[str, str] = {}
# Locks to prevent duplicate channel creation
self._channel_locks: dict[str, asyncio.Lock] = {}
# Category for session channels # Category for session channels
self.session_category: discord.CategoryChannel | None = None self.session_category: discord.CategoryChannel | None = None
# Guild reference # Guild reference
@@ -184,8 +194,17 @@ class GravityBot(commands.Bot):
async def _ensure_channel( async def _ensure_channel(
self, conversation_id: str, project_name: str self, conversation_id: str, project_name: str
) -> discord.TextChannel: ) -> discord.TextChannel:
"""Get or create a Discord channel for a session.""" """Get or create a Discord channel for a session (thread-safe)."""
# Check if channel already exists # Fast path: already mapped
if conversation_id in self.session_channels:
return self.session_channels[conversation_id]
# Get or create a lock for this conversation
if conversation_id not in self._channel_locks:
self._channel_locks[conversation_id] = asyncio.Lock()
async with self._channel_locks[conversation_id]:
# Double-check after acquiring lock
if conversation_id in self.session_channels: if conversation_id in self.session_channels:
return self.session_channels[conversation_id] return self.session_channels[conversation_id]
@@ -259,6 +278,8 @@ class GravityBot(commands.Bot):
if channel: if channel:
if event.file_name == "task.md": if event.file_name == "task.md":
await self._send_task_update(channel, event) await self._send_task_update(channel, event)
elif event.file_name.endswith(".metadata.json"):
await self._send_metadata_update(channel, event)
else: else:
await self._send_artifact_content(channel, event) await self._send_artifact_content(channel, event)
@@ -311,6 +332,42 @@ class GravityBot(commands.Bot):
await channel.send(chunk) await channel.send(chunk)
await asyncio.sleep(0.5) 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) @tasks.loop(minutes=5)
async def session_cleanup_loop(self): async def session_cleanup_loop(self):
"""Periodically check for inactive sessions and archive their channels.""" """Periodically check for inactive sessions and archive their channels."""

View File

@@ -34,12 +34,17 @@ class Config:
"walkthrough.md", "walkthrough.md",
} }
# Also monitor these patterns (matched by suffix)
WATCHED_SUFFIXES: set = {
".metadata.json", # artifact summary changes
}
# Discord message limits # Discord message limits
DISCORD_MSG_LIMIT: int = 2000 DISCORD_MSG_LIMIT: int = 2000
DISCORD_EMBED_DESC_LIMIT: int = 4096 DISCORD_EMBED_DESC_LIMIT: int = 4096
# Channel naming # Channel naming
CHANNEL_PREFIX: str = "gravity" CHANNEL_PREFIX: str = "AG"
@classmethod @classmethod
def validate(cls) -> list[str]: def validate(cls) -> list[str]:

View File

@@ -4,21 +4,22 @@ Entry point that runs the brain watcher and Discord bot together.
""" """
import asyncio import asyncio
import io
import logging import logging
import signal
import sys import sys
from config import Config from config import Config
from watcher import BrainWatcher from watcher import BrainWatcher
from bot import GravityBot 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( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S", datefmt="%Y-%m-%d %H:%M:%S",
handlers=[ handlers=[
logging.StreamHandler(sys.stdout), logging.StreamHandler(_utf8_stdout),
logging.FileHandler("gravity_control.log", encoding="utf-8"), logging.FileHandler("gravity_control.log", encoding="utf-8"),
], ],
) )

View File

@@ -118,6 +118,8 @@ class BrainEventHandler(FileSystemEventHandler):
file_name = path.name file_name = path.name
if file_name not in Config.WATCHED_FILES: if file_name not in Config.WATCHED_FILES:
# Check suffix patterns
if not any(file_name.endswith(s) for s in Config.WATCHED_SUFFIXES):
return return
if self._should_debounce(str(path)): if self._should_debounce(str(path)):