refactor(cleanup): v0.5.0 Collector 제거 + dead code 정리 + HttpBridgeContext 버그 수정
- DELETE collector.py (523줄) - main.py: BOT_MODE=remote 분기 제거 - gateway.py: Collector REST 6개 endpoint 제거 (311→168줄) - bridge.py: RemoteTransport 제거 (480→270줄) - config.py: REMOTE_BRIDGE_URL 제거 - extension.ts: dead code 4개 + stale module vars 제거 - step-probe.ts: getStepProbeContext() 추가, autoApproveEnabled 제거 - FIX: HttpBridgeContext stale primitive (getter 패턴으로 수정) - ADD: extension.log rotation (10MB→2MB tail) - docs: architecture.md, tech-stack.md, known-issues.md 업데이트
This commit is contained in:
214
bridge.py
214
bridge.py
@@ -12,10 +12,6 @@ Protocol:
|
||||
2. Bot reads pending/ → sends Discord message with ✅/❌ buttons
|
||||
3. User clicks button → Bot writes JSON to response/
|
||||
4. VS Code Extension reads response/ → executes action
|
||||
|
||||
Transport layer:
|
||||
LocalTransport — file-based (default, single-PC)
|
||||
RemoteTransport — HTTP-based (future: multi-PC collector mode)
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -150,216 +146,6 @@ class LocalTransport(BridgeTransport):
|
||||
(self.bridge_dir / sub).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
class RemoteTransport(BridgeTransport):
|
||||
"""HTTP-based transport for Collector → Gateway communication.
|
||||
|
||||
Maps BridgeTransport methods to Gateway API endpoints:
|
||||
list_json_files("pending") → GET /api/pending (returns list)
|
||||
write_json("pending", ...) → POST /api/pending
|
||||
read_json("response", ...) → GET /api/response/{rid}
|
||||
write_json("commands", ...) → (not used by Collector, Gateway pushes commands)
|
||||
etc.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str, api_key: str = ""):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self._headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
self._headers["Authorization"] = f"Bearer {api_key}"
|
||||
self._session = None # aiohttp.ClientSession — lazy created
|
||||
|
||||
# Connection health
|
||||
self.connected = False
|
||||
self._consecutive_failures = 0
|
||||
self._max_failures_before_warning = 3
|
||||
|
||||
# Rate limit backoff
|
||||
self._rate_limited_until = 0.0 # timestamp until which we should not send requests
|
||||
self._backoff_seconds = 0.0 # current backoff duration (exponential)
|
||||
self._BACKOFF_BASE = 2.0
|
||||
self._BACKOFF_MAX = 60.0
|
||||
self._success_streak = 0 # consecutive successes for gradual backoff reduction
|
||||
|
||||
# Retry queue: list of (method, path, data) tuples
|
||||
self._retry_queue: list[tuple[str, str, dict | None]] = []
|
||||
self._retry_queue_max = 100
|
||||
|
||||
logger.info(f"RemoteTransport: {self.base_url} (auth={'yes' if api_key else 'no'})")
|
||||
|
||||
async def _get_session(self):
|
||||
"""Lazy-create aiohttp session."""
|
||||
if self._session is None or self._session.closed:
|
||||
import aiohttp
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
self._session = aiohttp.ClientSession(
|
||||
headers=self._headers, timeout=timeout
|
||||
)
|
||||
return self._session
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP session."""
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
@property
|
||||
def is_rate_limited(self) -> bool:
|
||||
"""Check if we are currently in a rate-limit backoff period."""
|
||||
return time.time() < self._rate_limited_until
|
||||
|
||||
def _apply_backoff(self, retry_after: float = 0):
|
||||
"""Apply exponential backoff for rate limiting."""
|
||||
self._success_streak = 0 # Reset success streak on any failure
|
||||
if retry_after > 0:
|
||||
self._backoff_seconds = min(retry_after, self._BACKOFF_MAX)
|
||||
else:
|
||||
if self._backoff_seconds == 0:
|
||||
self._backoff_seconds = self._BACKOFF_BASE
|
||||
else:
|
||||
self._backoff_seconds = min(self._backoff_seconds * 2, self._BACKOFF_MAX)
|
||||
self._rate_limited_until = time.time() + self._backoff_seconds
|
||||
logger.warning(f"RemoteTransport: backing off {self._backoff_seconds:.0f}s (until +{self._backoff_seconds:.0f}s)")
|
||||
|
||||
def _on_request_success(self):
|
||||
"""Gradually reduce backoff after consecutive successes.
|
||||
|
||||
Instead of instantly resetting to 0 (which causes the 1s oscillation loop
|
||||
when 7 loops share one transport), require sustained success before reducing.
|
||||
"""
|
||||
if self._backoff_seconds <= 0:
|
||||
return # Already at zero, nothing to do
|
||||
self._success_streak += 1
|
||||
if self._success_streak >= 5:
|
||||
# Halve the backoff (gradual cooldown)
|
||||
self._backoff_seconds = self._backoff_seconds / 2
|
||||
if self._backoff_seconds < 0.5:
|
||||
self._backoff_seconds = 0
|
||||
self._rate_limited_until = 0
|
||||
self._success_streak = 0
|
||||
|
||||
async def _arequest(self, method: str, path: str, data: dict | None = None) -> dict | None:
|
||||
"""Async non-blocking HTTP request to Gateway API."""
|
||||
# Skip if in backoff period (except health checks)
|
||||
if self.is_rate_limited and path != "/health":
|
||||
return None
|
||||
|
||||
session = await self._get_session()
|
||||
url = f"{self.base_url}{path}"
|
||||
try:
|
||||
kwargs = {}
|
||||
if data is not None:
|
||||
kwargs["json"] = data
|
||||
async with session.request(method, url, **kwargs) as resp:
|
||||
if resp.status >= 400:
|
||||
if resp.status == 401:
|
||||
logger.error("RemoteTransport: 401 Unauthorized — check GATEWAY_API_KEY")
|
||||
elif resp.status == 429:
|
||||
retry_after = float(resp.headers.get("Retry-After", 0))
|
||||
self._apply_backoff(retry_after)
|
||||
else:
|
||||
logger.warning(f"RemoteTransport: {method} {path} → {resp.status}")
|
||||
return None
|
||||
result = await resp.json()
|
||||
if not self.connected:
|
||||
logger.info("RemoteTransport: ✅ Gateway connected")
|
||||
self.connected = True
|
||||
self._consecutive_failures = 0
|
||||
self._on_request_success()
|
||||
return result
|
||||
except Exception as e:
|
||||
self._consecutive_failures += 1
|
||||
if self._consecutive_failures == self._max_failures_before_warning:
|
||||
logger.error(f"RemoteTransport: ❌ Gateway unreachable ({self._consecutive_failures} failures): {e}")
|
||||
elif self._consecutive_failures < self._max_failures_before_warning:
|
||||
logger.warning(f"RemoteTransport: {method} {path} → {e}")
|
||||
self.connected = False
|
||||
# Apply backoff on connection failures too
|
||||
if self._consecutive_failures >= self._max_failures_before_warning:
|
||||
self._apply_backoff()
|
||||
return None
|
||||
|
||||
async def _arequest_retry(self, method: str, path: str, data: dict | None = None) -> dict | None:
|
||||
"""Request with retry queue — failed POSTs are queued for later."""
|
||||
result = await self._arequest(method, path, data)
|
||||
if result is None and method == "POST" and data is not None:
|
||||
if len(self._retry_queue) < self._retry_queue_max:
|
||||
self._retry_queue.append((method, path, data))
|
||||
return result
|
||||
|
||||
async def flush_retry_queue(self):
|
||||
"""Retry queued failed requests."""
|
||||
if not self._retry_queue or not self.connected:
|
||||
return
|
||||
queue = self._retry_queue[:]
|
||||
self._retry_queue.clear()
|
||||
succeeded = 0
|
||||
for method, path, data in queue:
|
||||
result = await self._arequest(method, path, data)
|
||||
if result is None:
|
||||
if len(self._retry_queue) < self._retry_queue_max:
|
||||
self._retry_queue.append((method, path, data))
|
||||
break
|
||||
succeeded += 1
|
||||
if succeeded:
|
||||
logger.info(f"[RETRY] flushed {succeeded}/{len(queue)} queued requests")
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if Gateway is reachable."""
|
||||
result = await self._arequest("GET", "/health")
|
||||
return result is not None and result.get("status") == "ok"
|
||||
|
||||
# ─── Async methods (used by Collector) ───
|
||||
|
||||
async def awrite_json(self, subdir: str, filename: str, data: dict) -> None:
|
||||
if subdir == "pending":
|
||||
await self._arequest_retry("POST", "/api/pending", data)
|
||||
elif subdir == "response":
|
||||
rid = data.get("request_id", filename.replace(".json", ""))
|
||||
await self._arequest_retry("POST", f"/api/response/{rid}", data)
|
||||
|
||||
async def aread_json(self, subdir: str, filename: str) -> dict | None:
|
||||
rid = filename.replace(".json", "")
|
||||
if subdir == "response":
|
||||
return await self._arequest("GET", f"/api/response/{rid}")
|
||||
return None
|
||||
|
||||
async def apoll_commands(self, project: str) -> list[dict]:
|
||||
result = await self._arequest("GET", f"/api/commands/{project}")
|
||||
if result and isinstance(result, dict):
|
||||
return result.get("commands", [])
|
||||
return []
|
||||
|
||||
async def aregister_session(self, conv_id: str, project: str) -> None:
|
||||
await self._arequest_retry("POST", "/api/register", {
|
||||
"conversation_id": conv_id, "project_name": project,
|
||||
})
|
||||
|
||||
async def asend_chat(self, project: str, content: str, *, attached_files: list[dict] | None = None) -> None:
|
||||
payload: dict = {"project_name": project, "content": content}
|
||||
if attached_files:
|
||||
payload["attached_files"] = attached_files
|
||||
await self._arequest_retry("POST", "/api/chat", payload)
|
||||
|
||||
async def asend_event(self, event_data: dict) -> None:
|
||||
await self._arequest_retry("POST", "/api/event", event_data)
|
||||
|
||||
# ─── Sync stubs (ABC compliance, not used in Collector) ───
|
||||
|
||||
def list_json_files(self, subdir: str) -> list[str]:
|
||||
return []
|
||||
|
||||
def read_json(self, subdir: str, filename: str) -> dict | None:
|
||||
return None
|
||||
|
||||
def write_json(self, subdir: str, filename: str, data: dict) -> None:
|
||||
pass
|
||||
|
||||
def delete_file(self, subdir: str, filename: str) -> bool:
|
||||
return True
|
||||
|
||||
def ensure_dirs(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ─── Bridge Protocol (uses Transport) ───
|
||||
|
||||
|
||||
Reference in New Issue
Block a user