fix(ext+hub): v0.5.2 Idle→Resume 신호 소실 3중 버그 수정 — auth_fail 재연결 + pending_owners 보존 + step-probe 리셋

This commit is contained in:
Variet Worker
2026-03-21 10:51:02 +09:00
parent 94cbda6f3d
commit 5aad82c727
9 changed files with 59 additions and 15 deletions

View File

@@ -53,6 +53,12 @@
- **해결** (v0.5.1): `approval-handler.ts` L384에 `browser_subagent` 추가, `step-probe.ts` L481/L549에 `browser_subagent`/`open_browser_url` step_type 분류 추가 (`549af6d`) - **해결** (v0.5.1): `approval-handler.ts` L384에 `browser_subagent` 추가, `step-probe.ts` L481/L549에 `browser_subagent`/`open_browser_url` step_type 분류 추가 (`549af6d`)
- **주의**: 새로운 AG 도구 추가 시 반드시 (1) step-probe step_type 매핑 (2) approval-handler RPC payload 매핑 양쪽 모두 업데이트 - **주의**: 새로운 AG 도구 추가 시 반드시 (1) step-probe step_type 매핑 (2) approval-handler RPC payload 매핑 양쪽 모두 업데이트
### [2026-03-21] Idle→Resume 신호 소실 — 3중 버그
- **증상**: AG 장시간 idle 후 작업 재개 시 Discord 승인 신호가 전달되지 않음
- **원인**: (1) `ws-client.ts` `auth_fail``shouldReconnect=false` — JWT 24h 만료 시 WS 영구 종료. (2) `hub.py` `_disconnect`에서 유일 연결 시 `pending_owners` 삭제 — 재연결 후 Discord 버튼 무효. (3) `step-probe.ts` `stallProbed=true` + `lastPendingStepIndex=N`이 WS 재연결 시 리셋 안 됨 — WAITING step 재전송 영구 차단
- **해결** (v0.5.2): (1) `auth_fail``registrationCode` 재시도. (2) `pending_owners` orphan 마커로 보존+재할당. (3) `resetPendingStateForReconnect()` + `onConnected`에서 호출
- **주의**: WS `onConnected`에서 반드시 step-probe 상태 리셋 필수. `stallProbed`/`lastPendingStepIndex`는 TTL 없는 영구 값
--- ---
> [!NOTE] > [!NOTE]
@@ -79,3 +85,4 @@
| 10 | **diff_review는 VS Code 커맨드만 유효** — RPC 3개 전략 모두 실패 확정 | diff_review RPC dead-end | | 10 | **diff_review는 VS Code 커맨드만 유효** — RPC 3개 전략 모두 실패 확정 | diff_review RPC dead-end |
| 11 | **HttpBridgeContext에 프리미티브 by-value 복사 금지** — 별도 객체 생성 시 getter 사용 | HttpBridgeContext stale primitive | | 11 | **HttpBridgeContext에 프리미티브 by-value 복사 금지** — 별도 객체 생성 시 getter 사용 | HttpBridgeContext stale primitive |
| 12 | **새 AG 도구 추가 시 step-probe step_type 매핑 + approval-handler RPC payload 매핑 양쪽 필수** | browser_subagent Allow | | 12 | **새 AG 도구 추가 시 step-probe step_type 매핑 + approval-handler RPC payload 매핑 양쪽 필수** | browser_subagent Allow |
| 13 | **WS `onConnected`에서 step-probe 상태 리셋 필수**`stallProbed`/`lastPendingStepIndex`는 TTL 없는 영구 값 | Idle→Resume 신호 소실 |

View File

@@ -2,4 +2,5 @@
| # | 시간 | 작업 | 커밋 | 상태 | | # | 시간 | 작업 | 커밋 | 상태 |
|---|------|------|------|------| |---|------|------|------|------|
| 1 | 07:30 | v0.5.0 이후 이슈 조사 — 외부 네트워크 접근(서버 정상 확인), browser_subagent Allow RPC 매핑 수정, .env 정리 | `549af6d` | ✅ | | 1 | 07:30 | v0.5.1 browser_subagent Allow RPC 매핑 수정 + .env 정리 | `549af6d` | ✅ |
| 2 | 10:35 | v0.5.2 Idle→Resume 신호 소실 3중 버그 수정: auth_fail 재연결, pending_owners 보존, step-probe 리셋 | — | 🔧 |

View File

@@ -443,6 +443,8 @@ async function activate(context) {
logToFile(`[WS] Connected: ${connId} instance=#${instanceNum}`); logToFile(`[WS] Connected: ${connId} instance=#${instanceNum}`);
statusBar.text = '$(check) Bridge WS'; statusBar.text = '$(check) Bridge WS';
statusBar.tooltip = `Gravity Bridge: ${projectName} (WS #${instanceNum})`; statusBar.tooltip = `Gravity Bridge: ${projectName} (WS #${instanceNum})`;
// Reset step-probe state so WAITING steps are re-detected after reconnect
(0, step_probe_1.resetPendingStateForReconnect)();
}, },
onDisconnected: () => { onDisconnected: () => {
logToFile('[WS] Disconnected — using file fallback'); logToFile('[WS] Disconnected — using file fallback');

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
"name": "gravity-bridge", "name": "gravity-bridge",
"displayName": "Gravity Bridge", "displayName": "Gravity Bridge",
"description": "Antigravity ↔ Discord 브리지 연동 확장", "description": "Antigravity ↔ Discord 브리지 연동 확장",
"version": "0.5.1", "version": "0.5.2",
"publisher": "variet", "publisher": "variet",
"engines": { "engines": {
"vscode": "^1.100.0" "vscode": "^1.100.0"

View File

@@ -16,7 +16,7 @@ import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import * as cp from 'child_process'; import * as cp from 'child_process';
import { WSBridgeClient, WSResponseData, WSCommandData } from './ws-client'; import { WSBridgeClient, WSResponseData, WSCommandData } from './ws-client';
import { initStepProbe, BridgeContext, writePendingApproval, tryApprovalStrategies, writeRegistration, getApprovalContext, resetPendingState, handleDiffReviewResponse, getActiveSessionId as getStepProbeSessionId, getStepProbeContext } from './step-probe'; import { initStepProbe, BridgeContext, writePendingApproval, tryApprovalStrategies, writeRegistration, getApprovalContext, resetPendingState, resetPendingStateForReconnect, handleDiffReviewResponse, getActiveSessionId as getStepProbeSessionId, getStepProbeContext } from './step-probe';
import { startHttpBridge, getDeterministicPort, HttpBridgeContext } from './http-bridge'; import { startHttpBridge, getDeterministicPort, HttpBridgeContext } from './http-bridge';
import { setupApprovalObserver } from './html-patcher'; import { setupApprovalObserver } from './html-patcher';
import { watchCommandsDir, handleWSCommand, disposeCommandsWatcher, CommandHandlerContext } from './command-handler'; import { watchCommandsDir, handleWSCommand, disposeCommandsWatcher, CommandHandlerContext } from './command-handler';
@@ -433,6 +433,8 @@ export async function activate(context: vscode.ExtensionContext) {
logToFile(`[WS] Connected: ${connId} instance=#${instanceNum}`); logToFile(`[WS] Connected: ${connId} instance=#${instanceNum}`);
statusBar.text = '$(check) Bridge WS'; statusBar.text = '$(check) Bridge WS';
statusBar.tooltip = `Gravity Bridge: ${projectName} (WS #${instanceNum})`; statusBar.tooltip = `Gravity Bridge: ${projectName} (WS #${instanceNum})`;
// Reset step-probe state so WAITING steps are re-detected after reconnect
resetPendingStateForReconnect();
}, },
onDisconnected: () => { onDisconnected: () => {
logToFile('[WS] Disconnected — using file fallback'); logToFile('[WS] Disconnected — using file fallback');

View File

@@ -85,6 +85,19 @@ export function resetPendingState(): void {
ctx.sawRunningAfterPending = false; ctx.sawRunningAfterPending = false;
} }
/**
* Reset step-probe state after WS reconnection.
* Without this, stallProbed=true + lastPendingStepIndex=N permanently block
* re-detection of WAITING steps whose pending was lost during disconnect.
*/
export function resetPendingStateForReconnect(): void {
ctx.lastPendingStepIndex = -1;
ctx.stallProbed = false;
ctx.sawRunningAfterPending = false;
recentPendingSteps.clear();
ctx.logToFile('[STEP-PROBE] Reset pending state for WS reconnect');
}
// handleDiffReviewResponse → moved to ./approval-handler.ts // handleDiffReviewResponse → moved to ./approval-handler.ts
/** /**

View File

@@ -359,14 +359,21 @@ export class WSBridgeClient {
case 'auth_fail': { case 'auth_fail': {
const reason = (msg as any).reason || 'Unknown'; const reason = (msg as any).reason || 'Unknown';
this.logFn(`[WS] Auth failed: ${reason}`); this.logFn(`[WS] Auth failed: ${reason}`);
// Clear session token if it was rejected // Clear session token if it was rejected (e.g. expired after 24h)
this.sessionToken = ''; this.sessionToken = '';
if (this.registrationCode) {
// Token expired → retry with registration code on next reconnect
this.logFn('[WS] Retrying with registration code...');
this._cleanup();
this._scheduleReconnect();
} else {
// No registration code available → permanent failure
// MUST set shouldReconnect=false BEFORE _cleanup(), because _cleanup() // MUST set shouldReconnect=false BEFORE _cleanup(), because _cleanup()
// closes the WS → triggers close event → _onDisconnect() → _scheduleReconnect(). // closes the WS → triggers close event → _onDisconnect() → _scheduleReconnect().
// Without this, auth failures cause infinite reconnect loops.
this.shouldReconnect = false; this.shouldReconnect = false;
this._cleanup(); this._cleanup();
this.handlers.onError?.(`Auth failed: ${reason}`); this.handlers.onError?.(`Auth failed: ${reason}`);
}
break; break;
} }

20
hub.py
View File

@@ -269,11 +269,17 @@ class WSHub:
self.project_connections[conn.project] = set() self.project_connections[conn.project] = set()
self.project_connections[conn.project].add(conn.conn_id) self.project_connections[conn.project].add(conn.conn_id)
# FIX: Reassign orphaned pending_owners (from dead conn_ids) to this new connection. # FIX: Reassign orphaned pending_owners (from dead conn_ids or orphan markers)
# When Extension reconnects, old conn_id entries become stale. # to this new connection. When Extension reconnects, old entries become stale.
reassigned = 0 reassigned = 0
for rid, cid in list(self.pending_owners.items()): for rid, cid in list(self.pending_owners.items()):
if cid not in self.connections: if cid.startswith("orphan:"):
# Only reassign orphans from the same project
if cid != f"orphan:{conn.project}":
continue
self.pending_owners[rid] = conn.conn_id
reassigned += 1
elif cid not in self.connections:
self.pending_owners[rid] = conn.conn_id self.pending_owners[rid] = conn.conn_id
reassigned += 1 reassigned += 1
if reassigned: if reassigned:
@@ -324,7 +330,13 @@ class WSHub:
f"(disconnected {conn_id})" f"(disconnected {conn_id})"
) )
else: else:
del self.pending_owners[rid] # Preserve as orphan marker instead of deleting —
# will be reassigned when Extension reconnects
self.pending_owners[rid] = f"orphan:{project}"
logger.info(
f"[HUB] Orphaned pending {rid[:12]} "
f"(no active connections in {project})"
)
# Close WebSocket if still open # Close WebSocket if still open
if not conn.ws.closed: if not conn.ws.closed: