# 运行手册

> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。

最后更新:2026-07-06(蓝绿双实例无感部署 B 档落地:`deploy/update_bluegreen.sh` + `zcbot@.service` 模板 unit + nginx upstream 切流;`tasks.run_owner`(0020)/ 实例色 sandbox 容器 / 微信长轮询 PG advisory lock 选主)

---

## 环境

- **Python**:虚拟环境 `.venv/`,所有依赖装在里面。一律用 `.venv/Scripts/python.exe ...`(Windows)/`.venv/bin/python ...`(Unix),不要全局 `python`(litellm/python-pptx 等会 ModuleNotFoundError)。
- **配置文件 `.env`**(项目根,git 忽略,litellm 自动加载):
  ```
  DEEPSEEK_API_KEY=sk-...
  # 用 GLM 的话再加一条;国际站 z.ai 用 ZAI_API_KEY,国内站 bigmodel.cn 用 ZHIPUAI_API_KEY(对应 config/models/glm.yaml 的 api_key_env 字段)
  ZHIPUAI_API_KEY=...
  # 豆包(火山方舟)统一 key,三处共用:可选。
  #   1) 文本/Agent 模型 config/models/doubao.yaml(Seed 2.1 turbo/pro、自进化 evolving)—— 走 Ark OpenAI 兼容端点
  #   2) 图像生成 seedream tool(0.22 元/张)
  #   3) 视频生成 seedance tool(Seedance 2.0 Fast,文生视频,480p 4s ¥1.86 ~ 720p 15s ¥12+,异步等 30-90s)
  # 未设:豆包文本模型选不了,seedream/seedance 两个 tool 都不出现
  ARK_API_KEY=...
  # documents skill(内部知识库 document_search API):可选。设了后注册
  # document_list_kb / document_search / document_download 三个 host-side tool;
  # key 只留宿主后端,sandbox/run_python 不读取。
  DOCUMENT_SEARCH_API_KEY=...
  # 可选:覆盖默认 base_url(默认 https://ai.ctc-zc.com:8100/api)
  # DOCUMENT_SEARCH_URL=https://ai.ctc-zc.com:8100/api
  # pymatgen skill 的 Materials Project 接入:可选。设了后注册
  # mp_search_summary / mp_get_structure / mp_get_entries 三个 host-side tool;
  # 离线分析(CIF / POSCAR + SpacegroupAnalyzer + XRDCalculator + CEMENT_PHASES)仍在 sandbox 跑。
  # 申请 https://materialsproject.org/api(免费)
  MP_API_KEY=...
  # research skill 的 paper_server 文献库:GET 接口要 API Key(paper_server Django admin
  # 里建 ApiKey,helper 以 ?api_key= 查询参数带上)。research 在 sandbox 里直连 paper_server,
  # 所以这把 key 会透传进 sandbox / 容器(唯一例外;低价值只读,可随时在 admin 撤销)。
  # 未设:search/fetch 即报 "paper_server auth failed (HTTP 400, not_authenticated)"。
  PAPER_SERVER_API_KEY=...
  # 可选:覆盖 paper_server 地址(host 与 docker 模式均生效)
  # PAPER_SERVER_URL=http://paper.xxhhcty.xyz:8080
  # 本地 / 内网部署 LLM(`config/models/local.yaml`,DeepSeek-R1 满血 / QwQ-32B 原生 32K,
  # 共享同一台推理服务 http://182.54.21.126:9000/v1)。涉密任务用户显式选 `local.r1` / `local.qwq`
  # 代替默认 deepseek_v4.flash;未设 env 时这两条 variant 调用即抛 RuntimeError(其他模型不影响)
  LOCAL_LLM_API_KEY=...
  # unifyllm 网关(config/models/unifyllm.yaml 文本模型 + config/media/unifyllm.yaml
  # 图像后端 gpt_image,均 pro 档):可选。
  # 未设:unifyllm.* 文本 variant 调用即抛 RuntimeError;gpt_image tool 不注册、
  # 图像模型下拉仍列出但选了也 fallback 回 seedream(其他模型不影响)。
  # ★ 服务器直连 unifyllm.ai TLS 握手失败,需代理出口(HTTPS_PROXY);
  #   部署前 `.venv/Scripts/python.exe scripts/diag_unifyllm.py` 冒烟验证(7 模型 tool-calling)
  UNIFYLLM_API_KEY=sk-...
  ZCBOT_DB_URL=postgresql://user:pass@host:5432/zcbot
  # main.py web 必填(probe/db/user 不验)
  PLATFORM_KEY=<≥16 字符随机串,platform 机器对机器入口校验>
  JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造>
  # 可选:覆盖默认 7d JWT TTL
  # ZCBOT_JWT_TTL_SECONDS=604800
  # 可选:滑动续签阈值(秒)。token 剩余寿命低于此值时,任一鉴权请求都会就地重签一张
  # 新 token,经响应头 X-Refreshed-Token / X-Token-Expires-At 带回(前端 dev SPA 自动换,
  # platform SDK 见头即换)→ 活跃用户永不因 TTL 到期掉线。默 TTL 的 1/4;设 0 关闭(退回过期即重登)
  # ZCBOT_JWT_REFRESH_THRESHOLD_SECONDS=151200
  # 可选:设了之后登录页右下角"+ 管理员添加用户"入口才工作(未设 → 接口返 503)
  # ZCBOT_ADMIN_TOKEN=<≥32 字符随机串,管理员发用户共享口令>
  # 可选:web run 线程池大小(asyncio.to_thread 默认池),默 min(32, cpu+4)。每个活跃
  # 对话整个 run 期占 1 线程,active_runs 逼近此值即排队(看 journalctl 的 [stats] 行);
  # 并发不够再调大(先确认是真并发高、而非单条 run 慢)。
  # ZCBOT_RUN_MAX_WORKERS=16
  # 定时任务发邮件(send_email tool + 定时任务 notify 兜底投递,DESIGN §8.5):可选。
  # 三者齐了才挂 send_email tool(没配的部署 agent 看不到这个工具);密钥只留宿主、不进 sandbox。
  # SMTP_HOST=smtp.qq.com
  # SMTP_PORT=465                # 默 465;465→SSL,其余→STARTTLS(可用 SMTP_TLS=ssl|starttls|none 覆盖)
  # SMTP_USER=you@qq.com
  # SMTP_PASSWORD=<授权码/应用专用密码,非登录密码>
  # SMTP_FROM=you@qq.com         # 可选,默认取 SMTP_USER
  # SMTP_FROM_NAME=总院科研辅助智能体   # 可选,发件人显示名,默"总院科研辅助智能体"(不暴露内部代号)
  # 工具失败聚集巡检(0.58.11,反 9dcae061"挂 90 天无人知"):web 进程每天扫近 7 天
  # tool 错误消息 + 被丢弃的畸形 tool_call 参数(0.58.19,kind=malformed),同签名 >=5 次
  # 且跨 >=2 task 判聚集 → 发下面邮箱(复用上面 SMTP_*;未配邮箱或 SMTP 则只打日志
  # [toolfail] 行)。**只发近 24h 仍活跃的聚集**(0.58.19):去重集是内存态、每次部署清零,
  # 不加活跃过滤的话高频部署期每次重启都会把 7 天窗口内早已安静的存量聚集重发一遍。
  # admin 页"工具失败"表可随时看低阈值全量(含已安静的灰行)。
  # ZCBOT_DEVELOPER_EMAIL=dev@example.com
  # ZCBOT_TOOLFAIL_SCAN_INTERVAL=86400   # 秒,默 86400;<=0 关掉巡检
  # broker 外置(DESIGN §7.0,蓝绿双实例跨进程 SSE/cancel):可选。设了 → event/cancel
  # 走 Redis pub/sub(蓝绿两实例读同一份 .env,天然同 URL);不设 → 进程内 broker
  # (dev / 单实例,零依赖不用起 redis)。设了但连不上 → 启动 fail-fast(报错见故障兜底)。
  # 蓝绿部署强烈建议设:消掉切换窗口「刷新丢直播 / 停止失灵」两个边缘,也是将来稳态
  # 双实例分流的前提。
  # ZCBOT_REDIS_URL=redis://127.0.0.1:6379/0
  # 单次 LLM 请求超时(秒),默 600(与 litellm 默认一致,显式化 + 可调)。长思考模型
  # 被掐("600s 无字节 → run 标 error")再调大;流式正常出 chunk 不触发。
  # ZCBOT_LLM_TIMEOUT_S=600
  # 后台进程(bg proc,DESIGN §8.12;shell/run_python background=true):每用户并发上限,默 3。
  # 状态锚 <user_root>/.zcbot_procs/,后台默认限时 7200s(工具 timeout 参数可调,cap 86400)。
  # ZCBOT_MAX_BG_PROCS=3
  # 定时任务守护循环(DESIGN §8.5,随 web 进程起,plain-asyncio 仿 _disk_scanner):
  # ZCBOT_DISABLE_SCHEDULER=1    # 可选,整体关掉调度(对照 Claude Code CLAUDE_CODE_DISABLE_CRON)
  # ZCBOT_SCHEDULER_TICK_SECONDS=10   # 可选,扫描间隔,默 10s(只决定最坏延迟≤1tick,不影响会否漏)
  # ZCBOT_SCHEDULER_CONCURRENCY=4     # 可选,并发跑的定时 run 上限,默 4
  # 微信接入(ClawBot 个人微信,DESIGN §8.7):可选。开关在才挂 wechat_push tool + 起入站长轮询。
  # ZCBOT_WECHAT_BOT_ENABLED=1        # 渠道总开关;开启后 lifespan 起入站管理器,用户可扫码绑定
  # ZCBOT_WECHAT_SECRET_KEY=<随机串>  # 凭据(bot_token/context_token)列加密密钥;缺则退明文标记(公测兜底)
  # ZCBOT_WECHAT_BASE_URL=...         # 可选,覆盖 iLink base(默 https://ilinkai.weixin.qq.com)
  # 企业微信(渠道 B,出站推送 + 入站对话,§8.7):三件套齐才挂推送。无条件主动推,补 ClawBot 24h 窗口短板。
  # WECOM_CORPID=ww...                # 企业 ID(管理员:我的企业→企业信息)
  # WECOM_AGENTID=1000002             # 自建应用 AgentId
  # WECOM_SECRET=...                  # 自建应用 Secret
  # ZCBOT_PUBLIC_BASE_URL=https://zcbot.example.com  # 可选,OAuth 回调主机(扫码绑定→「企业微信授权登录」可信域名;应用主页免登→「网页授权及JS-SDK可信域名」;缺则取请求 base)
  # 入站对话(可选,要公网 HTTPS):企微后台「应用→接收消息→设置 API 接收」填回调 URL + 下面两项,
  # 用户即可在企业微信里直接和 zcbot 对话(回调 URL = <公网 base>/v1/wecom/callback)。
  # WECOM_CALLBACK_TOKEN=...          # 接收消息 Token(企微后台生成)
  # WECOM_CALLBACK_AESKEY=...         # EncodingAESKey(43 字符,企微后台生成)
  # 讯飞语音听写(web 端「🎙 语音」麦克风输入 + 企微入站语音消息转写,core/asr_xfyun.py):
  # 可选,三件套齐才可用,未配时 web 端转写返 501、企微语音回「未配置」提示。
  # 控制台 https://console.xfyun.cn/ 开通「语音听写(流式版)」取凭据。
  # 验证链路:`.venv/Scripts/python.exe scripts/diag_asr.py`(静音冒烟)或带 PCM 文件参数真测。
  # XFYUN_APPID=...
  # XFYUN_API_KEY=...
  # XFYUN_API_SECRET=...
  # 讯飞录音文件转写(agent 工具 transcribe_audio —— 用户上传的录音/会议音频转文字,
  # core/asr_lfasr.py):可选,APPID 与上面共用 + 独立 SecretKey(控制台「录音文件转写
  # 标准版」页,不是听写的 APIKey/APISecret)。未配时 agent 里不出现该工具。
  # 单文件 ≤5 小时 / ≤500MB,服务端解码 + 说话人分离,不需要本地 ffmpeg。
  # 验证链路:`.venv/Scripts/python.exe scripts/diag_lfasr.py <音频文件> [cn|en]`。
  # XFYUN_LFASR_SECRET_KEY=...
  # 企微语音消息解码(core/audio.py)额外需系统装 ffmpeg(Linux `apt install ffmpeg`;
  # 不在 PATH 时用下行指向可执行文件)。web 端麦克风输入不需要 ffmpeg。
  # FFMPEG_PATH=/usr/bin/ffmpeg      # 可选,默认从 PATH 找
  # 对外品牌名:zcbot 是内部代号,所有用户可见文案(页面标题/顶栏/登录卡/微信·企微推送与
  # 提示页)统一用品牌名。/healthz 返回 brand 字段,前端 boot 拉取覆盖静态页默认值。
  # ZCBOT_BRAND_NAME=总院科研辅助助手   # 可选,默认即此值
  ```
  > litellm 在 import 时副作用加载 .env;入口走 `main.py`,`.env` 自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...`。
- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`、`segno`、`cryptography`)。
- **微信接入(ClawBot,§8.7)**:① `main.py db upgrade head` 带上 migration `0012`;② `.env` 设 `ZCBOT_WECHAT_BOT_ENABLED=1` + `ZCBOT_WECHAT_SECRET_KEY=<串>`;③ 用户登录后点**左栏 rail「微信」按钮**(`/static/wechat_bind.html` 仍保留作独立/嵌入入口)扫码绑定(需个人微信 8.0.70+ 且灰度到 ClawBot 插件)。绑定后在微信「微信 ClawBot」对话即走 zcbot;**主动推送需用户近 24h 在微信开口过一次**(冷启动/超期推不出,退邮件兜底)。**支持语音消息**(voice_item SILK v3 → pilk 解码 → 讯飞 IAT 转写进对话,回执「🎤 已识别:…」;需 `XFYUN_*` 三件套 + ffmpeg + pilk,pilk 随 requirements 装)。
- **企业微信(渠道 B,纯推送,§8.7)**:① 管理员建自建应用 → 填 `WECOM_CORPID/AGENTID/SECRET`(+ 可见范围含目标用户);② `main.py db upgrade head`。**绑定两条路,任选**:
  - **手填 userid(无域名时,最省)**:rail「微信」modal 企业微信段填成员 userid(管理后台→通讯录→点成员→「账号」)→ 保存。**推送是出站调用,不需要域名/HTTPS**,这条最省事。
  - **扫码授权登录(要 HTTPS 域名)**:管理员在应用→**「企业微信授权登录」**里把 zcbot 域名配进可信域名(注意不是「网页授权可信域名」,是另一项)+ 设 `ZCBOT_PUBLIC_BASE_URL`;用户点「扫码绑定」→ 桌面浏览器出二维码 → 企业微信 App 扫码确认。回调 `/v1/wecom/oauth/callback` 公开(身份从 HMAC state 验)。链接走 `login.work.weixin.qq.com/wwlogin/sso/login`(不是网页授权 `oauth2/authorize`,后者只能在企微客户端内打开 → 桌面浏览器会报「请在企业微信客户端打开链接」)。
  - 绑定后简报/结果**无条件主动推**(不挑活跃度、无 24h 窗口),适合必达。
  - **入站对话(可选,要公网 HTTPS)**:企微后台「应用 → 接收消息 → 设置 API 接收」填回调 URL `<公网 base>/v1/wecom/callback` + 自动生成的 Token / EncodingAESKey → 写进 env `WECOM_CALLBACK_TOKEN` / `WECOM_CALLBACK_AESKEY` → 保存时企微 GET 验 URL(`/v1/wecom/callback` GET 自动回 echostr)。配好后用户在企业微信里直接给应用发消息即走 zcbot 对话(与个人微信各一张会话上下文)。agent 跑完走 message/send 主动推回(非被动同步,故无 5s 限制)。**支持文本 + 图片 + 文件 + 语音**(图片/文件走 media/get 下载,落盘进会话目录 inbound/;语音下载后 ffmpeg 解码 → 讯飞 IAT 转写成文本进对话,识别结果先推一条「🎤 已识别:…」回显,需 `XFYUN_*` 三件套 + 系统 ffmpeg,缺哪个回对应提示);视频/位置等暂不处理;未绑定/空消息静默。
  - **应用主页免登(可选,要公网 HTTPS)**:企微后台应用「应用主页」填 `<公网 base>/v1/wecom/entry`,并在**「网页授权及 JS-SDK 可信域名」**登记 zcbot 域名(与上面扫码的「企业微信授权登录」可信域名是**两项不同设置**,都要配才能两端通;域名校验文件放 `ZCBOT_WECOM_VERIFY_DIR`,同扫码路径)。之后成员在企业微信里点开应用 → snsapi_base 静默授权 → 已绑定者直接进 embed 控制台(`dev.html?embed=1&wecom=1`,token 走 URL fragment,401 自动回 entry 静默重签);**未绑定者提示先在电脑端控制台完成绑定**(不自动建号,避免重复账号)。外部浏览器打开同一 URL 会退到 wwlogin 扫码。
    - **内部链路**(排障用):`GET /v1/wecom/entry`(302 → 企微 snsapi_base 网页授权,静默无感)→ `GET /v1/wecom/entry/callback`(code → userid → 反查 `channel_bindings` 企微绑定 → 签 JWT)→ 302 `dev.html?embed=1&wecom=1#token=…&user_id=…`(token 走 **URL fragment**,前端读完立即 `history.replaceState` 清掉,不进 access log)。身份映射走 `channel_bindings` 的企微绑定(**不同于** iframe embed 的 platform 维护 user_id);401 时前端 `location.replace("/v1/wecom/entry")` 静默重签。此变体与 platform iframe 嵌入(见 `EMBED.md`)是**两条独立 token 来路**,互不涉及。
  - **推荐布局(聊天优先)**:企微后台**不配「应用主页」**(点应用直进会话,新员工上来就能打字),应用「自定义菜单」放「工作台」view 按钮指向 `<公网 base>/v1/wecom/entry`——免登链路照常用,控制台从菜单进。配了主页则点应用只进主页,会话要等应用发过第一条消息才出现在消息列表(冷启动),不推荐。
  - **未绑定成员发消息 → 回绑定指引**(不再静默):聊天优先布局下新员工第一动作就是打字,回调对未绑定成员的 text/图片/文件消息每条回一句"先去控制台绑定"(事件不回)。未绑定成员点菜单「工作台」则落在绑定提示页(不自动建号)。
- **channel 长会话上下文(微信/企业微信通用,0019)**:常驻会话不再无限膨胀。① **自动分段**——入站时距上次消息超过 `config.json` 的 `channel.session_gap_hours`(默 **6** 小时,设 `<=0` 关闭)→ 软重置:只把「最后一条 user 消息起」喂模型(保留上一轮做续聊锚点),之前的历史仍全留 DB,网页端照旧翻完整记录;② **手动新话题**——用户在微信/企业微信里直接发「新话题 / 新会话 / `/new` / 清空上下文」→ 硬重置,彻底从零(回执提示已归档)。两者都**不删任何消息**,只移动「喂给模型的窗口起点」`tasks.context_base_idx`。网页端「清空对话」(`POST /v1/tasks/{id}/clear`)仍整清并把 base 归 0。需 `main.py db upgrade head` 带上 `0019`。
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。
- **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。
- **用户管理**(`users.email/password_hash/role`,0005 UNIQUE(email)、0009 role):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令/角色)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。**用户自助改密**:登录后顶栏「改密码」按钮(走 `POST /v1/auth/change_password`,需知道旧密码);改邮箱 / 用户忘了旧密码无法自助 → 手动 SQL(见故障兜底)。
- **角色与管理后台**(`users.role` ∈ `user`/`admin`):admin 才显顶栏"管理"入口 → `/static/admin.html`(非 admin 403)。页面:左侧目录(点击滚到对应区)+ 运行态/任务/用户用量/按模型/各用户用量/存储;「按模型」「各用户用量」支持时间筛选(全部/近7天/近30天)+ 排序(按成本/按用量),「各用户用量」「存储」分页;顶栏「导出 PDF」走浏览器打印(在打印对话框选"另存为 PDF",列表取前 10)。提管理员 `main.py user role --email X --role admin`(改完即时生效,role 走 DB 查不进 JWT)。`ZCBOT_ADMIN_TOKEN` 是另一回事(发用户共享口令),与 role 互不相干。

---

## 一次性初始化

```bash
# 1) 装依赖(若 .venv 不在)
python -m venv .venv
.venv/Scripts/python.exe -m pip install -r requirements.txt

# 2) 准备 .env(见上)

# 3) DB schema 上车
.venv/Scripts/python.exe main.py db upgrade head
.venv/Scripts/python.exe main.py db current   # 应输出 0010 (head)
```

---

## 日常命令

> 入口 `main.py {web, db, probe, user}`。所有 task / 消息 / 文件交互走 `main.py web` 起服务后浏览器(dev SPA)或 platform 端 / curl 调 `/v1/*`。

### Web 服务

```bash
# 默认 127.0.0.1:8765;dev SPA 在 /,Swagger UI 在 /docs
.venv/Scripts/python.exe main.py web
.venv/Scripts/python.exe main.py web --port 9000
.venv/Scripts/python.exe main.py web --reload   # 文件改动自动重启
```

### DB / probe / user

```bash
# 模型能力对账(费 token)
.venv/Scripts/python.exe main.py probe --model deepseek_v4.flash
.venv/Scripts/python.exe main.py probe --model glm.pro          # 智谱 GLM-5.1(走 litellm zai provider + 国内站 bigmodel.cn)
.venv/Scripts/python.exe main.py probe --model local.r1         # 内网 DeepSeek-R1(满血,128K),涉密任务用;需 .env 设 LOCAL_LLM_API_KEY
.venv/Scripts/python.exe main.py probe --model local.qwen3      # 内网 Qwen3-30B-A3B(MoE,原生 32K);共享 LOCAL_LLM_API_KEY

# DB migration
.venv/Scripts/python.exe main.py db upgrade head
.venv/Scripts/python.exe main.py db downgrade -1
.venv/Scripts/python.exe main.py db current

# 发用户(两条路径,任选其一)
# a) CLI:
.venv/Scripts/python.exe main.py user add --email alice@example.com --password "atLeast6"
# → [ok] user added  email=alice@example.com  role=user  user_id=<uuid>
# b) 登录页右下角"+ 管理员添加用户":需先在 .env 里设 `ZCBOT_ADMIN_TOKEN`,
#    弹窗输入 email/密码/管理员口令/角色,POST /v1/auth/admin/create_user 落库。
#    没设 env → 接口直接返 503,UI 入口会报"admin create_user disabled"。

# 可选:把已有 user_id(platform_key 入口创的)接到邮箱密码路径
.venv/Scripts/python.exe main.py user add --email bob@x.com --password "s3cret" --user-id <UUID>

# 角色:user(默认)/ admin。admin 才能开顶栏"管理"入口 → /static/admin.html 管理后台
# (监控总览:运行态/用量/任务/用户/存储)。建用户时带 --role,或事后改:
.venv/Scripts/python.exe main.py user add --email ops@x.com --password "s3cret" --role admin
.venv/Scripts/python.exe main.py user role --email alice@example.com --role admin
# → [ok] role set  email=alice@example.com  role=admin  user_id=<uuid>

# 撤用户:先清 tasks(messages CASCADE)再 DELETE user
# psql> DELETE FROM tasks WHERE user_id=(SELECT user_id FROM users WHERE email='alice@example.com');
# psql> DELETE FROM users WHERE email='alice@example.com';
```

### Auth + curl 用 token 调 /v1/*

两条 login 路径,签**同款 JWT**。所有 `/v1/tasks*` 需 `Authorization: Bearer <jwt>`。

```bash
# 路径 1:邮箱密码(dev SPA / 同事试用 — 推荐)
curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login_password \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"atLeast6"}'

# 路径 2:platform_key + 指定 user_id(机器对机器)
curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"user_id":"<UUID>","platform_key":"<value of $PLATFORM_KEY>"}'

# 调 /v1/*
TOKEN="eyJ..."
curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/tasks
```

**dev SPA**:打开 `http://127.0.0.1:8765/`(自动 302 → `/static/dev.html`),登录页两 tab(默认"邮箱密码",备用"UUID + PLATFORM_KEY",last-used 持久化 LS)进入 3 栏(task / chat / files)。给同事试用:`main.py user add` 发用户,**不用重启** web(每次 login 都查 DB),把 URL + 邮箱密码分别发给同事。

**iframe 嵌入**(platform 主页内嵌):URL 加 `?embed=1&parent_origin=<父页面 origin>`,触发 embed 模式 —— 藏左上 brand / 退出按钮,登录页不显示,新建任务挪到任务面板;父页面通过 `postMessage` 协议推 JWT(`zcbot-ready` / `zcbot-token` / `zcbot-401`)。完整对接手册见 `EMBED.md`(URL 参数 / 协议 / 后端 SSO 示例 / 父端前端示例 / 安全 / 故障兜底)。

### 路由表

全 JSON,CORS `allow_origins=["*"]`;详细 schema 见 `/docs`。

| 方法 + 路径 | 用途 | Auth |
|---|---|---|
| `GET /healthz` | `{"status":"ok","version":"<zcbot 版本>"}` | 豁免 |
| `GET /` | 302 → `/static/dev.html` dev SPA | 豁免 |
| `GET /docs` `/openapi.json` | Swagger UI / OpenAPI schema | 豁免 |
| `GET /static/*` | dev.html 等静态文件 | 豁免 |
| `POST /v1/auth/login` | platform 机器对机器;body `{user_id, platform_key}` → `{token,expires_at,user_id,ttl_seconds}` | 豁免 |
| `POST /v1/auth/login_password` | dev SPA 邮箱密码;body `{email, password}` → `{token,...,email,...}`;邮箱不存在 / 密码错 / 未设密码统一 403 | 豁免 |
| `POST /v1/auth/change_password` | dev SPA 顶栏「改密码」;body `{old_password, new_password}`(user_id 取自 JWT)→ `{ok:true}`;新密码 <6→400、旧密码错 / 无密码(platform_key 建的行)→403 | 必填 |
| `POST /v1/tasks` | 创建 task,body `{name(req), working_dir?, description?, skill?}` | 必填 |
| `GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering=` | 列任务,默认 `-created_at`;响应 `{page, page_size, count, results}`;`ordering` DRF 风格逗号分隔 `-field` 倒序,allowlist created_at/updated_at/name/status | 必填 |
| `GET /v1/tasks/{id}` | 单 task meta + `n_messages`;跨 user → 404 | 必填 |
| `PATCH /v1/tasks/{id}` | `{status?,description?,name?,skill?}`;active 不让从 web 切回 | 必填 |
| `DELETE /v1/tasks/{id}` | **软删**(204):置 `deleted_at`,从列表隐藏;messages/usage_events 及工作目录文件全部保留(留作语料 + 可恢复),不动任何磁盘文件;已软删幂等 204 | 必填 |
| `POST /v1/tasks/{id}/restore` | 恢复软删的 task(置 `deleted_at=NULL`),重新出现在列表;返回 task meta;未软删幂等成功;跨 user / 不存在 → 404 | 必填 |
| `GET /v1/folders` | 列当前 user 工作目录 + n_tasks + last_used | 必填 |
| `GET /v1/skills` | 列当前 user 可用 skill(内置 + 自己的);每项带 `source`(builtin/user)/`overrides_builtin`;另返 `load_errors`(用户 skill 因 frontmatter 坏未加载的) | 必填 |
| `GET /v1/skills/{name}` | 返某 skill 完整 SKILL.md 正文(前端「技能」modal 点开查看);同名按 user wins | 必填 |
| `DELETE /v1/skills/{name}` | 删当前 user 私有 skill(`.skills/<name>/` 整目录);只删 user 源,内置不可删 → 404;`.skills` 文件面板隐藏,这是 UI 上删自己 skill 的唯一入口 | 必填 |
| `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传 | 必填 |
| `POST /v1/tasks/{id}/messages` | `{content, image_model?=""}` 发消息;返 `{events_url}`;**`run_status` 是 running/cancelling → 409**(单活 run;error 起新 run 时清);`image_model` 是 `config/media/doubao.yaml` image 段的 variant key(空 → 沿用 yaml 第一个),仅本 run 装配 SeedreamTool 时使用,不入 DB;UI 应 disable send 直到 SSE `done` | 必填 |
| `GET /v1/tasks/{id}/events` | SSE 流(`event: <type>` + `data: <json>`);订阅 task 当前活动 | 必填 |
| `POST /v1/tasks/{id}/cancel` | 协作式 cancel;`run_status != running` → 409;LLM 走 streaming,chunk 间 poll cancel — 延迟 100ms 级,基本秒退 | 必填 |
| `GET /v1/procs` | 当前用户全部后台进程(bg proc,§8.12;shell/run_python `background=true` 启动);纯文件系统读取,前端运行条 5s 轮询用 | 必填 |
| `POST /v1/tasks/{id}/procs/{proc_id}/kill` | 强制终止后台进程(host 杀进程树 / docker rm 容器);幂等 | 必填 |
| `POST /v1/tasks/{id}/clear` | 清空当前 task 全部 messages + reset `tasks.tokens_prompt/completion/cost_cny` 三列累计 + `run_status='idle'`;`usage_events`(账单记账)**不动**,只 `message_id` 列变 NULL;run 活跃中(running/cancelling)→ 409(先 cancel);FS 文件保留 | 必填 |
| `POST /v1/tasks/{id}/optimize_prompt` | body `{text(req, ≤4000), image_model?=""}`;同步调当前 task model 润色草稿,返 `{optimized, model_profile, tokens_in, tokens_out, cost_cny}`;**不**写 messages、**不**累计 task 三列(顶栏数字不污染),只在 `usage_events` 写一行 `kind="prompt_optimize"`(对账可见);不与主对话 run 互斥(允许 streaming 中并行润色) | 必填 |
| `WS /v1/asr/stream` | 流式语音转写(前端「🎙 语音」主通道):连上后首条文本帧发 `{"token":"<jwt>"}`(WS 塞不了 header,token 不走 query 防进 access log,失败 close 4401)→ 之后二进制帧 = PCM 分片(16k/16bit/mono)实时转发讯飞;任意文本帧(约定 `{"type":"end"}`)= 说完。服务端回推 `{"text","final"}` 增量全文(wpgs 动态修正已合并),final=true 为最终结果;错误回 `{"error"}` 后关闭。nginx 反代该路径需支持 WS Upgrade(`proxy_set_header Upgrade/Connection`) | 首消息 |
| `POST /v1/asr/transcribe` | body 为裸 PCM(16kHz/16bit/单声道/小端,`application/octet-stream`)→ 讯飞 IAT 整段转写,返 `{text}`;>60s → 413;`XFYUN_*` env 未配 → 501;讯飞侧错误 → 502(带错误码提示)。流式 WS 连不上时前端的兜底通道 | 必填 |
| `GET /v1/files?path=` | 列 user_root 下条目 + 面包屑;dotfile 隐藏 | 必填 |
| `GET /v1/files/download?path=` | 下单文件 | 必填 |
| `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir,重名覆盖 | 必填 |
| `POST /v1/files/delete` | `{path, recursive?=false}`;`recursive=false` 文件或空目录(非空 → 400);`recursive=true` `shutil.rmtree` —— 顶层目录被 task 引用 → 409(先 DELETE task);空目录两种模式都可删,task.working_dir 字段不动,下次 build_agent 按需 mkdir 重建 | 必填 |
| `POST /v1/files/rename` | `{path, new_name}`;sibling 已存在 → 409;**path 顶层目录** → 同事务 UPDATE tasks.working_dir + FOR UPDATE 锁;有 running/cancelling → 409;check_no_subtask 防嵌套 → 409 | 必填 |
| `GET /v1/tasks/{id}/export` | 对话导出 .docx | 必填 |
| `GET /v1/models` | 列 chat LLM 模型清单(扫 `config/models/*.yaml`),前端顶栏切换 / 新建对话框下拉用 | 必填 |
| `GET /v1/image_models` | 列图像生成 variant 清单(扫 `config/media/doubao.yaml` image 段),前端"生图"下拉用;yaml 无 image variant → 空列表 → UI 隐藏下拉 | 必填 |

**SSE 事件**(每帧 `event: <type>` + `data: <JSON>`):`run_start{}` → `llm_start{}` → `text{delta}` / `tool_call{name,args,args_preview}` / `tool_result{name,preview,truncated}` → `llm_end{prompt_tokens,completion_tokens}` → `done{}`;cancel 走 `cancelled{}` 后随 `done{}` 收流;异常走 `error{msg}`。30s 无 event 服务端发 `: ping` 心跳。nginx 反代记得关 buffering(响应头已带 `X-Accel-Buffering: no` 默认起效)。

**SSE 客户端注意**:浏览器原生 `EventSource` 不支持自定义 header,无法塞 Bearer token。要么 `fetch + ReadableStream` 自解 SSE 帧(dev.html 走的就是这条),要么后端日后加 `?token=...` query(目前不支持,避免 token 进 access log)。

---

## 部署(Ubuntu / systemd)

> 单机最小拓扑:`/opt/zcbot/`(代码 + `.venv` + `.env`)+ systemd unit。按需改路径 / user / port。

### 一次性

```bash
sudo useradd -r -s /sbin/nologin -d /opt/zcbot zcbot   # 跑服务的非 root 用户
sudo chown -R zcbot:zcbot /opt/zcbot
# 把 .env 权限收紧(含 JWT_SECRET / PLATFORM_KEY)
sudo chmod 600 /opt/zcbot/.env
sudo chown zcbot:zcbot /opt/zcbot/.env

# PPTX 在线预览(DESIGN §8.3):web 进程(本 host,非 sandbox)调 soffice 把 .pptx 转 PDF。
# 装 LibreOffice Impress + 中文字体(缺则前端 .pptx 自动回退到"下载查看",不报错)。
sudo apt-get install -y --no-install-recommends libreoffice-impress fonts-noto-cjk
```

### unit 文件 `/etc/systemd/system/zcbot.service`

```ini
[Unit]
Description=zcbot web (FastAPI/uvicorn)
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
Type=simple
User=zcbot
WorkingDirectory=/opt/zcbot
# 显式让 systemd 装载 .env(KEY=value 行,不展开 ${...},不留 shell 引号)
EnvironmentFile=/opt/zcbot/.env
ExecStart=/opt/zcbot/.venv/bin/python main.py web --host 0.0.0.0 --port 8765
Restart=on-failure
RestartSec=2
KillSignal=SIGTERM
# ★ 优雅 drain:SIGTERM 后 zcbot 先拒新 run(503)、等在跑的 run 收尾(见
# config/agent.yaml `shutdown` 段:drain_timeout 30s + cancel_grace 15s)。
# TimeoutStopSec 必须 > drain_timeout + cancel_grace + 余量(还要算 sandbox 容器清扫
# + uvicorn graceful 5s),否则 systemd 中途 SIGKILL 把 drain 砍掉、in-flight run 仍
# 被标 error,白做。改 agent.yaml 那两个数值时这里跟着调。
TimeoutStopSec=90
KillMode=mixed
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
```

启用 + 日常:

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now zcbot
sudo systemctl status zcbot | head
sudo journalctl -u zcbot -f                   # 实时日志
sudo journalctl -u zcbot | grep '\[stats\]'   # 并发/线程池采样:active_runs 逼近 max_workers 即排队 → 调 ZCBOT_RUN_MAX_WORKERS
sudo systemctl restart zcbot                  # 重启:先 drain 在跑的 run 再换新版,新发消息期间 503(客户端自动重试)
sudo systemctl stop zcbot
```

> **不要再用 `kill -HUP`**:uvicorn 不响应 SIGHUP(没装 handler,落 Python 默认),也不会 reload 代码。Ubuntu 上要么 `systemctl restart`,要么用下面 `--reload` 自动模式。

### 无感更新(对 SSE 也尽量不抖)

**底座:`systemctl restart` 现在优雅 drain 在跑的 run**(2026-06-10)。SIGTERM 后 zcbot 先置 draining 拒新 `POST /messages`(返 503 + `Retry-After`),等所有在跑的 run 自然收尾再换新版;超 `drain_timeout`(config/agent.yaml `shutdown` 段,默 30s)的转协作式 cancel(= 用户按停止,标 idle 不报 error、可重发),再过 `cancel_grace`(默 15s)仍没退的才留给 SIGKILL。**效果**:重启不再把正在跑的对话标 `error`。代价:部署期"新点发送"会吃几十秒 503 窗口 —— dev SPA 已对 503 / 交接拒连退避重试(显"服务更新中"),platform 前端建议加同款。要彻底消灭这个 503 窗口才需要下面 B(蓝绿),A 的 drain 是单实例能做到的上限。**前提**:unit `TimeoutStopSec > drain_timeout + cancel_grace`(见上方 unit 注释)。

下面两挡是另一个维度(REST / SSE 抖动平滑),与 drain 正交:

**A. 简易档:`--reload`**(dogfood / 单实例机可用)
ExecStart 加 `--reload`,`git pull` 后 uvicorn 监听到文件变动自动重起子进程,REST 抖动 < 1s。**代价**:SSE 连接被切断,**dev.html 自动重连 3 次(1s/2s/4s 退避)**;若新进程已被启动 reaper 标 `run_status=error`,重连立即收 done,卡片末尾追加红色"连接已断开,请重发"。期间 LLM 吐的 delta 丢失(broker 不持久化 event,接受)。3 次仍失败 → 同上提示,用户重发即可。

```ini
ExecStart=/opt/zcbot/.venv/bin/python main.py web --host 0.0.0.0 --port 8765 --reload
```

`sudo systemctl restart zcbot` 一次生效。之后**只 `git pull` 即可**,不用再 restart;改 unit 文件本身才需 daemon-reload + restart。

### 蓝绿部署(B 档,已实现,生产推荐)

两个 systemd 实例 `zcbot@blue`(127.0.0.1:8766)/ `zcbot@green`(127.0.0.1:8767),**nginx 对外监听原单实例端口 8765** —— 客户端 / platform 嵌入 URL 一个字不用改;upstream(`/etc/nginx/zcbot_upstream.conf`,单行 `server` 指令)在两实例间切流量。日常部署一条命令:

```bash
sudo bash /opt/zcbot/deploy/update_bluegreen.sh
# --skip-build          透传给 update.sh(跳过 sandbox 镜像重建)
# --switch blue|green   只切 nginx 流量到指定色(不拉码不重启;半途恢复 / 手动调流)
# DRAIN_WAIT_SECONDS=N  等旧色在跑 run 自然收尾的上限(默 600s)
```

脚本流程:`update.sh --prepare-only`(拉码 / pip / migration / build,全复用单实例脚本)→ 起空闲色实例 → healthz 验活(核对 `instance` 字段)→ 改写 upstream + nginx reload(**新请求全走新实例,零 503**)→ **轮询 DB 等旧色在跑 run 自然清空**(上限 `DRAIN_WAIT_SECONDS`,期间旧 SSE 继续直播不断流 —— 直接 stop 的话 uvicorn `timeout_graceful_shutdown=5` 会 5s 掐长连接)→ stop 旧实例(残留 run 走 SIGTERM drain / 协作式 cancel)→ 旧色 SIGKILL 残留的 running 行标 error → enable 新色 / disable 旧色(重启机器只拉起 live 色)。

效果 vs 单实例 `update.sh`:**没有 503 窗口,在跑对话的 SSE 直播也不断**。

**broker 外置(`ZCBOT_REDIS_URL`,0.42 起,蓝绿强烈建议设)**:不设时 event/cancel broker 是进程内的(`web/broker.py`),切换后旧色 drain 的几分钟窗口内有两个边缘 —— ① 刷新页面 SSE 连到新实例、看不到旧实例上 run 的直播(run 照常落库,完了刷新看历史);② 点「停止」送达不到旧实例上的 run(等自然收尾)。`.env` 设 `ZCBOT_REDIS_URL=redis://127.0.0.1:6379/0`(两实例共用同一 .env,自动同源)后 event/cancel 走 Redis pub/sub,**这两个边缘消失**,SSE 订阅与停止按钮跨实例可达;顺带打开将来稳态双实例分流(nginx upstream 挂两行)的大门。取舍与方案见 DESIGN §7.0;redis 运行中挂掉只降级直播丢帧(10s 节流 log),run 不受影响,恢复后 1s 内自愈。

蓝绿并存期的互踩问题已在代码层解决(单实例部署 `ZCBOT_INSTANCE` 未设,以下全部退回原行为):
- **启动 reaper**:`tasks.run_owner`(migration 0020)记 run 归属实例,实例启动只收自己色(+ NULL 遗留)的孤儿 run,不误杀另一实例在跑的
- **sandbox 容器**:容器名 / label 带实例色(`zcbot-sandbox-blue-<uid>` + `zcbot.instance` label),`shutdown_all` 只清自己色;切换窗口同 user 可能短暂双容器,idle reaper 会收
- **定时任务**:`claim_due_jobs` 本就 `FOR UPDATE SKIP LOCKED` 原子认领,双实例不重复触发,无需改
- **微信 ClawBot 长轮询**:PG advisory lock 选主(getupdates 是消费型,双拉会抢消息),同时只有一个实例拉;旧实例停 → 锁随连接释放 → 新实例 ≤15s 接管

**一次性 bootstrap(从单实例切到蓝绿)** —— 这一步本身仍有一次普通重启窗口,"无感"从下一次部署开始:

```bash
cd /opt/zcbot
# 1) 先用旧脚本把含蓝绿支持的代码 + migration 0020 部署上去(最后一次有 503 窗口的部署)
sudo bash deploy/update.sh

# 2) 装模板 unit(实例 env 已随仓库在 deploy/instances/{blue,green}.env)
sudo cp deploy/zcbot@.service /etc/systemd/system/
sudo systemctl daemon-reload

# 3) 装 nginx + 反代配置 + 初始 upstream 指 blue(conf 里按需改 server_name / TLS)
#    此时先别 reload —— nginx 要监听 8765,旧单实例服务还占着
sudo apt-get install -y nginx
sudo cp deploy/nginx/zcbot.conf.example /etc/nginx/conf.d/zcbot.conf
echo 'server 127.0.0.1:8766;' | sudo tee /etc/nginx/zcbot_upstream.conf

# 3.5) 装 redis(broker 外置,强烈建议)+ .env 加一行;默认绑 127.0.0.1 即可
sudo apt-get install -y redis-server
redis-cli ping    # 应回 PONG
echo 'ZCBOT_REDIS_URL=redis://127.0.0.1:6379/0' | sudo tee -a /opt/zcbot/.env

# 4) 停旧单实例服务(优雅 drain,释放 8765)→ 清无实例 label 的旧 sandbox 容器
#    → 起 blue(8766)→ nginx 接管 8765。停旧到 nginx 起来之间是本次唯一的中断窗口
sudo systemctl disable --now zcbot
sudo docker rm -f $(sudo docker ps -aq -f label=zcbot.product=sandbox) 2>/dev/null || true
sudo systemctl enable --now zcbot@blue
curl -fsS http://127.0.0.1:8766/healthz     # 应含 "instance":"blue"
sudo nginx -t && sudo systemctl reload nginx
curl -fsS http://127.0.0.1:8765/healthz     # 经 nginx,应同样返回 ok

# 5) 之后日常部署一律:
sudo bash deploy/update_bluegreen.sh
```

注意:
- **对外端口不变**:nginx 接管 8765,客户端 / platform 嵌入 / EMBED.md 对接方零改动;实例(8766/8767)只绑 127.0.0.1 不对外。原来若用 uvicorn 原生 TLS(`--ssl-certfile`)跑 8765,证书挪到 nginx 层配。
- **回滚 ≠ 切回旧色**:代码目录两实例共享,旧实例重启后跑的也是新代码。回滚 = `git revert` 后重跑 `update_bluegreen.sh`。`--switch` 只用于半途恢复(如新实例切完流量后挂了,切回还活着的那个色)。
- `deploy/update.sh`(单实例版)保留不废:蓝绿脚本内部复用它 `--prepare-only`;单实例部署机(host backend / 内网机)继续用它,行为不变。
- unit 的 `TimeoutStopSec=90` 与 `config/agent.yaml` shutdown 段(drain 30s + cancel 15s)的约束同单实例:改 yaml 数值时模板 unit 跟着调。

### 部署 SOP

> 蓝绿部署机(生产,已 bootstrap)一律用 `sudo bash deploy/update_bluegreen.sh`(见上节)。
> 下面的 `update.sh` 是单实例版:host backend 机 / 内网机 / 未切蓝绿的部署继续用。

**一把梭(单实例):`deploy/update.sh`**

```bash
# 在服务器上,root 跑(脚本内部文件操作降到 zcbot 用户)
sudo bash /opt/zcbot/deploy/update.sh
# 或(已带可执行位):sudo /opt/zcbot/deploy/update.sh
```

脚本顺序写死:`git pull --ff-only` → `pip install -r` → `db upgrade head` → **`docker build` sandbox 镜像** → **`systemctl restart zcbot`** → `curl /healthz` 验活。要点:

- **build 必须在 restart 之前**:sandbox 容器 per-user 长驻 + 复用,`tools/` 是 build 进镜像的(非 mount)。restart 时 `shutdown_all` 清旧容器,下次 `ensure()` 才用新 `zcbot-sandbox:latest` 重建 —— 顺序反了新 tools/ 要等下次重启才生效。
- **平台渲染层 `rendering/`(2026-06-23 起)**:各 skill 出 docx/pdf 调 `python /sandbox/rendering/render.py --profile {brief,paper,proposal} --format {docx,pdf}`(不再各自带 render_docx.py)。`rendering/` 随 `pool.py` **bind-mount 进 `/sandbox/rendering`**(restart 重建容器才挂上),pdf 依赖 `markdown`(已进 requirements,镜像重建才内置)+ 镜像自带 chromium。**这次升级要整体重建镜像 + restart 一并 deploy**——旧 render_docx 路径已删,只推代码不重建会让 brief/paper/proposal/patent/standard 渲染失败。沙盒 chromium 渲 pdf 的冒烟探针:`deploy/sandbox/probe_chromium_pdf.sh`(服务器上跑,用法见脚本头)。
- **sandbox build 每次都跑没关系**:layer cache 让重活(pip ~1G / chromium / 字体 / mermaid,都在 `COPY tools/` 之上)在改代码部署时秒过;只有 `requirements.txt` 变了才整体重建(~5-10min,正好也是该重建的时候)。host backend 机器 / 临时不想动 docker:`sudo bash deploy/update.sh --skip-build`。
- **镜像源默认:pip+apt 清华、npm 腾讯**(`PIP_INDEX_URL=pypi.tuna.tsinghua.edu.cn/simple/` / `APT_MIRROR=mirrors.tuna.tsinghua.edu.cn` / `NPM_REGISTRY=mirrors.cloud.tencent.com/npm/`)。pip 选清华是因为**腾讯 PyPI 曾返回损坏的 litellm wheel**(index hash 对、文件字节不对 → pip `DO NOT MATCH THE HASHES`),且**阿里 PyPI 又一度滞后**(litellm 只到 1.82.6,卡死 `>=1.83.0`);清华境内稳 + 同步及时。npm 用腾讯是因为**清华不提供 npm registry**、npmmirror 访问不稳,腾讯 npm 历来 OK(坏 wheel 只是腾讯 PyPI 的事,npm 不受影响;备选华为 / USTC npm 源)。要命中 docker cache 就别多组源来回换(换源从 pip 层炸开全重跑)。想用官方源:`PIP_INDEX_URL= sudo -E bash deploy/update.sh`(置空即回落 Dockerfile 官方默认)。host venv 的 step 2 pip 也吃这个源(脚本显式 `--index-url`,不靠 host pip.conf)。
- **输出精简**:step 2 pip 带 `-q`(日常部署依赖没变,省掉整屏 "already satisfied";装新包/报错仍输出);step 4 docker build 走默认 TTY 进度 UI(分层折叠刷新,直观)。
- **脚本会自更新重跑**:`git pull` 若动了 `deploy/update.sh` 本身,脚本会 `exec` 新版本从头重跑(旧脚本的变量默认值在 pull 前就求值了、bash 又按字节偏移边读边跑,不重跑会跑出过期行为 —— 这是首次拉到改 update.sh 的提交时"改了源还报旧错"的根因)。日志出现「update.sh 自身有更新 —— 用新版本重跑」即正常;`ZCBOT_UPDATE_REEXEC=1` 防死循环。
- **migration 取 DB URL**:`db/migrations/env.py` 直接读 `os.environ['ZCBOT_DB_URL']`(不读 .env),脚本从 `.env` 抠出来显式 `env ZCBOT_DB_URL=... ` 喂进 `main.py db upgrade`。
- **前置守卫**:非 root / 不是 git 仓库 / 工作区脏(已跟踪文件有未提交改动)/ 缺 .env → 直接中止。`/healthz` 15s 内不返回 `ok` → dump `journalctl -n 50` 并以非零退出。

> 一次性 bootstrap(useradd / 写 unit / `enable --now`)见上方"一次性"段,**不在 update.sh 里**。改 `.env` / unit 文件本身后,update.sh 的 restart 一样会让新 `.env` 生效;但改 unit 的 `[Unit]/[Service]` 字段需先手动 `sudo systemctl daemon-reload`。

**手动逐条(脚本跑挂了排查用)**

```bash
cd /opt/zcbot
sudo -u zcbot git pull --ff-only
sudo -u zcbot .venv/bin/python -m pip install -r requirements.txt
sudo -u zcbot env ZCBOT_DB_URL="$(grep ^ZCBOT_DB_URL= .env | cut -d= -f2-)" \
    .venv/bin/python main.py db upgrade head
sudo -u zcbot docker build -f deploy/sandbox/Dockerfile \
    --build-arg HOST_UID=$(id -u zcbot) --build-arg HOST_GID=$(id -g zcbot) \
    -t zcbot-sandbox:latest .
sudo systemctl restart zcbot
curl -fsS http://127.0.0.1:8765/healthz                # {"status":"ok"}
sudo journalctl -u zcbot -n 50
```

---

## Sandbox(Stage C,Ubuntu)

> 为外部用户开放前必须完成。当前 dogfood + 信任同事白名单阶段可跳过 ── 默 backend = host,
> `shell` / `run_python` 仍走 subprocess(未隔离)。Step 3 已接入 DockerExecutor:
> `ZCBOT_SANDBOX_BACKEND=docker` 切容器执行;`host`(默)保留本地 Windows / 同事 dogfood。
>
> 启用 docker backend 的前置条件:
> 1. 部署机有 docker daemon,zcbot 用户在 `docker` group
> 2. `zcbot-sandbox:latest` 镜像已 build(`HOST_UID/GID` 对齐)
> 3. `.env` 至少有 `ZCBOT_PG_IPS=<PG实际IP>`(§7.5 #1 PG 单独 block 一遍)
> 4. lifespan 启动失败会 fail-fast(`RuntimeError: sandbox init failed`),不静默退到 host

### 镜像构建

容器内 uid/gid 与 host `zcbot` 用户必须对齐(bind mount 保 host owner;错配导致 EACCES):

```bash
# 1) 确保 zcbot 用户存在,uid 拿出来
id -u zcbot      # 期望:整数,后面用作 HOST_UID
id -g zcbot      # 期望:整数

# 2) 构建镜像(build context = repo 根)
cd /opt/zcbot
sudo -u zcbot docker build \
    -f deploy/sandbox/Dockerfile \
    --build-arg HOST_UID=$(id -u zcbot) \
    --build-arg HOST_GID=$(id -g zcbot) \
    -t zcbot-sandbox:latest .

# 境内访问 PyPI 抖动 / ReadTimeout → 加 --build-arg 切换镜像源:
#   腾讯云内网(腾讯云轻量 / CVM 上免外网带宽):
#     --build-arg PIP_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple/
#   阿里云:
#     --build-arg PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/
#   清华:
#     --build-arg PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/
# 镜像源走 https,通常不需 --trusted-host;若用 http 源加
#     --build-arg PIP_TRUSTED_HOST=<host_without_scheme>

# apt 源同款(chromium + nodejs + npm 体积大,deb.debian.org 境内慢):
#   --build-arg APT_MIRROR=http://mirrors.cloud.tencent.com    # 腾讯云内网(推 http,见下)
#   --build-arg APT_MIRROR=http://mirrors.aliyun.com           # 阿里云
# 推荐 http 而非 https:apt 包用 GPG 签名校验,HTTPS 无额外安全收益,且腾讯云
# 内网 mirror 走 https 偶发触发 OpenSSL 3 严格的 `unexpected eof while reading`
# (旧版 OpenSSL 1.1.1 容忍,新版 fail)。Dockerfile 已加 apt retry=5 + 关
# pipeline + no-cache 兜底偶发抖动。

# npm 源同款(@mermaid-js/mermaid-cli + 依赖,境内访问 registry.npmjs.org 也慢):
#   --build-arg NPM_REGISTRY=https://mirrors.cloud.tencent.com/npm/   # 腾讯云
#   --build-arg NPM_REGISTRY=https://registry.npmmirror.com/          # 阿里(npmmirror)
# 镜像内自带 Chromium + mermaid-cli + puppeteer-config.json;mmdc 被 wrapper 包了一层
# (/usr/local/bin/mmdc → 自动注入 -p /sandbox/puppeteer-config.json,除非显式传 -p),
# 所以容器内**裸调 `mmdc -i x.md -o x.png` 就能出图**,无需 --no-sandbox / 自写配置。
# render_diagrams.py 等走 `which mmdc` 的脚本透明受益(原靠 MERMAID_PUPPETEER_CONFIG
# env,已删 ── mmdc 本就不读它,改 wrapper 兜底)。host 上跑无 wrapper,行为不变
# build 末尾有**渲染 canary**(真渲一张中文最小图):chromium/字体链坏则 build 当场
# 失败。apt 拿到的 chromium 点版本可能自带崩溃 bug(2026-07 实锤 150.0.7871.46),
# canary 拦下后改 Dockerfile 的 `ARG CHROMIUM_REFRESH=<日期>` 重建刷新 chromium 层

# 3) 创建 sandbox 网络(bridge,dogfood 阶段保留 outbound NAT —— 让模型能 pip/curl 公网;
#    iptables 仍挡内网/cloud metadata/PG。--internal 完全禁出站是外部用户开放时才改,见 §7.5 #2)
sudo -u zcbot docker network create zcbot-sandbox-net
# 或 SandboxPool.setup_pool() 自动 ensure(ensure_network 即建非 internal bridge)
```

### Sandbox 相关 env(.env 加)

```
# Backend 选择(默 host):
#   host   = shell/run_python 走 host subprocess(本地 Windows / dogfood)
#   docker = shell/run_python 走 per-user 容器 docker exec(部署机 / 外部用户)
# ZCBOT_SANDBOX_BACKEND=docker

# 容器内 exec 用户(默 zcbot,docker 查容器 /etc/passwd 拿 uid)
# ZCBOT_SANDBOX_EXEC_USER=zcbot

# 容器镜像 tag(默 zcbot-sandbox:latest)
# ZCBOT_SANDBOX_IMAGE=zcbot-sandbox:latest
# 容器 runtime(切 gVisor 用 runsc,Firecracker 用 kata;默 runc)
# ZCBOT_SANDBOX_RUNTIME=
# 空闲多少秒回收(默 300)
# ZCBOT_SANDBOX_IDLE_TTL=300
# 资源限制(优先级 env > yaml `sandbox.*` > 默);改后重启 web 新起容器生效
# ZCBOT_SANDBOX_MEMORY=2g
# ZCBOT_SANDBOX_CPUS=1.0
# ZCBOT_SANDBOX_PIDS_LIMIT=256
# ZCBOT_SANDBOX_SHM_SIZE=512m   # chromium/mmdc 渲 mermaid 的 /dev/shm(docker 默 64MB 不够会挂超时)
# PG 实际 IP,逗号分隔。defense-in-depth ── 即便落内网三段(§7.5 #1),
# init.sh 再加一遍 DROP 规则。生产部署必填。
ZCBOT_PG_IPS=10.1.2.3,10.1.2.4
```

> workspace 根目录**没有 env 覆盖**:固定 `ROOT/workspace`(由 yaml `workspace_dir` 决定,默 `workspace`)。
> 要把重写入区落独立数据盘,用 **bind mount** 把数据盘目录接到 `ROOT/workspace`(逻辑路径不变),
> 不要指向 ROOT 外的绝对路径 —— DB 的 `working_dir` 锚定 ROOT 存相对串,ROOT 外路径会 raise。
> 详「workspace 落独立数据盘」段。

### 验证

Step 3 之后,推荐用集成验证(web 起 docker backend + dev SPA 发 `shell` / `run_python` 消息):

```bash
# 启动 web 时切 docker backend(.env 已设 PG_IPS / SANDBOX_BACKEND=docker)
ZCBOT_SANDBOX_BACKEND=docker .venv/bin/python main.py web

# 触发任一 shell / run_python 消息后,容器应已起
sudo -u zcbot docker ps --filter label=zcbot.product=sandbox
# 应看到 zcbot-sandbox-<your-uid>,STATUS = Up ...
# 5 分钟无新消息后 reaper 自动 rm
```

也可直接起一个测试容器单验 hardening(不依赖 web 进程):

```bash
USER_ID=00000000-0000-0000-0000-000000000001
sudo -u zcbot docker run -d \
    --name zcbot-sandbox-$USER_ID \
    --label zcbot.product=sandbox \
    --label zcbot.user_id=$USER_ID \
    --network zcbot-sandbox-net \
    --read-only --tmpfs /tmp:exec,size=512m,mode=1777 \
    --cap-drop=ALL --cap-add=NET_ADMIN \
    --security-opt=no-new-privileges \
    --pids-limit=256 --memory=2g --cpus=1.0 \
    -v /opt/zcbot/workspace/users/$USER_ID:/workspace \
    -e ZCBOT_PG_IPS=10.1.2.3 \
    zcbot-sandbox:latest

# 看 iptables 规则确实 apply 了(应 6+1 条 DROP)
sudo -u zcbot docker exec zcbot-sandbox-$USER_ID iptables -L OUTPUT -n --line-numbers

# 看 non-root 用户(uid 应 = host zcbot uid)
sudo -u zcbot docker exec --user $(id -u zcbot):$(id -g zcbot) \
    zcbot-sandbox-$USER_ID id

# 看 rootfs read-only(应报 Read-only file system)
sudo -u zcbot docker exec --user $(id -u zcbot):$(id -g zcbot) \
    zcbot-sandbox-$USER_ID touch /badtest

# 销毁
sudo -u zcbot docker rm -f zcbot-sandbox-$USER_ID
```

Step 4 引入 egress proxy 后,完整 5 条红队用例(metadata / loopback / 跨 user / nohup
残留 / allowlist 外 403)进 `tests/test_sandbox_redteam.py` 自动化跑。

### Stage C 完整部署速查

首次部署 / 改了 init.sh / Dockerfile / requirements.txt → 重 build:

```bash
cd /home/lighthouse/zcbot
docker build -t zcbot-sandbox:latest -f deploy/sandbox/Dockerfile \
    --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) \
    --build-arg APT_MIRROR=https://mirrors.tuna.tsinghua.edu.cn \
    --build-arg PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/ \
    --build-arg NPM_REGISTRY=https://mirrors.cloud.tencent.com/npm/ \
    .
```

只改 host 侧 docker run flag / pool.py(没动 Dockerfile / init.sh)→ 不用重 build,
清旧容器 + 重启 web 即可:

```bash
docker rm -f $(docker ps -aq -f label=zcbot.product=sandbox) 2>/dev/null
systemctl restart zcbot
```

跑 migration(新增 / 修改了 db/migrations/versions/* / models.py):

```bash
.venv/bin/python main.py db upgrade head
```

启用 docker backend 重启 web(确保 `.env` 有 `ZCBOT_SANDBOX_BACKEND=docker` 或 systemd
unit 已设):

```bash
systemctl restart zcbot
journalctl -u zcbot -e --no-pager | tail -30   # 看 startup log
# 应见:
#   [startup] [warn] fs quota: ext4 on ... (dogfood 阶段 warn 正常)
#   [startup] swept N stale sandbox container(s)
#   [disk_scanner] initial scan: N user(s)
#   INFO: Uvicorn running on 0.0.0.0:8765
```

### 日常运维 / 验证

```bash
# 当前活跃 sandbox 容器
docker ps --filter label=zcbot.product=sandbox \
    --format 'table {{.Names}}\t{{.Status}}\t{{.RunningFor}}'

# 某容器跑了什么(看 docker run 完整参数 + env + mount)
SBC=$(docker ps -q -f label=zcbot.product=sandbox | head -1)
docker inspect $SBC --format '{{json .Config.Env}}' | python3 -m json.tool
docker inspect $SBC --format '{{json .HostConfig.Binds}}'

# 容器内 DNS + 网络验证(应见 nameserver 8.8.8.8 / 114.114.114.114)
docker exec $SBC cat /etc/resolv.conf
docker exec $SBC getent hosts www.baidu.com
docker exec $SBC curl -sI -m 5 https://www.baidu.com | head -1

# iptables 规则(应见红线段 DROP + 127.0.0.11:53 ACCEPT 例外)
docker exec $SBC iptables -L OUTPUT -n --line-numbers

# 容器内 zcbot user 与 host 对齐(uid 应一致)
docker exec $SBC id
id   # host 上跑

# 容器资源占用
docker stats --no-stream $SBC

# 看本会话 sandbox 启动 stderr(init.sh 的 [init] ... 行)
docker logs $SBC

# 杀某 user 容器(强制下次 ensure 重建,可用于 hotfix 后切镜像)
docker rm -f zcbot-sandbox-<user-uuid>

# 清所有 sandbox 容器(reaper 兜底,不影响 web 进程)
docker rm -f $(docker ps -aq -f label=zcbot.product=sandbox) 2>/dev/null
```

磁盘配额查看(0008 表):

```bash
# 当前所有 user 用量
psql "$ZCBOT_DB_URL" -c "
    SELECT user_id, bytes_used/1024/1024 AS mb, file_count, scanned_at
    FROM user_disk_usage ORDER BY bytes_used DESC;"

# 强制扫描某 user(改 yaml interval 太久不耐烦时)
.venv/bin/python -c "
from pathlib import Path
from uuid import UUID
from core.storage.disk_quota import scan_user_dir, upsert_user_usage
uid = UUID('<your-user-uuid>')
b, c = scan_user_dir(Path('/home/lighthouse/zcbot/workspace/users') / str(uid))
upsert_user_usage(uid, b, c)
print(f'{b/1024/1024:.1f} MB / {c} files')"
```

DNS / resolv.conf 文件位置(host 侧):

```bash
# 由 SandboxPool 在首次 _docker_run 时生成,每次重建容器会覆盖刷新
cat /home/lighthouse/zcbot/workspace/.sandbox/resolv.conf
```

### 部署前置对账

切 `ZCBOT_SANDBOX_BACKEND=docker` 之前跑一次:

```bash
sudo -u zcbot .venv/bin/python main.py sandbox check
```

输出形如 `[ok] / [warn] / [err]` × 5 项 + 汇总 `N/5 passed`,exit code 0=可启动 / 1=有 err
要修。5 项对应:① docker daemon 可达 ② `zcbot-sandbox:latest` 镜像存在 ③
`zcbot-sandbox-net` network 存在(缺也能跑,lifespan 自动 ensure)④ 镜像内 zcbot
uid 与 host uid 对齐(错配 → exec 写 `/workspace` 全 EACCES)⑤ `workspace/users/`
所在 fs 类型可 quota。

lifespan 启动时同样会打第 ⑤ 项的 WARN 到 stdout(`[startup] [warn] fs quota ...`),
应用层周期扫描仍生效;**仅外部用户开放前必须把 ⑤ 升级到 OS 层 quota**。

### workspace 落独立数据盘(prod,大空间 + quota fs)

prod 的 `workspace/users/<uuid>/` 是重写入区(报告 / 图 / pptx 等大件落这,DB 只存元数据)。
推荐挂一块独立数据盘(xfs prjquota),空间和 OS 层配额一步到位。

**手法是 bind mount,不是 env 覆盖。** 代码把 workspace 固定解析成 `ROOT/workspace`,且 DB 的
`working_dir` 经 `core/paths.py` 锚定 ROOT 存**相对串**(`to_db_path` 对 ROOT 外的绝对路径直接
raise)。所以**不能**让 workspace 指向 ROOT 外的 `/data/...`(三处会各算各的:文件面板走
`resolve_workspace` 看数据盘、agent 走 `from_db_path` 看 `ROOT/workspace`、新建 task 直接 500)。
正解:让物理数据落 `/data`,但逻辑路径仍是 `ROOT/workspace` —— 用 bind mount 把数据盘目录接上去。
`bind` 不像 symlink 会被 `.resolve()` 展开,内核路径保持 `ROOT/workspace`,`relative_to(ROOT)` 照常过,
**DB 一个字不用改,dev 不受影响(dev 不 bind,直接用本地 `ROOT/workspace`)**。

PG 不必跟着搬:它是元数据库,长期个位数~几十 G,根盘够用;留默认 `/var/lib/postgresql/<ver>/main`
更省坑(`pg_ctlcluster` / AppArmor 按标准路径来)。等 `pg_database_size` 真奔 30–40G、根盘紧了
再迁,那时 `/data` 下加个 `postgresql/` 子目录布局照样兼容。

```bash
# 假设新盘是整块裸盘 /dev/vdb(lsblk 看,无分区表直接整盘格,数据盘惯例)
# 0) 停服务(迁移时别再写 workspace)
sudo systemctl stop zcbot

# 1) 整盘格 xfs
sudo mkfs.xfs /dev/vdb

# 2) 写 fstab(UUID + prjquota),挂 /data
sudo mkdir -p /data
UUID=$(sudo blkid -s UUID -o value /dev/vdb)
echo "UUID=$UUID /data xfs defaults,prjquota 0 0" | sudo tee -a /etc/fstab
sudo mount -a
findmnt -no FSTYPE,OPTIONS /data            # 期望:xfs ... prjquota

# 3) 准备数据盘上的物理目录 + 迁现有 workspace 数据(prod 若还空则跳过 rsync)
sudo mkdir -p /data/zcbot/workspace
sudo rsync -aXS /home/ubuntu/zcbot/workspace/ /data/zcbot/workspace/
sudo chown -R ubuntu:ubuntu /data/zcbot/workspace   # uid 必须 == 容器内 zcbot(HOST_UID)

# 4) bind mount:物理 /data/zcbot/workspace,逻辑仍是 ROOT/workspace(写 fstab 持久化)
sudo mkdir -p /home/ubuntu/zcbot/workspace
echo "/data/zcbot/workspace /home/ubuntu/zcbot/workspace none bind 0 0" | sudo tee -a /etc/fstab
sudo mount -a
findmnt /home/ubuntu/zcbot/workspace        # 确认 bind 上了,FS 显示落 /data 那块盘

sudo systemctl start zcbot
python3 main.py sandbox check               # fs quota 那项应变 [ok](探测的是 bind 后的真实 fs)
```

确认 `sandbox check` 通过、新 task 文件确实落到 `/data` 那块盘后即可。`/data/zcbot/workspace` 下叫什么、
嵌几层随意,关键是 bind 到 `ROOT/workspace`。

> **开机顺序硬化(强烈建议)**:给 systemd unit `[Service]` 加 `RequiresMountsFor=/home/ubuntu/zcbot/workspace`,
> 否则若开机时 bind mount 还没就绪,service 抢先启动会把文件写进根盘那个空壳 `ROOT/workspace`(数据分叉)。
> 加完 `sudo systemctl daemon-reload`。

### 配额硬化(§7.5 #4,外部开放前必做)

应用层磁盘配额能挡常规超额,**但扫描间隙打满共享 fs 拖死同节点**这条硬要 OS 层
quota。`sandbox check` 第 ⑤ 项会探测当前 fs 状态:

| 探测结果 | 含义 | 处理 |
|---|---|---|
| `fs quota: xfs with prjquota on ...` | ok,可直接 `xfs_quota -x` 给 user 加配额 | (无需处理) |
| `fs quota: ext4 with project quota on ...` | ok,可 `quota -P` | (无需处理) |
| `fs quota: zfs on ...` | ok,在 dataset 层 `zfs set quota=` | (无需处理) |
| `fs quota: xfs ... NO prjquota mount option` | fs 支持但 mount 时没启 | 见下方 xfs 步骤 |
| `fs quota: ext4 ... NO project quota option` | 同上 | `sudo tune2fs -O project,quota <dev>` + remount |
| `fs quota: btrfs ...` | qgroup 配置复杂 | 生产推荐换 xfs 单独分区,或自行验 `btrfs qgroup` |
| `fs quota: tmpfs/overlay/... ` | 通常 Docker-in-Docker 或本地 dev | 生产必须挂独立分区 |

**xfs 升级步骤(推荐方案)**:

```bash
# 1) 确认 workspace 在哪个 mount(假设 /opt 是独立 xfs 分区)
findmnt --target /opt/zcbot/workspace

# 2) 启用 prjquota(写入 /etc/fstab 让 reboot 后保留)
sudo mount -o remount,prjquota /opt

# 3) 给某 user 加 project quota(<pid> 自定义整数 id,与 user_id 映射建表跟踪)
echo "1001 /opt/zcbot/workspace/users/<user_uuid>" | sudo tee -a /etc/projects
echo "zcbot_<user_uuid>:1001" | sudo tee -a /etc/projid
sudo xfs_quota -x -c "project -s zcbot_<user_uuid>" /opt
sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
```

`<pid>` ↔ `user_uuid` 映射手工维护(`/etc/projects` 是数字 id,zcbot 侧需建表追踪;
**首期外部开放前补一个 `main.py sandbox quota-set --user-id <uuid> --gb 10` 子命令**
读写 /etc/projects + 调 xfs_quota,这是 Step 4 / 5 之后真上线前一步,当前不做)。

不做这步等于"软配额 + 信任用户不写满" -- dogfood + 信任同事白名单阶段够用,
**外部用户开放是 hard prereq**。

---

## 故障兜底

| 现象 | 原因 / 处理 |
|---|---|
| `ZCBOT_DB_URL is not set` | `.env` 没写 / litellm 链路没触发。直跑脚本时 `import litellm` 或 `export ZCBOT_DB_URL=...` |
| `ModuleNotFoundError: litellm` | 用了全局 `python`,改 `.venv/Scripts/python.exe ...` |
| Windows 控制台 emoji 崩 | Python stdout 是 GBK。用 `[OK]` / `[ng]` 等 ASCII 标签(见 memory) |
| research 报 `paper_server auth failed (HTTP 400, not_authenticated)` | paper_server GET 接口已要求 API Key。`.env` 配 `PAPER_SERVER_API_KEY=`(paper_server Django admin 里建 ApiKey);docker 模式会自动透传进容器,改完重启 web 进程生效 |
| `db upgrade` 报 `column already exists` | DB 已被改过,`db current` 确认 revision,必要时手 ALTER 或 `db downgrade base` 重来 |
| Resume 找不到 task | dev SPA 左侧 task 列表看 task_id 是否在;或 `curl /v1/tasks` 拉 |
| task 删了文件还在 | 现在 `DELETE /v1/tasks/{id}` 是**软删**,本就不动任何磁盘文件(留作语料 + 可恢复);要清磁盘走 `POST /v1/files/delete`。彻底物理删 task(及 messages)留给将来的管理员清理工具;当前如需手动:`psql> DELETE FROM tasks WHERE task_id=...`(messages/usage_events CASCADE) |
| Sandbox 容器内 `touch /workspace/x` 报 `Permission denied` | 容器 uid 1000 与 host `zcbot` 用户 uid 不一致(bind mount 保 host owner)。`docker build --build-arg HOST_UID=$(id -u zcbot)` 重建镜像 |
| Sandbox 容器 build 完起不来,`docker logs` 显示 iptables 报错 | 缺 NET_ADMIN cap(`--cap-add=NET_ADMIN` 漏了)或 kernel 不支持(WSL2 / OpenVZ 环境不能跑)。Ubuntu 物理 / KVM 正常。验:`docker exec ... iptables -V` |
| 启动报 `ZCBOT_SANDBOX_BACKEND=docker but sandbox init failed: ...` | docker daemon 没起 / 用户不在 docker group / network create 失败。先跑 `main.py sandbox check` 看哪一项 err |
| `[startup] [warn] fs quota: <fstype> on ...` | workspace 所在 fs 没启 OS 层 quota。dogfood 阶段忽略;外部用户开放前必须升级 xfs prjquota / ext4 project / zfs(详 RUN.md「配额硬化」段) |
| prod 想把 workspace 落独立数据盘 | **别用 env / 别指 ROOT 外绝对路径**(workspace 锚定 ROOT,ROOT 外会让文件面板 / agent / 新建 task 三家分叉)。用 **bind mount** 把 `/data/...` 接到 `ROOT/workspace`,逻辑路径不变,DB 不用改。详「workspace 落独立数据盘」段 |
| 文件面板"目录尚未创建"但文件确实在 / agent 写的文件面板看不到 | workspace 被指到了 ROOT 外(旧 `ZCBOT_WORKSPACE_DIR` 绝对路径残留)→ 文件面板走 `resolve_workspace` 看一处、agent 走 DB `from_db_path`(锚 ROOT)看另一处。删掉 env、改用 bind mount(见上段),三家归一 |
| `docker run zcbot-sandbox:latest` 报 `Unable to find image` | 镜像没 build。`sudo -u zcbot docker build -f deploy/sandbox/Dockerfile --build-arg HOST_UID=$(id -u zcbot) --build-arg HOST_GID=$(id -g zcbot) -t zcbot-sandbox:latest .` |
| 后台进程(bg proc)疑似残留 / 想手工排查 | docker 模式:`docker ps --filter label=zcbot.product=proc` 列在跑的 proc 容器,`docker rm -f zcbot-proc-<id>` 手杀;host 模式:状态锚在 `<user_root>/.zcbot_procs/<task_id>/<proc_id>/`(proc.json 有 pid,exit_code 文件在 = 已结束)。web 进程每小时 sweep 自动回收(终态目录 7 天 TTL);正常终止走前端停止按钮 / `check_process(action="kill")` |
| 后台进程状态显示 lost | 进程没留退出码就没了 —— 宿主重启 / OOM killer / docker daemon 重启把它带走(bg proc 扛 zcbot 重启和蓝绿,但不扛宿主级重启)。output.log 保留到中断为止,需重新 background=true 发起 |
| 镜像 build pip 报 `THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE`(本仓 requirements 未钉 hash) | **不是被篡改、也不是 require-hashes**:镜像 index 声明的 wheel hash 与它实际吐出的文件字节不符 = 该镜像存的文件损坏 / 截断(2026-06-03 腾讯源就这么坏过 litellm-1.87.0)。换源重 build:`PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/ sudo -E bash deploy/update.sh`。验真伪:`https://pypi.org/pypi/<pkg>/<ver>/json` 看官方 sha256 是哪边对。与下面"版本滞后(Could not find)"是两回事 |
| 镜像 build pip 报 `ReadTimeoutError: HTTPSConnectionPool(host='files.pythonhosted.org', ...)` | 境内访问 PyPI 抖动。加 `--build-arg PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/`(清华,现默认)或腾讯 / 阿里源,详 RUN.md「镜像构建」段。Dockerfile 已把 pip timeout 拉到 60s,主因仍是源不通而非超时 |
| pip 报 `Could not find a version that satisfies the requirement litellm>=1.83.0`(伴随一串 `Ignored ... yanked versions: 0.1.xxxx`) | 用的镜像源同步滞后,没有该新版本。**阿里 PyPI 一度只到 litellm 1.82.6** —— update.sh 默认已是清华源(同步及时)。若手动 build 撞到:换 `PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/` 或腾讯源。那串 `0.1.xxxx` 是 litellm 远古版本,纯干扰信息 |
| 镜像 build npm 装 mermaid-cli 慢 / fail | npm 源境内慢。默认已用腾讯 `https://mirrors.cloud.tencent.com/npm/`(清华无 npm 源;npmmirror 访问不稳被弃)。备选:华为 `https://repo.huaweicloud.com/repository/npm/` / USTC `https://npmreg.mirrors.ustc.edu.cn/`,手动 build 加 `--build-arg NPM_REGISTRY=...` |
| 镜像 build npm 报 `ECONNRESET ... Invalid response body while trying to fetch` | mirror 偶发断流(腾讯源实测),非配置问题。0.52.3 起 Dockerfile 已把 npm fetch-retries 拉到 5,一般重跑即过;**若本次部署不涉及镜像内容变化,直接 `update_bluegreen.sh --skip-build` 跳过重建**,镜像留到下次更新再建 |
| 镜像 build apt 报 `OpenSSL error: ... unexpected eof while reading` | 某些 mirror HTTPS 端偶发 close_notify 缺失,OpenSSL 3 严格 fail(腾讯 / 阿里见过;清华一般不犯)。改用 http 形式:`--build-arg APT_MIRROR=http://mirrors.tuna.tsinghua.edu.cn`(apt 包 GPG 签名校验,无 HTTPS 安全收益)。Dockerfile 已配 apt retry=5 + 关 pipeline,重 build 一般直接过 |
| 容器内 shell 写工作目录报 `Permission denied`(but `sandbox check` ⑤ HOST_UID aligned ok) | DockerExecutor 写死了 `--user 1000:1000` 不会自动跟 build 的 HOST_UID 同步(改 `--user zcbot` 后已修)。仍报错检查镜像内 `docker run --rm --entrypoint id zcbot-sandbox:latest zcbot` 输出 uid 是否 = `id -u $(whoami)` |
| 容器内 mmdc 渲 mermaid 报 `Failed to launch chromium` / `No usable sandbox` | chromium 在 `--cap-drop=ALL` 下自家 setuid sandbox 起不来,要 `--no-sandbox`。镜像已落 `/sandbox/puppeteer-config.json` + 给 mmdc 套了 wrapper 自动 `-p` 注入 ── **裸调 `mmdc -i x -o y` 就该成**。仍跪:`docker exec ... cat /usr/local/bin/mmdc` 看 wrapper 在不在(老镜像没 rebuild 则没有);或显式 `mmdc -p /sandbox/puppeteer-config.json -i x -o y` |
| 容器内 mmdc 渲图卡到 **timeout** 而非报错 | chromium 默认用 `/dev/shm`,docker 不传 `--shm-size` 时只 64MB → 起不来一直挂。已在 `pool.py` 给 `docker run` 加 `--shm-size`(默 512m,env `ZCBOT_SANDBOX_SHM_SIZE` / yaml `sandbox.shm_size`)。**已 running 的旧容器不会自动生效**,重启 web + 等 idle 回收(或 `docker rm -f zcbot-sandbox-<uid>`)后新起的才带。实测脚本 `deploy/sandbox/probe_mermaid.sh` |
| 容器内 mmdc 报 `Failed to launch the browser process: Code: null`(伴满屏 dbus 报错),wrapper / `-p` 配置都给了照崩;或直接跑 chromium `Trace/breakpoint trap (core dumped)` | 两个已实锤根因(2026-07 排查,dbus 报错全程是干扰项——成功时也打):**① `--pids-limit` 打满**(线程也计数,chromium 一次启动 ~150-200 线程 + shell 超时残留的进程树),特征是 stderr 有 `pthread_create: Resource temporarily unavailable (11)`;已默认调 1024(yaml `sandbox.pids_limit`),改后需等旧容器回收。**② apt 烤进镜像的 chromium 点版本自带崩溃 bug**(150.0.7871.46 启动即 int3 自杀、零日志、strace 无失败 syscall,上游 .100 已修),特征是全新容器首调即秒崩、`--only-upgrade chromium` 后就好;修法:改 Dockerfile `ARG CHROMIUM_REFRESH=<日期>` 重建(只刷 chromium 层),build 末尾 canary 从此在 build 时就拦住坏版本。判别工具:`probe_mermaid.sh`(shm/配置类)、`probe_chromium_bisect.sh`(hardening 逐 flag)、`probe_chromium_round3.sh`(FATAL/strace);hardening/AppArmor/seccomp/内存/ulimit 已全部排查无罪,别再从这些方向猜 |
| 容器内 mmdc **恒定 ~2m15s 超时**(user CPU 近 0、输出 `[object Object]`、无产物),小图大图同时长;probe 脚本里却秒出图 | **init.sh 的 `127.0.0.0/8` DROP 掐死容器自身 loopback**(2026-07-12 终案,task 9dcae061;90 天 67 次 timeout 的真凶,chromium 坏版本只解释 26 次 launch fail):puppeteer 控 chromium 走 `127.0.0.1` 的 DevTools WebSocket,SYN 被 DROP → TCP 重试打满 ~127s 放弃。容器内 127.0.0.1 是自己 netns 的 lo、到不了宿主,DROP 纯误伤。已修:init.sh 首条 `-o lo -j ACCEPT`(**镜像重建 + 回收存量容器才生效**)。**probe 大坑**:`docker run --entrypoint bash` 起的容器不跑 init.sh、没有 iptables,验不出这类问题——复现线上必须默认 entrypoint 起容器再 `docker exec`;1 vCPU 渲 `-w 2800 -s 2` 大图实测仅 ~5s,"尺寸大导致超时"是误判 |
| 模型不渲本地 mmdc、反复试 `mermaid.ink` 等在线渲图服务还失败 | 容器**有外网**(bridge+NAT),但 `mermaid.ink` 等**境外服务易被墙/不稳**,渲图不该依赖出站。docker backend 的 system prompt「运行环境」段(`agent_builder.py` 注入)已写明"渲图走本地 mmdc、别调在线服务";撞到多半是 prompt 没更新 / 跑在 host backend。渲 mermaid 一律 `mmdc -i x.md -o x.png` |
| Export 报 "无可导出内容" | task 没 messages(只 system 不算);先发条消息再 export |
| `NoSubtaskError: working_dir ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 working_dir 嵌套(child / parent)。**同项目多对话**用**完全相同**的 working_dir;否则改成 sibling(平级) |
| `main.py web` 启动后 curl 连不上 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地服务 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY` 或 `curl --noproxy '*'`。验通:`curl --noproxy '*' http://127.0.0.1:8765/healthz` |
| 「🎙 语音」点了没反应 / 提示"此环境不支持录音" | 浏览器只在**安全上下文**(HTTPS 或 localhost)开放麦克风;http://IP 访问必现。上 HTTPS 或本机 localhost 试。凭据/链路问题跑 `scripts/diag_asr.py` 定位(11200=讯飞服务未开通或量用完,10105/10313=appid 错) |
| 讯飞握手 403 `Your IP address is not allowed` | 讯飞控制台该应用开了 **IP 白名单**,当前机器出口 IP 不在名单(服务器好用、开发机忽然全挂就是这个)。console.xfyun.cn → 应用 → 语音听写 → IP 白名单加出口 IP 或关闭 |
| 企微入站突然全无反应(文字也不回,journal 里连 `[wecom] inbound` 行都没有) | 回调注册在**企微平台侧失效**了(2026-07-08 实测:服务器/代码/env 全正常,消息就是不投递)。企微管理后台 → 应用 → 接收消息 → **重新保存一次 API 接收配置**(Token/AESKey 不用换,保存时企微重新 GET 验 URL)即恢复。判据:`[wecom] inbound` 行有没有,没有=平台没投递,与代码无关 |
| pip 装 pilk 报 `Building wheel for pilk ... error: command 'x86_64-linux-gnu-gcc' failed: No such file or directory` | pilk 没有 py3.12 Linux 预编译 wheel,pip 退回源码编译。**host 侧**缺编译链:`sudo apt install -y build-essential python3-dev` 后重跑 update(C 源很小,秒编)。**sandbox 镜像 build** 撞到同错 = 老版 Dockerfile(0.52.2 起 build 时 grep 剔除 `[host-only]` 行,容器内用不到 pilk),`git pull` 后重跑 update 即可 |
| 语音输入没有实时字幕 / 松手后仍要等几秒 | 流式 WS(`/v1/asr/stream`)被反代挡了(nginx 未透传 Upgrade 头),前端静默退回整段 POST 兜底 —— 功能不坏但慢。nginx 加 WS 反代段(`deploy/nginx/zcbot.conf.example` 已带 `location = /v1/asr/stream`),`nginx -t && systemctl reload nginx` |
| SSE 卡住不流(经 nginx) | 反代要关 buffering — 后端响应头已带 `X-Accel-Buffering: no`(nginx ≥1.5.6 默认认)。仍卡看 nginx 配 `proxy_buffering off; proxy_read_timeout 3600s;` |
| platform CORS preflight 失败 | 本地 dev `allow_origins=["*"]` 应该没事;部署后看是否按 platform 域名收紧过头 |
| `POST /v1/tasks/{id}/messages` 返 409 `task already has an active run` | 上一条消息的 BG run 还没跑完;等流式 done 或点 stop / `POST .../cancel`;服务异常下 `run_status` 卡 `running`/`cancelling`,启动 reaper 会清 |
| `POST /v1/tasks/{id}/cancel` 返 409 `task not running` | `run_status` 不是 `running`(idle / cancelling / error 都不能 cancel);dev SPA 自动忽略不报错 |
| `POST /v1/tasks/{id}/clear` 返 409 `task has an active run` | 当前 run 还没跑完;先点停止 / `POST .../cancel` 等流式 done 再清空 |
| 点 stop 后流式没立刻停 | streaming 改造后正常路径秒退;若仍卡可能是 ① httpx 连接 close 没立刻关(GC 时机)/ ② 模型 thinking 阶段长时间不吐 chunk,等下一个 chunk 到达才能 poll cancel(罕见) |
| `[startup] reaped N stale active run(s)` | 上次 web 进程未正常 finish 留下 N 个孤儿 run,启动 lifespan 自动标 error。info 级,无需处理 |
| `seedream` tool 没出现在对话里 | `.env` 没设 `ARK_API_KEY`,build_agent 跳过注册。设了重启 web 即可;无需迁移、无需 DB 改动 |
| 图像模型选了「GPT 生图」却仍走 seedream / 报错 | `.env` 没设 `UNIFYLLM_API_KEY`(gpt_image tool 未注册,静默 fallback 豆包);或服务器没代理出口(直连 unifyllm.ai TLS 失败)。跑 `scripts/diag_unifyllm.py` 验证连通后重启 web |
| `document_*` tool 没出现在对话里 | `.env` 没设 `DOCUMENT_SEARCH_API_KEY`,build_agent 跳过注册。设了重启 web 即可;key 不进入 sandbox。 |
| 文件区点 `.pptx` 弹"服务器未装 LibreOffice"/ 直接回退下载 | web host(非 sandbox)没装 soffice。`sudo apt-get install -y --no-install-recommends libreoffice-impress fonts-noto-cjk` 后**重启 web**。dev(Windows)`winget install TheDocumentFoundation.LibreOffice`。验:`soffice --version` 或 `python -c "from web.pptx_render import find_soffice; print(find_soffice())"` |
| `.pptx` 预览首次慢几秒 | 正常 —— soffice 冷启 + 转换 ~2-4s,转完缓存到源同目录 `.preview/<stem>.<hash>.pdf`,再点即时。源文件一改(mtime/size 变)hash 变、自动重转 |
| `mp_*` tool 没出现在对话里 | `.env` 没设 `MP_API_KEY`,build_agent 跳过注册。设了重启 web 即可;Materials Project 联网查询走 host-side tool,离线 pymatgen 不受影响。 |
| 豆包调价了 | 改 `config/media/doubao.yaml` 的 `price_cny_per_image` 一行 → 重启 web。**历史 usage_events 不受影响**(units jsonb 里有当时单价 snapshot,聚合查仍按旧价);新写入按新价。涨价瞬间到改 YAML 中间这段记账偏低,开发期接受 |
| `kill -HUP <pid>` 后 `/openapi.json` 没新接口 | uvicorn **不响应 SIGHUP**(没装 handler,落 Python 默认终止;Windows 上信号本身无效)。Ubuntu 上用 `systemctl restart zcbot`,或 unit 加 `--reload` 让 uvicorn 监听文件自动重起(见"部署"段)。验证:`curl -s http://127.0.0.1:8765/openapi.json \| python3 -c 'import sys,json;print([p for p in json.load(sys.stdin)["paths"] if "auth" in p])'` |
| `systemctl restart zcbot` 要等几十秒才退 | 正常 —— 优雅 drain 在等在跑的 run 收尾(`shutdown.drain_timeout` 默 30s),没在跑 run 时秒退。journal 出现 `[shutdown] draining N in-flight run(s)` 即正常。真急(不在乎杀掉在跑 run):`systemctl kill -s KILL zcbot` |
| 部署后在跑的对话被标 `error: server restarted before run finished` | 该 run 在 drain 期内没收尾、cancel 也没在 `cancel_grace` 内退,被 SIGKILL 后下次启动 reaper 标的。多半是 run 卡在不 poll cancel 的长动作(如单次超长 docker exec)或 `TimeoutStopSec` 配得比 drain 预算还小被提前 SIGKILL。先核对 unit `TimeoutStopSec > drain_timeout + cancel_grace`;真有超长 run 把 `drain_timeout` 调大 |
| 定时任务「跑到一半没推送」/ crons 页显示「上次失败: 运行超过超时上限 Ns 未完成」 | job 跑满 `timeout_seconds` 被协作式中断(还没写完 / 没推送)。**0.32.2 起超时记 error**(此前误记 ok 看不出来),计入连续失败、到阈值自动停用。**0.32.4 起新建 job 默认超时 1800s**(此前默认 0=不限;`DEFAULT_TIMEOUT_SECONDS`),`0` 仍可显式设"不限"。处置:报告类重活(多刊检索+渲 docx)若仍不够,把该 job `timeout_seconds` 再调大或设 0;被自动停用的重新 enable。诊断单个 job 用 `scripts/diag_sched_e621.py <job_id 前缀>` |
| `POST /v1/files/rename` 返 409 `folder has active run(s)` | 顶层目录被某 running/cancelling 的 task 占用;先 cancel 等流式 done 再 rename |
| `POST /v1/files/rename` 返 409 `... 前缀嵌套` | 改名后会与其他 task 的 working_dir 形成嵌套;换不冲突的 new_name |
| `POST /v1/files/upload` 返 413 `已达磁盘配额上限` | per-user 20GB(yaml `quotas.disk_bytes_per_user`)。让用户在 dev SPA 右侧文件栏删旧产物 / 大文件,或改 yaml 升配重启 web |
| `[warn] network zcbot-sandbox-net is --internal (legacy)` | 上一版 sandbox network 创建时带了 `--internal`(完全禁 outbound),当前 dogfood 阶段放开。`docker stop $(docker ps -aq -f label=zcbot.product=sandbox) ; docker network rm zcbot-sandbox-net`,重启 web 自动 recreate 为非 internal |
| tool write/edit 返 `[Error] 已达磁盘配额上限` | 同 upload 413,见上 |
| 容器内 `curl https://www.baidu.com` 报 `Temporary failure in name resolution` | docker user-defined bridge network 上 /etc/resolv.conf 默 `nameserver 127.0.0.11`(embedded DNS),腾讯云轻量等场景 daemon 探测上游失败 → embedded DNS forward 跪。修法:yaml `sandbox.dns` 指定 `[8.8.8.8, 114.114.114.114]`,SandboxPool 把 host 侧 `<workspace>/.sandbox/resolv.conf` 文件 bind mount `-v ...:/etc/resolv.conf:ro` 覆盖容器默 ro mount,绕开 embedded DNS。`docker rm -f $(docker ps -aq -f label=zcbot.product=sandbox)` + `systemctl restart zcbot` 让新容器按 mount 配置生效 |
| init.sh 报 `/etc/resolv.conf: Read-only file system` | docker 默把 /etc/resolv.conf 当 ro mount,init.sh 内 `cat >` 写不进。host 侧 bind mount 已是主路径(见上),init.sh 写仅作 fallback 且失败 robust 不退出容器 |
| 启动报 `PLATFORM_KEY env not set` / `JWT_SECRET env not set` | D' 过渡 auth 强制双 env 必填。生成 `python -c "import secrets;print(secrets.token_urlsafe(48))"` 各填一,写 `.env` 重起 |
| `/v1/auth/login_password` 返 403 `invalid email or password` | 邮箱不存在 / `password_hash` 列为空(platform_key 入口建的 user) / 密码错。`SELECT user_id, email, password_hash IS NOT NULL AS has_pw FROM users WHERE email=...` 核对;无行 → `main.py user add`;有行无密码 → `UPDATE users SET password_hash=...`(用 `.venv/Scripts/python.exe -c "from web.auth import hash_password;print(hash_password('xxx'))"` 算)或 `user add --user-id` 接到现有 user_id |
| `main.py user add` 报 `IntegrityError ... uq_users_email` | 邮箱已存在,改 email 或先 `DELETE FROM users WHERE email=...`(先清该 user 的 tasks) |
| `main.py user add` 报 `IntegrityError ... users_pkey` | `--user-id` 撞已有 UUID,换一个或不传让随机生成 |
| 登录页"+ 管理员添加用户"提交后 503 `admin create_user disabled` | `ZCBOT_ADMIN_TOKEN` env 未设,功能默关。设了 env 重启 web 即可;或临时回退 `main.py user add` |
| 登录页"+ 管理员添加用户"返 403 `invalid admin_token` | 弹窗里管理员口令栏填错或没复制完整。跟 `.env` 里 `ZCBOT_ADMIN_TOKEN` 比对(注意末尾空格 / 引号) |
| 改了用户邮箱 / 密码后他登不上 | `UPDATE users SET email=...` 不影响 user_id(行同一行,task 仍归属),用新邮箱登即可;DB 里应存小写(后端 lower() 后查)。改密 `UPDATE users SET password_hash=<bcrypt>` 同理。**用户知道旧密码时优先让他用顶栏「改密码」自助**,只有忘了旧密码 / 改邮箱才手动 SQL |
| 顶栏「改密码」返 403 `该账号未设置密码` | 该 user 是 platform_key(UUID+PLATFORM_KEY)入口建的占位行,`password_hash` 为空,无旧密码可验。先手动 `UPDATE users SET password_hash=<bcrypt>` 设一个,再让他用密码登 + 自助改 |
| `/v1/*` 全返 401 `missing Authorization: Bearer` | 没拿 token 或没带 header。先 login 拿 token,curl 加 `-H "Authorization: Bearer $TOKEN"` |
| `/v1/*` 返 401 `token expired` | JWT 默 7d TTL 到期,重 login。要更长改 `ZCBOT_JWT_TTL_SECONDS` env |
| dev.html SSE 收不到流(消息发出去 UI 没动) | EventSource 不支持 header,dev.html 走 `fetch + ReadableStream`。devtools Network 看 POST /messages 是否 202 + events_url GET 是否 200 + Content-Type 是 text/event-stream;401 → token 过期,logout 重 login |
| matplotlib / mermaid 出的 PNG 里中文全是方块(豆腐块 □) | sandbox 镜像缺中文字体。Dockerfile 已加 `fonts-noto-cjk fonts-wqy-microhei` + `fc-cache`,但**改了 Dockerfile 必须重 build 镜像 + 清旧容器**才生效(旧容器仍跑老镜像):`docker build -t zcbot-sandbox:latest -f deploy/sandbox/Dockerfile --build-arg HOST_UID=$(id -u) --build-arg HOST_GID=$(id -g) .` → `docker rm -f $(docker ps -aq -f label=zcbot.product=sandbox)` → `systemctl restart zcbot`。验证:`docker run --rm zcbot-sandbox:latest fc-list :lang=zh` 应列出 Noto/WenQuanYi |
| dev.html 显示 "load failed" 立刻回登录页 | token 过期或 JWT_SECRET 服务端变了。已自动跳登录页,按上次 tab 重登 |
| dev.html 顶栏出现"连接断开,重连中…(N/3)" | SSE 流被切(`--reload` 重启 / nginx 切换 / 网络抖)。客户端自动重连,1s/2s/4s 退避;新进程已 reaper 标 error 则立即收 done + 卡片末尾"请重发"提示;若服务端还活着会继续看后续 delta(断开期间的丢失,broker 不持久化) |
| 对话里偶发 `[Error] invalid JSON arguments` / warn「工具调用参数损坏…重试」 | deepseek v4 系(flash/**pro 也踩过**,2026-07 task 716ed3be)**大参数工具调用(大 write/run_python,≈3K+ 字符中文长文)偶发把内容碎片错位粘进 arguments 或退化成空 `{}`**(上游流式抖动,同轮流式重 roll 连挂强相关)。`core/loop.py` 已自动兜底:畸形参数丢弃整轮,**首败即降级非流式重试**(≤3 次总尝试),非流式历史上从未再畸形。每次丢弃会留痕两路:服务端日志 `[malformed] task=... tool=... head=... tail=...`(journalctl 里 grep `\[malformed\]` 取证/报 provider case)+ `usage_events` 一行 `kind=tool_malformed`(cost=0,admin 页「工具失败」表以 kind=malformed 聚集展示,可按模型分组看哪个 provider 在坏)。仍频繁撞 → 引导模型把大文件拆小分块写 |
| run 直接报错终止(对话里红卡「上次运行出错」),错误含 `余额不足` / `insufficient quota` / `AuthenticationError` | provider 级故障(2026-07 task 2a1bc25d:Zai 余额不足,该 provider 上所有 run 全挂,用户发「继续」也无用)。给对应 provider 充值 / 换 key 后重发消息即可续跑。观测三路(0.58.21):对话内持久错误卡(下次 run 起跑才消失)+ admin「工具失败」表 kind=run 聚集(数据源 usage_events kind=run_error,`tasks.run_error` 只留最后一次)+ 余额/认证类命中即时邮件 `ZCBOT_DEVELOPER_EMAIL`(同签名 6h 冷却,SMTP 未配只打日志 `[runerror]`) |
| 蓝绿脚本报 `旧单实例服务 zcbot.service 还在跑` | 还没做蓝绿 bootstrap(旧服务占 8765 与 `zcbot@blue` 冲突)。走 RUN.md「蓝绿部署」bootstrap 段:`disable --now zcbot` 后起 `zcbot@blue` |
| 蓝绿 wait_health 报 `instance 不是 blue/green` | 实例 env 没生效:`/etc/systemd/system/zcbot@.service` 未装 / 没 `daemon-reload` / `deploy/instances/<色>.env` 缺失。`systemctl cat zcbot@blue` 核对 `EnvironmentFile` 两行都在 |
| 蓝绿切换后用户直播不动、但任务其实在跑 | 用户恰在切换窗口刷新页面,run 还在旧实例上而 SSE 连到了新实例(broker 进程内不跨实例)。run 照常跑完落库,等旧实例日志安静后让用户刷新看历史;非 bug,已知边缘 |
| 蓝绿并存期微信 ClawBot 没反应 | 入站长轮询走 PG advisory lock 选主。两实例 journalctl 里找 `[wechat] inbound leader lock acquired`——都没有 → 检查 DB 连通;锁在已停实例上不可能(连接断锁即释),新实例最长 15s 接管 |
| 蓝绿部署后旧色 sandbox 容器残留 | 正常:旧实例 stop 时 `shutdown_all` 只清自己色,失败/超时的靠下次该色启动兜底。手动清:`docker rm -f $(docker ps -aq -f label=zcbot.instance=<色>)` |
| nginx 上 TLS 后 `nginx -t` 报 `duplicate listen options` / HTTP 跳转不生效 | 同端口只能一条 listen(`listen 8765 ssl` 与 `listen 8765` 不能并存);「HTTP 自动跳 HTTPS(同端口)」用 `error_page 497 =301 https://$host:8765$request_uri;`——`if ($scheme = http)` 在 ssl-only 端口永远不触发(握手层就报 497)。模板见 deploy/nginx/zcbot.conf.example TLS 注释段 |
| 平台调 `/v1/auth/login` 报 405 Method Not Allowed | 平台配了 `http://` 入口:明文 POST → 497 → 301 → requests 等客户端跟随 301 时把 POST 降成 GET → 只挂 POST 的路由回 405。治本:平台 base URL 换 `https://`(明文首跳会暴露 platform_key);服务端把 497 跳转从 `=301` 改 `=308`(保留方法/body)作兼容 |
| 用 IP 访问弹「连接不是专用连接」 | 证书签给域名,IP 访问域名对不上,TLS 固有行为。`error_page 497` 跳转目标**写死域名**(别用 `$host`),明文 http://IP 进来即被带回域名无警告;https://IP 直连的警告 nginx 层无解(握手先于跳转)——入口统一域名,内网解析不了域名就让 IT 加内网 DNS 记录 |
| 启动报 `ZCBOT_REDIS_URL 已设但 Redis 连不上` | redis-server 没起 / URL 错。`systemctl status redis-server` + `redis-cli ping` 验;或从 `.env` 删掉该行回退进程内 broker(蓝绿切换边缘会回来,见「broker 外置」段) |
| 日志刷 `[broker] redis publish failed`(10s 节流) | redis 运行中挂了。降级:直播丢帧、SSE 卡 ping,run 本身照常跑完落库;`systemctl restart redis-server` 恢复后 pubsub reader 1s 退避自动重连,无需重启 zcbot |
| 蓝绿切换窗口刷新丢直播 / 停止失灵(设了 ZCBOT_REDIS_URL 仍出现) | 两实例 journalctl 找 `[broker] redis pub/sub enabled` —— 没有 = 该实例没读到 env(改完 .env 没 restart 实例);有但仍异常看是否伴随 publish failed(redis 本身故障) |
| 长任务跑到一半停下、提示「已达单轮步数上限…回复『继续』可接着跑」 | **预期行为非崩溃**:单个 run 自主步数到 backstop(`config/models/*.yaml` 的 `max_iterations`,flash 120 / pro 150)就主动停,回 `[reached max iterations]`。直接回复「继续」即续跑。经常撞顶=任务确实大,可调高对应 variant 的 `max_iterations` 或换 pro |
| 任务停下提示「连续 N 步无净产出…已自动停止」(回 `[stopped: no progress]`) | **空转熔断**:连续 `_STALL_LIMIT`(loop.py,默认 8)步所有 tool 都只返 `[Error]`/重复结果/被拦 = 没在推进,主动停以免空烧 token。说明模型卡死在某个错上——**换思路 / 补充信息再回复「继续」**,别原样重发(会再次撞同一墙) |

---

## 关键路径与文件

- **入口**:`main.py`(`web / db / probe / user`)→ `core/agent_builder.py::build_agent`
- **核心**:`core/{agent_builder, loop, session, task, llm, memory, paths}.py` + `core/storage/{engine,models,utils}.py` + `db/migrations/`
- **工具**:`tools/{fs, shell, run_python, skill_tool}.py`
- **Web**:`web/{app.py, auth.py, broker.py, sinks.py}` + `web/static/dev.html`(dev SPA)+ `web/static/vendor/`(office 预览 jszip/docx-preview/xlsx)
- **配置**:`config/agent.yaml` + `config/models/*.yaml`(§3.2 Model Profile)
  - **模型档位(per-account 模型访问)**:`config/agent.yaml` `model_tiers` 段定义「档位→可用模型 id 集合」;`users.plan` 存档位名,空/未知 → `default` 档,`role=admin` 全开。管理后台「各用户用量」表的「档位」下拉改 plan(`PATCH /v1/admin/users/{uid}/plan`);档位定义见 `GET /v1/admin/tiers`。改 `model_tiers` 后**重启 web** 生效;无需 migration(`plan` 列 0001 起就有)。模型 id:文本=`family.variant`,图/视频=variant key。行为:用户只看到本档模型;显式选档外模型 403;老 task 下次发消息若模型已不在档位内 → 自动落回 `deepseek_v4.flash`。
- **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露,§3.5)
- **Workspace**(per-user 子树,user_id 来自 JWT `sub`):
  - `workspace/users/<user_id>/.memory/{core.md, extended/}` — 跨 task 记忆,FS 永久,dotfile 隔离
  - `workspace/users/<user_id>/.skills/<name>/SKILL.md` — 用户私有 skill,dotfile 隐藏;只对该用户生效,与内置同名则覆盖内置(user wins)。由 agent 工具 `save_skill` / `fork_skill` 写(host-side,不走沙箱 fs);docker 下随 user_root bind 到 `/workspace/.skills`
  - `workspace/users/<user_id>/<working_dir>/` — 工作目录,用户起名,同 working_dir 多 task 共享

---

## 维护约定

- **改对外行为(CLI 选项 / env / 文件布局)→ 同步本文档**。bug 修不动这个,只动 PROGRESS。
- 故障兜底新增:用过一次的真实坑,写一行,不预测。
- 跟 DESIGN/PROGRESS 边界:DESIGN 写"为什么",PROGRESS 写"做到哪",RUN 写"怎么跑"。
