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:
@@ -440,3 +440,27 @@
|
||||
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 오프셋)
|
||||
- **주의**: `_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
11
bot.py
@@ -180,6 +180,7 @@ class GravityBot(commands.Bot):
|
||||
self.session_category: discord.CategoryChannel | None = None
|
||||
self.guild: discord.Guild | None = None
|
||||
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
|
||||
|
||||
def _write_command(self, project: str, text: str, **kwargs):
|
||||
@@ -771,6 +772,16 @@ class GravityBot(commands.Bot):
|
||||
if message.author == self.user:
|
||||
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
|
||||
project = self.channel_to_project.get(message.channel.id)
|
||||
if not project:
|
||||
|
||||
5
docs/devlog/2026-03-15.md
Normal file
5
docs/devlog/2026-03-15.md
Normal 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` | ✅ |
|
||||
31
docs/devlog/entries/20260315-001.md
Normal file
31
docs/devlog/entries/20260315-001.md
Normal 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 중복 방지 |
|
||||
@@ -223,6 +223,10 @@ function processCommandFile(filePath) {
|
||||
autoApproveEnabled = !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) {
|
||||
// Send message to Antigravity — use VS Code command (most reliable)
|
||||
@@ -243,15 +247,18 @@ function processCommandFile(filePath) {
|
||||
function watchCommandsDir() {
|
||||
const cmdDir = path.join(bridgePath, 'commands');
|
||||
// Process existing files
|
||||
try {
|
||||
for (const f of fs.readdirSync(cmdDir)) {
|
||||
if (f.endsWith('.json')) {
|
||||
processCommandFile(path.join(cmdDir, f));
|
||||
const processAllCommands = () => {
|
||||
try {
|
||||
for (const f of fs.readdirSync(cmdDir)) {
|
||||
if (f.endsWith('.json')) {
|
||||
processCommandFile(path.join(cmdDir, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
// Watch for new files
|
||||
catch { }
|
||||
};
|
||||
processAllCommands();
|
||||
// Watch for new files (may not fire reliably on Windows)
|
||||
try {
|
||||
commandsWatcher = fs.watch(cmdDir, (event, filename) => {
|
||||
if (filename && filename.endsWith('.json') && event === 'rename') {
|
||||
@@ -263,6 +270,10 @@ function watchCommandsDir() {
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
// Polling fallback: fs.watch on Windows can silently fail
|
||||
setInterval(() => {
|
||||
processAllCommands();
|
||||
}, 3000);
|
||||
}
|
||||
// ─── SDK Integration ───
|
||||
async function initSDK(context) {
|
||||
@@ -1992,8 +2003,12 @@ function setupMonitor() {
|
||||
lastPendingStepIndex = actualIndex;
|
||||
lastPendingTime = Date.now();
|
||||
sawRunningAfterPending = false;
|
||||
// Auto-approve: skip Discord, approve directly
|
||||
if (autoApproveEnabled) {
|
||||
// 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
|
||||
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);
|
||||
}
|
||||
@@ -2055,8 +2070,12 @@ function setupMonitor() {
|
||||
lastPendingStepIndex = si;
|
||||
lastPendingTime = Date.now();
|
||||
sawRunningAfterPending = false;
|
||||
// Auto-approve: skip Discord, approve directly
|
||||
if (autoApproveEnabled) {
|
||||
// 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
|
||||
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);
|
||||
}
|
||||
@@ -2718,8 +2737,9 @@ function writePendingApproval(data) {
|
||||
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
|
||||
&& existing.conversation_id === data.conversation_id // CRITICAL: same session only
|
||||
&& data.step_index !== undefined && existing.step_index === data.step_index) {
|
||||
const age = nowMs - (existing.timestamp * 1000);
|
||||
if (age < DEDUP_WINDOW_MS && age >= 0) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -188,6 +188,10 @@ function processCommandFile(filePath: string) {
|
||||
autoApproveEnabled = !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) {
|
||||
// Send message to Antigravity — use VS Code command (most reliable)
|
||||
recentDiscordSentTexts.set(text.trim(), Date.now());
|
||||
@@ -207,15 +211,19 @@ function watchCommandsDir() {
|
||||
const cmdDir = path.join(bridgePath, 'commands');
|
||||
|
||||
// Process existing files
|
||||
try {
|
||||
for (const f of fs.readdirSync(cmdDir)) {
|
||||
if (f.endsWith('.json')) {
|
||||
processCommandFile(path.join(cmdDir, f));
|
||||
const processAllCommands = () => {
|
||||
try {
|
||||
for (const f of fs.readdirSync(cmdDir)) {
|
||||
if (f.endsWith('.json')) {
|
||||
processCommandFile(path.join(cmdDir, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
} catch { }
|
||||
};
|
||||
|
||||
// Watch for new files
|
||||
processAllCommands();
|
||||
|
||||
// Watch for new files (may not fire reliably on Windows)
|
||||
try {
|
||||
commandsWatcher = fs.watch(cmdDir, (event, filename) => {
|
||||
if (filename && filename.endsWith('.json') && event === 'rename') {
|
||||
@@ -226,6 +234,11 @@ function watchCommandsDir() {
|
||||
}
|
||||
});
|
||||
} catch { }
|
||||
|
||||
// Polling fallback: fs.watch on Windows can silently fail
|
||||
setInterval(() => {
|
||||
processAllCommands();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// ─── SDK Integration ───
|
||||
@@ -1982,8 +1995,11 @@ function setupMonitor() {
|
||||
lastPendingStepIndex = actualIndex;
|
||||
lastPendingTime = Date.now();
|
||||
sawRunningAfterPending = false;
|
||||
// Auto-approve: skip Discord, approve directly
|
||||
if (autoApproveEnabled) {
|
||||
// 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
|
||||
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);
|
||||
} else {
|
||||
@@ -2043,8 +2059,11 @@ function setupMonitor() {
|
||||
lastPendingStepIndex = si;
|
||||
lastPendingTime = Date.now();
|
||||
sawRunningAfterPending = false;
|
||||
// Auto-approve: skip Discord, approve directly
|
||||
if (autoApproveEnabled) {
|
||||
// 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
|
||||
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);
|
||||
} else {
|
||||
@@ -2672,8 +2691,9 @@ function writePendingApproval(data: { conversation_id: string; command: string;
|
||||
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
|
||||
&& existing.conversation_id === data.conversation_id // CRITICAL: same session only
|
||||
&& data.step_index !== undefined && existing.step_index === data.step_index) {
|
||||
const age = nowMs - (existing.timestamp * 1000);
|
||||
if (age < DEDUP_WINDOW_MS && age >= 0) {
|
||||
|
||||
Reference in New Issue
Block a user