Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
16c93f6
test(download): 修复 DownloadPage 偶发失败——等待锚点从静态平台名换成数据驱动内容 (#504)
Yanyutin753 Sep 8, 2026
815b0d3
chore(release): bump 版本 2.10.0 → 2.10.1
Yanyutin753 Sep 8, 2026
c4bdbbd
fix(frontend): ask human 审批卡换行修复与移动端适配,重设计沙箱确认清单与定时任务卡
Yanyutin753 Sep 8, 2026
7533401
fix(sandbox): 本地沙箱断联自愈——未 ACK 幂等重推 + daemon 去重与 hello 快速失败 + 通道心跳先发射
Yanyutin753 Sep 8, 2026
6a464a4
fix(frontend): OAuth 登录按钮点击即进入 loading,防止连点重复跳转
Yanyutin753 Sep 8, 2026
0c01ffe
feat(task): arq worker 与 API 分进程——独立 worker 入口 + k8s/compose 双部署适配
Yanyutin753 Sep 8, 2026
a5c99b0
merge: worker 拆分并入断联自愈 PR——同批上线
Yanyutin753 Sep 8, 2026
feea4ab
merge: ask human 审批卡修复并入 2.10.1 发版批次
Yanyutin753 Sep 8, 2026
6c5a3ff
merge: 沙箱断联自愈与 worker 拆分并入 2.10.1 发版批次
Yanyutin753 Sep 8, 2026
988fe41
merge: OAuth 按钮 loading 修复并入 2.10.1 发版批次
Yanyutin753 Sep 8, 2026
9311dac
chore(release): uv.lock 版本随 pyproject 对齐 2.10.1
Yanyutin753 Sep 8, 2026
d255a0f
style(test): ruff format 修复 test_daemon.py 空行——补 PR #507 CI 遗留
Yanyutin753 Sep 8, 2026
a2901e7
Merge pull request #505 from Yanyutin753/chore/bump-2.10.1
Yanyutin753 Sep 8, 2026
f1750a8
fix(config): ARQ_EMBEDDED_WORKER 改为 env 权威——DB 种子值不得拉起 API 内嵌 worker …
Yanyutin753 Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/lambchat_sandbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@
2.x/0.3.x 均放行,旧 daemon 经 self-update 平滑升到对齐版本。
"""

__version__ = "2.10.0"
__version__ = "2.10.1"
61 changes: 59 additions & 2 deletions client/lambchat_sandbox/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)。"""
Expand Down Expand Up @@ -113,6 +142,7 @@ async def run_daemon(

client: ChannelClient | None = None
attempt = 0
dedupe = _CallDedupe()
try:
while True:
if client is not None:
Expand All @@ -129,6 +159,7 @@ async def run_daemon(
cfg=cfg,
executor=executor_,
auditor=auditor_,
dedupe=dedupe,
)
except TransportAuthError:
await _silently_close(client)
Expand All @@ -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))
Expand All @@ -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(
Expand All @@ -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 第四段)供服务端门读取。
Expand All @@ -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,
{
Expand Down
30 changes: 22 additions & 8 deletions client/lambchat_sandbox/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from __future__ import annotations

import asyncio
import contextlib
import json
import random
Expand All @@ -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 主循环。
Expand Down Expand Up @@ -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:
Expand Down
34 changes: 34 additions & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions frontend/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions frontend/ios/App/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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)";
Expand All @@ -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 = "";
Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "lambchat-frontend",
"private": true,
"version": "2.10.0",
"version": "2.10.1",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading