fix(bridge): 5 bug fixes for approval signal drop and Discord relay

- DEDUP: add conversation_id guard to prevent cross-session step_index collision
- step_probe: suppress pending when projectName=default (empty window)
- watchCommandsDir: add 3s polling fallback (fs.watch silent fail on Windows)
- auto toggle: write chat_snapshot confirmation back to Discord
- bot on_message: add message ID dedup for Gateway event replay
This commit is contained in:
2026-03-15 08:18:26 +09:00
parent 1f96997831
commit 40e3cd550f
7 changed files with 136 additions and 25 deletions

View File

@@ -440,3 +440,27 @@
3. **asyncio burst**: 7개 루프가 같은 `asyncio.sleep(3)` → 같은 tick에 동시 깨어남 → 순간 burst로 Gateway 1초 윈도우(30 req) 초과 3. **asyncio burst**: 7개 루프가 같은 `asyncio.sleep(3)` → 같은 tick에 동시 깨어남 → 순간 burst로 Gateway 1초 윈도우(30 req) 초과
- **해결**: (1) `_on_request_success()` — 연속 5회 성공 후에만 백오프 절반 감소, (2) `_poll_commands_loop` adaptive 간격 (빈 응답 시 3s→10s→30s→60s), (3) Gateway 윈도우 1s/30→10s/100, (4) 루프 stagger (0~3.5s 오프셋) - **해결**: (1) `_on_request_success()` — 연속 5회 성공 후에만 백오프 절반 감소, (2) `_poll_commands_loop` adaptive 간격 (빈 응답 시 3s→10s→30s→60s), (3) Gateway 윈도우 1s/30→10s/100, (4) 루프 stagger (0~3.5s 오프셋)
- **주의**: `_reset_backoff()` [즉시 리셋] 패턴은 **다중 소비자가 같은 transport를 공유하는 환경에서 절대 사용 금지**. 단일 성공이 전체 백오프를 무효화함 - **주의**: `_reset_backoff()` [즉시 리셋] 패턴은 **다중 소비자가 같은 transport를 공유하는 환경에서 절대 사용 금지**. 단일 성공이 전체 백오프를 무효화함
### [2026-03-15] DEDUP step_index 크로스 세션 충돌 — 승인 신호 누락
- **증상**: variet_agent에서 WAITING step 감지 → pending 미생성 → Discord 승인 요청 미전달 → 10분+ 대기
- **원인**: `writePendingApproval()`의 DEDUP 로직이 `step_index`로 중복 검사 시 `conversation_id`를 비교하지 않음. 세션 A(step=28)와 세션 B(step=28)가 동일시되어 DEDUP skip. 각 세션의 step_index는 0부터 시작하므로 크로스 세션 충돌 빈번
- **해결**: DEDUP 조건에 `existing.conversation_id === data.conversation_id` 가드 추가
- **주의**: `project_name` 가드만으로는 불충분 — 같은 Extension 인스턴스가 여러 세션을 볼 수 있음. 반드시 `conversation_id`까지 비교 필요
### [2026-03-15] fs.watch silent fail — Discord→Extension 명령 전달 불가
- **증상**: `!auto`, `!stop`, 텍스트 릴레이 등 Discord→Extension 방향 명령이 전부 작동 안 함. Extension log에 `[AUTO]` 로그 0건
- **원인**: `watchCommandsDir()``fs.watch`가 Windows에서 silent fail. watcher 세팅은 되지만 이벤트가 실제로 fire 안 됨. 실측 테스트에서 command 파일 드롭 후 2초 대기 → 미소비 확인
- **해결**: `fs.watch` 유지 + 3초 `setInterval` 폴링 fallback 추가. `processAllCommands()` 함수로 공통화
- **주의**: `fs.watch`는 Windows에서 구조적으로 불안정 — 이 프로젝트의 response watcher (known-issue [2026-03-11])에서도 동일 문제. **새 watcher 추가 시 반드시 polling fallback 병행**
### [2026-03-15] projectName=default 승인 오발 — workspace 없는 AG 창
- **증상**: workspace 없는 AG 창(Empty Window)이 step_probe로 다른 프로젝트의 WAITING step 감지 → `project_name: "default"` pending 생성 → `#ag-default` 채널로 전달 → 유저 미확인
- **원인**: `detectProjectName()`이 workspace 없으면 `"default"` 반환. step_probe는 LS의 `GetAllCascadeTrajectories`**모든** 세션을 볼 수 있으므로, 다른 workspace 세션의 WAITING을 감지하여 잘못된 project_name으로 pending 생성
- **해결**: step_probe 2곳(normal + offset)에서 `projectName === 'default'`이면 pending 생성/auto-approve 억제. 로그만 남김
- **주의**: `#ag-default` 채널이 생성되면 유저가 인지하지 못하므로 치명적. Empty Window에서는 bridge 기능을 최소화해야 함
### [2026-03-15] Discord Gateway MESSAGE_CREATE 중복 — embed 이중 전송
- **증상**: 텍스트 메시지, `!auto` 등 Discord 명령 시 동일 embed가 2개 전송
- **원인**: Discord Gateway가 WebSocket 불안정 시 `MESSAGE_CREATE` 이벤트를 중복 전달 (known discord.py issue). 봇 프로세스 1개, 코드상 `on_message` 1회 실행 로직이지만 이벤트 자체가 2번 도착
- **해결**: `on_message``_processed_message_ids: set[int]` (bounded 200개) 중복 방지 추가
- **주의**: Gateway reconnection, RESUME 실패 시 발생 빈도 증가. message ID 기반 dedup이 가장 확실한 방어

11
bot.py
View File

@@ -180,6 +180,7 @@ class GravityBot(commands.Bot):
self.session_category: discord.CategoryChannel | None = None self.session_category: discord.CategoryChannel | None = None
self.guild: discord.Guild | None = None self.guild: discord.Guild | None = None
self.auto_approve_projects: set[str] = set() # projects with auto-approve enabled self.auto_approve_projects: set[str] = set() # projects with auto-approve enabled
self._processed_message_ids: set[int] = set() # dedup for Gateway event replay
self.gateway = None # Set by main.py in gateway mode self.gateway = None # Set by main.py in gateway mode
def _write_command(self, project: str, text: str, **kwargs): def _write_command(self, project: str, text: str, **kwargs):
@@ -771,6 +772,16 @@ class GravityBot(commands.Bot):
if message.author == self.user: if message.author == self.user:
return return
# Dedup: Discord Gateway can deliver MESSAGE_CREATE twice on reconnection
if message.id in self._processed_message_ids:
return
self._processed_message_ids.add(message.id)
# Keep set bounded (last 200 messages)
if len(self._processed_message_ids) > 200:
excess = len(self._processed_message_ids) - 100
for _ in range(excess):
self._processed_message_ids.pop()
# Determine project from channel # Determine project from channel
project = self.channel_to_project.get(message.channel.id) project = self.channel_to_project.get(message.channel.id)
if not project: if not project:

View File

@@ -0,0 +1,5 @@
# 2026-03-15 Devlog
| # | 시간 | 작업 | 커밋 | 상태 |
|---|------|------|------|------|
| 001 | 07:00~08:16 | 승인 신호 누락 진단 & 5건 버그 수정 (DEDUP collision, fs.watch fail, default 보호, auto 확인, msg dedup) | `pending` | ✅ |

View File

@@ -0,0 +1,31 @@
# 승인 신호 누락 진단 & 5건 버그 수정
- **시간**: 2026-03-15 07:00~08:16
- **Commit**: `pending`
## 결정 사항
### DEDUP conversation_id 가드
- `step_index` 만으로 중복 판정 → 크로스 세션 충돌 빈번
- `project_name`만으로 불충분 (같은 Extension이 여러 세션 관찰 가능)
- **`conversation_id`까지 비교**가 정확한 DEDUP 조건
### fs.watch 대신 polling
- Windows에서 `fs.watch` silent fail 확인 (실측 테스트)
- response watcher도 같은 이슈 있음 (known-issue [2026-03-11])
- **모든 watcher에 polling fallback 병행** 원칙 확립
### stallProbed 시간 기반 리셋 — 불채택
- 유저 의견: fix #1로 DEDUP 해결되면 자연스럽게 delta>0 → stallProbed 리셋
- 30초 리셋은 LS stale 시 불필요한 RPC 호출만 증가
- LS stale은 AG 내부 문제 → AG 재시작이 올바른 해결
## 5건 수정 요약
| # | 파일 | 수정 |
|---|------|------|
| 1 | extension.ts | DEDUP `conversation_id` 가드 |
| 2 | extension.ts | `projectName=default` pending 억제 |
| 3 | extension.ts | commands dir 폴링 fallback |
| 4 | extension.ts | auto 확인 chat_snapshot |
| 5 | bot.py | `on_message` 메시지 ID 중복 방지 |

View File

@@ -223,6 +223,10 @@ function processCommandFile(filePath) {
autoApproveEnabled = !autoApproveEnabled; autoApproveEnabled = !autoApproveEnabled;
} }
logToFile(`[AUTO] auto-approve toggled → ${autoApproveEnabled}`); logToFile(`[AUTO] auto-approve toggled → ${autoApproveEnabled}`);
// Confirm back to Discord
const emoji = autoApproveEnabled ? '🟢' : '🔴';
const mode = autoApproveEnabled ? '자동 승인 활성' : '수동 승인 모드';
writeChatSnapshot(`${emoji} **Extension 확인**: ${mode} (project=${projectName})`);
} }
else if (text) { else if (text) {
// Send message to Antigravity — use VS Code command (most reliable) // Send message to Antigravity — use VS Code command (most reliable)
@@ -243,6 +247,7 @@ function processCommandFile(filePath) {
function watchCommandsDir() { function watchCommandsDir() {
const cmdDir = path.join(bridgePath, 'commands'); const cmdDir = path.join(bridgePath, 'commands');
// Process existing files // Process existing files
const processAllCommands = () => {
try { try {
for (const f of fs.readdirSync(cmdDir)) { for (const f of fs.readdirSync(cmdDir)) {
if (f.endsWith('.json')) { if (f.endsWith('.json')) {
@@ -251,7 +256,9 @@ function watchCommandsDir() {
} }
} }
catch { } catch { }
// Watch for new files };
processAllCommands();
// Watch for new files (may not fire reliably on Windows)
try { try {
commandsWatcher = fs.watch(cmdDir, (event, filename) => { commandsWatcher = fs.watch(cmdDir, (event, filename) => {
if (filename && filename.endsWith('.json') && event === 'rename') { if (filename && filename.endsWith('.json') && event === 'rename') {
@@ -263,6 +270,10 @@ function watchCommandsDir() {
}); });
} }
catch { } catch { }
// Polling fallback: fs.watch on Windows can silently fail
setInterval(() => {
processAllCommands();
}, 3000);
} }
// ─── SDK Integration ─── // ─── SDK Integration ───
async function initSDK(context) { async function initSDK(context) {
@@ -1992,8 +2003,12 @@ function setupMonitor() {
lastPendingStepIndex = actualIndex; lastPendingStepIndex = actualIndex;
lastPendingTime = Date.now(); lastPendingTime = Date.now();
sawRunningAfterPending = false; sawRunningAfterPending = false;
// Skip pending for workspace-less AG windows (project=default)
if (projectName === 'default') {
logToFile(`[STEP-PROBE] skip pending: projectName=default (no workspace)`);
}
else if (autoApproveEnabled) {
// Auto-approve: skip Discord, approve directly // Auto-approve: skip Discord, approve directly
if (autoApproveEnabled) {
logToFile(`[AUTO] auto-approving step=${actualIndex} cmd='${command.substring(0, 60)}'`); logToFile(`[AUTO] auto-approving step=${actualIndex} cmd='${command.substring(0, 60)}'`);
tryApprovalStrategies(true, activeSessionId, ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search', 'replace_file_content', 'write_to_file', 'multi_replace_file_content'].includes(toolName) ? 'file_permission' : toolName, actualIndex); tryApprovalStrategies(true, activeSessionId, ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search', 'replace_file_content', 'write_to_file', 'multi_replace_file_content'].includes(toolName) ? 'file_permission' : toolName, actualIndex);
} }
@@ -2055,8 +2070,12 @@ function setupMonitor() {
lastPendingStepIndex = si; lastPendingStepIndex = si;
lastPendingTime = Date.now(); lastPendingTime = Date.now();
sawRunningAfterPending = false; sawRunningAfterPending = false;
// Skip pending for workspace-less AG windows (project=default)
if (projectName === 'default') {
logToFile(`[STEP-PROBE] skip pending: projectName=default (no workspace)`);
}
else if (autoApproveEnabled) {
// Auto-approve: skip Discord, approve directly // Auto-approve: skip Discord, approve directly
if (autoApproveEnabled) {
logToFile(`[AUTO] auto-approving step=${si} cmd='${command.substring(0, 60)}'`); logToFile(`[AUTO] auto-approving step=${si} cmd='${command.substring(0, 60)}'`);
tryApprovalStrategies(true, activeSessionId, ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search', 'replace_file_content', 'write_to_file', 'multi_replace_file_content'].includes(toolName) ? 'file_permission' : toolName, si); tryApprovalStrategies(true, activeSessionId, ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search', 'replace_file_content', 'write_to_file', 'multi_replace_file_content'].includes(toolName) ? 'file_permission' : toolName, si);
} }
@@ -2718,8 +2737,9 @@ function writePendingApproval(data) {
return; return;
} }
} }
// Dedup: skip if step_probe already created pending for same step_index (within window) // Dedup: skip if step_probe already created pending for same step_index IN SAME SESSION (within window)
if (existing.status === 'pending' && existing.project_name === projectName if (existing.status === 'pending' && existing.project_name === projectName
&& existing.conversation_id === data.conversation_id // CRITICAL: same session only
&& data.step_index !== undefined && existing.step_index === data.step_index) { && data.step_index !== undefined && existing.step_index === data.step_index) {
const age = nowMs - (existing.timestamp * 1000); const age = nowMs - (existing.timestamp * 1000);
if (age < DEDUP_WINDOW_MS && age >= 0) { if (age < DEDUP_WINDOW_MS && age >= 0) {

File diff suppressed because one or more lines are too long

View File

@@ -188,6 +188,10 @@ function processCommandFile(filePath: string) {
autoApproveEnabled = !autoApproveEnabled; autoApproveEnabled = !autoApproveEnabled;
} }
logToFile(`[AUTO] auto-approve toggled → ${autoApproveEnabled}`); logToFile(`[AUTO] auto-approve toggled → ${autoApproveEnabled}`);
// Confirm back to Discord
const emoji = autoApproveEnabled ? '🟢' : '🔴';
const mode = autoApproveEnabled ? '자동 승인 활성' : '수동 승인 모드';
writeChatSnapshot(`${emoji} **Extension 확인**: ${mode} (project=${projectName})`);
} else if (text) { } else if (text) {
// Send message to Antigravity — use VS Code command (most reliable) // Send message to Antigravity — use VS Code command (most reliable)
recentDiscordSentTexts.set(text.trim(), Date.now()); recentDiscordSentTexts.set(text.trim(), Date.now());
@@ -207,6 +211,7 @@ function watchCommandsDir() {
const cmdDir = path.join(bridgePath, 'commands'); const cmdDir = path.join(bridgePath, 'commands');
// Process existing files // Process existing files
const processAllCommands = () => {
try { try {
for (const f of fs.readdirSync(cmdDir)) { for (const f of fs.readdirSync(cmdDir)) {
if (f.endsWith('.json')) { if (f.endsWith('.json')) {
@@ -214,8 +219,11 @@ function watchCommandsDir() {
} }
} }
} catch { } } catch { }
};
// Watch for new files processAllCommands();
// Watch for new files (may not fire reliably on Windows)
try { try {
commandsWatcher = fs.watch(cmdDir, (event, filename) => { commandsWatcher = fs.watch(cmdDir, (event, filename) => {
if (filename && filename.endsWith('.json') && event === 'rename') { if (filename && filename.endsWith('.json') && event === 'rename') {
@@ -226,6 +234,11 @@ function watchCommandsDir() {
} }
}); });
} catch { } } catch { }
// Polling fallback: fs.watch on Windows can silently fail
setInterval(() => {
processAllCommands();
}, 3000);
} }
// ─── SDK Integration ─── // ─── SDK Integration ───
@@ -1982,8 +1995,11 @@ function setupMonitor() {
lastPendingStepIndex = actualIndex; lastPendingStepIndex = actualIndex;
lastPendingTime = Date.now(); lastPendingTime = Date.now();
sawRunningAfterPending = false; sawRunningAfterPending = false;
// Skip pending for workspace-less AG windows (project=default)
if (projectName === 'default') {
logToFile(`[STEP-PROBE] skip pending: projectName=default (no workspace)`);
} else if (autoApproveEnabled) {
// Auto-approve: skip Discord, approve directly // Auto-approve: skip Discord, approve directly
if (autoApproveEnabled) {
logToFile(`[AUTO] auto-approving step=${actualIndex} cmd='${command.substring(0, 60)}'`); logToFile(`[AUTO] auto-approving step=${actualIndex} cmd='${command.substring(0, 60)}'`);
tryApprovalStrategies(true, activeSessionId, ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search', 'replace_file_content', 'write_to_file', 'multi_replace_file_content'].includes(toolName) ? 'file_permission' : toolName, actualIndex); tryApprovalStrategies(true, activeSessionId, ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search', 'replace_file_content', 'write_to_file', 'multi_replace_file_content'].includes(toolName) ? 'file_permission' : toolName, actualIndex);
} else { } else {
@@ -2043,8 +2059,11 @@ function setupMonitor() {
lastPendingStepIndex = si; lastPendingStepIndex = si;
lastPendingTime = Date.now(); lastPendingTime = Date.now();
sawRunningAfterPending = false; sawRunningAfterPending = false;
// Skip pending for workspace-less AG windows (project=default)
if (projectName === 'default') {
logToFile(`[STEP-PROBE] skip pending: projectName=default (no workspace)`);
} else if (autoApproveEnabled) {
// Auto-approve: skip Discord, approve directly // Auto-approve: skip Discord, approve directly
if (autoApproveEnabled) {
logToFile(`[AUTO] auto-approving step=${si} cmd='${command.substring(0, 60)}'`); logToFile(`[AUTO] auto-approving step=${si} cmd='${command.substring(0, 60)}'`);
tryApprovalStrategies(true, activeSessionId, ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search', 'replace_file_content', 'write_to_file', 'multi_replace_file_content'].includes(toolName) ? 'file_permission' : toolName, si); tryApprovalStrategies(true, activeSessionId, ['view_file', 'list_dir', 'find_by_name', 'read_file', 'grep_search', 'replace_file_content', 'write_to_file', 'multi_replace_file_content'].includes(toolName) ? 'file_permission' : toolName, si);
} else { } else {
@@ -2672,8 +2691,9 @@ function writePendingApproval(data: { conversation_id: string; command: string;
return; return;
} }
} }
// Dedup: skip if step_probe already created pending for same step_index (within window) // Dedup: skip if step_probe already created pending for same step_index IN SAME SESSION (within window)
if (existing.status === 'pending' && existing.project_name === projectName if (existing.status === 'pending' && existing.project_name === projectName
&& existing.conversation_id === data.conversation_id // CRITICAL: same session only
&& data.step_index !== undefined && existing.step_index === data.step_index) { && data.step_index !== undefined && existing.step_index === data.step_index) {
const age = nowMs - (existing.timestamp * 1000); const age = nowMs - (existing.timestamp * 1000);
if (age < DEDUP_WINDOW_MS && age >= 0) { if (age < DEDUP_WINDOW_MS && age >= 0) {