diff --git a/companion/codex_display/app_server.py b/companion/codex_display/app_server.py index 259cafe..8e86d6b 100644 --- a/companion/codex_display/app_server.py +++ b/companion/codex_display/app_server.py @@ -23,7 +23,8 @@ def find_codex_binary() -> str: return bundled raise AppServerError( - "找不到 Codex CLI;请设置 CODEX_BIN,或安装包含 codex 的 ChatGPT/Codex App" + "Codex CLI not found; set CODEX_BIN or install the ChatGPT/Codex app " + "that bundles codex" ) @@ -84,9 +85,9 @@ async def stop(self) -> None: async def wait_for_exit(self) -> None: if self._reader_task is None: - raise AppServerError("Codex app-server 尚未启动") + raise AppServerError("Codex app-server has not started") await self._reader_task - raise AppServerError("Codex app-server 已退出") + raise AppServerError("Codex app-server exited") async def request( self, method: str, params: Optional[Dict[str, Any]] = None @@ -112,7 +113,7 @@ async def notify(self, method: str, params: Dict[str, Any]) -> None: async def _send(self, message: Dict[str, Any]) -> None: if self._process is None or self._process.stdin is None: - raise AppServerError("Codex app-server 尚未启动") + raise AppServerError("Codex app-server has not started") payload = json.dumps(message, separators=(",", ":")) + "\n" self._process.stdin.write(payload.encode("utf-8")) await self._process.stdin.drain() @@ -123,7 +124,7 @@ async def _read_loop(self) -> None: while True: line = await self._process.stdout.readline() if not line: - error = AppServerError("Codex app-server 已退出") + error = AppServerError("Codex app-server exited") for future in self._pending.values(): if not future.done(): future.set_exception(error) diff --git a/companion/codex_display/ble.py b/companion/codex_display/ble.py index 91f20de..2fbfbb6 100644 --- a/companion/codex_display/ble.py +++ b/companion/codex_display/ble.py @@ -42,7 +42,7 @@ async def run_forever(self) -> None: backoff = 1.0 while True: try: - logger.info("正在搜索 %s…", self._device_name) + logger.info("Scanning for %s...", self._device_name) device = await BleakScanner.find_device_by_filter( lambda candidate, advertisement: ( (candidate.name or "") == self._device_name @@ -58,11 +58,11 @@ async def run_forever(self) -> None: self.connected_event.clear() if "turned off" in str(error).lower(): logger.error( - "蓝牙不可用或当前启动程序没有权限;请检查“系统设置 → " - "隐私与安全性 → 蓝牙”" + "Bluetooth is unavailable or permission is missing; " + "check System Settings > Privacy & Security > Bluetooth" ) else: - logger.warning("BLE 扫描失败:%s", error) + logger.warning("BLE scan failed: %s", error) await asyncio.sleep(backoff) backoff = min(backoff * 2, 60) continue @@ -83,12 +83,12 @@ def on_disconnect(_: Any) -> None: try: await client.connect() backoff = 1.0 - logger.info("已连接 %s", device.name or device.address) + logger.info("Connected to %s", device.name or device.address) await self._run_connection(client, disconnected) except asyncio.CancelledError: raise except Exception as error: - logger.warning("BLE 连接中断:%s", error) + logger.warning("BLE connection interrupted: %s", error) finally: self.connected_event.clear() await self._disconnect(client) @@ -105,9 +105,9 @@ async def _disconnect(self, client: Any) -> None: timeout=BLE_DISCONNECT_TIMEOUT_SECONDS, ) except asyncio.TimeoutError: - logger.warning("BLE 断开超时,继续重新扫描") + logger.warning("BLE disconnect timed out; resuming scan") except Exception as error: - logger.warning("BLE 断开失败:%s", error) + logger.warning("BLE disconnect failed: %s", error) async def _run_connection( self, client: Any, disconnected: asyncio.Event @@ -176,7 +176,7 @@ async def _write_gatt_char( timeout=BLE_OPERATION_TIMEOUT_SECONDS, ) except asyncio.TimeoutError as error: - raise asyncio.TimeoutError("BLE GATT 写入超时") from error + raise asyncio.TimeoutError("BLE GATT write timed out") from error async def _command_loop(self, client: Any) -> None: while client.is_connected: @@ -196,7 +196,7 @@ async def _command_loop(self, client: Any) -> None: except ValueError as error: ok, message = False, str(error) except Exception as error: - logger.exception("设备动作执行失败:%s", error) + logger.exception("Device action failed: %s", error) ok, message = False, "ACTION FAILED" payload = encode_result(request_id, ok, message) if request_id > 0: diff --git a/companion/codex_display/main.py b/companion/codex_display/main.py index 14de970..8622b7c 100644 --- a/companion/codex_display/main.py +++ b/companion/codex_display/main.py @@ -71,7 +71,8 @@ async def refresh(self) -> None: self._sequence += 1 current = self._current_snapshot() logging.info( - "数据已更新:剩余 %d%%,today %s%d,7d %d,reset %d,running %d", + "Data updated: remaining %d%%, today %s%d, 7d %d, " + "reset %d, running %d", current.remaining_percent, "~" if current.tokens_today_estimated else "", current.tokens_today, @@ -81,7 +82,19 @@ async def refresh(self) -> None: ) async def activity_changed(self) -> None: + try: + count, paths = await collect_active_thread_state(self._client) + except Exception as error: + logging.warning("Immediate RUN reconciliation failed: %s", error) + count = None + paths = set() async with self._lock: + if self._snapshot is not None and count is not None: + self._snapshot = replace( + self._snapshot, + active_threads=count, + ) + self._active_paths = paths self._sequence += 1 self.status_changed.set() @@ -110,7 +123,7 @@ async def reconcile_active_loop( try: await cache.reconcile_active() except Exception as error: - logging.warning("RUN 定期校准失败:%s", error) + logging.warning("Periodic RUN reconciliation failed: %s", error) async def run(args: argparse.Namespace) -> None: @@ -155,7 +168,7 @@ async def run(args: argparse.Namespace) -> None: if app_server_task in done: app_server_task.result() companion_task.result() - raise RuntimeError("BLE Companion 意外退出") + raise RuntimeError("BLE companion exited unexpectedly") finally: for task in tasks: if not task.done(): @@ -206,7 +219,7 @@ def main() -> None: except KeyboardInterrupt: pass except Exception: - logging.exception("Companion 异常退出") + logging.exception("Companion exited unexpectedly") raise SystemExit(1) diff --git a/companion/tests/test_app_server.py b/companion/tests/test_app_server.py index c54cc8a..cebf830 100644 --- a/companion/tests/test_app_server.py +++ b/companion/tests/test_app_server.py @@ -1,12 +1,16 @@ import asyncio +import json import unittest from companion.codex_display.app_server import AppServerClient, AppServerError -from companion.codex_display.main import reconcile_active_loop +from companion.codex_display.main import SnapshotCache, reconcile_active_loop from companion.codex_display.metrics import collect_snapshot class FakeClient: + def __init__(self): + self.threads = [{"status": {"type": "active"}}] + async def request(self, method, params=None): if method == "account/rateLimits/read": return { @@ -21,7 +25,7 @@ async def request(self, method, params=None): return {"dailyUsageBuckets": []} if method == "thread/list": self.thread_params = params - return {"data": [{"status": {"type": "active"}}]} + return {"data": self.threads} raise AssertionError(method) @@ -36,6 +40,18 @@ def test_collects_all_three_sources(self): class LifecycleTests(unittest.IsolatedAsyncioTestCase): + async def test_hook_change_reconciles_cached_active_count(self): + client = FakeClient() + cache = SnapshotCache(client, refresh_seconds=60) + await cache.refresh() + self.assertEqual(json.loads(await cache.encoded())["a"], 1) + + client.threads = [] + await cache.activity_changed() + + self.assertEqual(json.loads(await cache.encoded())["a"], 0) + self.assertTrue(cache.status_changed.is_set()) + async def test_wait_for_exit_reports_unexpected_app_server_exit(self): client = AppServerClient("/usr/bin/false") client._reader_task = asyncio.create_task(asyncio.sleep(0))