fix(bridge): 채널 중복 생성 race condition 수정 + AG- 접두사 + metadata 모니터링
This commit is contained in:
95
bot.py
95
bot.py
@@ -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."""
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
7
main.py
7
main.py
@@ -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"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)):
|
||||||
|
|||||||
Reference in New Issue
Block a user