diff --git a/api/discord_bot.py b/api/discord_bot.py index 6dc959a..9748b60 100644 --- a/api/discord_bot.py +++ b/api/discord_bot.py @@ -55,6 +55,9 @@ bot = commands.Bot( # 워크스페이스 매니저 (전역) ws_manager = WorkspaceManager() +# 실행 중인 작업 추적 (채널ID → asyncio.Task) +_running_tasks: dict[int, asyncio.Task] = {} + # ────────────────────────────────────────────── # 대화 기억 @@ -222,6 +225,24 @@ async def on_message(message: discord.Message): if not user_text: return + # 취소 명령어 확인 + cancel_keywords = {"취소", "stop", "cancel", "중지", "멈춰"} + if user_text.lower() in cancel_keywords: + channel_id = message.channel.id + if channel_id in _running_tasks and not _running_tasks[channel_id].done(): + _running_tasks[channel_id].cancel() + del _running_tasks[channel_id] + await message.reply( + embed=discord.Embed( + title="🛑 작업 취소됨", + description="실행 중인 작업을 취소했습니다.", + color=0xE74C3C, + ) + ) + else: + await message.reply("실행 중인 작업이 없습니다.") + return + # 통합 프롬프트 호출 async with message.channel.typing(): try: @@ -246,7 +267,29 @@ async def on_message(message: discord.Message): await message.channel.send( f"ℹ️ {note} 미설정 상태입니다. 로컬 작업만 진행됩니다." ) - await _handle_task(message, user_text, ws) + + # 작업을 추적 가능한 Task로 실행 + channel_id = message.channel.id + if channel_id in _running_tasks and not _running_tasks[channel_id].done(): + await message.reply("⚠️ 이미 작업이 실행 중입니다. `취소` 후 다시 요청하세요.") + return + + async def _tracked_task(): + try: + await _handle_task(message, user_text, ws) + except asyncio.CancelledError: + await message.channel.send( + embed=discord.Embed( + title="🛑 작업 취소됨", + description="작업이 사용자에 의해 취소되었습니다.", + color=0xE74C3C, + ) + ) + finally: + _running_tasks.pop(channel_id, None) + + task = asyncio.create_task(_tracked_task()) + _running_tasks[channel_id] = task elif mode == "clarify": question = result.get("question", "더 구체적으로 말씀해 주시겠어요?") embed = discord.Embed( diff --git a/core/task_pipeline.py b/core/task_pipeline.py index ad03714..b5fe145 100644 --- a/core/task_pipeline.py +++ b/core/task_pipeline.py @@ -113,37 +113,6 @@ class TaskPipeline: return processed - # ────────────────────────────────────────── - # 프로젝트 파일 읽기 (공용) - # ────────────────────────────────────────── - - def _read_project_files(self) -> list[str]: - """프로젝트의 모든 텍스트 파일 읽기.""" - import os - - project_files = [] - project_root = Path(self.project_path) - skip_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv", "dist", "build"} - binary_exts = {".png", ".jpg", ".jpeg", ".gif", ".ico", ".woff", ".woff2", ".ttf", - ".eot", ".mp3", ".mp4", ".zip", ".tar", ".gz", ".exe", ".dll", - ".so", ".pyc", ".pyo", ".db", ".sqlite"} - - for root, dirs, files in os.walk(self.project_path): - dirs[:] = [d for d in dirs if d not in skip_dirs] - for fname in files: - fpath = Path(root) / fname - if fpath.suffix.lower() in binary_exts: - continue - try: - rel = fpath.relative_to(project_root) - content = fpath.read_text(encoding="utf-8", errors="replace") - project_files.append( - f"### {rel}\n```\n{content}\n```" - ) - except (OSError, UnicodeDecodeError): - continue - - return project_files # ────────────────────────────────────────── # Planner 자가 검증 (오케스트레이션) @@ -237,7 +206,9 @@ class TaskPipeline: f"위 정보를 바탕으로 총평을 작성하세요." ) - response = await self.gemini.call("summarizer", prompt, timeout=60) + response = await self.gemini.call_agent( + "summarizer", prompt, cwd=self.project_path, timeout=120, + ) self._log("summarize", user_request, response) summary = self._extract_json(response) @@ -249,121 +220,6 @@ class TaskPipeline: "next_steps": [], } - # ────────────────────────────────────────── - # 전체 파이프라인 (재시도 루프 포함) - # ────────────────────────────────────────── - - async def execute(self, user_request: str) -> dict: - """Plan -> Code(에이전트, 병렬) -> Review -> 재시도 -> 총평 -> 기록. - - Coder가 에이전트 모드로 직접 파일을 생성/수정합니다. - 리뷰 실패 시 최대 MAX_REVIEW_RETRIES회 재시도합니다. - 성공/실패 모두 docs에 기록됩니다. - """ - result = { - "request": user_request, - "plan": None, - "code_outputs": [], - "review": None, - "summary": None, - "errors": [], - "retry_count": 0, - } - - try: - # 1. Plan - plan = await self.plan(user_request) - result["plan"] = plan - - tasks = plan.get("tasks", []) - if not tasks: - result["summary"] = { - "title": "태스크 없음", - "summary": "Planner가 태스크를 생성하지 못했습니다.", - "changes": [], - "warnings": ["요청을 더 구체적으로 해주세요."], - "next_steps": [], - } - self.docs.record_session(user_request, result["summary"], plan) - return result - - # 2. Code + Review (재시도 루프) - review = None - code_outputs = [] - for attempt in range(1 + MAX_REVIEW_RETRIES): - # Code 병렬 실행 (에이전트 모드 — 파일 직접 쓰기) - code_outputs = await self.code_parallel(tasks) - result["code_outputs"] = [o[:500] for o in code_outputs] - - error_count = sum(1 for o in code_outputs if o.startswith("[ERROR]")) - if error_count > 0: - result["errors"].append( - f"코딩 실패: {error_count}/{len(tasks)}개 (시도 {attempt+1})" - ) - - # Review - review = await self.batch_review(tasks, code_outputs) - result["review"] = review - - # 리뷰 통과 여부 확인 - passed = review.get("passed", True) - if isinstance(passed, str): - passed = passed.lower() in ("true", "yes", "pass") - - if passed: - logger.info(f"리뷰 통과 (시도 {attempt+1})") - break - else: - result["retry_count"] = attempt + 1 - if attempt < MAX_REVIEW_RETRIES: - logger.warning( - f"리뷰 실패 -- 재시도 {attempt+2}/{1+MAX_REVIEW_RETRIES}" - ) - # 리뷰 피드백을 태스크에 추가 - feedback = review.get("summary", str(review))[:500] - for task in tasks: - task["review_feedback"] = ( - f"이전 시도에서 다음 리뷰 피드백을 받았습니다. " - f"반드시 수정하세요:\n{feedback}" - ) - else: - result["errors"].append( - f"리뷰 {1+MAX_REVIEW_RETRIES}회 시도 모두 실패" - ) - - # 3. 총평 - summary = await self.summarize( - user_request, plan, code_outputs, review - ) - if result["errors"]: - existing_warnings = summary.get("warnings", []) - summary["warnings"] = existing_warnings + result["errors"] - if result["retry_count"] > 0: - summary["retries"] = result["retry_count"] - result["summary"] = summary - - except Exception as e: - result["errors"].append(f"파이프라인 오류: {str(e)}") - result["summary"] = { - "title": "작업 실패", - "summary": f"파이프라인 실행 중 오류 발생: {str(e)}", - "changes": [], - "warnings": result["errors"], - "next_steps": ["오류 내용 확인 후 다시 시도"], - } - self._log("pipeline_error", user_request, str(e)) - - finally: - self.docs.record_session( - user_request, - result.get("summary", {"summary": "기록 없음"}), - result.get("plan"), - ) - self.docs.append_changelog( - result.get("summary", {}).get("title", user_request[:50]) - ) - - return result # ────────────────────────────────────────── # 유틸리티