feat(workspace): resolve_workspace 加 ZCBOT_WORKSPACE_DIR env 覆盖(per-host 落盘)

prod 要把重写入的 workspace/users/ 落到独立数据盘(xfs prjquota),但
config/agent.yaml 的 workspace_dir 是 dev/prod 共用提交的,改绝对路径会带歪 dev。
resolve_workspace 优先级改为 env ZCBOT_WORKSPACE_DIR > yaml workspace_dir > 默 "workspace"
(对齐 sandbox.* 的 yaml+env 模式);env/cfg 值都按 ROOT/<值> 解析,绝对路径直接生效。
dev 不设 env 维持 ROOT/workspace,prod systemd 设数据盘绝对路径,两边不抢同一份 yaml。

PG 暂不迁(元数据库小,留默认 /var/lib/postgresql 少坑)。

RUN.md:env 段加 ZCBOT_WORKSPACE_DIR + 新增「workspace 落独立数据盘」段
(mkfs.xfs + fstab prjquota + rsync 迁移 + systemd env)+ 故障表一行。
PROGRESS.md:2026-06-02 加一条。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-02 14:42:00 +08:00
parent 198e95cd84
commit 382a85e88e
3 changed files with 63 additions and 1 deletions

View File

@ -23,6 +23,8 @@
### 2026-06-02 ### 2026-06-02
- **`resolve_workspace` 加 env 覆盖 `ZCBOT_WORKSPACE_DIR`(per-host 部署,不碰共用 yaml)**:prod 想把重写入的 `workspace/users/` 落到独立数据盘(1T xfs prjquota,空间 + OS 层配额一步到位),但 `config/agent.yaml``workspace_dir` 是 dev/prod 共用提交的,改成绝对路径会带歪 dev。改法:`core/agent_builder.py:resolve_workspace` 优先级改为 **显式 arg > env `ZCBOT_WORKSPACE_DIR` > cfg `workspace_dir` > 默 `workspace`**,env/cfg 值都 `ROOT / ws`(POSIX 上绝对右操作数覆盖左 → 绝对路径直接生效,相对挂 repo 根)。prod systemd 设 `Environment=ZCBOT_WORKSPACE_DIR=/data/zcbot-workspace`,dev 不设照旧。PG 暂不迁(元数据库小,留默认 `/var/lib/postgresql` 少坑,等真涨到 3040G 再说)。**对外行为(env 变量)变化 → 更 RUN.md**:env 段加 `ZCBOT_WORKSPACE_DIR`、新增「workspace 落独立数据盘」段(整盘 mkfs.xfs + fstab prjquota + rsync 迁移 + systemd env)、故障表加一行。DESIGN 不动(无架构/schema 变化)。
- **修 embed 模式"登录页一闪而过"(绘制时机,非鉴权)**:`web/static/dev.html` 的 `#login` 默认 `display:flex` 且带 `login-in .35s` 动画,而加 `body.embed-mode`(→ CSS 隐藏 `#login`)的 `embedInit()` 在 body 末尾才跑;单文件 3800+ 行,浏览器常在解析到底部脚本前就先把登录卡画出来 → 闪一下。改法:在 `<body>` 第一行加一段同步内联脚本,`?embed=1` 时立即 `document.body.classList.add("embed-mode")`,赶在 `#login` 解析/绘制之前隐藏它 → 根本不绘制。只是"绘制闸门",底部 `embedInit()`(postMessage 握手 / `embed-waiting` 覆盖层 / token 分支)完全不动,`embed-mode` 幂等。未提前加 `embed-waiting`(有 stored token 时 `embedInit``enterApp` 不移除等待层会卡死,故等待层决定仍留底部按 token 判)。**bug 修复,DESIGN 不动;URL 参数/命令/env 无变化,RUN 不动**。 - **修 embed 模式"登录页一闪而过"(绘制时机,非鉴权)**:`web/static/dev.html` 的 `#login` 默认 `display:flex` 且带 `login-in .35s` 动画,而加 `body.embed-mode`(→ CSS 隐藏 `#login`)的 `embedInit()` 在 body 末尾才跑;单文件 3800+ 行,浏览器常在解析到底部脚本前就先把登录卡画出来 → 闪一下。改法:在 `<body>` 第一行加一段同步内联脚本,`?embed=1` 时立即 `document.body.classList.add("embed-mode")`,赶在 `#login` 解析/绘制之前隐藏它 → 根本不绘制。只是"绘制闸门",底部 `embedInit()`(postMessage 握手 / `embed-waiting` 覆盖层 / token 分支)完全不动,`embed-mode` 幂等。未提前加 `embed-waiting`(有 stored token 时 `embedInit``enterApp` 不移除等待层会卡死,故等待层决定仍留底部按 token 判)。**bug 修复,DESIGN 不动;URL 参数/命令/env 无变化,RUN 不动**。
### 2026-06-01 ### 2026-06-01

48
RUN.md
View File

@ -372,6 +372,14 @@ sudo -u zcbot docker network create --internal zcbot-sandbox-net
# PG 实际 IP,逗号分隔。defense-in-depth ── 即便落内网三段(§7.5 #1), # PG 实际 IP,逗号分隔。defense-in-depth ── 即便落内网三段(§7.5 #1),
# init.sh 再加一遍 DROP 规则。生产部署必填。 # init.sh 再加一遍 DROP 规则。生产部署必填。
ZCBOT_PG_IPS=10.1.2.3,10.1.2.4 ZCBOT_PG_IPS=10.1.2.3,10.1.2.4
# workspace 根目录(per-host 覆盖)。来源只有两个:这条 env,或 yaml `workspace_dir`
# (默值 = `workspace`);env 设了就用 env,没设就用 yaml 那条。两者都按 `ROOT/<值>` 解析
# (绝对路径直接生效,相对路径挂 repo 根下)。所以**不设这条 env → 走 yaml 的
# workspace_dir=workspace → ROOT/workspace(即 zcbot 下的 workspace)**,dev 维持原样。
# prod 设成数据盘绝对路径,把重写入的 user 子树落过去(下方「workspace 落独立数据盘」段),
# 不碰提交进仓库的 agent.yaml,两边不抢同一份配置。
# ZCBOT_WORKSPACE_DIR=/data/zcbot-workspace
``` ```
### 验证 ### 验证
@ -544,6 +552,45 @@ uid 与 host uid 对齐(错配 → exec 写 `/workspace` 全 EACCES)⑤ `workspa
lifespan 启动时同样会打第 ⑤ 项的 WARN 到 stdout(`[startup] [warn] fs quota ...`), lifespan 启动时同样会打第 ⑤ 项的 WARN 到 stdout(`[startup] [warn] fs quota ...`),
应用层周期扫描仍生效;**仅外部用户开放前必须把 ⑤ 升级到 OS 层 quota**。 应用层周期扫描仍生效;**仅外部用户开放前必须把 ⑤ 升级到 OS 层 quota**。
### workspace 落独立数据盘(prod,大空间 + quota fs)
prod 的 `workspace/users/<uuid>/` 是重写入区(报告 / 图 / pptx 等大件落这,DB 只存元数据)。
推荐挂一块独立数据盘(xfs prjquota),空间和 OS 层配额一步到位。**dev 不动** —— 走提交进
仓库的相对 `workspace_dir`(= `ROOT/workspace`);prod 在 systemd unit 里设 env
`ZCBOT_WORKSPACE_DIR` 指到数据盘,两边不抢同一份 `agent.yaml`
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 若 workspace 还空则跳过 rsync),owner 对齐跑服务的账号
sudo rsync -aXS /home/ubuntu/zcbot/workspace/ /data/zcbot-workspace/
sudo chown -R ubuntu:ubuntu /data/zcbot-workspace # uid 必须 == 容器内 zcbot(HOST_UID)
# 4) systemd unit 加 env 指过去(Environment= 或 EnvironmentFile 的 .env),重启
# Environment=ZCBOT_WORKSPACE_DIR=/data/zcbot-workspace
sudo systemctl daemon-reload && sudo systemctl start zcbot
python3 main.py sandbox check # fs quota 那项应变 [ok]
```
确认 `sandbox check` 通过、新 task 文件确实落 `/data/zcbot-workspace/users/...` 后,再删旧
`/home/ubuntu/zcbot/workspace`
### 配额硬化(§7.5 #4,外部开放前必做) ### 配额硬化(§7.5 #4,外部开放前必做)
应用层磁盘配额能挡常规超额,**但扫描间隙打满共享 fs 拖死同节点**这条硬要 OS 层 应用层磁盘配额能挡常规超额,**但扫描间隙打满共享 fs 拖死同节点**这条硬要 OS 层
@ -598,6 +645,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
| Sandbox 容器 build 完起不来,`docker logs` 显示 iptables 报错 | 缺 NET_ADMIN cap(`--cap-add=NET_ADMIN` 漏了)或 kernel 不支持(WSL2 / OpenVZ 环境不能跑)。Ubuntu 物理 / KVM 正常。验:`docker exec ... iptables -V` | | 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 | | 启动报 `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「配额硬化」段) | | `[startup] [warn] fs quota: <fstype> on ...` | workspace 所在 fs 没启 OS 层 quota。dogfood 阶段忽略;外部用户开放前必须升级 xfs prjquota / ext4 project / zfs(详 RUN.md「配额硬化」段) |
| prod 想把 workspace 落独立数据盘,但 `agent.yaml` 是 dev/prod 共用提交的 | 别改 `workspace_dir`(会带歪 dev)。prod systemd 设 env `ZCBOT_WORKSPACE_DIR=/data/zcbot-workspace`(优先级 env > yaml > 默)。详「workspace 落独立数据盘」段 |
| `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 .` | | `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 报 `ReadTimeoutError: HTTPSConnectionPool(host='files.pythonhosted.org', ...)` | 境内访问 PyPI 抖动。加 `--build-arg PIP_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple/`(腾讯云内网)或阿里云 / 清华源,详 RUN.md「镜像构建」段。Dockerfile 已把 pip timeout 拉到 60s,主因仍是源不通而非超时 | | 镜像 build pip 报 `ReadTimeoutError: HTTPSConnectionPool(host='files.pythonhosted.org', ...)` | 境内访问 PyPI 抖动。加 `--build-arg PIP_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/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 默认已改腾讯源(到 1.88)。若手动 build 撞到:换 `PIP_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple/` 或清华源。那串 `0.1.xxxx` 是 litellm 远古版本,纯干扰信息 | | 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 默认已改腾讯源(到 1.88)。若手动 build 撞到:换 `PIP_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple/` 或清华源。那串 `0.1.xxxx` 是 litellm 远古版本,纯干扰信息 |

View File

@ -17,6 +17,7 @@ state.json 已删除(元数据全在 PG)。
""" """
from __future__ import annotations from __future__ import annotations
import os
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Callable, Optional, Tuple from typing import Callable, Optional, Tuple
@ -94,8 +95,19 @@ def _resolve_executor(
def resolve_workspace(workspace: Optional[str], cfg: Optional[dict] = None) -> Path: def resolve_workspace(workspace: Optional[str], cfg: Optional[dict] = None) -> Path:
"""workspace 根解析,优先级:显式 arg > env `ZCBOT_WORKSPACE_DIR` > cfg `workspace_dir` > "workspace"
env 覆盖给 per-host 部署用 prod systemd 里设 `ZCBOT_WORKSPACE_DIR=/data/zcbot-workspace`
把重写入的 user 子树落到独立数据盘(xfs prjquota),dev 不设就吃提交进仓库的相对
`workspace_dir`(= ROOT/workspace),两边不抢同一份 yamlenv / cfg 值绝对相对都行:
`ROOT / "/abs"` POSIX 上即 "/abs"(绝对右操作数覆盖左),相对则挂在 repo 根下
"""
cfg = cfg or load_config() cfg = cfg or load_config()
p = Path(workspace) if workspace else ROOT / cfg.get("workspace_dir", "workspace") if workspace:
p = Path(workspace)
else:
ws = os.getenv("ZCBOT_WORKSPACE_DIR") or cfg.get("workspace_dir", "workspace")
p = ROOT / ws
p.mkdir(parents=True, exist_ok=True) p.mkdir(parents=True, exist_ok=True)
return p return p