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

163
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.
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."""