diff --git a/client/lambchat_sandbox/__init__.py b/client/lambchat_sandbox/__init__.py index 67c6911ae..dc0e80022 100644 --- a/client/lambchat_sandbox/__init__.py +++ b/client/lambchat_sandbox/__init__.py @@ -11,4 +11,4 @@ 2.x/0.3.x 均放行,旧 daemon 经 self-update 平滑升到对齐版本。 """ -__version__ = "2.10.0" +__version__ = "2.10.1" diff --git a/client/lambchat_sandbox/daemon.py b/client/lambchat_sandbox/daemon.py index d46b1d34e..a5af5c04a 100644 --- a/client/lambchat_sandbox/daemon.py +++ b/client/lambchat_sandbox/daemon.py @@ -36,6 +36,7 @@ import signal import sys import time +from collections import deque from collections.abc import AsyncIterator, Awaitable, Callable from pathlib import Path @@ -66,6 +67,34 @@ # 省一次 HTTP 往返;长命令到点补 ack,服务端 30s ACK 死线(远大于本值)无虞 _EXEC_ACK_DELAY_S = 8.0 +# call_id 去重环容量:服务端 dispatch 断联重推是 at-least-once 投递,重复帧 +# 幂等跳过(重复执行用户机器上的命令是不可接受的副作用)。重复帧总在几秒 +# 内到达,容量只需覆盖一个重推窗口内的调用数。 +_RECENT_CALL_IDS_MAX = 512 + + +class _CallDedupe: + """跨连接的 call_id 去重:断联重连后收到的重复帧跳过执行。 + + FIFO 环形淘汰:容量之外的旧 id 被遗忘——数小时前的迟到重复帧理论上会 + 重执行,但重推窗口只有 ACK 死线(30s),现实中不存在这种迟到。 + """ + + def __init__(self, capacity: int = _RECENT_CALL_IDS_MAX) -> None: + self._seen: set[str] = set() + self._order: deque[str] = deque() + self._capacity = capacity + + def remember(self, call_id: str) -> bool: + """首次见到返回 True 并登记;重复返回 False。""" + if call_id in self._seen: + return False + self._seen.add(call_id) + self._order.append(call_id) + while len(self._order) > self._capacity: + self._seen.discard(self._order.popleft()) + return True + def _default_machine_name() -> str: """machine_name 未配置时的展示名退回 hostname(截断防超长 URL)。""" @@ -113,6 +142,7 @@ async def run_daemon( client: ChannelClient | None = None attempt = 0 + dedupe = _CallDedupe() try: while True: if client is not None: @@ -129,6 +159,7 @@ async def run_daemon( cfg=cfg, executor=executor_, auditor=auditor_, + dedupe=dedupe, ) except TransportAuthError: await _silently_close(client) @@ -148,7 +179,13 @@ async def run_daemon( ) raise except Exception as exc: # noqa: BLE001 - 任何单连接失败都退避重连 - print(f"[sandbox] 通道断开: {exc};退避后重连…", file=sys.stderr, flush=True) + # httpx 超时族的 str() 为空串(ReadTimeout/ConnectTimeout), + # 只打消息会得到『通道断开: 』的盲日志——必须带类型名。 + print( + f"[sandbox] 通道断开: {type(exc).__name__}: {exc};退避后重连…", + file=sys.stderr, + flush=True, + ) attempt += 1 # 保留当前 client(流已关但 httpx 连接池可用)跨退避窗口:取消时仍能 post_offline await sleep_fn(backoff_delay(attempt)) @@ -165,10 +202,13 @@ async def _handle_channel( cfg: SandboxConfig, executor: Executor, auditor: Auditor, + dedupe: _CallDedupe | None = None, ) -> None: """单次连接内逐条处理 ToolCall;流结束/异常交回外层重连循环。""" async for call in calls: - await _process_call(client, call, cfg=cfg, executor=executor, auditor=auditor) + await _process_call( + client, call, cfg=cfg, executor=executor, auditor=auditor, dedupe=dedupe + ) async def _process_call( @@ -178,9 +218,14 @@ async def _process_call( cfg: SandboxConfig, executor: Executor, auditor: Auditor, + dedupe: _CallDedupe | None = None, ) -> None: """单条 ToolCall 的完整决策链:审计 received → ack → op 分发 → 迟到检查 → 执行 → done。 + call_id 去重(dedupe 非 None 时):服务端 dispatch 在 ACK 死线内对未确认 + 调用幂等重推(断联窗口丢帧的自愈),重复帧记 audit 后直接跳过——同一 + 调用绝不执行两次。 + 确认门控不在本层(spec §3.5 服务端实现):服务端统一确认门在 dispatch 前以 ask_human interrupt 完成,daemon 只收到已确认的执行请求,到达即执行。 ``confirm_policy`` 仍随连接上报(connect URL 第四段)供服务端门读取。 @@ -195,6 +240,18 @@ async def _process_call( path = str(call.payload.get("path", "")) session_id = _session_id_from_cwd(virtual_cwd) started = time.monotonic() + if dedupe is not None and not dedupe.remember(call.call_id): + auditor.log( + session_id, + { + "event": "duplicate_skipped", + "call_id": call.call_id, + "op": call.op, + "command": command, + "path": path, + }, + ) + return auditor.log( session_id, { diff --git a/client/lambchat_sandbox/transport.py b/client/lambchat_sandbox/transport.py index ad0bcd266..154ac6ceb 100644 --- a/client/lambchat_sandbox/transport.py +++ b/client/lambchat_sandbox/transport.py @@ -10,6 +10,7 @@ from __future__ import annotations +import asyncio import contextlib import json import random @@ -32,6 +33,12 @@ _CHANNEL_READ_TIMEOUT_S = 45.0 _CHANNEL_CONNECT_TIMEOUT_S = 10.0 +# hello 阶段独立超时(秒):健康服务端建连即发 hello(毫秒级),迟迟不到 +# 说明帧被断联通道吞掉(滚动发布切换/代理僵死)。不等 45s 读超时,快速 +# 失败进退避重连,把 daemon 掉线窗口从分钟级压到秒级(2026-09-09 生产断联 +# 实测:hello 丢失的连接挂满 45s 才重连)。 +_HELLO_TIMEOUT_S = 12.0 + # 结果回传/offline 通知的 per-request 超时(秒)。client 全局 timeout=None 是给 # SSE 长连接用的(心跳流不能被读超时切断),POST 沿用同一默认时服务端半死会让 # 回传永久挂起,拖垮 daemon 主循环。 @@ -189,14 +196,21 @@ async def connect(self) -> tuple[dict[str, Any], AsyncIterator[ToolCall]]: try: await _raise_for_status(response, "channel") hello: dict[str, Any] | None = None - async for line in lines: - frame = parser.feed(line) - if frame is None or frame.event != "hello": - continue - data = _parse_json_object(frame.data) - if data is not None: - hello = data - break + try: + # hello 独立短超时:僵死连接(建连后首帧永不到达)快速失败 + async with asyncio.timeout(_HELLO_TIMEOUT_S): + async for line in lines: + frame = parser.feed(line) + if frame is None or frame.event != "hello": + continue + data = _parse_json_object(frame.data) + if data is not None: + hello = data + break + except TimeoutError: + raise TransportError( + f"channel: {_HELLO_TIMEOUT_S:.0f}s 内未收到 hello 帧(连接疑似僵死)" + ) from None if hello is None: raise TransportError("SSE 通道在 hello 帧前关闭") except BaseException: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 58cf3d015..602ceaee8 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -59,6 +59,40 @@ services: - LOG_LEVEL=INFO - ENABLE_MESSAGE_HISTORY=true - EVENT_MERGE_INTERVAL=60 + # 任务执行与 API 分进程(与 k8s 部署同构):重任务不饿死 API 事件循环 + # (SSE/沙箱通道心跳停发),API 重启不杀运行中的任务。任务由 + # lambchat-worker 服务消费(Redis 队列协同)。 + - ARQ_EMBEDDED_WORKER=false + volumes: + - lamb-data:/app/data + - ./workspace:/app/workspace + - ./uploads:/app/uploads + + lambchat-worker: + container_name: lambchat-worker + image: ghcr.io/yanyutin753/lambchat:latest + restart: always + command: ["/app/.venv/bin/python", "-m", "src.infra.task.worker_main"] + deploy: + resources: + limits: + memory: 2G + reservations: + memory: 512M + depends_on: + redis: + condition: service_healthy + mongodb: + condition: service_healthy + environment: + - TZ=Asia/Shanghai + - REDIS_URL=redis://redis:6379/0 + - MONGODB_URL=mongodb://mongodb:27017 + - E2B_API_KEY=${E2B_API_KEY:-} + - E2B_TEMPLATE=${E2B_TEMPLATE:-base} + - LLM_MODEL_CACHE_SIZE=${LLM_MODEL_CACHE_SIZE:-50} + - SESSION_MAX_EVENTS_PER_TRACE=${SESSION_MAX_EVENTS_PER_TRACE:-10000} + - LOG_LEVEL=INFO volumes: - lamb-data:/app/data - ./workspace:/app/workspace diff --git a/frontend/android/app/build.gradle b/frontend/android/app/build.gradle index eb4b44977..2df315804 100644 --- a/frontend/android/app/build.gradle +++ b/frontend/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.lambchat.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 2100 - versionName "2.10.0" + versionCode 2101 + versionName "2.10.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/frontend/ios/App/App.xcodeproj/project.pbxproj b/frontend/ios/App/App.xcodeproj/project.pbxproj index 973e2e4d2..a5c400699 100644 --- a/frontend/ios/App/App.xcodeproj/project.pbxproj +++ b/frontend/ios/App/App.xcodeproj/project.pbxproj @@ -352,7 +352,7 @@ INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 2.10.0; + MARKETING_VERSION = 2.10.1; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = com.lambchat.app; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -372,7 +372,7 @@ INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 2.10.0; + MARKETING_VERSION = 2.10.1; PRODUCT_BUNDLE_IDENTIFIER = com.lambchat.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; diff --git a/frontend/package.json b/frontend/package.json index e7d819e66..f69411efc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "lambchat-frontend", "private": true, - "version": "2.10.0", + "version": "2.10.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 0c4dbab82..d9716b161 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "LambChat", - "version": "2.10.0", + "version": "2.10.1", "identifier": "com.lambchat.app", "build": { "frontendDist": "../dist", diff --git a/frontend/src/components/auth/AuthPage.tsx b/frontend/src/components/auth/AuthPage.tsx index 621f06b71..f76263def 100644 --- a/frontend/src/components/auth/AuthPage.tsx +++ b/frontend/src/components/auth/AuthPage.tsx @@ -21,6 +21,7 @@ import { ShieldCheck, Workflow, Database, + Loader2, } from "lucide-react"; import { PasswordInput } from "./PasswordInput"; import toast from "react-hot-toast"; @@ -107,6 +108,10 @@ export function AuthPage({ onSuccess, initialMode }: AuthPageProps) { const [oauthProviders, setOauthProviders] = useState< { id: string; name: string }[] >([]); + // 点击跳转 OAuth 提供商期间锁住按钮(导航发生前页面仍可交互,防止连点) + const [oauthPendingProvider, setOauthPendingProvider] = useState< + string | null + >(null); const [registrationEnabled, setRegistrationEnabled] = useState(true); const [turnstileConfig, setTurnstileConfig] = useState({ enabled: false, @@ -186,12 +191,14 @@ export function AuthPage({ onSuccess, initialMode }: AuthPageProps) { setTurnstileKey((prev) => prev + 1); }, [mode]); - // OAuth 登录处理 + // OAuth 登录处理:点击即进入 loading,直到页面跳走;失败才复位 const handleOAuthLogin = useCallback( async (provider: string) => { + setOauthPendingProvider(provider); try { await loginWithOAuth(provider); } catch { + setOauthPendingProvider(null); toast.error(t("auth.oauthLoginFailed")); } }, @@ -790,49 +797,56 @@ export function AuthPage({ onSuccess, initialMode }: AuthPageProps) {