fix(ext,bot): 통신 아키텍처 감사 — writeRegistration 이중쓰기 + ApprovalView fallback + scanner 최적화

- step-probe.ts: writeRegistration WS 후 return 추가 (파일 이중쓰기 방지)
- bot.py: ApprovalView approve/reject/choice — send_response_to_pending_owner 반환값 확인 + file bridge fallback (5곳)
- bot.py: scanner 주기 3s/5s → 30s (Hub 모드 불필요 I/O 감소)
This commit is contained in:
Variet Worker
2026-03-17 21:30:05 +09:00
parent 47cc838d9d
commit 0fae7e32aa
4 changed files with 32 additions and 16 deletions

View File

@@ -718,3 +718,14 @@
- **해결**: `handleWSCommand``ctx.recentDiscordSentTexts.set(text.trim(), Date.now())` 추가 - **해결**: `handleWSCommand``ctx.recentDiscordSentTexts.set(text.trim(), Date.now())` 추가
- **주의**: 파일 기반 `_processCommandFile`에는 이미 마킹 있었음 (L178). WS 경로 추가 시 동일 패턴 적용 필수 - **주의**: 파일 기반 `_processCommandFile`에는 이미 마킹 있었음 (L178). WS 경로 추가 시 동일 패턴 적용 필수
### [2026-03-17] writeRegistration 이중 쓰기 — WS 전송 후 파일도 작성
- **증상**: WS 연결 상태에서도 `bridge/register/` 디렉토리에 등록 파일이 계속 생성됨
- **원인**: `writeRegistration()` (step-probe.ts L162)이 WS 전송 후 `return` 없이 파일 쓰기도 실행. `writeChatSnapshot`/`writePendingApproval`은 WS→return 패턴인데 `writeRegistration`만 누락
- **해결**: WS 전송 후 `return` 추가하여 파일 쓰기 건너뛰기 (v0.4.5)
- **주의**: 새 WS 전송 함수 추가 시 반드시 file fallback과 상호 배타적 `return` 확인
### [2026-03-17] ApprovalView Hub 응답 실패 시 silent loss — fallback 없음
- **증상**: Extension이 pending 생성 후 WS 연결 끊김 → Discord에서 승인 클릭 → AG에 전달 안 됨 (무한 대기)
- **원인**: `ApprovalView` approve/reject/choice 콜백이 `if self.hub:``send_response_to_pending_owner()` 실행. 반환값 미확인. Hub가 있으면 file bridge를 아예 건너뛰므로 Hub 전달 실패 시 응답 소실
- **해결**: `delivered = await hub.send_response_to_pending_owner()` 반환값 확인 → `False``bridge.write_response()` fallback (4곳 + `_auto_approve_via_hub` 1곳, 총 5곳)
- **주의**: `send_response_to_pending_owner``pending_owners`에 conn_id가 없으면 `False` 반환 (Extension 재연결/disconnect 시). Hub 존재 ≠ 전달 성공

29
bot.py
View File

@@ -98,12 +98,13 @@ class ApprovalView(discord.ui.View):
"project_name": getattr(self.request, 'project_name', ''), "project_name": getattr(self.request, 'project_name', ''),
} }
# Hub WS route (primary — reaches remote Extensions) # Hub WS route (primary — reaches remote Extensions)
delivered = False
if self.hub: if self.hub:
await self.hub.send_response_to_pending_owner(self.request.request_id, { delivered = await self.hub.send_response_to_pending_owner(self.request.request_id, {
"type": "response", "data": response_data, "type": "response", "data": response_data,
}) })
else: if not delivered:
# File bridge (fallback — only when Hub is unavailable) # File bridge fallback (Hub unavailable OR owner disconnected)
self.bridge.write_response(UserResponse(**response_data)) self.bridge.write_response(UserResponse(**response_data))
embed = interaction.message.embeds[0] if interaction.message.embeds else None embed = interaction.message.embeds[0] if interaction.message.embeds else None
if embed: if embed:
@@ -130,11 +131,12 @@ class ApprovalView(discord.ui.View):
"step_type": getattr(self.request, 'step_type', ''), "step_type": getattr(self.request, 'step_type', ''),
"project_name": getattr(self.request, 'project_name', ''), "project_name": getattr(self.request, 'project_name', ''),
} }
delivered = False
if self.hub: if self.hub:
await self.hub.send_response_to_pending_owner(self.request.request_id, { delivered = await self.hub.send_response_to_pending_owner(self.request.request_id, {
"type": "response", "data": response_data, "type": "response", "data": response_data,
}) })
else: if not delivered:
self.bridge.write_response(UserResponse(**response_data)) self.bridge.write_response(UserResponse(**response_data))
embed = interaction.message.embeds[0] if interaction.message.embeds else None embed = interaction.message.embeds[0] if interaction.message.embeds else None
if embed: if embed:
@@ -156,11 +158,12 @@ class ApprovalView(discord.ui.View):
"step_type": getattr(self.request, 'step_type', ''), "step_type": getattr(self.request, 'step_type', ''),
"project_name": getattr(self.request, 'project_name', ''), "project_name": getattr(self.request, 'project_name', ''),
} }
delivered = False
if self.hub: if self.hub:
await self.hub.send_response_to_pending_owner(self.request.request_id, { delivered = await self.hub.send_response_to_pending_owner(self.request.request_id, {
"type": "response", "data": response_data, "type": "response", "data": response_data,
}) })
else: if not delivered:
self.bridge.write_response(UserResponse(**response_data)) self.bridge.write_response(UserResponse(**response_data))
embed = interaction.message.embeds[0] if interaction.message.embeds else None embed = interaction.message.embeds[0] if interaction.message.embeds else None
if embed: if embed:
@@ -608,7 +611,7 @@ class GravityBot(commands.Bot):
# ─── Approval Scanner ──────────────────────────────────────────── # ─── Approval Scanner ────────────────────────────────────────────
@tasks.loop(seconds=3) @tasks.loop(seconds=30) # Hub mode: WS is primary, file scan is fallback only
async def pending_approval_scanner(self): async def pending_approval_scanner(self):
"""Scan bridge/pending/ for new approval requests + reload registrations. """Scan bridge/pending/ for new approval requests + reload registrations.
@@ -1081,8 +1084,9 @@ class GravityBot(commands.Bot):
"""Auto-approve a pending request via Hub.""" """Auto-approve a pending request via Hub."""
self._sent_approval_ids.add(request.request_id) self._sent_approval_ids.add(request.request_id)
delivered = False
if self.hub: if self.hub:
await self.hub.send_response_to_pending_owner(request.request_id, { delivered = await self.hub.send_response_to_pending_owner(request.request_id, {
"type": "response", "type": "response",
"data": { "data": {
"request_id": request.request_id, "request_id": request.request_id,
@@ -1092,9 +1096,8 @@ class GravityBot(commands.Bot):
"project_name": request.project_name, "project_name": request.project_name,
}, },
}) })
# Hub delivered — skip file bridge to prevent dual delivery if not delivered:
else: # File bridge fallback (Hub unavailable OR owner disconnected)
# File bridge fallback (only when Hub is unavailable)
self.bridge.write_response(UserResponse( self.bridge.write_response(UserResponse(
request_id=request.request_id, approved=True, request_id=request.request_id, approved=True,
step_type=request.step_type, step_type=request.step_type,
@@ -1230,7 +1233,7 @@ class GravityBot(commands.Bot):
# ─── Chat Snapshot Scanner ───────────────────────────────────────── # ─── Chat Snapshot Scanner ─────────────────────────────────────────
@tasks.loop(seconds=5) @tasks.loop(seconds=30) # Hub mode: WS is primary, file scan is fallback only
async def chat_snapshot_scanner(self): async def chat_snapshot_scanner(self):
"""Scan bridge/chat_snapshots/ for AI response dumps.""" """Scan bridge/chat_snapshots/ for AI response dumps."""
try: try:

View File

@@ -8,7 +8,8 @@
| 012 | 09:00~17:44 | VSIX E2E: workspaceUri, 이중발송, ApprovalRequest, ApprovalView WS, 응답 라우팅 | `2eea5fa` | ✅ | | 012 | 09:00~17:44 | VSIX E2E: workspaceUri, 이중발송, ApprovalRequest, ApprovalView WS, 응답 라우팅 | `2eea5fa` | ✅ |
| 013 | 18:05~18:45 | Extension 모듈 분리 #398: http-bridge, html-patcher, command-handler 추출 (1296→650줄) | `6640d42` | ✅ | | 013 | 18:05~18:45 | Extension 모듈 분리 #398: http-bridge, html-patcher, command-handler 추출 (1296→650줄) | `6640d42` | ✅ |
| 014 | 18:45~20:35 | WS+File dual-delivery 수정 + 에코 릴레이 수정 + VSIX v0.4.4 빌드 | `0da6291` | ✅ | | 014 | 18:45~20:35 | WS+File dual-delivery 수정 + 에코 릴레이 수정 + VSIX v0.4.4 빌드 | `0da6291` | ✅ |
| 015 | 20:45~21:00 | Accept All WS regression 수정 + auto_approve 이중쓰기 수정 + VSIX v0.4.5 | | 🔧 | | 015 | 20:45~21:00 | Accept All WS regression 수정 + auto_approve 이중쓰기 수정 + VSIX v0.4.5 | `47cc838` | |
| 016 | 21:00~21:27 | 통신 아키텍처 나노단위 감사: writeRegistration 이중쓰기 + ApprovalView fallback + scanner 최적화 | — | ✅ |
### #010 상세 ### #010 상세
- **문서**: architecture.md(250줄), tech-stack.md(100줄), conventions.md(100줄) 전면 재작성 + Wiki 동기화 - **문서**: architecture.md(250줄), tech-stack.md(100줄), conventions.md(100줄) 전면 재작성 + Wiki 동기화

View File

@@ -161,14 +161,15 @@ export async function handleDiffReviewResponse(data: {
*/ */
export function writeRegistration(sessionId: string) { export function writeRegistration(sessionId: string) {
try { try {
// WS route (preferred) // WS route (preferred) — skip file write to prevent duplicate
if (ctx.wsBridge && ctx.wsBridge.isConnected()) { if (ctx.wsBridge && ctx.wsBridge.isConnected()) {
ctx.wsBridge.sendRegister({ ctx.wsBridge.sendRegister({
conversation_id: sessionId, conversation_id: sessionId,
project_name: ctx.projectName, project_name: ctx.projectName,
}); });
return; // WS delivered — skip file write
} }
// File route (fallback) // File route (fallback — only when WS is NOT connected)
const regDir = path.join(ctx.bridgePath, 'register'); const regDir = path.join(ctx.bridgePath, 'register');
if (!fs.existsSync(regDir)) { fs.mkdirSync(regDir, { recursive: true }); } if (!fs.existsSync(regDir)) { fs.mkdirSync(regDir, { recursive: true }); }
const regFile = path.join(regDir, `${sessionId}.json`); const regFile = path.join(regDir, `${sessionId}.json`);