Skip to content

Commit 1108266

Browse files
author
Grigory Rylov
committed
chore: sync local dev branch
1 parent 04d7a07 commit 1108266

32 files changed

Lines changed: 1745 additions & 6578 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Plan: Reproduce Bun 5-minute fetch timeout
2+
3+
## Problem
4+
5+
Bun хардкодит 5-минутный таймаут на `globalThis.fetch` (oven-sh/bun#16682). Когда LLM-провайдер (llama-server) думает дольше 5 минут, `FetchHttpClient` в `executor.ts` получает `TimeoutError` ровно через 5 минут. AI SDK путь (`aisdk.ts`) уже защищён через `timeout: false`, но нативный LLM путь — нет.
6+
7+
## Что делаем
8+
9+
Создаём standalone-скрипт (не bun:test — у bun:test дефолтный таймаут 30с), который:
10+
11+
1. Запускает `Bun.serve` сервер, который держит соединение 6 минут перед ответом
12+
2. Делает `fetch()` с `AbortSignal.timeout(30 * 60 * 1000)` (30 мин) — чтобы убедиться, что это НЕ наш таймаут срабатывает
13+
3. Логирует ошибку — ожидаем `TimeoutError` ровно через ~5 минут
14+
4. Затем повторяет запрос с `timeout: false` (Bun-specific) — должен завершиться успешно
15+
16+
## Файл
17+
18+
`packages/llm/test/bun-fetch-timeout-repro.ts` — standalone скрипт, запускается через `bun run`
19+
20+
## Детали скрипта
21+
22+
Два теста, каждый с задержкой ответа 6 минут:
23+
24+
```
25+
// Test 1 — проблема (без timeout:false):
26+
// Bun.serve: delay 6 min → fetch({ signal: AbortSignal.timeout(30 min) })
27+
// → Ожидаем TimeoutError через ~5 мин (Bun's hardcoded limit)
28+
//
29+
// Test 2 — фикс (с timeout:false):
30+
// Bun.serve: delay 6 min → fetch({ signal: AbortSignal.timeout(30 min), timeout: false })
31+
// → Ожидаем успешный ответ через 6 мин
32+
```
33+
34+
Оба теста запускаются параллельно (Promise.all), общее время ~6.5 мин.
35+
36+
## Как проверить
37+
38+
```bash
39+
bun run packages/llm/test/bun-fetch-timeout-repro.ts
40+
```
41+
42+
Скрипт выведет:
43+
- Test 1: `FAIL: TimeoutError after ~5 min` — подтверждает проблему
44+
- Test 2: `OK: Response received after ~6 min` — подтверждает фикс через `timeout: false`
45+
46+
## Следующий шаг (после подтверждения)
47+
48+
Применить фикс в `packages/llm/src/route/executor.ts` — переопределить `FetchHttpClient.Fetch` через `Layer.succeed(FetchHttpClient.Fetch, customFetch)` с `timeout: false` + `AbortSignal.timeout(30 min)`.

bot/config.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,9 @@
1515
"thinking_peer_id": 2000000506,
1616
"llama_server_path": "llama-server",
1717
"llama_server_host": "http://localhost:8081", # URL удалённого llama-server
18-
"opencode_config_path": "~/.config/opencode/opencode.json",
18+
"opencode_config_path": "~/.config/lildax/config.json",
1919
"models": [],
2020
"default_model": "qwen3.5-122b",
21-
"request_timeout": 1800000,
2221
}
2322

2423

@@ -92,9 +91,6 @@ def load_config(config_path: str = "config.json") -> dict:
9291
# Provider URL for CLI --provider-url flag (LLAMA_SERVER_HOST + /v1)
9392
PROVIDER_URL = (args.llama_host or LLAMA_SERVER_HOST).rstrip("/") + "/v1"
9493

95-
# Request timeout for CLI --timeout flag (milliseconds, default 30 minutes)
96-
REQUEST_TIMEOUT = CONFIG.get("request_timeout", 1800000)
97-
9894
# Model для --model флага, через CLI аргумент или из конфига.
9995
# Разрешаем алиас в реальное имя модели (через MODELS[alias].model)
10096
_default_alias = args.model or DEFAULT_MODEL
@@ -160,7 +156,6 @@ def switch_config(config_name: str) -> bool:
160156
current_module.OPENCODE_BIN = (SCRIPT_DIR / current_module.OPENCODE_BIN).resolve()
161157
current_module.OPENCODE_CONFIG_PATH = Path(new_config.get("opencode_config_path", "~/.config/opencode/opencode.json")).expanduser()
162158
current_module.PROVIDER_URL = (args.llama_host or new_config.get("llama_server_host", "http://localhost:8081")).rstrip("/") + "/v1"
163-
current_module.REQUEST_TIMEOUT = new_config.get("request_timeout", 1800000)
164159
# CLI_MODEL: разрешаем алиас в реальное имя модели
165160
_alias = args.model or new_config.get("default_model")
166161
_models_dict = new_config.get("models", {})
@@ -170,5 +165,5 @@ def switch_config(config_name: str) -> bool:
170165
# Обновляем args.config чтобы importlib.reload(config) тоже подхватил
171166
if hasattr(current_module.args, 'config'):
172167
current_module.args.config = config_str
173-
168+
174169
return True

bot/llama_server.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,6 @@ async def do_restart(
283283
ready = await wait_for_llama_server(model_alias=alias)
284284

285285
if ready:
286-
await vk_client.send_message(user_id, f"✅ Модель {alias} загружена и готова!")
287286
logger.info(f"Model {alias} loaded successfully")
288287
else:
289288
await vk_client.send_message(

bot/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ async def main():
112112
workdir = Path(session_mgr.session_workdir[first_session_id])
113113
logger.info(f"Restored workdir from session {first_session_id}: {workdir}")
114114

115-
opencode_process = OpenCodeProcess(model=bot_config.CLI_MODEL, provider_url=bot_config.PROVIDER_URL, workdir=workdir, timeout=bot_config.REQUEST_TIMEOUT)
115+
opencode_process = OpenCodeProcess(model=bot_config.CLI_MODEL, provider_url=bot_config.PROVIDER_URL, workdir=workdir)
116116
logger.info(f"OpenCodeProcess created with workdir={opencode_process.workdir}")
117117
await opencode_process.start()
118118

bot/models.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,4 @@ def model_to_api_format(model: str) -> dict:
2424
return {}
2525
if isinstance(model, dict):
2626
return {"model": model}
27-
if "/" in model:
28-
providerID, model_id = model.split("/", 1)
29-
return {"model": {"id": model_id, "providerID": providerID}}
3027
return {"model": {"id": model, "providerID": "cli"}}

bot/opencode_process.py

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,90 @@
22
Управление процессом OpenCode (lildax)
33
"""
44
import asyncio
5+
import json
56
import os
67
import subprocess
78
import socket
89
from pathlib import Path
910

10-
from config import OPENCODE_BIN, PROVIDER_URL, CLI_MODEL, REQUEST_TIMEOUT
11+
from config import OPENCODE_BIN, OPENCODE_CONFIG_PATH, PROVIDER_URL, CLI_MODEL, MCP_SERVERS
1112
from logging_config import logger
1213

1314

15+
def sync_mcp_to_lildax_config() -> bool:
16+
"""Синхронизирует MCP сервера из bot/config.json в lildax config.
17+
18+
Читает mcp_servers из bot/config.json и обновляет секцию 'mcp'
19+
в ~/.config/lildax/config.json.
20+
21+
Returns:
22+
True если успешно, иначе False
23+
"""
24+
try:
25+
# Путь к lildax config (OPENCODE_CONFIG_PATH)
26+
lildax_config_path = Path(OPENCODE_CONFIG_PATH).expanduser()
27+
28+
if not MCP_SERVERS:
29+
logger.debug("No mcp_servers in bot config, skipping sync")
30+
return True
31+
32+
# Читаем текущий lildax config
33+
try:
34+
with open(lildax_config_path, "r", encoding="utf-8") as f:
35+
lildax_config = json.load(f)
36+
except FileNotFoundError:
37+
logger.warning(f"Lildax config not found at {lildax_config_path}, creating new")
38+
lildax_config = {}
39+
except json.JSONDecodeError as e:
40+
logger.error(f"Invalid JSON in lildax config: {e}")
41+
return False
42+
43+
# Обновляем или создаём секцию mcp
44+
if "mcp" not in lildax_config:
45+
lildax_config["mcp"] = {}
46+
47+
# Формат: { "server-name": { "type": "local", "command": [...], "enabled": true } }
48+
for server_name, server_config in MCP_SERVERS.items():
49+
if isinstance(server_config, dict) and server_config.get("enabled", True):
50+
lildax_config["mcp"][server_name] = {
51+
"type": server_config.get("type", "local"),
52+
"command": server_config.get("command", []),
53+
}
54+
if "cwd" in server_config:
55+
lildax_config["mcp"][server_name]["cwd"] = server_config["cwd"]
56+
if "environment" in server_config:
57+
lildax_config["mcp"][server_name]["environment"] = server_config["environment"]
58+
59+
# Сохраняем обновлённый config
60+
lildax_config_path.parent.mkdir(parents=True, exist_ok=True)
61+
with open(lildax_config_path, "w", encoding="utf-8") as f:
62+
json.dump(lildax_config, f, indent=2, ensure_ascii=False)
63+
64+
logger.info(f"Synced {len(MCP_SERVERS)} MCP servers to {lildax_config_path}")
65+
return True
66+
67+
except Exception as e:
68+
logger.error(f"Failed to sync MCP to lildax config: {e}")
69+
return False
70+
71+
1472
class OpenCodeProcess:
1573
"""Управление процессом lildax serve."""
1674

17-
def __init__(self, model: str = None, provider_url: str = None, workdir: Path = None, timeout: int = None):
75+
def __init__(self, model: str = None, provider_url: str = None, workdir: Path = None):
1876
self.logger = logger
1977
self.process = None
2078
self.opencode_port = 4098
2179
self.model = model or CLI_MODEL
2280
self.provider_url = provider_url or PROVIDER_URL
23-
self.timeout = timeout or REQUEST_TIMEOUT
2481
self.workdir = workdir or Path.cwd()
2582
self.logger.debug(
26-
f"OpenCodeProcess initialized: workdir={self.workdir}, model={self.model}, provider_url={self.provider_url}, timeout={self.timeout}"
83+
f"OpenCodeProcess initialized: workdir={self.workdir}, model={self.model}, provider_url={self.provider_url}"
2784
)
2885

2986
def _remove_password_file(self):
3087
"""Удаляет файл password, чтобы lildax не требовал аутентификацию."""
31-
password_file = Path.home() / ".local" / "state" / "opencode" / "password"
88+
password_file = Path.home() / ".local" / "state" / "lildax" / "password"
3289
if password_file.exists():
3390
try:
3491
os.remove(password_file)
@@ -42,8 +99,6 @@ def _build_args(self) -> list[str]:
4299
args.extend(["--provider-url", self.provider_url])
43100
if self.model:
44101
args.extend(["--model", self.model])
45-
if self.timeout:
46-
args.extend(["--timeout", str(self.timeout)])
47102
return args
48103

49104
async def start(self):
@@ -66,6 +121,9 @@ async def start(self):
66121
break
67122
await asyncio.sleep(0.5)
68123

124+
# Синхронизируем MCP сервера в lildax config (после убийства, до старта)
125+
sync_mcp_to_lildax_config()
126+
69127
# Логирование в файл для отладки
70128
log_file_path = f"/tmp/lildax_{self.opencode_port}.log"
71129
log_file = None
@@ -103,13 +161,11 @@ async def start(self):
103161
if log_file:
104162
log_file.close()
105163

106-
async def restart(self, model: str = None, provider_url: str = None, workdir: Path = None, timeout: int = None):
164+
async def restart(self, model: str = None, provider_url: str = None, workdir: Path = None):
107165
if model:
108166
self.model = model
109167
if provider_url:
110168
self.provider_url = provider_url
111-
if timeout:
112-
self.timeout = timeout
113169
if workdir:
114170
self.logger.info(
115171
f"restart: updating workdir from {self.workdir} to {workdir}"

bot/session_manager.py

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ class SessionManager:
1818
def __init__(self, file_path: Path):
1919
self.file_path = file_path
2020
self.sessions: Dict[int, str] = {}
21-
self.seen_messages: Dict[str, set] = {}
2221
self.grant_mode: Dict[str, bool] = {}
2322
self.session_workdir: Dict[str, str] = {} # session_id -> workdir path
2423
self.child_sessions: Dict[str, str] = {} # child_session_id -> parent_session_id
@@ -29,9 +28,6 @@ def _load(self) -> None:
2928
with open(self.file_path, "r", encoding="utf-8") as f:
3029
data = json.load(f)
3130
self.sessions = {int(k): v for k, v in data.get("sessions", {}).items()}
32-
self.seen_messages = {
33-
sid: set(ids) for sid, ids in data.get("seen_messages", {}).items()
34-
}
3531
self.grant_mode = {
3632
sid: bool(val) for sid, val in data.get("grant_mode", {}).items()
3733
}
@@ -43,17 +39,13 @@ def _load(self) -> None:
4339
}
4440
except (FileNotFoundError, json.JSONDecodeError):
4541
self.sessions = {}
46-
self.seen_messages = {}
4742
self.grant_mode = {}
4843
self.session_workdir = {}
4944
self.child_sessions = {}
5045

5146
def _save(self) -> None:
5247
data = {
5348
"sessions": {str(k): v for k, v in self.sessions.items()},
54-
"seen_messages": {
55-
sid: list(ids) for sid, ids in self.seen_messages.items()
56-
},
5749
"grant_mode": dict(self.grant_mode),
5850
"session_workdir": dict(self.session_workdir),
5951
"child_sessions": dict(self.child_sessions),
@@ -73,8 +65,6 @@ async def get_or_create(self, user_id: int) -> str:
7365
# API возвращает {data: {id: ...}}
7466
session_id = resp_data.get("data", resp_data).get("id")
7567
self.sessions[user_id] = session_id
76-
if session_id not in self.seen_messages:
77-
self.seen_messages[session_id] = set()
7868
if session_id not in self.grant_mode:
7969
self.grant_mode[session_id] = False
8070
self._save()
@@ -83,22 +73,10 @@ async def get_or_create(self, user_id: int) -> str:
8373
)
8474
return session_id
8575

86-
def get_seen_messages(self, session_id: str) -> set:
87-
return self.seen_messages.get(session_id, set())
88-
89-
def add_seen_message(self, session_id: str, message_id: str):
90-
if session_id not in self.seen_messages:
91-
self.seen_messages[session_id] = set()
92-
self.seen_messages[session_id].add(message_id)
93-
logger.debug(f"Saved seen message {message_id} to file for session {session_id}")
94-
self._save()
95-
9676
def remove(self, user_id: int):
9777
if user_id in self.sessions:
9878
session_id = self.sessions[user_id]
9979
del self.sessions[user_id]
100-
if session_id in self.seen_messages:
101-
del self.seen_messages[session_id]
10280
if session_id in self.grant_mode:
10381
del self.grant_mode[session_id]
10482
if session_id in self.session_workdir:
@@ -141,8 +119,6 @@ def remove_session(self, user_id: int):
141119
if user_id in self.sessions:
142120
session_id = self.sessions[user_id]
143121
del self.sessions[user_id]
144-
if session_id in self.seen_messages:
145-
del self.seen_messages[session_id]
146122
if session_id in self.grant_mode:
147123
del self.grant_mode[session_id]
148124
if session_id in self.session_workdir:

0 commit comments

Comments
 (0)