zcbot/RUN.md

805 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 运行手册
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。
最后更新:2026-06-12(admin 角色 + /static/admin.html 管理后台:user role CLI / 建用户带 --role / 顶栏"管理"入口)
---
## 环境
- **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=...
# 本地 / 内网部署 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=...
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
# 可选:设了之后登录页右下角"+ 管理员添加用户"入口才工作(未设 → 接口返 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=总院科研辅助智能体 # 可选,发件人显示名,默"总院科研辅助智能体"(不暴露内部代号)
# 定时任务守护循环(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 回调主机(须在应用「企业微信授权登录」可信域名内;缺则取请求 base)
# 入站对话(可选,要公网 HTTPS):企微后台「应用→接收消息→设置 API 接收」填回调 URL + 下面两项,
# 用户即可在企业微信里直接和 zcbot 对话(回调 URL = <公网 base>/v1/wecom/callback)。
# WECOM_CALLBACK_TOKEN=... # 接收消息 Token(企微后台生成)
# WECOM_CALLBACK_AESKEY=... # EncodingAESKey(43 字符,企微后台生成)
```
> 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 在微信开口过一次**(冷启动/超期推不出,退邮件兜底)。
- **企业微信(渠道 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/);语音/视频/位置等暂不处理;未绑定/空消息静默。
- **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}`;新密码 <6400旧密码错 / 无密码(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 级,基本秒退 | 必填 |
| `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 中并行润色) | 必填 |
| `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 正交:
zcbot 现在 5 人级 + SSE 长连接,**严格"零中断"**(蓝绿 + nginx + SSE 客户端 reconnect 设计)代价高,不值得。有性价比的两挡:
**A. 简易档:`--reload`**(推荐当前规模)
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. 真无感档:nginx + 蓝绿双实例**(将来流量上来再上)
两个 systemd 实例 `zcbot@blue` / `zcbot@green`(模板 unit,`--port 8765` / `--port 8766`),nginx upstream 在中间切流程:
1. 部署到空闲实例(假设 green):`sudo systemctl restart zcbot@green`
2. `curl 127.0.0.1:8766/healthz` 验新版起来
3. nginx upstream 指向 green,`nginx -s reload` **新 REST 走 green,旧 SSE 还连在 blue 不断**
4. blue SSE 自然清空(`ss -tnp | grep :8765` 为空)再关 blue
**zcbot 端额外要做的事**:消息 broker 当前在 task 进程内(`web/broker.py`),蓝绿期间同 task 不同进程会丢事件nginx 侧用 `hash $arg_task_id consistent` 保同 task 落同实例可以缓解, task 创建分布是另一回事要做这条得先把 broker 改成 Redis pub/sub10 人内**不推**,留到真有需要再上
### 部署 SOP
**一把梭(推荐):`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`,部署时能看到装包进度;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,行为不变
# 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` 真奔 3040G根盘紧了
再迁,那时 `/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) |
| `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 .` |
| 镜像 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 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、反复试 `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` |
| 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 改动 |
| `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` 调大 |
| `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 5GB(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` / `[Error] bad arguments to write: ... missing required` | deepseek-v4-flash **大参数工具调用(大 write/run_python,≈7K+ 字符)偶发把内容碎片错位粘进 arguments 或退化成空 `{}`**(上游流式抖动)。`core/loop.py` 已自动兜底:畸形参数丢弃整轮重 roll(≤3 次)+ 最后一次降级非流式。仍频繁撞 → 引导模型**把大文件拆小 / 用 run_python 分块写**,或换 `deepseek_v4.pro`。前端看到 warn「工具调用参数损坏…重试」即此机制在生效 |
| 长任务跑到一半停下、提示「已达单轮步数上限…回复『继续』可接着跑」 | **预期行为非崩溃**:单个 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)
- **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 写"怎么跑"。