diff --git a/.agents/references/known-issues.md b/.agents/references/known-issues.md index f12c9f6..a39534a 100644 --- a/.agents/references/known-issues.md +++ b/.agents/references/known-issues.md @@ -284,3 +284,16 @@ - **원인**: Discord.py View의 기본 timeout이 300초(5분)로 설정됨 - **해결**: timeout을 1800초(30분)로 증가 - **주의**: Discord View timeout은 서버 재시작 후만 적용. 기존 메시지의 View는 이미 만료됨 + +### [2026-03-10] DOM Observer 승인 ENOENT — response 파일 Race Condition +- **증상**: Discord에서 ✅승인 → extension.log에 `ENOENT: response/xxx.json` 에러 → AG에서 Run 미실행 (간헐적) +- **원인**: `processResponseFile()` (response watcher)이 response 파일을 읽고 즉시 `fs.unlinkSync()` → renderer `pollResponse()`가 `GET /response/:rid`로 조회할 때 파일 이미 없음 +- **해결**: DOM observer 경로(`isDomObserver`)일 때 `processResponseFile()`에서 response 파일을 삭제하지 않고, HTTP handler(`GET /response/:rid`)가 renderer에 전달 후 삭제. step_probe 경로는 `clickTrigger`로 처리하므로 기존처럼 삭제 OK +- **주의**: DOM observer와 step_probe 두 경로가 독립적으로 pending을 생성할 수 있으므로, response 삭제 타이밍을 경로별로 구분해야 함 + +### [2026-03-10] Allow Once + Allow This Conversation — 별개 pending으로 분리되는 문제 +- **증상**: 파일 접근 시 Allow Once와 Allow This Conversation이 Discord에 2개 별도 메시지로 도착. Deny 선택지 없음 +- **원인**: renderer `scan()`이 한 사이클에 한 버튼만 처리 후 `return`. 각 버튼이 별도 pending으로 전송. Deny는 PATS 패턴에 미등록 +- **해결**: (1) `scan()`에서 `findButtonContainer()`+`collectSiblingButtons()`로 같은 컨테이너의 관련 버튼을 그룹화하여 `buttons` 배열로 전송, (2) `bot.py` `ApprovalView`가 `buttons` 배열이 있으면 `clear_items()` 후 동적 Discord 버튼 추가, (3) `bridge.py` `UserResponse`에 `button_index` 추가, (4) renderer `pollResponseGroup()`에서 `button_index`에 따라 DOM 버튼 클릭 +- **주의**: `buttons` 배열이 없는 legacy pending은 기존 2버튼(✅승인/❌거부)으로 표시. Deny를 PATS에 추가함 + diff --git a/bot.py b/bot.py index 01369a9..a8c83e4 100644 --- a/bot.py +++ b/bot.py @@ -32,16 +32,77 @@ logger = logging.getLogger(__name__) # ─── Discord UI Components ────────────────────────────────────────── class ApprovalView(discord.ui.View): - """Discord buttons for approving/rejecting Antigravity actions.""" + """Discord buttons for approving/rejecting Antigravity actions. - def __init__(self, bridge: BridgeProtocol, request: ApprovalRequest): + Supports two modes: + 1. Legacy: ✅ 승인 / ❌ 거부 (when no buttons array) + 2. Multi-choice: dynamic buttons from pending's buttons array + (e.g., ✅ Allow Once / ✅ Allow This Conversation / ❌ Deny) + """ + + def __init__(self, bridge: BridgeProtocol, request: ApprovalRequest, + buttons: list[dict] | None = None): super().__init__(timeout=1800) # 30 minutes self.bridge = bridge self.request = request self.responded = False + self.buttons_data = buttons + + if buttons and len(buttons) > 1: + # Multi-choice mode: remove the default decorated buttons first + # (they are added by @discord.ui.button at class definition time) + self.clear_items() + + # Add a Discord button for each option + for btn_info in buttons: + btn_text = btn_info.get("text", "?") + btn_index = btn_info.get("index", 0) + is_reject = btn_text.lower() in ("deny", "reject", "cancel", + "reject all", "decline", + "dismiss", "stop") + style = discord.ButtonStyle.red if is_reject else discord.ButtonStyle.green + emoji = "❌" if is_reject else "✅" + + button = discord.ui.Button( + label=f"{emoji} {btn_text}", + style=style, + custom_id=f"choice_{request.request_id}_{btn_index}", + ) + # Bind the callback with closure over btn_index and btn_text + button.callback = self._make_choice_callback(btn_index, btn_text, + is_reject) + self.add_item(button) + # else: use the default @discord.ui.button decorated methods below + + def _make_choice_callback(self, btn_index: int, btn_text: str, + is_reject: bool): + async def callback(interaction: discord.Interaction): + if self.responded: + await interaction.response.send_message("이미 응답됨", + ephemeral=True) + return + self.responded = True + self.bridge.write_response(UserResponse( + request_id=self.request.request_id, + approved=not is_reject, + button_index=btn_index, + )) + embed = interaction.message.embeds[0] if interaction.message.embeds else None + if embed: + color = discord.Color.red() if is_reject else discord.Color.green() + embed.color = color + emoji = "❌" if is_reject else "✅" + embed.set_footer( + text=f"{emoji} {btn_text} by {interaction.user.display_name}" + ) + await interaction.response.edit_message(embed=embed, view=None) + return callback @discord.ui.button(label="✅ 승인", style=discord.ButtonStyle.green) async def approve(self, interaction: discord.Interaction, button: discord.ui.Button): + # Only active in legacy mode (no buttons array) + if self.buttons_data and len(self.buttons_data) > 1: + return # multi-choice mode handles via dynamic buttons if self.responded: await interaction.response.send_message("이미 응답됨", ephemeral=True) return @@ -57,6 +118,9 @@ class ApprovalView(discord.ui.View): @discord.ui.button(label="❌ 거부", style=discord.ButtonStyle.red) async def reject(self, interaction: discord.Interaction, button: discord.ui.Button): + # Only active in legacy mode (no buttons array) + if self.buttons_data and len(self.buttons_data) > 1: + return # multi-choice mode handles via dynamic buttons if self.responded: await interaction.response.send_message("이미 응답됨", ephemeral=True) return @@ -516,21 +580,38 @@ class GravityBot(commands.Bot): async def _send_approval_request( self, channel: discord.TextChannel, request: ApprovalRequest ): + # Read buttons array from pending file (if present) + buttons = None + pending_file = self.bridge.pending_dir / f"{request.request_id}.json" + if pending_file.exists(): + try: + pending_data = json.loads( + pending_file.read_text(encoding="utf-8-sig") + ) + buttons = pending_data.get("buttons") + except (json.JSONDecodeError, OSError): + pass + + # Build embed description + desc_parts = [f"**명령어:**\n```\n{request.command[:1000]}\n```"] + if buttons and len(buttons) > 1: + # Multi-choice: show all options in description + btn_names = [b.get("text", "?") for b in buttons] + desc_parts.append(f"**선택지:** {' / '.join(btn_names)}") + if request.description: + desc_parts.append(request.description[:500]) + embed = discord.Embed( title="⚠️ 승인 요청", - description=( - f"**명령어:**\n```\n{request.command[:1000]}\n```\n" - f"{request.description[:500]}" - ), + description="\n".join(desc_parts), color=discord.Color.orange(), timestamp=datetime.now(timezone.utc), ) embed.set_footer(text=f"ID: {request.request_id}") - view = ApprovalView(self.bridge, request) + view = ApprovalView(self.bridge, request, buttons=buttons) msg = await channel.send(embed=embed, view=view) - pending_file = self.bridge.pending_dir / f"{request.request_id}.json" if pending_file.exists(): try: data = json.loads(pending_file.read_text(encoding="utf-8-sig")) diff --git a/bridge.py b/bridge.py index ef655d8..5230b59 100644 --- a/bridge.py +++ b/bridge.py @@ -53,6 +53,7 @@ class UserResponse: approved: bool user_input: str = "" timestamp: float = 0 + button_index: int = -1 # -1 = legacy (approve/reject), 0+ = specific button index class BridgeProtocol: diff --git a/docs/devlog/2026-03-10.md b/docs/devlog/2026-03-10.md new file mode 100644 index 0000000..2c85491 --- /dev/null +++ b/docs/devlog/2026-03-10.md @@ -0,0 +1,5 @@ +# 2026-03-10 Devlog + +| # | 시간 | 작업 설명 | 커밋 | 상태 | +|---|------|----------|------|------| +| 001 | 06:12~06:30 | Discord 승인 ENOENT race condition 수정 + 버튼 그룹화 (multi-choice) | `` | 🔧 | diff --git a/docs/devlog/entries/20260310-001.md b/docs/devlog/entries/20260310-001.md new file mode 100644 index 0000000..4df67a7 --- /dev/null +++ b/docs/devlog/entries/20260310-001.md @@ -0,0 +1,14 @@ +# Discord 승인 ENOENT 수정 + 버튼 그룹화 (multi-choice) + +- **시간**: 2026-03-10 06:12 ~ 06:30 +- **Commit**: `` +- **Vikunja**: #276 → 진행중 (E2E 검증 대기) + +## 결정 사항 +- **ENOENT 근본 원인**: extension.log에서 `[RESPONSE] error: ENOENT` 확인. `processResponseFile()`이 DOM observer response를 renderer가 polling하기 전에 삭제. DOM observer 경로만 response 보존, HTTP handler에서 삭제하도록 변경 +- **버튼 그룹화**: `findButtonContainer()` + `collectSiblingButtons()`로 같은 컨테이너의 관련 버튼을 하나의 pending으로 묶음. `buttons` 배열 + `button_index` response로 Discord에서 원하는 버튼만 정확히 클릭 + +## 미완료 +- AG 재시작 + V8 CachedData 삭제 후 E2E 검증 필요 +- Run 승인 반복 테스트 (5회 이상) +- Allow Once / Allow This Conversation / Deny 3개 선택지 동시 출현 확인 diff --git a/extension/out/extension.js b/extension/out/extension.js index 3aace34..3d943a4 100644 --- a/extension/out/extension.js +++ b/extension/out/extension.js @@ -492,7 +492,7 @@ function startObserverHttpBridge() { source: 'dom_observer', }; fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2)); - logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" ctx="${(data.description || '').substring(0, 50)}"`); + logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" btns=${(data.buttons || []).length} ctx="${(data.description || '').substring(0, 50)}"`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, request_id: rid })); } @@ -906,10 +906,14 @@ function generateApprovalObserverScript(_port) { {re:/^Accept$/i, type:'agent_step'}, {re:/^Allow/i, type:'permission'}, {re:/^Approve/i, type:'agent_step'}, + {re:/^Deny$/i, type:'permission'}, {re:/^Retry$/i, type:'error_recovery'}, {re:/^Dismiss$/i, type:'error_recovery'}, ]; + // ALL actionable button patterns (for grouping siblings in same container) + var ALL_ACTION_RE=[/^Run/i,/^Accept/i,/^Reject/i,/^Allow/i,/^Deny/i,/^Approve/i,/^Cancel$/i,/^Retry$/i,/^Dismiss$/i,/^Stop$/i,/^Decline$/i]; + // Reject button patterns for finding the counterpart var REJECT_RE=[/^reject$/i,/^reject all$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i,/^dismiss$/i]; @@ -954,6 +958,41 @@ function generateApprovalObserverScript(_port) { return desc.substring(0,500); } + // ── Find common container of related buttons ── + function findButtonContainer(btn){ + return btn.closest('[class*="step"]') + ||btn.closest('[class*="action"]') + ||btn.closest('[class*="tool"]') + ||btn.closest('[class*="cascade"]') + ||btn.closest('[class*="message"]') + ||btn.closest('[class*="dialog"]') + ||btn.closest('[class*="notification"]') + ||btn.parentElement; + } + + // ── Collect all actionable sibling buttons from a container ── + function collectSiblingButtons(container,triggerBtn){ + if(!container)return []; + var siblings=container.querySelectorAll('button'); + var result=[]; + for(var i=0;imaxPolls){ log('Poll timeout for '+rid); clearInterval(timer); - delete _sent[bid]; + delete _sent[groupKey]; + for(var ti=0;ti=0)?d.button_index:-1; + if(btnIdx>=0&&btnIdx { source: 'dom_observer', }; fs.writeFileSync(path.join(pendingDir, `${rid}.json`), JSON.stringify(pending, null, 2)); - logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" ctx="${(data.description || '').substring(0, 50)}"`); + logToFile(`[HTTP] pending created: ${rid} cmd="${data.command}" btns=${(data.buttons || []).length} ctx="${(data.description || '').substring(0, 50)}"`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, request_id: rid })); } catch (e: any) { @@ -885,10 +885,14 @@ function generateApprovalObserverScript(_port: number): string { {re:/^Accept$/i, type:'agent_step'}, {re:/^Allow/i, type:'permission'}, {re:/^Approve/i, type:'agent_step'}, + {re:/^Deny$/i, type:'permission'}, {re:/^Retry$/i, type:'error_recovery'}, {re:/^Dismiss$/i, type:'error_recovery'}, ]; + // ALL actionable button patterns (for grouping siblings in same container) + var ALL_ACTION_RE=[/^Run/i,/^Accept/i,/^Reject/i,/^Allow/i,/^Deny/i,/^Approve/i,/^Cancel$/i,/^Retry$/i,/^Dismiss$/i,/^Stop$/i,/^Decline$/i]; + // Reject button patterns for finding the counterpart var REJECT_RE=[/^reject$/i,/^reject all$/i,/^cancel$/i,/^deny$/i,/^stop$/i,/^decline$/i,/^dismiss$/i]; @@ -933,6 +937,41 @@ function generateApprovalObserverScript(_port: number): string { return desc.substring(0,500); } + // ── Find common container of related buttons ── + function findButtonContainer(btn){ + return btn.closest('[class*="step"]') + ||btn.closest('[class*="action"]') + ||btn.closest('[class*="tool"]') + ||btn.closest('[class*="cascade"]') + ||btn.closest('[class*="message"]') + ||btn.closest('[class*="dialog"]') + ||btn.closest('[class*="notification"]') + ||btn.parentElement; + } + + // ── Collect all actionable sibling buttons from a container ── + function collectSiblingButtons(container,triggerBtn){ + if(!container)return []; + var siblings=container.querySelectorAll('button'); + var result=[]; + for(var i=0;imaxPolls){ log('Poll timeout for '+rid); clearInterval(timer); - delete _sent[bid]; + delete _sent[groupKey]; + for(var ti=0;ti=0)?d.button_index:-1; + if(btnIdx>=0&&btnIdx