Author SHA1 Message Date
host 036323e620 feat(console): CHV executor option in dispatch/env selectors + experimental note in detail/list
- DispatchSection: add cloud-hypervisor option (labelled experimental,
  default off); default stays docker; hint states a KVM-host CHV worker
  must claim the task (otherwise it stays queued; attachments are
  rejected by the backend at creation) and that the frontend only passes
  the executor value through, never bypassing the backend allowlist.
- EnvSection: same option, because the backend env-create allowlist
  (local/docker/cloud-hypervisor) does allow it; note states env
  allowlist excludes cloud.
- TaskListSection: add cloud-hypervisor to the executor filter so CHV
  tasks are findable; list rows already showed executor.
- TaskDetailPanel: pure display note for cloud-hypervisor tasks
  (experimental, default off, no VM controls); detail already showed
  executor from the task API.
- README: document the CHV console entry, experimental marking, and
  verbatim backend-error display.
- Rebuilt cloud/console.html from src (tsc + vite single-file build);
  no backend change: executor is already returned by _task_to_dict.
2026-10-31 02:25:21 +00:00
host 445376743d feat: CHV 审核修复 P2(范围恢复为全做)
P2-1:preflight reuse/CPU/内存校验块小提取为
_preflight_config_checks(纯移动,深耦合处如实停)。
P2-2:build_vm_command 降级为私有 _build_vm_command;
Docker goal 内联解析顶部加正典分叉说明 + 同步检查清单。
P2-3:gaps grep 文本断言改成真实 Worker._run_attempt
行为断言。P2-4:README/build.sh 口径对齐(章节顺序、
BUILDINFO 名字列表、Node.js 不装写实)。P2-5:stop_vm
删除失败记审计。P2-6:CPU/内存校验统一进 preflight。
测试:原 84 + 新增 33 = 117 项,mock,无真 VM。
2026-10-31 02:01:03 +00:00
host 9771ed9e8c feat: CHV 审核修复(P0+P1,堆叠于 #230)
P0:seed 磁盘 CIDATA ISO 挂进 VmConfig 第二块只读盘;
restore provisioned 下复用钉死 known_hosts、缺失 fail-fast
绝不 keyscan;preflight 拦截 CHV_DISK_MODE=reuse(显式
实验二次确认 + 审计放行);SSH 目标选客机 IP 方案(显式
host > net 静态 ip > 显式转发宣称回环,三者皆无 fail-fast)。
P1:网关 extra_env 经 SSH env 前缀注入、worker 守卫收窄到
Local/CHV;取消/清理/看门狗接 CHV 通用路径;WarmPool
acquire 内 resume;容量门持久化计数;磁盘配额总量+余量
与已存在磁盘校验重拷;API 按 Content-Length 验完整性;
快照 file:// 验落盘再 shutdown;execute 死端口归类
vm_not_running。
P2 全部排除未做(用户范围收窄令,留待以后一轨)。
测试:原 84 + 新增 29 = 113 项,mock,无真 VM。
2026-10-31 01:54:13 +00:00
host 22b83f118a feat: CHV gap fill — worker breakpoint wiring, pool reconcile, per-VM host keys, egress validation, guest build script
Stack: #216 -> #221 -> this. Worker breakpoint branch now calls any
executor providing goal_resume_breakpoint_task (Local keeps its
original path, gateway tasks still loud-fail); WarmPool
reconcile_orphans is called on CHV worker startup and close with
audit-on-failure; per-VM SSH host keys are provisioned before VMM
start via cloud-init NoCloud seed (keyscan kept only as weak
fallback, CHV_HOST_KEY_MODE=provisioned|keyscan); CHV_NET_JSON is
validated in preflight and explicit direct egress is audited;
chv-guest/build.sh is now real steps with --dry-run and fails fast
without /dev/kvm. Tests: new test_chv_gaps.py (18), cloud full
1514 OK (skipped=4). No real VM verification: no KVM host.
2026-10-31 01:30:52 +00:00
host fde7bf4c3e feat: CHV 执行器优化(拆模块/去重复/SSH 钉死/池持久化/中途死亡分类)
- 拆模块:CHV 从 cloud/sandbox.py 巨类拆到 cloud/chv/(executor/
  pool/config/api_client/ssh_transport/disk/errors),sandbox 只留
  兼容 re-export,旧导入路径与新路径是同一对象
- 去重复:goal 解析、host 顶层参数、runner 超时抽成共享 helper,
  Local 与 CHV 在矩阵输入下结论全等(Docker goal-resume 变体
  有意保留内联版,见 PR 描述)
- 安全:SSH host key 按 VM 钉死(state_dir/<vm>/known_hosts,
  ssh-keyscan 首次捕获 TOFU),废除 no + /dev/null;CHV_SSH_USER
  空串与 CHV_SSH_PORT 越界直接拒绝,不静默 fallback
- execute 语义:只读已运行 VM,未运行报 vm_not_running,不为
  status 起 VM
- 预热池:registry 落盘、重启载入只当孤儿、reconcile 清理、
  损坏 quarantine;条目仍一次性,绝不复用脏 VM
- 长任务:VM 中途死亡判 interrupted(断点可续跑);中途心跳
  经评估不做(无客内进度信号可采)
- #215 未合并:只列复用清单,不复制模块

测试:原 42 项 + 新 24 项全过;相关旧套件与 cloud 全量 1496 项
全过(skipped=4)。验证仍是 mock + 干跑 + 单元测试,未做真机、
压测、安全审计;堆叠于 #216 之上,base 为 feat-cloud-hypervisor。
2026-10-30 19:45:33 +00:00
host c371bbdf1e feat: Cloud Hypervisor 执行器生产级 v1(cloud-hypervisor)
单任务单 VM:API 唤醒(preflight→vmm.ping→vm.create→vm.boot→SSH 就绪)、
暂停/快照/休眠等价/关闭/回收、预热快照池、每任务磁盘 copy、容量与磁盘配额门、
受控 egress fail-closed、VM 事件审计与分类失败;worker/server/autoscaler 接线
(executor=cloud-hypervisor,任务内 LLM 不可自选);默认关闭,不进生产路由。
未做真实 VM 验证:生产主机与本 VM 均无 /dev/kvm、无嵌套虚拟化,验证为 mock +
干跑命令 + 单元测试;真 VM、快照恢复、压测、安全审计见
docs/chv-production-readiness.md。CI 账单故障仍是硬门禁,本提交只到 PR OPEN。
2026-10-30 18:54:16 +00:00
28 changed files with 5915 additions and 163 deletions

No files matched your search

+8
View File
@@ -199,6 +199,14 @@ python3 -m cloud.server # 默认 127.0.0.1:8765,生产经 nginx + TLS
# 除 GET /health、/webhooks/*、/oauth/* 外,所有端点都要 X-API-Key 请求头
# 任务实时链路:GET /tasks/{id}/trace(快照)与 GET /tasks/{id}/trace/stream(SSE)
# 命令台:GET /console(每次现读 cloud/console.html,无需重启)
# CHV 前端入口(实验,默认关闭):新建任务与新建环境的执行器下拉里有
# cloud-hypervisor 选项;任务详情与任务列表的执行器筛选同样展示它。
# 前端只传 executor 值,安全边界仍在后端白名单(任务接口 allowlist 含
# cloud-hypervisor,环境接口 allowlist 为 local/docker/cloud-hypervisor);
# 需后端有 KVM 宿主上的 CHV worker 才会认领,否则任务一直排队,带附件
# 时建任务即被后端拒绝,拒绝文案经控制台原样展示(“提交失败:…”)。
# 详情页只有实验提示,没有 VM 暂停/恢复/关机/快照等控制按钮;VM 状态、
# IP、快照、预热池字段后端任务接口不返回,前端不展示、不编造。
```
- **任务状态机**:`queued → running → completed / failed /
+97
View File
@@ -0,0 +1,97 @@
# Cloud Hypervisor 客机镜像(chv-guest)——内容清单与版本化构建说明
> 本目录有清单与真实构建步骤(build.sh,补缺口轨已实做),
> 但镜像本身不在本轨内实际构建发布。原因写在前面,不藏着:
> 当前没有带 /dev/kvm 的宿主可以构建并实测 VM 镜像(生产
> 主机是 CVM、无嵌套虚拟化;本 VM 容器内同样无 /dev/kvm)。
> 构建与实测见 `docs/chv-production-readiness.md` 的“需镜像
> 构建 / 需 KVM 宿主实测”项,绝不在本轨声称镜像已建成、
> 已发布、已跑通。
## 1. 镜像定位
- 基础:Ubuntu 云镜像(与 `cloud/docker/Dockerfile` 的任务镜像
对齐能力,不是另起一套环境口径)。
- 载体:Cloud Hypervisor 直接内核启动(vmlinux)+ raw 磁盘
镜像,执行器用 `CHV_KERNEL` / `CHV_ROOTFS` 指向它们。
- 客内 host.py 位置:`/app/host.py`(与 Docker 的 `/app:ro`
同款约定,只是载体从容器只读挂载换成 VM 镜像内置代码;
代码版本 = 构建时仓库 commit,见 §3 版本化)。
- 绝不进镜像的东西:任何模型 key、系统 key、用户账号数据、
生产凭证。模型 key 由客机 cloud-init 预置的运行环境变量名
注入(与 Docker 的 `-e <名>` 同款惯例),不写进镜像层。
## 2. 内容清单(按现有 Docker 任务镜像能力对齐)
| 类别 | 内容 | 来源/版本口径 |
| --- | --- | --- |
| 语言与工具链 | Python 3.12、git(Node.js 不装:Playwright 驱动是 Python playwright,见 PLAYWRIGHT=1 分支;任务镜像 Dockerfile 里也没有 Node.js,本清单不虚构它) | 与任务镜像同款大版本,构建时钉小版本并记入构建日志 |
| 文档/图片工具 | pandoc、poppler-utils(pdftotext)、latexmk、texlive-latex-base、python-docx、openpyxl、python-pptx、PyMuPDF、Pillow | 逐项照抄 `cloud/docker/Dockerfile` 的 apt/pip 清单,不擅自加减 |
| agent 引擎 | codex、codex-code-mode-host、codex-resources/bwrap(成套,约 367M,缺 sibling 直接构建失败) | codex 0.164.0-alpha.9(与生产任务镜像一致);`CODEX_BIN_DIR` 由构建方提供,bin/ 不进仓库 |
| 浏览器 | Playwright + Chromium(浏览器自动化 MCP 用,PLAYWRIGHT=1 时才装) | Python playwright 装当时的 Chromium,未在 BUILDINFO 里钉浏览器版本——这是已知缺口,不是已钉死 |
| 客机基础 | openssh-server(SSH 通道用)、cloud-init、qemu-guest-agent(可选,宿主信息用) | sshd 默认只允许 key 登录,禁止密码登录 |
| 任务代码 | codex-app-server 仓库代码置于 `/app`(host.py 入口) | 版本见 §3,不挂载宿主代码目录(VM 与宿主无共享路径) |
中文 LaTeX 说明:与 Docker 镜像同款口径——xelatex + CJK
字体会再增数百 MB,本清单不默认包含;需要时单独一版镜像,
不在本轨悄悄加。
## 3. 版本化
镜像 tag 格式(构建时三项缺一即失败,不生成无版本镜像):
```
chv-guest-<ubuntu 版本>-<codex 版本>-<仓库 commit 短 hash>
```
例如(示例格式,非已建成):`chv-guest-24.04-0.164.0-alpha.9-aebc623`。
构建产物清单(构建方必须输出,执行器用它做 preflight 之外的
版本核对,v1 执行器暂不校验 tag,见就绪清单):
- `vmlinux`(与 Ubuntu 云镜像配套的内核)
- `disk.raw`(根文件系统镜像)
- `BUILDINFO.json`:`{ubuntu, codex_version, repo_commit, built_at,
contents: [清单逐项名]}`——注意:build.sh 生成的 contents 只有
名字列表,没有逐项版本号(feat-chv-fix 纠正 §3 旧措辞);
逐项版本只留在构建日志里,未进 BUILDINFO,这是已知缺口。
## 4. 构建脚本(补缺口轨:已从骨架改成真实步骤)
见 `build.sh`:参数校验 → 下载 Ubuntu 云镜像 → qemu-img
转 raw → virt-customize 装 §2 清单、拷入 codex 成套二进制
与仓库代码到 /app、写 host key 不重生成配置 →
virt-get-kernel 取内核 → 生成并校验 BUILDINFO.json。
`./build.sh --dry-run out/` 只打印命令、不执行、不要求
KVM;全量模式在无 /dev/kvm 的机器上第一步就明确退出
(`CHV_GUEST_BUILD needs a KVM host`),绝不假装构建成功。
本轨未在真机上跑过全量模式,镜像未建成、未发布,仍属
“需 KVM 宿主实测”项。
## 5. Host key 首启注入硬性要求(补缺口轨新增)
执行器的 per-VM 预置方案(见 `cloud/chv/hostkeys.py`)能
跑通的前提是客机镜像必须满足以下全部,否则预置在真机
上跑不通,执行器会 fail-fast,不回落成弱方案:
- 装 cloud-init 且支持 NoCloud seed(seed 磁盘(CIDATA ISO,feat-chv-fix 起挂进 VmConfig
第二块只读盘)由执行器在 VM 启动前生成,含 meta-data 与 user-data);
- 首启时先应用 seed 再启动 sshd,seed 里的 user-data
把该 VM 的 host key 对写进 /etc/ssh/ 并重启 sshd;
- 镜像里已钉死 `ssh_deletekeys: false` 与
`ssh_genkeytypes: []`(build.sh 已写),首启不得无条件
重新生成 host key——重生成会使预置的 known_hosts 立即
失效,SSH 钉死直接拒绝,这是设计,不是 bug;
- sshd 只允许 key 登录,禁止密码登录;
- seed 磁盘是 per-VM 敏感物,执行器在 VM 关闭/
回收时删除,镜像构建方不得把任何一把 host key 固化
进镜像(所有 VM 同 key 是安全事故)。
## 6. 与执行器的对接
- 执行器 preflight 只校验内核/镜像文件存在且可读,不校验镜像
内容版本——内容版本核对(BUILDINFO)是镜像发布流程的责任,
在就绪清单里标为“需镜像构建”项,不在本轨假装已做。
- 每任务磁盘:执行器 copy 模式下把 `disk.raw` 复制为每任务
磁盘,任务之间文件系统独立;reuse 模式仅单租户实验,preflight 拦截(须同时设 CHV_REUSE_SINGLE_TENANT_EXPERIMENT=1 并审计放行,feat-chv-fix)。
- 快照基准:`CHV_SNAPSHOT_URL` 指向的快照必须由本镜像的干净
启动状态制成(pause 后 snapshot),预热池用它做干净 VM 来源。
+138
View File
@@ -0,0 +1,138 @@
#!/bin/bash
# chv-guest 镜像构建脚本(补缺口轨:从 TODO 骨架改成真实步骤)。
#
# 用法(必须在带 /dev/kvm 的宿主上跑全量模式):
# CODEX_BIN_DIR=bin UBUNTU_VERSION=24.04 CODEX_VERSION=0.164.0-alpha.9 \
# REPO_COMMIT=$(git rev-parse --short HEAD) ./build.sh out/
# ./build.sh --dry-run out/ # 只打印命令,不执行、不要求 KVM
#
# 产物(out/ 内):
# vmlinux # virt-get-kernel 从云镜像取出的内核(CHV --kernel 指向它)
# disk.raw # qemu-img 转成的 raw 根文件系统镜像(CHV_ROOTFS 指向它)
# BUILDINFO.json # {tag, ubuntu, codex_version, repo_commit, built_at, contents}
# 绝不进镜像的东西:任何模型 key、系统 key、用户账号数据、
# 生产凭证;模型 key 由客机 cloud-init 预置的运行环境变量名
# 注入,不写进镜像层。
#
# 客机 host key 硬性要求(见 README §5,补缺口轨新增):
# 客机必须装 cloud-init 且支持 NoCloud seed,首启先应用
# seed 再启动 sshd,且不得无条件重新生成 host key,否则
# 执行器的 per-VM 预置钉死即失效。本脚本在镜像里写
# /etc/cloud/cloud.cfg.d/99-chv-hostkey.cfg(ssh_deletekeys:
# false、ssh_genkeytypes: []),把“不重生成”钉进镜像。
set -eu
DRY_RUN=0
if [ "${1:-}" = "--dry-run" ]; then
DRY_RUN=1
shift
fi
OUT="${1:-out}"
: "${CODEX_BIN_DIR:?CODEX_BIN_DIR 未设:需 npm 安装 @openai/codex 后的配对二进制目录}"
: "${UBUNTU_VERSION:?UBUNTU_VERSION 未设}"
: "${CODEX_VERSION:?CODEX_VERSION 未设}"
: "${REPO_COMMIT:?REPO_COMMIT 未设:构建时仓库 commit 短 hash}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="${REPO_DIR:-$(cd "$SCRIPT_DIR/.." && pwd)}"
TAG="chv-guest-${UBUNTU_VERSION}-${CODEX_VERSION}-${REPO_COMMIT}"
UBUNTU_IMAGE_URL="${UBUNTU_IMAGE_URL:-https://cloud-images.ubuntu.com/releases/${UBUNTU_VERSION}/release/ubuntu-${UBUNTU_VERSION}-server-cloudimg-amd64.img}"
run() {
if [ "$DRY_RUN" = "1" ]; then
echo "DRY-RUN: $*"
else
echo "+ $*"
"$@"
fi
}
# 参数校验(干跑与全量都做,不依赖 KVM)。
for bin in codex codex-code-mode-host; do
if [ ! -x "$CODEX_BIN_DIR/$bin" ]; then
echo "缺少配对二进制:$CODEX_BIN_DIR/$bin,构建失败" >&2
exit 1
fi
done
if [ ! -d "$CODEX_BIN_DIR/codex-resources" ]; then
echo "缺少 codex-resources(bwrap 载体),构建失败" >&2
exit 1
fi
if [ ! -f "$REPO_DIR/host.py" ] || [ ! -d "$REPO_DIR/cloud" ]; then
echo "REPO_DIR 不是 codex-app-server 仓库根(缺 host.py/cloud/):$REPO_DIR" >&2
exit 1
fi
if [ "$DRY_RUN" = "0" ]; then
if [ ! -e /dev/kvm ]; then
echo "CHV_GUEST_BUILD needs a KVM host: /dev/kvm 不存在" >&2
exit 1
fi
for tool in curl qemu-img virt-customize virt-get-kernel python3; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "缺少构建工具:$tool,构建失败" >&2
exit 1
fi
done
fi
run mkdir -p "$OUT"
IMG="$OUT/ubuntu-${UBUNTU_VERSION}-server-cloudimg-amd64.img"
if [ "$DRY_RUN" = "1" ]; then
echo "DRY-RUN: curl -fL $UBUNTU_IMAGE_URL -o $IMG"
echo "DRY-RUN: [ -s $IMG ] || exit 1"
else
if [ ! -s "$IMG" ]; then
curl -fL "$UBUNTU_IMAGE_URL" -o "$IMG"
fi
[ -s "$IMG" ] || { echo "下载的云镜像为空:$IMG" >&2; exit 1; }
if [ -n "${UBUNTU_IMAGE_SHA256:-}" ]; then
echo "$UBUNTU_IMAGE_SHA256 $IMG" | sha256sum -c -
fi
fi
run qemu-img convert -f qcow2 -O raw "$IMG" "$OUT/disk.raw"
# 清单软件(照抄 cloud/docker/Dockerfile 的 apt/pip 清单,
# 中文 LaTeX xelatex+CJK 故意不装,见 README;Playwright
# 默认不装,PLAYWRIGHT=1 时才加装,属可选步骤)。
run virt-customize -a "$OUT/disk.raw" \
--install git,pandoc,poppler-utils,latexmk,texlive-latex-base,openssh-server,cloud-init,qemu-guest-agent,python3,python3-pip \
--run-command 'pip3 install --no-cache-dir --break-system-packages python-docx openpyxl python-pptx PyMuPDF Pillow || pip3 install --no-cache-dir python-docx openpyxl python-pptx PyMuPDF Pillow' \
--copy-in "$CODEX_BIN_DIR/codex:/usr/local/bin/" \
--copy-in "$CODEX_BIN_DIR/codex-code-mode-host:/usr/local/bin/" \
--copy-in "$CODEX_BIN_DIR/codex-resources:/usr/local/bin/" \
--run-command 'chmod +x /usr/local/bin/codex /usr/local/bin/codex-code-mode-host; ln -sf /usr/local/bin/codex-resources/bwrap /usr/local/bin/bwrap' \
--mkdir /app \
--copy-in "$REPO_DIR/host.py:/app/" \
--copy-in "$REPO_DIR/cloud:/app/" \
--run-command 'printf "ssh_deletekeys: false\nssh_genkeytypes: []\n" > /etc/cloud/cloud.cfg.d/99-chv-hostkey.cfg; systemctl enable ssh || true'
if [ "${PLAYWRIGHT:-0}" = "1" ]; then
run virt-customize -a "$OUT/disk.raw" \
--run-command 'pip3 install --no-cache-dir playwright; python3 -m playwright install --with-deps chromium'
fi
run virt-get-kernel -a "$OUT/disk.raw" -o "$OUT/vmlinux"
# 产物校验(干跑只打印,不执行校验命令的副作用部分)。
if [ "$DRY_RUN" = "1" ]; then
echo "DRY-RUN: test -s $OUT/vmlinux && test -s $OUT/disk.raw"
echo "DRY-RUN: python3 生成 $OUT/BUILDINFO.json 并校验 tag=$TAG"
else
test -s "$OUT/vmlinux" || { echo "内核产物为空:$OUT/vmlinux" >&2; exit 1; }
test -s "$OUT/disk.raw" || { echo "磁盘产物为空:$OUT/disk.raw" >&2; exit 1; }
python3 - "$OUT/BUILDINFO.json" "$TAG" "$UBUNTU_VERSION" "$CODEX_VERSION" "$REPO_COMMIT" <<'PYEOF'
import json, sys, time
path, tag, ubuntu, codex, commit = sys.argv[1:6]
info = {"tag": tag, "ubuntu": ubuntu, "codex_version": codex,
"repo_commit": commit, "built_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"contents": ["git", "pandoc", "poppler-utils", "latexmk", "texlive-latex-base",
"openssh-server", "cloud-init", "qemu-guest-agent",
"python-docx", "openpyxl", "python-pptx", "PyMuPDF", "Pillow",
"codex", "codex-code-mode-host", "codex-resources/bwrap", "/app/host.py"]}
with open(path, "w") as fh:
json.dump(info, fh, indent=2)
PYEOF
python3 -c "import json; d=json.load(open('$OUT/BUILDINFO.json')); assert d['tag'] == '$TAG', d"
fi
echo "tag=$TAG"
echo "out=$OUT"
if [ "$DRY_RUN" = "1" ]; then
echo "干跑结束:未执行任何构建步骤,镜像未建成"
else
echo "构建完成:$OUT/vmlinux $OUT/disk.raw $OUT/BUILDINFO.json"
fi
+69
View File
@@ -1288,6 +1288,75 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
错误文本或返回结果。worker CLI 的 `--executor` 新增 cloud 选项,
构造 CloudExecutor;worker 的 run_one 走 dispatch_task,
CloudExecutor 的 dispatch_task/run_task 直接委托给 execute。
- CloudHypervisorExecutor(生产级 v1,`cloud/chv/`,旧路径
`cloud/sandbox.py` 只留兼容 re-export,执行器名
`cloud-hypervisor`,绝不用 `cloud` 缩写):
单任务单 VM:一个任务一个 VM、独立内核/文件系统/网络,
任务结束销毁,租户之间无共享路径、无东西向互访;VM 内
agent 可有 root,但只能影响本 VM。密钥按现有惯例走运行
时环境变量名注入,不进镜像、不进代码、不进任务 prompt
落库(SSH argv 里只有 key 文件路径,绝无 key 值)。
唤醒是 API 模式:preflight(见下)→ 容量门 → 磁盘配额门
→ 每任务磁盘供给(copy 模式复制基础镜像)→ 只以
`--api-socket` 起 VMM → 等 `vmm.ping` 通 → `PUT vm.create`
(VmConfig)→ `PUT vm.boot` → 等 guest SSH 就绪(默认
120 秒)才跑 host.py;超时关 VM 并报分类失败。直接启动
命令(`build_vm_command`)只作手动等价写法,执行器不走它。
暂停/快照/休眠:`pause_vm`/`resume_vm` 走 ch-remote;
`restore_vm` 先 restore 再显式 resume(恢复后默认 paused,
不 resume 不算醒);CHV 无原生 hibernate 端点,`hibernate_vm`
用 pause + snapshot + shutdown 等价实现,恢复走 restore。
预热池 `WarmPool`:预热条目是一次性干净快照 VM,acquire
消耗、release 一律销毁,绝不跨任务复用脏 VM;池空回落
冷启动,绝不降级 Docker/Local;池状态另落
`state_dir/warm-pool.json` registry,worker 重启后载入的
条目一律当孤儿、只能 `reconcile_orphans()` 清理,绝不
重新发给任务,registry 损坏时 quarantine 成 `.corrupt` 并
从空池开始。SSH host key 按 VM 钉死(#216 的
`StrictHostKeyChecking=no` + `/dev/null` 已废除):每 VM
在 `state_dir/<vm>/known_hosts` 有独立文件,首次启动经
ssh-keyscan 在本机回环端口捕获 key 写入该文件(首次信任
TOFU,只防首次之后的替换,不验证首次本身),此后 SSH
一律 `StrictHostKeyChecking=yes` + 该文件校验;客机用户
(`CHV_SSH_USER`,默认 root,显式空串直接拒绝)与端口
(`CHV_SSH_PORT`,默认 2222,1-65535)为显式配置。
`execute()` 只读已运行 VM:未运行报 `vm_not_running`
分类失败,不为一次 status 起 VM。长任务中途死亡
(传输异常、或结果已 failed 且登记 VMM 进程已死)判
`interrupted` 而非笼统 failed;中途心跳经评估未做
(host.py 单次 SSH 长回合无客内进度信号可采,另起轮询
只能证明 VM 活着)。关闭与清理:任务结束/
失败/取消时 `shutdown_vm`(ch-remote shutdown + 杀进程 +
删 socket),`cleanup_stale_vms` 处理已死/已关/socket 残留
三种情况。审计:VM 的 create/boot/ready/pause/resume/
snapshot/restore/shutdown/destroy/failed/recycled 均经
audit_callback 发出,worker 接线后转 AuthStore 审计;失败
带 kind 分类(kvm_missing/image_missing/guest_not_ready/
api_timeout/host_capacity_exceeded/disk_quota_exceeded 等),
不笼统 failed。资源与成本:每 VM 的 CPU/内存/磁盘配额、
超时、最大轮次(goal 上限 100)复用 worker 现有 quota 语义,
不另起一套计费;空闲休眠省成本;autoscaler 按 executor 过
滤队列深度并透传 `--executor` 给拉起的 worker。网络与治理:
VM 出口默认必须过 CHV_EGRESS_PROXY 白名单代理(未设且未
显式允许直连时 preflight 直接失败);审批门仍走任务的
approval 与 server 的 merge_gate,VM 内 agent 不能自批。
配置(环境变量,见该类 docstring 全表):CHV_BINARY、
CHV_KERNEL、CHV_ROOTFS(或 CHV_IMAGE 别名)、CHV_CPU、
CHV_MEM、CHV_STATE_DIR、CHV_SSH_KEY/USER/PORT、CHV_CMDLINE、
CHV_NET(直接命令用)/CHV_NET_JSON(API 用)、CHV_REMOTE、
CHV_MAX_VMS、CHV_DISK_MODE(copy 默认/reuse 仅实验)、
CHV_DISK_GB、CHV_EGRESS_PROXY、CHV_TASK_TIMEOUT、
CHV_SNAPSHOT_URL、CHV_WARM_POOL_SIZE、CHV_API_TIMEOUT、
CHV_READY_TIMEOUT/INTERVAL。默认关闭:server 默认执行器仍
是 docker,worker 默认 local,CHV 只在任务创建时显式选
`executor=cloud-hypervisor` 且有 CHV worker 认领时才跑;
任务内 LLM 绝不能发现/调用它(调度层选择,只在创建层可见:
POST /tasks 可选值、本文档、控制台选项,不新增任务内 MCP/
工具)。真实验证边界:未做真实 VM 启动验证——生产主机
本身是 CVM、无 /dev/kvm、无嵌套虚拟化,本 VM 容器内同样
无 /dev/kvm;真 VM、快照恢复、压测、安全审计均在
`docs/chv-production-readiness.md` 里标为待办,绝不声称
已生产可用。客机镜像内容清单与构建骨架见 `chv-guest/`。
- 自动 PR(默认关闭,显式触发,`POST /tasks/{id}/pr`,需项目
maintainer 以上权限 approve_merge):仅当任务状态为 merged 时
可调(其它状态 409;任务不存在 404;权限不足 403)。逻辑按顺序
+10
View File
@@ -304,6 +304,14 @@ class Autoscaler:
if not any(a == "--host-name" or a.startswith("--host-name=")
for a in self.worker_args):
cmd.extend(["--host-name", socket.gethostname()])
# 执行器透传:scaler 的 --executor 只算该类型队列深度,
# 不透传时拉起的 worker 默认 local,永远认领不到该类型
# 任务(cloud-hypervisor 池就是这样被饿死的)。worker_args
# 里已显式给 --executor 时以显式为准,不覆盖。
if self.executor and not any(
a == "--executor" or a.startswith("--executor=")
for a in self.worker_args):
cmd.extend(["--executor", self.executor])
return cmd
def _start_worker(self, worker_id):
@@ -553,6 +561,8 @@ def main(argv=None):
help="各 worker 子进程的日志目录")
parser.add_argument("--executor", default=None,
help="只按该执行器类型的就绪任务算深度;"
"同时透传给拉起的 worker(worker_args 里"
"已显式给 --executor 时以显式为准);"
"不给时按全部任务算(可能轻微多扩,"
"受 max-workers 封顶)")
parser.add_argument("worker_args", nargs=argparse.REMAINDER,
+18
View File
@@ -0,0 +1,18 @@
"""Cloud Hypervisor 执行器包(从 cloud/sandbox.py 巨类拆出)。
公开名与 #216 一致,cloud/sandbox.py 会 re-export 它们,
保证旧导入路径可用:
- CloudHypervisorExecutor:执行器本体(executor.py)
- WarmPool:预热池 + registry 持久化与孤儿清理(pool.py)
- ChvFailure:分类失败(errors.py)
子模块分工见 executor.py 头注释。本包顶层 import 不触
cloud.sandbox(执行器方法内按需 import 共享 helper),
所以任何导入顺序都不会循环。
"""
from cloud.chv.errors import ChvFailure
from cloud.chv.executor import CloudHypervisorExecutor
from cloud.chv.pool import WarmPool
__all__ = ["ChvFailure", "CloudHypervisorExecutor", "WarmPool"]
+122
View File
@@ -0,0 +1,122 @@
"""Cloud Hypervisor REST API 传输(叶子模块,不依赖
cloud.sandbox,避免循环导入)。
只用标准库 raw HTTP over Unix socket,无第三方依赖。
"""
import json
import os
import socket
def _unix_socket_api(socket_path, method, path, payload=None):
"""Cloud Hypervisor REST API 的默认传输:标准库 raw HTTP
over Unix socket(无第三方依赖)。返回 (status, body),
body 能解析 JSON 时为 dict,否则为原文本。socket 不存在
或连接失败时 raise RuntimeError(调用方据此判 api 超时
/失败,不编造 200)。"""
if not os.path.exists(socket_path):
raise RuntimeError("api socket 不存在:%s" % socket_path)
body = b""
if payload is not None:
body = json.dumps(payload).encode("utf-8")
req_lines = ["%s /api/v1%s HTTP/1.1" % (method, path),
"Host: localhost",
"Accept: application/json",
"Content-Type: application/json",
"Content-Length: %d" % len(body),
"Connection: close", "", ""]
raw = "\r\n".join(req_lines).encode("utf-8") + body
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(5)
try:
sock.connect(socket_path)
sock.sendall(raw)
chunks = []
# feat-chv-fix(P1-6):读超时不再当“响应结束”。
# 先读满响应头,再按 Content-Length 读满体部;超时、
# 提前关流、体部短于 Content-Length 一律 RuntimeError
# (调用方判 api_timeout / 传输失败),绝不解析半截
# 响应。chunked 与无 Content-Length 响应明确拒绝
# (Cloud Hypervisor REST 不应发 chunked;无长度则
# 无法证完整性)。
buf = b""
header_end = None
timed_out = False
while header_end is None:
try:
data = sock.recv(65536)
except socket.timeout:
timed_out = True
break
if not data:
break
buf += data
header_end = buf.find(b"\r\n\r\n")
if timed_out or header_end is None:
raise RuntimeError("CHV API 响应头不完整(超时或提前关流)")
header = buf[:header_end].decode("utf-8", errors="replace")
body = buf[header_end + 4:]
content_length = None
is_chunked = False
for line in header.split("\r\n")[1:]:
if ":" not in line:
continue
name, value = line.split(":", 1)
name = name.strip().lower()
value = value.strip()
if name == "content-length":
try:
content_length = int(value)
except ValueError:
raise RuntimeError("CHV API Content-Length 非法")
elif name == "transfer-encoding" \
and "chunked" in value.lower():
is_chunked = True
if is_chunked:
raise RuntimeError("CHV API 响应为 chunked,拒绝解析")
if content_length is None:
raise RuntimeError("CHV API 响应无 Content-Length,拒绝解析")
while len(body) < content_length:
try:
data = sock.recv(65536)
except socket.timeout:
raise RuntimeError(
"CHV API 响应体截断:已收 %d / 需 %d 字节"
% (len(body), content_length))
if not data:
raise RuntimeError(
"CHV API 响应体截断:已收 %d / 需 %d 字节"
% (len(body), content_length))
body += data
body = body[:content_length]
finally:
sock.close()
text = header + "\r\n\r\n" + body.decode("utf-8", errors="replace")
status = 0
if text.startswith("HTTP/"):
try:
status = int(text.split()[1])
except (IndexError, ValueError):
status = 0
body_text = body.decode("utf-8", errors="replace")
try:
parsed = json.loads(body_text) if body_text.strip() else {}
except ValueError:
parsed = body_text
return status, parsed
def normalize_api_result(result):
"""API 调用归一化:fake api_client 可返回 (status, body)
或 {"status":..., "body":...} 或带 .status 的对象,本
函数统一成 (status:int, body)。"""
if isinstance(result, tuple) and len(result) == 2:
return int(result[0]), result[1]
if isinstance(result, dict):
status = result.get("status", result.get("status_code"))
body = result.get("body", result)
return (int(status) if status is not None else 0), body
status = getattr(result, "status", None) \
or getattr(result, "status_code", None)
return (int(status) if status is not None else 0), result
+169
View File
@@ -0,0 +1,169 @@
"""Cloud Hypervisor 执行器配置解析(叶子模块,不依赖
cloud.sandbox,避免循环导入)。
职责只有两件:内存字符串解析,以及把构造参数 + 环境变量
解析成一份显式配置。参数优先于环境变量、环境变量优先于
默认值——与 #216 原 __init__ 的 `or` 链完全一致,只收紧了
两处原来会静默 fallback 的 nonsense 输入(见下文),收紧点
在 PR 描述里明写、单测钉住:
- CHV_SSH_USER 显式传空字符串:原来 `or` 链会静默 fallback
成 root(调用方以为没给用户、实际跑了 root),现在直接
ValueError。
- CHV_SSH_PORT 显式传 0、或解析后超出 1-65535:原来 0 会
静默 fallback 成 2222、其它越界值会原样进 ssh 命令,现在
直接 ValueError。
与 #215 的关系(只列清单,不复制模块):#215 未合并,
本轨不假装复用它。#215 合并后应再复用的点:超时类
(api_timeout/ready_timeout/ready_interval/task_timeout →
timeouts 模块)、门禁类(preflight fail-fast、容量门、磁盘
配额门、egress fail-closed → gates 模块)、配额类
(max_vms、disk_gb、任务成本走 worker quota → quota 模块)、
隔离类(单任务单 VM、磁盘 copy 模式、egress 代理、SSH 钉死
→ sandbox_policy 模块)。见 PR 描述的复用清单。
"""
import re
def _parse_memory_bytes(value):
"""内存字符串转字节:2G/512M/1024K/2048(无单位=字节)。
非法值 ValueError,绝不静默按默认算。"""
m = re.match(r"^\s*(\d+)\s*([KMGkmg]?)\s*[Bb]?\s*$",
str(value))
if not m:
raise ValueError("CHV_MEM 格式非法:%r" % (value,))
mult = {"": 1, "K": 1024, "M": 1024 ** 2, "G": 1024 ** 3}
return int(m.group(1)) * mult[m.group(2).upper()]
class ChvConfig:
"""解析后的 CHV 配置:纯数据对象,字段名与
CloudHypervisorExecutor 原 __init__ 的属性名一一对应,
执行器 __init__ 直接把字段抄到 self 上,旧测试读属性
的方式全部不变。"""
def __init__(self, **fields):
for key, value in fields.items():
setattr(self, key, value)
def build_chv_config(params, env):
"""把显式构造参数(params,None=未给)与环境变量(env,
默认为 os.environ 的调用方传入 dict)解析成 ChvConfig。
显式参数只有 __init__ 本来就有的那些;max_vms、磁盘模式、
磁盘配额、egress、快照、预热池目标数本来就没有显式参数,
仍只走环境变量(与 #216 一致,不悄悄加新参数面)。"""
env = env if env is not None else {}
binary = params.get("binary") or env.get("CHV_BINARY") \
or "cloud-hypervisor"
kernel = params.get("kernel") or env.get("CHV_KERNEL")
rootfs = params.get("rootfs") or params.get("image") \
or env.get("CHV_ROOTFS") or env.get("CHV_IMAGE")
cpus = str(params.get("cpus") or env.get("CHV_CPU") or "2")
memory = params.get("memory") or env.get("CHV_MEM") or "2G"
state_dir = params.get("state_dir") or env.get("CHV_STATE_DIR") \
or "/tmp/chv-vms"
ssh_key = params.get("ssh_key") or env.get("CHV_SSH_KEY")
if params.get("ssh_user") is not None \
and params.get("ssh_user") == "":
raise ValueError("CHV_SSH_USER 不能为空字符串")
ssh_user = params.get("ssh_user") or env.get("CHV_SSH_USER") \
or "root"
if params.get("ssh_port") is not None:
raw_port = params.get("ssh_port")
else:
raw_port = env.get("CHV_SSH_PORT") or 2222
try:
ssh_port = int(raw_port)
except (TypeError, ValueError):
raise ValueError("CHV_SSH_PORT 必须是整数")
if not 1 <= ssh_port <= 65535:
raise ValueError("CHV_SSH_PORT 必须在 1-65535 之间")
guest_host_py = params.get("guest_host_py") or "/app/host.py"
cmdline = params.get("cmdline") or env.get("CHV_CMDLINE") \
or "console=hvc0 root=/dev/vda1 rw"
net = params.get("net") or env.get("CHV_NET")
net_json = params.get("net_json") or env.get("CHV_NET_JSON")
kvm_path = params.get("kvm_path") \
if params.get("kvm_path") is not None else "/dev/kvm"
cpuinfo_path = params.get("cpuinfo_path") \
if params.get("cpuinfo_path") is not None else "/proc/cpuinfo"
ch_remote = params.get("ch_remote") or env.get("CHV_REMOTE") \
or "ch-remote"
# P0-4 SSH 目标解析(feat-chv-fix):显式 CHV_SSH_HOST 优先;
# 其次取 VmConfig net 首个带 ip 的条目(静态 tap 客机 IP,
# 即选定的客机 IP 发现方案);两者皆无时只有显式
# CHV_SSH_FORWARD=1 才允许回环 127.0.0.1(运维宣称宿主
# NAT 已把该端口转发到客机 sshd),否则执行器在 wake/
# wait 时报 ssh_target_undeclared,绝不无条件假设回环能通。
# vsock 未选:需客内 agent + 宿主代理两套新代码(#216 已
# 否决);纯 NAT 方案未选:DNAT 是宿主防火墙外部状态,
# 执行器代码既不能创建也不能校验它,写进代码仍是假保证。
ssh_host = params.get("ssh_host") or env.get("CHV_SSH_HOST")
ssh_forward = env.get("CHV_SSH_FORWARD") == "1"
# P0-3:reuse 实验二次确认(名字即带实验含义)。
reuse_experiment = env.get(
"CHV_REUSE_SINGLE_TENANT_EXPERIMENT") == "1"
model = params.get("model")
try:
max_vms = int(env.get("CHV_MAX_VMS") or 4)
except ValueError:
raise ValueError("CHV_MAX_VMS 必须是整数")
disk_mode = env.get("CHV_DISK_MODE") or "copy"
if disk_mode not in ("copy", "reuse"):
raise ValueError(
"CHV_DISK_MODE 只能是 copy 或 reuse:%s" % disk_mode)
disk_gb_raw = env.get("CHV_DISK_GB")
disk_gb = float(disk_gb_raw) if disk_gb_raw else None
egress_proxy = env.get("CHV_EGRESS_PROXY")
allow_direct_egress = env.get("CHV_ALLOW_DIRECT_EGRESS") == "1"
host_key_mode = params.get("host_key_mode") \
or env.get("CHV_HOST_KEY_MODE") or "keyscan"
if host_key_mode not in ("provisioned", "keyscan"):
raise ValueError(
"CHV_HOST_KEY_MODE 只能是 provisioned 或 keyscan:%s"
% host_key_mode)
task_timeout_raw = env.get("CHV_TASK_TIMEOUT")
task_timeout = float(task_timeout_raw) \
if task_timeout_raw else None
snapshot_url = env.get("CHV_SNAPSHOT_URL")
try:
warm_pool_size = int(env.get("CHV_WARM_POOL_SIZE") or 0)
except ValueError:
raise ValueError("CHV_WARM_POOL_SIZE 必须是整数")
try:
api_timeout = float(
params.get("api_timeout")
if params.get("api_timeout") is not None
else env.get("CHV_API_TIMEOUT") or 30)
ready_timeout = float(
params.get("ready_timeout")
if params.get("ready_timeout") is not None
else env.get("CHV_READY_TIMEOUT") or 120)
ready_interval = float(
params.get("ready_interval")
if params.get("ready_interval") is not None
else env.get("CHV_READY_INTERVAL") or 5)
except ValueError:
raise ValueError("CHV_*_TIMEOUT/INTERVAL 必须是数字")
if ready_interval <= 0:
ready_interval = 5
return ChvConfig(
binary=binary, kernel=kernel, rootfs=rootfs, cpus=cpus,
memory=memory, state_dir=state_dir, ssh_key=ssh_key,
ssh_user=ssh_user, ssh_port=ssh_port,
guest_host_py=guest_host_py, cmdline=cmdline, net=net,
net_json=net_json, kvm_path=kvm_path,
cpuinfo_path=cpuinfo_path, ch_remote=ch_remote, model=model,
max_vms=max_vms, disk_mode=disk_mode, disk_gb=disk_gb,
ssh_host=ssh_host, ssh_forward=ssh_forward,
reuse_experiment=reuse_experiment,
egress_proxy=egress_proxy,
allow_direct_egress=allow_direct_egress,
host_key_mode=host_key_mode,
task_timeout=task_timeout, snapshot_url=snapshot_url,
warm_pool_size=warm_pool_size, api_timeout=api_timeout,
ready_timeout=ready_timeout, ready_interval=ready_interval)
+104
View File
@@ -0,0 +1,104 @@
"""Cloud Hypervisor 每任务磁盘供给(叶子模块)。
单任务单 VM 的文件系统独立性就靠这里:copy 模式每任务
复制一份基础镜像,两个任务绝不写同一文件;reuse 模式
直接返回基础镜像路径(仅单租户实验,生产拦截见执行器
preflight,须同时设 CHV_REUSE_SINGLE_TENANT_EXPERIMENT=1
并审计放行)。
feat-chv-fix(P1-5)配额口径:不再只比基础镜像大小。
check_disk_quota 改为三项:state_dir 已用总量 + 本次
拷贝(基础镜像大小)+ 宿主剩余空间必须容纳本次拷贝;
镜像不可读时不再静默跳过,报 disk_quota_unverified
(preflight 在 wake 路径上会先报 image_missing,这里
是直接调用者的第二道门)。provision_task_disk 对已存在
磁盘校验大小:与源不一致即重拷;源不可读即报失败,
绝不复用脏磁盘。
"""
import os
import shutil
from cloud.chv.errors import ChvFailure
def _dir_total_bytes(path):
total = 0
for root, _dirs, files in os.walk(path):
for name in files:
try:
total += os.path.getsize(os.path.join(root, name))
except OSError:
pass
return total
def check_disk_quota(rootfs, disk_gb, state_dir=None):
"""磁盘配额门(feat-chv-fix 口径):
- disk_gb 未设时不检查;
- 镜像不可读时报 disk_quota_unverified,不静默跳过;
- 镜像大小超过 disk_gb 即 disk_quota_exceeded;
- state_dir 已给时:已用总量 + 本次拷贝超过 disk_gb
即 disk_quota_exceeded;宿主剩余空间不足以容纳本次
拷贝即 disk_quota_exceeded(宿主余量校验)。
"""
if disk_gb is None or not rootfs:
return
try:
size = os.path.getsize(rootfs)
except OSError:
raise ChvFailure(
"disk_quota_unverified",
"磁盘镜像不可读,配额无法校验:%s" % rootfs)
if size > disk_gb * 1024 ** 3:
raise ChvFailure(
"disk_quota_exceeded",
"磁盘镜像 %d 字节超过配额 %.1f GB" % (size, disk_gb))
if state_dir and os.path.isdir(state_dir):
used = _dir_total_bytes(state_dir)
if used + size > disk_gb * 1024 ** 3:
raise ChvFailure(
"disk_quota_exceeded",
"磁盘总量 %d 字节 + 本次 %d 字节超过配额 %.1f GB"
% (used, size, disk_gb))
try:
free = shutil.disk_usage(state_dir).free
except OSError:
free = None
if free is not None and free < size:
raise ChvFailure(
"disk_quota_exceeded",
"宿主剩余空间 %d 字节不足以容纳本次磁盘拷贝 %d 字节"
% (free, size))
def provision_task_disk(state_dir, vm_name, rootfs, disk_mode):
"""每任务磁盘路径:copy 模式复制到
state_dir/<vm>/disk.raw;reuse 模式直接返回基础镜像路径。
feat-chv-fix:已存在磁盘须校验——大小与源不一致即
重拷(脏磁盘不复用);源不可读即 ChvFailure,绝不
静默返回旧磁盘。"""
if disk_mode == "reuse":
return rootfs
task_dir = os.path.join(state_dir, vm_name)
os.makedirs(task_dir, exist_ok=True)
task_disk = os.path.join(task_dir, "disk.raw")
if os.path.exists(task_disk):
try:
same = os.path.getsize(task_disk) == os.path.getsize(rootfs)
except OSError:
raise ChvFailure(
"disk_quota_unverified",
"任务磁盘或源镜像不可读,无法校验复用:%s" % task_disk)
if same:
return task_disk
os.remove(task_disk)
try:
shutil.copyfile(rootfs, task_disk)
except OSError as exc:
raise ChvFailure(
"disk_quota_unverified",
"任务磁盘拷贝失败:%s" % exc)
return task_disk
+12
View File
@@ -0,0 +1,12 @@
"""Cloud Hypervisor 执行器的分类失败(叶子模块,不依赖
cloud.sandbox,避免循环导入)。"""
class ChvFailure(RuntimeError):
"""Cloud Hypervisor 分类失败:RuntimeError 的子类(worker
现有 except Exception 路径照常捕获),另带 kind 字段供
审计与失败分类用,绝不笼统只写 failed。kind 取值见
CloudHypervisorExecutor.preflight / wake_vm 的注释。"""
def __init__(self, kind, message):
super().__init__("chv_failure:%s: %s" % (kind, message))
self.kind = kind
File diff suppressed because it is too large. Load diff
+180
View File
@@ -0,0 +1,180 @@
"""按 VM 生成的 SSH host key 预置(替代 TOFU 的强方案)。
安全模型(补缺口轨,替代 #221 的 ssh-keyscan 首次捕获):
- 绝不用镜像固定同一把 host key:所有 VM 共用一把 key 时,
一台 VM 泄漏即全网可冒充,属安全事故。
- 每 VM 一次性生成:宿主在 VMM 启动前为该 VM 生成一对
ed25519 host key(私钥只活在该 VM 的 seed 里,随 VM
销毁而删,绝不跨 VM 复用、绝不写进镜像、绝不进已知
主机文件之外的共享位置)。
- 公钥预置钉死:生成后立即把公钥写进该 VM 的
state_dir/<vm>/known_hosts(格式 [127.0.0.1]:<port> ...),
执行器在 VM 启动前就已钉死,启动后 SSH 直接用该文件
校验,换 key 即拒绝——不再靠启动后 ssh-keyscan 抓到的
key 来“事后钉死”。
- 私钥注入通道(选 cloud-init NoCloud,写清唯一通道):
seed 目录 state_dir/<vm>/seed/ 含 meta-data(instance-id
即 VM 名)、user-data(write_files 写 /etc/ssh/ 的 host
key 对并重启 sshd)。客机镜像硬性要求(见
chv-guest/README.md §5):必须装 cloud-init 且支持
NoCloud seed、首启时先应用 seed 再启动 sshd、且不得在
seed 应用前/后无条件重新生成 host key(否则预置即失效)。
seed 磁盘/目录是 per-VM 敏感物,VM 关闭/回收时必须删。
- 回落边界:ssh-keyscan 首次捕获只作弱回落
(CHV_HOST_KEY_MODE=keyscan,且文档标为弱方案):
它只能防首次之后的替换,不能验证首次本身。生产部署
必须用 provisioned 模式;provisioned 模式下 seed 工具
或 keygen 不可用时直接 fail-fast,绝不静默回落 keyscan。
本模块只做纯函数与文件操作,不跑 VM、不联网、不含
任何私钥值(测试用假 runner 时私钥由测试自己写文件)。
"""
import os
def host_key_paths(state_dir, vm_name):
"""该 VM 的 host key 文件路径(私钥/公钥/known_hosts)。"""
base = os.path.join(state_dir, vm_name)
return {
"private": os.path.join(base, "ssh_host_ed25519_key"),
"public": os.path.join(base, "ssh_host_ed25519_key.pub"),
"known_hosts": os.path.join(base, "known_hosts"),
"seed_dir": os.path.join(base, "seed"),
}
def known_hosts_line(port, public_key_text, host="127.0.0.1"):
"""公钥文件内容转 known_hosts 行:只取前两列(类型+数据)。
公钥文件形如 'ssh-ed25519 AAAA... [comment]',known_hosts
行形如 '[<host>]:<port> ssh-ed25519 AAAA...'(host 缺省
回环,仅作兼容;客机 IP 方案下 host 应为解析出的客机 IP)。
非法内容 ValueError,绝不把陌生行原样写进文件。"""
parts = (public_key_text or "").strip().split()
if len(parts) < 2 or not parts[0].startswith("ssh-"):
raise ValueError("host 公钥格式非法")
return "[%s]:%s %s %s" % (host, port, parts[0], parts[1])
def seed_disk_path(state_dir, vm_name):
"""该 VM 的 NoCloud seed 磁盘镜像路径(CIDATA 卷标 ISO)。"""
return os.path.join(state_dir, vm_name, "seed.iso")
def build_seed_disk(seed_dir, seed_disk, runner=None):
"""把 seed 目录做成 CIDATA 卷标 ISO(feat-chv-fix,P0-1 的
注入载体):genisoimage -volid CIDATA -joliet -rock。
runner 不给时用 subprocess;测试可注入假 runner(假
runner 负责把 seed_disk 文件写出来)。工具失败、返回码
非 0、或产物不存在/为空时一律 RuntimeError,调用方
(wake_vm provisioned 路径)fail-fast,绝不带着“seed
目录已写但未挂进 VmConfig”的 VM 继续启动。seed 目录
本身只是中间物,不再被当成注入通道。"""
cmd = ["genisoimage", "-output", seed_disk, "-volid", "CIDATA",
"-joliet", "-rock", seed_dir]
if runner is not None:
result = runner(cmd)
rc = getattr(result, "returncode", 0)
if rc not in (0, None):
raise RuntimeError("genisoimage 失败:returncode=%s" % rc)
else:
import subprocess
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError("genisoimage 失败:%s" % result.stderr)
if not os.path.isfile(seed_disk) or os.path.getsize(seed_disk) == 0:
raise RuntimeError("seed 磁盘未生成:%s" % seed_disk)
return seed_disk
def build_seed_user_data(private_key_text, public_key_text):
"""cloud-init user-data:把 host key 对写进客机 /etc/ssh。
注意:user-data 本身含私钥,只写进 per-VM seed 目录
(0600),随 VM 销毁而删,绝不进镜像、绝不进日志、
绝不进任务 prompt 落库。"""
if not private_key_text or not public_key_text:
raise ValueError("host key 对缺失,无法生成 seed")
lines = [
"#cloud-config",
"ssh_deletekeys: false",
"write_files:",
" - path: /etc/ssh/ssh_host_ed25519_key",
" permissions: '0600'",
" owner: root:root",
" content: |",
]
for line in private_key_text.rstrip("\n").split("\n"):
lines.append(" " + line)
lines += [
" - path: /etc/ssh/ssh_host_ed25519_key.pub",
" permissions: '0644'",
" owner: root:root",
" content: |",
]
for line in public_key_text.rstrip("\n").split("\n"):
lines.append(" " + line)
lines += [
"runcmd:",
" - chmod 600 /etc/ssh/ssh_host_ed25519_key",
" - systemctl restart ssh || service ssh restart || true",
]
return "\n".join(lines) + "\n"
def write_seed(seed_dir, vm_name, private_key_text, public_key_text):
"""写 seed 目录:meta-data + user-data(均为 0600)。
返回 seed 目录路径。seed 目录本身不含已知主机之外的
共享数据,调用方(执行器关闭/回收)负责删除它。"""
os.makedirs(seed_dir, exist_ok=True)
meta_path = os.path.join(seed_dir, "meta-data")
user_path = os.path.join(seed_dir, "user-data")
with open(meta_path, "w") as fh:
fh.write("instance-id: %s\nlocal-hostname: %s\n"
% (vm_name, vm_name))
os.chmod(meta_path, 0o600)
with open(user_path, "w") as fh:
fh.write(build_seed_user_data(private_key_text, public_key_text))
os.chmod(user_path, 0o600)
return seed_dir
def generate_host_keypair(state_dir, vm_name, runner=None,
keygen="ssh-keygen"):
"""为该 VM 生成(或复用本 VM 已有的)host key 对。
同一 VM 重启/恢复时复用已有对(否则钉死的 known_hosts
立即失效);不同 VM 的 state_dir/<vm> 不同,绝不共用。
runner 不给时用 subprocess,测试可注入假 runner(假
runner 负责把私钥/公钥文件写出来)。keygen 失败即
RuntimeError,调用方不得回落 keyscan(provisioned
模式)或必须显式标弱回落(keyscan 模式,见执行器)。"""
paths = host_key_paths(state_dir, vm_name)
if (os.path.exists(paths["private"])
and os.path.exists(paths["public"])):
return paths
base = os.path.dirname(paths["private"])
os.makedirs(base, exist_ok=True)
cmd = [keygen, "-t", "ed25519", "-N", "",
"-f", paths["private"]]
if runner is not None:
result = runner(cmd)
rc = getattr(result, "returncode", 0)
if rc not in (0, None):
raise RuntimeError("ssh-keygen 失败:returncode=%s" % rc)
else:
import subprocess
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError("ssh-keygen 失败:%s" % result.stderr)
if not (os.path.exists(paths["private"])
and os.path.exists(paths["public"])):
raise RuntimeError("ssh-keygen 未生成 host key 对")
try:
os.chmod(paths["private"], 0o600)
except OSError:
pass
return paths
+280
View File
@@ -0,0 +1,280 @@
"""快照预热池 WarmPool(含 state_dir registry 持久化与孤儿清理)。
隔离红线(与 #216 一致,绝不妥协):预热条目是一次性的
干净快照 VM,acquire 消耗掉它,release 一律销毁,绝不把
一个任务用过的 VM(带任务文件、带 agent 痕迹)翻出来给
下一个任务/租户复用;池子空了就回落冷启动(wake_vm),
绝不因为池空就降级 Docker/Local。
Registry(本轨新增):
- 文件:state_dir/warm-pool.json(registry_path 可覆盖),
内容为 {<合成名>: {"state", "vm", "task_id", "snapshot_url"}}。
每次 prewarm/acquire/release 后立即落盘(临时文件 + 原子
rename),worker 崩溃后重启仍能发现上一世的条目。
- 载入时绝不复用:新池实例从 registry 读到的条目一律标为
orphan(不计入 warm_count,acquire 绝不发给任务),因为
旧 VMM 进程已随旧 worker 消失,无法验证它还是当初那个
干净快照 VM。它们只能走 reconcile_orphans() 清理。
- reconcile_orphans():worker 启动/清理时的显式入口,对
每个 orphan/assigned 条目尽力调 executor.shutdown_vm
(清 socket 与残留登记),条目清掉并落盘,返回已清理的
VM 名列表。registry 里没有的残留 socket 仍由执行器的
cleanup_stale_vms() 扫 state_dir 处理,两者互补,不互相
替代;reconcile 绝不在构造时自动执行(同一 state_dir 只
能有一个活池,构造时自动清理会误杀另一个活池的 VM)。
- 损坏 fail-safe:registry 不是合法 JSON / 顶层不是 dict 时,
整个文件 quarantine 成 <path>.corrupt(尽力而为),池子
从空开始,绝不用损坏数据复用 VM;单条目结构非法时只
丢该条(记 registry_corrupt_entries),不连坐其它条目。
registry 写失败时只记日志警告、不炸任务流(池子内存态
仍可用,重启后孤儿改由 socket 扫描兜底)。
"""
import json
import logging
import os
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
_REGISTRY_FILENAME = "warm-pool.json"
class WarmPool:
"""快照预热池(快速启动设计,接口与状态机在代码里)。
目标:冷启动(preflight + 起 VMM + create + boot + SSH
就绪)是慢路径;预热池提前把干净 VM 起好并暂停,任务
来时 acquire 直接 resume,省掉冷启动的大头。
状态机:cold(未预热)→ prewarming(wake 中)→ warm
(已暂停待分配)→ assigned(已分配给某任务)→
running(已 resume)→ recycled/destroyed(release 后)。
prewarm 的实现:对每个条目调 executor.wake_vm(冷启动)
再 pause_vm,得到 paused 的干净 VM;mock 单测里 wake/
pause 均可注入假实现,真机上这一步需要 KVM 宿主(见
就绪清单“需 KVM 宿主实测”)。snapshot_url 未设时
prewarm 返回 [](无基准快照可预热,不是静默造快照)。
"""
def __init__(self, executor, size=None, snapshot_url=None,
registry_path=None):
self.executor = executor
self.size = int(size if size is not None
else getattr(executor, "warm_pool_size", 0))
self.snapshot_url = snapshot_url \
if snapshot_url is not None \
else getattr(executor, "snapshot_url", None)
self._entries = {} # 合成名 -> {"state", "vm", "task", ...}
self.registry_path = registry_path
if self.registry_path is None:
state_dir = getattr(executor, "state_dir", None)
if state_dir:
self.registry_path = os.path.join(
state_dir, _REGISTRY_FILENAME)
self.registry_corrupt = False
self.registry_corrupt_entries = 0
self._load_registry()
# -- registry ------------------------------------------------------
def _load_registry(self):
"""载入 registry:读到的条目一律标 orphan(见模块
docstring,绝不直接当 warm 发给任务)。无文件时为空
池;损坏时 quarantine 并从空开始。"""
if not self.registry_path:
return
try:
with open(self.registry_path, "r") as fh:
data = json.load(fh)
except FileNotFoundError:
return
except (OSError, ValueError):
self.registry_corrupt = True
self._quarantine_registry()
return
if not isinstance(data, dict):
self.registry_corrupt = True
self._quarantine_registry()
return
for name, entry in data.items():
if not isinstance(entry, dict) \
or not entry.get("vm") \
or entry.get("state") not in ("warm", "assigned"):
self.registry_corrupt_entries += 1
continue
self._entries[name] = {
"state": "orphan",
"vm": entry["vm"],
"task": {"id": entry.get("task_id") or name},
"snapshot_url": entry.get("snapshot_url"),
}
def _quarantine_registry(self):
"""把损坏的 registry 文件挪到 <path>.corrupt 旁(尽力
而为),原路径不再留损坏数据给下一次载入。"""
if not self.registry_path:
return
try:
os.replace(self.registry_path,
self.registry_path + ".corrupt")
except OSError:
_LOGGER.warning("chv_pool_registry_quarantine_failed "
"path=%s", self.registry_path)
def _save_registry(self):
"""落盘当前条目(只写 warm/assigned 态;orphan 是
待清理的上一世条目,reconcile 后即删,不写回)。
写失败只记日志,不炸任务流。"""
if not self.registry_path:
return
data = {}
for name, entry in self._entries.items():
if entry["state"] not in ("warm", "assigned"):
continue
task = entry.get("task") or {}
task_id = task.get("id") if isinstance(task, dict) \
else None
data[name] = {
"state": entry["state"],
"vm": entry.get("vm"),
"task_id": task_id,
"snapshot_url": entry.get("snapshot_url"),
}
try:
directory = os.path.dirname(self.registry_path)
if directory:
os.makedirs(directory, exist_ok=True)
tmp_path = self.registry_path + ".tmp"
with open(tmp_path, "w") as fh:
json.dump(data, fh)
os.replace(tmp_path, self.registry_path)
except OSError:
_LOGGER.warning("chv_pool_registry_save_failed path=%s",
self.registry_path)
def reconcile_orphans(self):
"""清理上一世残留条目:对每个 orphan/assigned 条目
尽力 shutdown_vm(清 socket 与残留登记),条目清掉
并落盘,返回已清理的 VM 名列表。worker 启动/清理时
显式调它(见模块 docstring 的单活池假设)。"""
cleaned = []
for name, entry in list(self._entries.items()):
if entry["state"] not in ("orphan", "assigned"):
continue
vm = entry.get("vm") or name
try:
self.executor.shutdown_vm(vm)
except Exception:
pass
self._entries.pop(name, None)
cleaned.append(vm)
if cleaned:
self._save_registry()
try:
self.executor._emit(
"warm_pool_reconcile", {"id": None},
"cleaned=%s" % ",".join(cleaned))
except Exception:
pass
return cleaned
# -- 池操作 ----------------------------------------------------------
def warm_count(self):
return sum(1 for e in self._entries.values()
if e["state"] == "warm")
def orphan_count(self):
"""上一世残留条目数(待 reconcile,不可分配)。"""
return sum(1 for e in self._entries.values()
if e["state"] in ("orphan", "assigned"))
def prewarm(self, count=None):
"""预热到目标数:已有 warm 条目不重复建;每个新条目
用一个合成任务名 wake + pause(合成任务 id 为
warm-<序号>,绝不与真实任务 id 撞名)。返回新增的
warm VM 名列表。"""
if self.snapshot_url is None:
return []
target = int(count) if count is not None else self.size
added = []
i = 0
while self.warm_count() < target:
i += 1
name = "warm-%d" % i
if name in self._entries:
continue
task = {"id": name}
self._entries[name] = {"state": "prewarming",
"task": task}
try:
self.executor.wake_vm(task)
self.executor.pause_vm(task)
except Exception:
self._entries.pop(name, None)
raise
vm = self.executor.vm_name(task)
self._entries[name] = {
"state": "warm", "task": task, "vm": vm,
"snapshot_url": self.snapshot_url}
added.append(name)
if added:
self._save_registry()
return added
def acquire(self, task):
"""分配一个预热 VM 给任务(feat-chv-fix,P1-3 接线):
有 warm 条目时先调 executor.resume_vm() 把暂停的 VM
真正唤醒(resume 失败即条目作废、继续找下一个,
绝不把 paused 的 VM 当 running 发给任务),再标
assigned 并返回 mode=warm;resume 全部失败或无 warm
条目时返回 mode=cold(调用方回落 wake_vm 冷启动)。
注意:本方法只解决“acquire 不再悬空 resume 责任”
这一半;worker dispatch 前查池的另一半仍未接线
(见执行器/就绪文档的“未接线缺口”标注),预热收益
在生产路径上仍不是已交付特性。"""
for name, entry in list(self._entries.items()):
if entry["state"] != "warm":
continue
resume = getattr(self.executor, "resume_vm", None)
if resume is not None:
try:
resume(entry.get("task") or task)
except Exception:
self._entries.pop(name, None)
self._save_registry()
continue
# 无 resume_vm 的鸭子型执行器(旧测试替身):
# 保持旧 assigned 语义,不因此丢弃条目;真实
# CloudHypervisorExecutor 一律有 resume_vm,走
# 上面分支,责任不悬空。
entry["state"] = "assigned"
entry["task"] = task
self._save_registry()
try:
self.executor._emit(
"warm_pool_acquire", task,
"vm=%s" % name)
except Exception:
pass
return {"mode": "warm", "vm": name,
"pool_task": entry["task"]}
return {"mode": "cold", "vm": None, "pool_task": None}
def release(self, task, pool_vm=None):
"""释放:无论 warm 还是 cold 路径,VM 一律销毁
(executor.shutdown_vm),池条目清掉;绝不把用过
的 VM 放回 warm 池。返回 True 表示确实销毁了一个
条目或 VM。"""
destroyed = False
if pool_vm and pool_vm in self._entries:
self._entries.pop(pool_vm, None)
destroyed = True
try:
if self.executor.shutdown_vm(task):
destroyed = True
except Exception:
pass
self._save_registry()
self.executor._emit(
"warm_pool_release", task,
"vm=%s destroyed=%s" % (pool_vm, destroyed))
return destroyed
+107
View File
@@ -0,0 +1,107 @@
"""Cloud Hypervisor 客机 SSH 传输(叶子模块,不依赖
cloud.sandbox,避免循环导入)。
安全模型(本轨的核心变化,替代 #216 的
StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null):
- 每 VM 一个独立 known_hosts 文件:
state_dir/<vm 名>/known_hosts(与每任务磁盘同目录,
任务销毁时不自动删——known_hosts 留在宿主 state_dir 里,
供事后审计该 VM 当时钉死的 key;VM 名本身已含任务 id,
不会串到别的 VM 上)。
- 首次信任(TOFU,边界必须写清,不假装是强验证):
首次启动时客机 host key 未知,执行器用 ssh-keyscan 经
宿主转发端口(127.0.0.1:<port>,只走本机回环)抓一次
当时呈现的 key,写入该 VM 的 known_hosts 文件;此后
同一 VM 的每次 SSH 都用 StrictHostKeyChecking=yes +
该文件校验,VM 被替换 / 中间人换 key 时 SSH 直接拒绝,
不再静默接受。首次捕获本身不验证“这台客机就是我们要
的那台”——它只防首次之后的替换;更强的方案是镜像构建
/cloud-init 时把客机 host key 预置进 known_hosts(未来
工作,见 docs/chv-production-readiness.md 的待办),本轨
在 mock 测试里只能验证“捕获→写文件→后续命令带钉死
选项”的管线,绝不声称 mock 验证了首次密钥的真实性。
- 密钥红线不变:argv 里只有 key 文件路径,绝无 key 值;
known_hosts 文件只含 host key 公钥行,不含任何私钥。
"""
import os
def known_hosts_path(state_dir, vm_name):
"""该 VM 的 known_hosts 文件路径:state_dir/<vm>/known_hosts。"""
return os.path.join(state_dir, vm_name, "known_hosts")
def build_ssh_command(ssh_port, ssh_key, ssh_user, egress_proxy,
known_hosts_file, inner_cmd, ssh_host="127.0.0.1",
extra_env=None):
"""构造 SSH 命令:ssh -p <端口> -i <key 文件> + 钉死选项
+ user@<ssh_host> + 客内命令。
feat-chv-fix(P0-4):ssh_host 不再在传输层写死回环,
由执行器解析传入(显式 CHV_SSH_HOST / net 客机 IP /
显式转发宣称下的回环);本函数缺省回环仅为旧调用点
兼容,执行器路径绝不依赖该缺省。
extra_env(feat-chv-fix,P1-1 网关注入):经同一 env
前缀进客机环境(与 egress 代理前缀同款机制),不进
镜像/代码/prompt;值本身只出现在宿主侧 ssh argv(与
worker 同一信任边界),执行器绝不把完整命令记入日志。
egress_proxy 已设时客内命令前加 env 前缀(代理地址本身
不是密钥;客机出口只走该白名单代理)。"""
inner = list(inner_cmd)
env_assignments = []
if extra_env:
for key in sorted(extra_env):
env_assignments.append("%s=%s" % (key, extra_env[key]))
if egress_proxy:
env_assignments += [
"HTTPS_PROXY=%s" % egress_proxy,
"HTTP_PROXY=%s" % egress_proxy,
"NO_PROXY=localhost,127.0.0.1"]
if env_assignments:
inner = ["env"] + env_assignments + inner
return ["ssh", "-p", str(ssh_port),
"-i", ssh_key,
"-o", "StrictHostKeyChecking=yes",
"-o", "UserKnownHostsFile=%s" % known_hosts_file,
"-o", "BatchMode=yes",
"%s@%s" % (ssh_user, ssh_host)] + inner
def parse_keyscan_output(text, host="127.0.0.1"):
"""从 ssh-keyscan 的 stdout 里挑出目标主机的 host key
行:形如 "[<host>]:<port> ssh-ed25519 AAAA..."(-p 时
的标准写法)或 "<host> ssh-rsa AAAA..."。其它主机行、
注释行、空行一律丢掉,绝不把陌生主机的 key 写进文件。
host 缺省回环仅为旧调用点兼容;客机 IP 方案下应传
解析出的客机 IP。"""
kept = []
for line in (text or "").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split()
if len(parts) < 3:
continue
line_host, keytype = parts[0], parts[1]
if line_host != host \
and not line_host.startswith("[%s]:" % host):
continue
if not keytype.startswith("ssh-"):
continue
kept.append(line)
return kept
def write_known_hosts(path, key_lines):
"""原子写 known_hosts:先写同目录临时文件再 rename,
文件权限 0600(只含公钥行,仍按敏感文件对待)。"""
directory = os.path.dirname(path)
if directory:
os.makedirs(directory, exist_ok=True)
tmp_path = path + ".tmp"
with open(tmp_path, "w") as fh:
fh.write("\n".join(key_lines) + "\n")
os.chmod(tmp_path, 0o600)
os.replace(tmp_path, path)
@@ -78,12 +78,13 @@ export default function DispatchSection() {
{ctx.envs.map((e) => <option key={e.id} value={e.id} disabled={e.status !== "ready"}>{e.name}({e.status})</option>)}
</select>
<select aria-label="执行器" value={executor} onChange={(e) => setExecutor(e.target.value)}>
<option value="docker">docker(默认,多用户下隔离最强)</option><option value="local">local</option><option value="cloud">cloud</option>
<option value="docker">docker(默认,多用户下隔离最强)</option><option value="local">local</option><option value="cloud">cloud</option><option value="cloud-hypervisor">cloud-hypervisor(实验,默认关闭)</option>
</select>
<label><input type="checkbox" checked={goalMode} onChange={(e) => setGoalMode(e.target.checked)} /> goal 模式</label>
<label><input type="checkbox" checked={goalInvincible} onChange={(e) => setGoalInvincible(e.target.checked)} /> goal 模式无敌版</label>
<label><input type="checkbox" checked={planMode} onChange={(e) => setPlanMode(e.target.checked)} /> 计划模式(只读)</label>
</Bar>
{executor === "cloud-hypervisor" ? <div className="muted">实验执行器,默认关闭:需后端已开启 CHV worker 且宿主有 KVM,否则任务无人认领、一直排队;带附件时建任务即被后端拒绝(错误文案原样显示在下方)。前端只传 executor 值,不绕过后端白名单;默认仍是 docker。</div> : null}
<textarea className="prompt stacked" placeholder="任务 prompt(必填)" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
<textarea className="coord stacked" placeholder="协调员子任务 JSON(可选),留空为普通任务" value={coordParts} onChange={(e) => setCoordParts(e.target.value)} />
<div className="muted">协调员格式:[{"{"}"name":"后端","prompt":"做后端"{"}"},{"{"}"name":"前端","prompt":"做前端"{"}"}];提交后 worker 并行建子任务、等齐、合流汇总。不能与 goal/计划模式同用。协调员与 goal/计划互斥:勾了 goal 或计划模式时不要填协调员 JSON,提交前会校验并拦截。子任务费用记父任务创建人账;父任务创建时按分工数预留配额,余额不足时整个父任务被挡下。</div>
@@ -31,10 +31,11 @@ export default function EnvSection() {
<input type="text" placeholder="新环境名" value={name} onChange={(e) => setName(e.target.value)} />
<input type="text" placeholder="分支(可选,空=默认分支)" value={branch} onChange={(e) => setBranch(e.target.value)} />
<select aria-label="新环境执行器" value={executor} onChange={(e) => setExecutor(e.target.value)}>
<option value="">执行器:不限</option><option value="local">local</option><option value="docker">docker</option>
<option value="">执行器:不限</option><option value="local">local</option><option value="docker">docker</option><option value="cloud-hypervisor">cloud-hypervisor(实验,默认关闭)</option>
</select>
<button onClick={async () => { await ctx.createEnv(name, branch, executor); setName(""); setBranch(""); }}>新建环境</button>
</Bar>
{executor === "cloud-hypervisor" ? <div className="muted">实验执行器,默认关闭:环境创建白名单同样允许 cloud-hypervisor(后端环境接口 allowlist:local / docker / cloud-hypervisor,不含 cloud);仍需后端有 KVM 宿主上的 CHV worker 才会认领,否则任务排队。前端只传值,不绕过后端白名单。</div> : null}
<span className="muted">{ctx.envMsg}</span>
</Card>
);
@@ -126,7 +126,7 @@ export default function TaskDetailPanel() {
<div><dt>任务</dt><dd>{t.id}</dd></div>
<div><dt>turn</dt><dd>{shortId(t.turn_id)}</dd></div>
<div><dt>root turn</dt><dd>{shortId(t.root_turn_id)}</dd></div>
<div><dt>executor</dt><dd>{t.executor || ""}</dd></div>
<div><dt>executor</dt><dd>{t.executor || ""}{t.executor === "cloud-hypervisor" ? "(实验执行器,默认关闭;仅展示,无 VM 控制按钮)" : ""}</dd></div>
<div><dt>主机</dt><dd>{t.worktree_host || ""}</dd></div>
<div><dt>模型</dt><dd>{t.model || "默认"}</dd></div>
<div><dt>强度</dt><dd>{t.effort || "默认"}</dd></div>
@@ -38,7 +38,7 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) {
{["queued", "running", "completed", "failed", "interrupted", "merged", "merge-failed"].map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<select aria-label="执行器筛选" value={ctx.executorFilter} onChange={(e) => ctx.setExecutorFilter(e.target.value)}>
<option value="">执行器:全部</option><option value="local">local</option><option value="docker">docker</option><option value="cloud">cloud</option>
<option value="">执行器:全部</option><option value="local">local</option><option value="docker">docker</option><option value="cloud">cloud</option><option value="cloud-hypervisor">cloud-hypervisor(实验)</option>
</select>
</div>
<div className="bar">
+15 -15
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Codex 命令台</title>
<script type="module" crossorigin>(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const g of document.querySelectorAll('link[rel="modulepreload"]'))f(g);new MutationObserver(g=>{for(const C of g)if(C.type==="childList")for(const A of C.addedNodes)A.tagName==="LINK"&&A.rel==="modulepreload"&&f(A)}).observe(document,{childList:!0,subtree:!0});function h(g){const C={};return g.integrity&&(C.integrity=g.integrity),g.referrerPolicy&&(C.referrerPolicy=g.referrerPolicy),g.crossOrigin==="use-credentials"?C.credentials="include":g.crossOrigin==="anonymous"?C.credentials="omit":C.credentials="same-origin",C}function f(g){if(g.ep)return;g.ep=!0;const C=h(g);fetch(g.href,C)}})();function Kh(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var no={exports:{}},ui={};/**
<script type="module" crossorigin>(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const g of document.querySelectorAll('link[rel="modulepreload"]'))f(g);new MutationObserver(g=>{for(const N of g)if(N.type==="childList")for(const A of N.addedNodes)A.tagName==="LINK"&&A.rel==="modulepreload"&&f(A)}).observe(document,{childList:!0,subtree:!0});function h(g){const N={};return g.integrity&&(N.integrity=g.integrity),g.referrerPolicy&&(N.referrerPolicy=g.referrerPolicy),g.crossOrigin==="use-credentials"?N.credentials="include":g.crossOrigin==="anonymous"?N.credentials="omit":N.credentials="same-origin",N}function f(g){if(g.ep)return;g.ep=!0;const N=h(g);fetch(g.href,N)}})();function Kh(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var no={exports:{}},ui={};/**
* @license React
* react-jsx-runtime.production.js
*
@@ -12,7 +12,7 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Rh;function ky(){if(Rh)return ui;Rh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function h(f,g,C){var A=null;if(C!==void 0&&(A=""+C),g.key!==void 0&&(A=""+g.key),"key"in g){C={};for(var S in g)S!=="key"&&(C[S]=g[S])}else C=g;return g=C.ref,{$$typeof:i,type:f,key:A,ref:g!==void 0?g:null,props:C}}return ui.Fragment=r,ui.jsx=h,ui.jsxs=h,ui}var Dh;function By(){return Dh||(Dh=1,no.exports=ky()),no.exports}var c=By(),uo={exports:{}},yt={};/**
*/var Rh;function ky(){if(Rh)return ui;Rh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function h(f,g,N){var A=null;if(N!==void 0&&(A=""+N),g.key!==void 0&&(A=""+g.key),"key"in g){N={};for(var S in g)S!=="key"&&(N[S]=g[S])}else N=g;return g=N.ref,{$$typeof:i,type:f,key:A,ref:g!==void 0?g:null,props:N}}return ui.Fragment=r,ui.jsx=h,ui.jsxs=h,ui}var Dh;function By(){return Dh||(Dh=1,no.exports=ky()),no.exports}var c=By(),uo={exports:{}},yt={};/**
* @license React
* react.production.js
*
@@ -20,7 +20,7 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Uh;function qy(){if(Uh)return yt;Uh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),h=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),g=Symbol.for("react.profiler"),C=Symbol.for("react.consumer"),A=Symbol.for("react.context"),S=Symbol.for("react.forward_ref"),H=Symbol.for("react.suspense"),R=Symbol.for("react.memo"),Y=Symbol.for("react.lazy"),x=Symbol.for("react.activity"),k=Symbol.for("react.view_transition"),W=Symbol.iterator;function V(y){return y===null||typeof y!="object"?null:(y=W&&y[W]||y["@@iterator"],typeof y=="function"?y:null)}var I={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},X=Object.assign,ht={};function gt(y,D,ct){this.props=y,this.context=D,this.refs=ht,this.updater=ct||I}gt.prototype.isReactComponent={},gt.prototype.setState=function(y,D){if(typeof y!="object"&&typeof y!="function"&&y!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,y,D,"setState")},gt.prototype.forceUpdate=function(y){this.updater.enqueueForceUpdate(this,y,"forceUpdate")};function xt(){}xt.prototype=gt.prototype;function Z(y,D,ct){this.props=y,this.context=D,this.refs=ht,this.updater=ct||I}var Tt=Z.prototype=new xt;Tt.constructor=Z,X(Tt,gt.prototype),Tt.isPureReactComponent=!0;var w=Array.isArray;function Q(){}var et={H:null,A:null,T:null,S:null},zt=Object.prototype.hasOwnProperty;function Bt(y,D,ct){var it=ct.ref;return{$$typeof:i,type:y,key:D,ref:it!==void 0?it:null,props:ct}}function vt(y,D){return Bt(y.type,D,y.props)}function wt(y){return typeof y=="object"&&y!==null&&y.$$typeof===i}function q(y){var D={"=":"=0",":":"=2"};return"$"+y.replace(/[=:]/g,function(ct){return D[ct]})}var ot=/\/+/g;function rt(y,D){return typeof y=="object"&&y!==null&&y.key!=null?q(""+y.key):D.toString(36)}function G(y){switch(y.status){case"fulfilled":return y.value;case"rejected":throw y.reason;default:switch(typeof y.status=="string"?y.then(Q,Q):(y.status="pending",y.then(function(D){y.status==="pending"&&(y.status="fulfilled",y.value=D)},function(D){y.status==="pending"&&(y.status="rejected",y.reason=D)})),y.status){case"fulfilled":return y.value;case"rejected":throw y.reason}}throw y}function N(y,D,ct,it,Nt){var _t=typeof y;(_t==="undefined"||_t==="boolean")&&(y=null);var Rt=!1;if(y===null)Rt=!0;else switch(_t){case"bigint":case"string":case"number":Rt=!0;break;case"object":switch(y.$$typeof){case i:case r:Rt=!0;break;case Y:return Rt=y._init,N(Rt(y._payload),D,ct,it,Nt)}}if(Rt)return Nt=Nt(y),Rt=it===""?"."+rt(y,0):it,w(Nt)?(ct="",Rt!=null&&(ct=Rt.replace(ot,"$&/")+"/"),N(Nt,D,ct,"",function(ce){return ce})):Nt!=null&&(wt(Nt)&&(Nt=vt(Nt,ct+(Nt.key==null||y&&y.key===Nt.key?"":(""+Nt.key).replace(ot,"$&/")+"/")+Rt)),D.push(Nt)),1;Rt=0;var P=it===""?".":it+":";if(w(y))for(var dt=0;dt<y.length;dt++)it=y[dt],_t=P+rt(it,dt),Rt+=N(it,D,ct,_t,Nt);else if(dt=V(y),typeof dt=="function")for(y=dt.call(y),dt=0;!(it=y.next()).done;)it=it.value,_t=P+rt(it,dt++),Rt+=N(it,D,ct,_t,Nt);else if(_t==="object"){if(typeof y.then=="function")return N(G(y),D,ct,it,Nt);throw D=String(y),Error("Objects are not valid as a React child (found: "+(D==="[object Object]"?"object with keys {"+Object.keys(y).join(", ")+"}":D)+"). If you meant to render a collection of children, use an array instead.")}return Rt}function ut(y,D,ct){if(y==null)return y;var it=[],Nt=0;return N(y,it,"","",function(_t){return D.call(ct,_t,Nt++)}),it}function St(y){if(y._status===-1){var D=y._result,ct=D();ct.then(function(it){(y._status===0||y._status===-1)&&(y._status=1,y._result=it,ct.status===void 0&&(ct.status="fulfilled",ct.value=it))},function(it){(y._status===0||y._status===-1)&&(y._status=2,y._result=it,ct.status===void 0&&(ct.status="rejected",ct.reason=it))}),y._status===-1&&(y._status=0,y._result=ct)}if(y._status===1)return y._result.default;throw y._result}var pt=typeof reportError=="function"?reportError:function(y){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var D=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof y=="object"&&y!==null&&typeof y.message=="string"?String(y.message):String(y),error:y});if(!window.dispatchEvent(D))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",y);return}console.error(y)};function ge(y){var D=et.T,ct={};ct.types=D!==null?D.types:null,et.T=ct;try{var it=y(),Nt=et.S;Nt!==null&&Nt(ct,it),typeof it=="object"&&it!==null&&typeof it.then=="function"&&it.then(Q,pt)}catch(_t){pt(_t)}finally{D!==null&&ct.types!==null&&(D.types=ct.types),et.T=D}}function we(y){var D=et.T;if(D!==null){var ct=D.types;ct===null?D.types=[y]:ct.indexOf(y)===-1&&ct.push(y)}else ge(we.bind(null,y))}var Ce={map:ut,forEach:function(y,D,ct){ut(y,function(){D.apply(this,arguments)},ct)},count:function(y){var D=0;return ut(y,function(){D++}),D},toArLine truncated
*/var Uh;function qy(){if(Uh)return yt;Uh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),h=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),g=Symbol.for("react.profiler"),N=Symbol.for("react.consumer"),A=Symbol.for("react.context"),S=Symbol.for("react.forward_ref"),H=Symbol.for("react.suspense"),R=Symbol.for("react.memo"),Y=Symbol.for("react.lazy"),x=Symbol.for("react.activity"),k=Symbol.for("react.view_transition"),W=Symbol.iterator;function V(y){return y===null||typeof y!="object"?null:(y=W&&y[W]||y["@@iterator"],typeof y=="function"?y:null)}var I={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},X=Object.assign,ht={};function gt(y,D,ct){this.props=y,this.context=D,this.refs=ht,this.updater=ct||I}gt.prototype.isReactComponent={},gt.prototype.setState=function(y,D){if(typeof y!="object"&&typeof y!="function"&&y!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,y,D,"setState")},gt.prototype.forceUpdate=function(y){this.updater.enqueueForceUpdate(this,y,"forceUpdate")};function xt(){}xt.prototype=gt.prototype;function Z(y,D,ct){this.props=y,this.context=D,this.refs=ht,this.updater=ct||I}var Tt=Z.prototype=new xt;Tt.constructor=Z,X(Tt,gt.prototype),Tt.isPureReactComponent=!0;var w=Array.isArray;function Q(){}var et={H:null,A:null,T:null,S:null},zt=Object.prototype.hasOwnProperty;function Bt(y,D,ct){var it=ct.ref;return{$$typeof:i,type:y,key:D,ref:it!==void 0?it:null,props:ct}}function vt(y,D){return Bt(y.type,D,y.props)}function wt(y){return typeof y=="object"&&y!==null&&y.$$typeof===i}function q(y){var D={"=":"=0",":":"=2"};return"$"+y.replace(/[=:]/g,function(ct){return D[ct]})}var ot=/\/+/g;function rt(y,D){return typeof y=="object"&&y!==null&&y.key!=null?q(""+y.key):D.toString(36)}function G(y){switch(y.status){case"fulfilled":return y.value;case"rejected":throw y.reason;default:switch(typeof y.status=="string"?y.then(Q,Q):(y.status="pending",y.then(function(D){y.status==="pending"&&(y.status="fulfilled",y.value=D)},function(D){y.status==="pending"&&(y.status="rejected",y.reason=D)})),y.status){case"fulfilled":return y.value;case"rejected":throw y.reason}}throw y}function C(y,D,ct,it,Nt){var _t=typeof y;(_t==="undefined"||_t==="boolean")&&(y=null);var Rt=!1;if(y===null)Rt=!0;else switch(_t){case"bigint":case"string":case"number":Rt=!0;break;case"object":switch(y.$$typeof){case i:case r:Rt=!0;break;case Y:return Rt=y._init,C(Rt(y._payload),D,ct,it,Nt)}}if(Rt)return Nt=Nt(y),Rt=it===""?"."+rt(y,0):it,w(Nt)?(ct="",Rt!=null&&(ct=Rt.replace(ot,"$&/")+"/"),C(Nt,D,ct,"",function(ce){return ce})):Nt!=null&&(wt(Nt)&&(Nt=vt(Nt,ct+(Nt.key==null||y&&y.key===Nt.key?"":(""+Nt.key).replace(ot,"$&/")+"/")+Rt)),D.push(Nt)),1;Rt=0;var P=it===""?".":it+":";if(w(y))for(var dt=0;dt<y.length;dt++)it=y[dt],_t=P+rt(it,dt),Rt+=C(it,D,ct,_t,Nt);else if(dt=V(y),typeof dt=="function")for(y=dt.call(y),dt=0;!(it=y.next()).done;)it=it.value,_t=P+rt(it,dt++),Rt+=C(it,D,ct,_t,Nt);else if(_t==="object"){if(typeof y.then=="function")return C(G(y),D,ct,it,Nt);throw D=String(y),Error("Objects are not valid as a React child (found: "+(D==="[object Object]"?"object with keys {"+Object.keys(y).join(", ")+"}":D)+"). If you meant to render a collection of children, use an array instead.")}return Rt}function ut(y,D,ct){if(y==null)return y;var it=[],Nt=0;return C(y,it,"","",function(_t){return D.call(ct,_t,Nt++)}),it}function St(y){if(y._status===-1){var D=y._result,ct=D();ct.then(function(it){(y._status===0||y._status===-1)&&(y._status=1,y._result=it,ct.status===void 0&&(ct.status="fulfilled",ct.value=it))},function(it){(y._status===0||y._status===-1)&&(y._status=2,y._result=it,ct.status===void 0&&(ct.status="rejected",ct.reason=it))}),y._status===-1&&(y._status=0,y._result=ct)}if(y._status===1)return y._result.default;throw y._result}var pt=typeof reportError=="function"?reportError:function(y){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var D=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof y=="object"&&y!==null&&typeof y.message=="string"?String(y.message):String(y),error:y});if(!window.dispatchEvent(D))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",y);return}console.error(y)};function ge(y){var D=et.T,ct={};ct.types=D!==null?D.types:null,et.T=ct;try{var it=y(),Nt=et.S;Nt!==null&&Nt(ct,it),typeof it=="object"&&it!==null&&typeof it.then=="function"&&it.then(Q,pt)}catch(_t){pt(_t)}finally{D!==null&&ct.types!==null&&(D.types=ct.types),et.T=D}}function we(y){var D=et.T;if(D!==null){var ct=D.types;ct===null?D.types=[y]:ct.indexOf(y)===-1&&ct.push(y)}else ge(we.bind(null,y))}var Ce={map:ut,forEach:function(y,D,ct){ut(y,function(){D.apply(this,arguments)},ct)},count:function(y){var D=0;return ut(y,function(){D++}),D},toArLine truncated
* @license React
* scheduler.production.js
*
@@ -28,7 +28,7 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var wh;function Gy(){return wh||(wh=1,(function(i){function r(G,N){var ut=G.length;G.push(N);t:for(;0<ut;){var St=ut-1>>>1,pt=G[St];if(0<g(pt,N))G[St]=N,G[ut]=pt,ut=St;else break t}}function h(G){return G.length===0?null:G[0]}function f(G){if(G.length===0)return null;var N=G[0],ut=G.pop();if(ut!==N){G[0]=ut;t:for(var St=0,pt=G.length,ge=pt>>>1;St<ge;){var we=2*(St+1)-1,Ce=G[we],y=we+1,D=G[y];if(0>g(Ce,ut))y<pt&&0>g(D,Ce)?(G[St]=D,G[y]=ut,St=y):(G[St]=Ce,G[we]=ut,St=we);else if(y<pt&&0>g(D,ut))G[St]=D,G[y]=ut,St=y;else break t}}return N}function g(G,N){var ut=G.sortIndex-N.sortIndex;return ut!==0?ut:G.id-N.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var C=performance;i.unstable_now=function(){return C.now()}}else{var A=Date,S=A.now();i.unstable_now=function(){return A.now()-S}}var H=[],R=[],Y=1,x=null,k=3,W=!1,V=!1,I=!1,X=!1,ht=typeof setTimeout=="function"?setTimeout:null,gt=typeof clearTimeout=="function"?clearTimeout:null,xt=typeof setImmediate<"u"?setImmediate:null;function Z(G){for(var N=h(R);N!==null;){if(N.callback===null)f(R);else if(N.startTime<=G)f(R),N.sortIndex=N.expirationTime,r(H,N);else break;N=h(R)}}function Tt(G){if(I=!1,Z(G),!V)if(h(H)!==null)V=!0,w||(w=!0,wt());else{var N=h(R);N!==null&&rt(Tt,N.startTime-G)}}var w=!1,Q=-1,et=5,zt=-1;function Bt(){return X?!0:!(i.unstable_now()-zt<et)}function vt(){if(X=!1,w){var G=i.unstable_now();zt=G;var N=!0;try{t:{V=!1,I&&(I=!1,gt(Q),Q=-1),W=!0;var ut=k;try{e:{for(Z(G),x=h(H);x!==null&&!(x.expirationTime>G&&Bt());){var St=x.callback;if(typeof St=="function"){x.callback=null,k=x.priorityLevel;var pt=St(x.expirationTime<=G);if(G=i.unstable_now(),typeof pt=="function"){x.callback=pt,Z(G),N=!0;break e}x===h(H)&&f(H),Z(G)}else f(H);x=h(H)}if(x!==null)N=!0;else{var ge=h(R);ge!==null&&rt(Tt,ge.startTime-G),N=!1}}break t}finally{x=null,k=ut,W=!1}N=void 0}}finally{N?wt():w=!1}}}var wt;if(typeof xt=="function")wt=function(){xt(vt)};else if(typeof MessageChannel<"u"){var q=new MessageChannel,ot=q.port2;q.port1.onmessage=vt,wt=function(){ot.postMessage(null)}}else wt=function(){ht(vt,0)};function rt(G,N){Q=ht(function(){G(i.unstable_now())},N)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(G){G.callback=null},i.unstable_forceFrameRate=function(G){0>G||125<G?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):et=0<G?Math.floor(1e3/G):5},i.unstable_getCurrentPriorityLevel=function(){return k},i.unstable_next=function(G){switch(k){case 1:case 2:case 3:var N=3;break;default:N=k}var ut=k;k=N;try{return G()}finally{k=ut}},i.unstable_requestPaint=function(){X=!0},i.unstable_runWithPriority=function(G,N){switch(G){case 1:case 2:case 3:case 4:case 5:break;default:G=3}var ut=k;k=G;try{return N()}finally{k=ut}},i.unstable_scheduleCallback=function(G,N,ut){var St=i.unstable_now();switch(typeof ut=="object"&&ut!==null?(ut=ut.delay,ut=typeof ut=="number"&&0<ut?St+ut:St):ut=St,G){case 1:var pt=-1;break;case 2:pt=250;break;case 5:pt=1073741823;break;case 4:pt=1e4;break;default:pt=5e3}return pt=ut+pt,G={id:Y++,callback:N,priorityLevel:G,startTime:ut,expirationTime:pt,sortIndex:-1},ut>St?(G.sortIndex=ut,r(R,G),h(H)===null&&G===h(R)&&(I?(gt(Q),Q=-1):I=!0,rt(Tt,ut-St))):(G.sortIndex=pt,r(H,G),V||W||(V=!0,w||(w=!0,wt()))),G},i.unstable_shouldYield=Bt,i.unstable_wrapCallback=function(G){var N=k;return function(){var ut=k;k=N;try{return G.apply(this,arguments)}finally{k=ut}}}})(so)),so}var kh;function Qy(){return kh||(kh=1,co.exports=Gy()),co.exports}var ro={exports:{}},He={};/**
*/var wh;function Gy(){return wh||(wh=1,(function(i){function r(G,C){var ut=G.length;G.push(C);t:for(;0<ut;){var St=ut-1>>>1,pt=G[St];if(0<g(pt,C))G[St]=C,G[ut]=pt,ut=St;else break t}}function h(G){return G.length===0?null:G[0]}function f(G){if(G.length===0)return null;var C=G[0],ut=G.pop();if(ut!==C){G[0]=ut;t:for(var St=0,pt=G.length,ge=pt>>>1;St<ge;){var we=2*(St+1)-1,Ce=G[we],y=we+1,D=G[y];if(0>g(Ce,ut))y<pt&&0>g(D,Ce)?(G[St]=D,G[y]=ut,St=y):(G[St]=Ce,G[we]=ut,St=we);else if(y<pt&&0>g(D,ut))G[St]=D,G[y]=ut,St=y;else break t}}return C}function g(G,C){var ut=G.sortIndex-C.sortIndex;return ut!==0?ut:G.id-C.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var N=performance;i.unstable_now=function(){return N.now()}}else{var A=Date,S=A.now();i.unstable_now=function(){return A.now()-S}}var H=[],R=[],Y=1,x=null,k=3,W=!1,V=!1,I=!1,X=!1,ht=typeof setTimeout=="function"?setTimeout:null,gt=typeof clearTimeout=="function"?clearTimeout:null,xt=typeof setImmediate<"u"?setImmediate:null;function Z(G){for(var C=h(R);C!==null;){if(C.callback===null)f(R);else if(C.startTime<=G)f(R),C.sortIndex=C.expirationTime,r(H,C);else break;C=h(R)}}function Tt(G){if(I=!1,Z(G),!V)if(h(H)!==null)V=!0,w||(w=!0,wt());else{var C=h(R);C!==null&&rt(Tt,C.startTime-G)}}var w=!1,Q=-1,et=5,zt=-1;function Bt(){return X?!0:!(i.unstable_now()-zt<et)}function vt(){if(X=!1,w){var G=i.unstable_now();zt=G;var C=!0;try{t:{V=!1,I&&(I=!1,gt(Q),Q=-1),W=!0;var ut=k;try{e:{for(Z(G),x=h(H);x!==null&&!(x.expirationTime>G&&Bt());){var St=x.callback;if(typeof St=="function"){x.callback=null,k=x.priorityLevel;var pt=St(x.expirationTime<=G);if(G=i.unstable_now(),typeof pt=="function"){x.callback=pt,Z(G),C=!0;break e}x===h(H)&&f(H),Z(G)}else f(H);x=h(H)}if(x!==null)C=!0;else{var ge=h(R);ge!==null&&rt(Tt,ge.startTime-G),C=!1}}break t}finally{x=null,k=ut,W=!1}C=void 0}}finally{C?wt():w=!1}}}var wt;if(typeof xt=="function")wt=function(){xt(vt)};else if(typeof MessageChannel<"u"){var q=new MessageChannel,ot=q.port2;q.port1.onmessage=vt,wt=function(){ot.postMessage(null)}}else wt=function(){ht(vt,0)};function rt(G,C){Q=ht(function(){G(i.unstable_now())},C)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(G){G.callback=null},i.unstable_forceFrameRate=function(G){0>G||125<G?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):et=0<G?Math.floor(1e3/G):5},i.unstable_getCurrentPriorityLevel=function(){return k},i.unstable_next=function(G){switch(k){case 1:case 2:case 3:var C=3;break;default:C=k}var ut=k;k=C;try{return G()}finally{k=ut}},i.unstable_requestPaint=function(){X=!0},i.unstable_runWithPriority=function(G,C){switch(G){case 1:case 2:case 3:case 4:case 5:break;default:G=3}var ut=k;k=G;try{return C()}finally{k=ut}},i.unstable_scheduleCallback=function(G,C,ut){var St=i.unstable_now();switch(typeof ut=="object"&&ut!==null?(ut=ut.delay,ut=typeof ut=="number"&&0<ut?St+ut:St):ut=St,G){case 1:var pt=-1;break;case 2:pt=250;break;case 5:pt=1073741823;break;case 4:pt=1e4;break;default:pt=5e3}return pt=ut+pt,G={id:Y++,callback:C,priorityLevel:G,startTime:ut,expirationTime:pt,sortIndex:-1},ut>St?(G.sortIndex=ut,r(R,G),h(H)===null&&G===h(R)&&(I?(gt(Q),Q=-1):I=!0,rt(Tt,ut-St))):(G.sortIndex=pt,r(H,G),V||W||(V=!0,w||(w=!0,wt()))),G},i.unstable_shouldYield=Bt,i.unstable_wrapCallback=function(G){var C=k;return function(){var ut=k;k=C;try{return G.apply(this,arguments)}finally{k=ut}}}})(so)),so}var kh;function Qy(){return kh||(kh=1,co.exports=Gy()),co.exports}var ro={exports:{}},He={};/**
* @license React
* react-dom.production.js
*
@@ -36,7 +36,7 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Bh;function Xy(){if(Bh)return He;Bh=1;var i=yo();function r(Y){var x="https://react.dev/errors/"+Y;if(1<arguments.length){x+="?args[]="+encodeURIComponent(arguments[1]);for(var k=2;k<arguments.length;k++)x+="&args[]="+encodeURIComponent(arguments[k])}return"Minified React error #"+Y+"; visit "+x+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function h(){}var f={d:{f:h,r:function(){throw Error(r(522))},D:h,C:h,L:h,m:h,X:h,S:h,M:h},p:0,findDOMNode:null},g=Symbol.for("react.portal"),C=Symbol.for("react.recoverable"),A=Symbol.for("react.optimistic_key");function S(Y,x,k){var W=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:g,key:W==null?null:W===A?A:""+W,children:Y,containerInfo:x,implementation:k}}var H=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function R(Y,x){if(Y==="font")return"";if(typeof x=="string")return x==="use-credentials"?x:""}return He.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,He.browser=function(Y){return{$$typeof:C,_reason:Y}},He.createPortal=function(Y,x){var k=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!x||x.nodeType!==1&&x.nodeType!==9&&x.nodeType!==11)throw Error(r(299));return S(Y,x,null,k)},He.flushSync=function(Y){var x=H.T,k=f.p;try{if(H.T=null,f.p=2,Y)return Y()}finally{H.T=x,f.p=k,f.d.f()}},He.preconnect=function(Y,x){typeof Y=="string"&&(x?(x=x.crossOrigin,x=typeof x=="string"?x==="use-credentials"?x:"":void 0):x=null,f.d.C(Y,x))},He.prefetchDNS=function(Y){typeof Y=="string"&&f.d.D(Y)},He.preinit=function(Y,x){if(typeof Y=="string"&&x&&typeof x.as=="string"){var k=x.as,W=R(k,x.crossOrigin),V=typeof x.integrity=="string"?x.integrity:void 0,I=typeof x.fetchPriority=="string"?x.fetchPriority:void 0;k==="style"?f.d.S(Y,typeof x.precedence=="string"?x.precedence:void 0,{crossOrigin:W,integrity:V,fetchPriority:I}):k==="script"&&f.d.X(Y,{crossOrigin:W,integrity:V,fetchPriority:I,nonce:typeof x.nonce=="string"?x.nonce:void 0})}},He.preinitModule=function(Y,x){if(typeof Y=="string")if(typeof x=="object"&&x!==null){if(x.as==null||x.as==="script"){var k=R(x.as,x.crossOrigin);f.d.M(Y,{crossOrigin:k,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0})}}else x==null&&f.d.M(Y)},He.preload=function(Y,x){if(typeof Y=="string"&&typeof x=="object"&&x!==null&&typeof x.as=="string"){var k=x.as,W=R(k,x.crossOrigin);f.d.L(Y,k,{crossOrigin:W,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,type:typeof x.type=="string"?x.type:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0,referrerPolicy:typeof x.referrerPolicy=="string"?x.referrerPolicy:void 0,imageSrcSet:typeof x.imageSrcSet=="string"?x.imageSrcSet:void 0,imageSizes:typeof x.imageSizes=="string"?x.imageSizes:void 0,media:typeof x.media=="string"?x.media:void 0})}},He.preloadModule=function(Y,x){if(typeof Y=="string")if(x){var k=R(x.as,x.crossOrigin);f.d.m(Y,{as:typeof x.as=="string"&&x.as!=="script"?x.as:void 0,crossOrigin:k,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0})}else f.d.m(Y)},He.requestFormReset=function(Y){f.d.r(Y)},He.unstable_batchedUpdates=function(Y,x){return Y(x)},He.useFormState=function(Y,x,k){return H.H.useFormState(Y,x,k)},He.useFormStatus=function(){return H.H.useHostTransitionStatus()},He.version="19.3.1",He}var qh;function Ly(){if(qh)return ro.exports;qh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(r){console.error(r)}}return i(),ro.exports=Xy(),ro.exports}/**
*/var Bh;function Xy(){if(Bh)return He;Bh=1;var i=yo();function r(Y){var x="https://react.dev/errors/"+Y;if(1<arguments.length){x+="?args[]="+encodeURIComponent(arguments[1]);for(var k=2;k<arguments.length;k++)x+="&args[]="+encodeURIComponent(arguments[k])}return"Minified React error #"+Y+"; visit "+x+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function h(){}var f={d:{f:h,r:function(){throw Error(r(522))},D:h,C:h,L:h,m:h,X:h,S:h,M:h},p:0,findDOMNode:null},g=Symbol.for("react.portal"),N=Symbol.for("react.recoverable"),A=Symbol.for("react.optimistic_key");function S(Y,x,k){var W=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:g,key:W==null?null:W===A?A:""+W,children:Y,containerInfo:x,implementation:k}}var H=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function R(Y,x){if(Y==="font")return"";if(typeof x=="string")return x==="use-credentials"?x:""}return He.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,He.browser=function(Y){return{$$typeof:N,_reason:Y}},He.createPortal=function(Y,x){var k=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!x||x.nodeType!==1&&x.nodeType!==9&&x.nodeType!==11)throw Error(r(299));return S(Y,x,null,k)},He.flushSync=function(Y){var x=H.T,k=f.p;try{if(H.T=null,f.p=2,Y)return Y()}finally{H.T=x,f.p=k,f.d.f()}},He.preconnect=function(Y,x){typeof Y=="string"&&(x?(x=x.crossOrigin,x=typeof x=="string"?x==="use-credentials"?x:"":void 0):x=null,f.d.C(Y,x))},He.prefetchDNS=function(Y){typeof Y=="string"&&f.d.D(Y)},He.preinit=function(Y,x){if(typeof Y=="string"&&x&&typeof x.as=="string"){var k=x.as,W=R(k,x.crossOrigin),V=typeof x.integrity=="string"?x.integrity:void 0,I=typeof x.fetchPriority=="string"?x.fetchPriority:void 0;k==="style"?f.d.S(Y,typeof x.precedence=="string"?x.precedence:void 0,{crossOrigin:W,integrity:V,fetchPriority:I}):k==="script"&&f.d.X(Y,{crossOrigin:W,integrity:V,fetchPriority:I,nonce:typeof x.nonce=="string"?x.nonce:void 0})}},He.preinitModule=function(Y,x){if(typeof Y=="string")if(typeof x=="object"&&x!==null){if(x.as==null||x.as==="script"){var k=R(x.as,x.crossOrigin);f.d.M(Y,{crossOrigin:k,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0})}}else x==null&&f.d.M(Y)},He.preload=function(Y,x){if(typeof Y=="string"&&typeof x=="object"&&x!==null&&typeof x.as=="string"){var k=x.as,W=R(k,x.crossOrigin);f.d.L(Y,k,{crossOrigin:W,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,type:typeof x.type=="string"?x.type:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0,referrerPolicy:typeof x.referrerPolicy=="string"?x.referrerPolicy:void 0,imageSrcSet:typeof x.imageSrcSet=="string"?x.imageSrcSet:void 0,imageSizes:typeof x.imageSizes=="string"?x.imageSizes:void 0,media:typeof x.media=="string"?x.media:void 0})}},He.preloadModule=function(Y,x){if(typeof Y=="string")if(x){var k=R(x.as,x.crossOrigin);f.d.m(Y,{as:typeof x.as=="string"&&x.as!=="script"?x.as:void 0,crossOrigin:k,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0})}else f.d.m(Y)},He.requestFormReset=function(Y){f.d.r(Y)},He.unstable_batchedUpdates=function(Y,x){return Y(x)},He.useFormState=function(Y,x,k){return H.H.useFormState(Y,x,k)},He.useFormStatus=function(){return H.H.useHostTransitionStatus()},He.version="19.3.1",He}var qh;function Ly(){if(qh)return ro.exports;qh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(r){console.error(r)}}return i(),ro.exports=Xy(),ro.exports}/**
* @license React
* react-dom-client.production.js
*
@@ -44,37 +44,37 @@
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var Yh;function Vy(){if(Yh)return ii;Yh=1;var i=Qy(),r=yo(),h=Ly();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var l=2;l<arguments.length;l++)e+="&args[]="+encodeURIComponent(arguments[l])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function g(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function C(t){for(var e=t,l=e;l&&!l.alternate;)e=l,(e.flags&4098)!==0&&(t=e.return),l=e.return;for(;e.return;)e=e.return;return e.tag===3?t:null}function A(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function S(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function H(t){if(C(t)!==t)throw Error(f(188))}function R(t){var e=t.alternate;if(!e){if(e=C(t),e===null)throw Error(f(188));return e!==t?null:t}for(var l=t,a=e;;){var n=l.return;if(n===null)break;var u=n.alternate;if(u===null){if(a=n.return,a!==null){l=a;continue}break}if(n.child===u.child){for(u=n.child;u;){if(u===l)return H(n),t;if(u===a)return H(n),e;u=u.sibling}throw Error(f(188))}if(l.return!==a.return)l=n,a=u;else{for(var s=!1,o=n.child;o;){if(o===l){s=!0,l=n,a=u;break}if(o===a){s=!0,a=n,l=u;break}o=o.sibling}if(!s){for(o=u.child;o;){if(o===l){s=!0,l=u,a=n;break}if(o===a){s=!0,a=u,l=n;break}o=o.sibling}if(!s)throw Error(f(189))}}if(l.alternate!==a)throw Error(f(190))}if(l.tag!==3)throw Error(f(188));return l.stateNode.current===l?t:e}function Y(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=Y(t),e!==null)return e;t=t.sibling}return null}function x(t,e,l,a,n,u){for(;t!==null;){if((t.tag===5||t.tag===27||t.tag===6)&&l(t,a,n,u)||(t.tag!==22||t.memoizedState===null)&&(e||t.tag!==5&&t.tag!==27)&&x(t.child,e,l,a,n,u))return!0;t=t.sibling}return!1}function k(t){for(t=t.return;t!==null;){if(t.tag===3||t.tag===5||t.tag===27)return t;t=t.return}return null}function W(t){var e=!1;for(t=t.return;t!==null&&(t.tag===4&&(e=!0),!(t.tag===3||t.tag===5||t.tag===27));)t=t.return;return e}function V(t){var e=[null,null],l=k(t);return l===null||I(e,t,l.child,{foundSelf:!1}),e}function I(t,e,l,a){for(;l!==null;){if(l===e)a.foundSelf=!0;else if(l.tag===5||l.tag===27||l.tag===6){if(a.foundSelf)return t[1]=l,!0;t[0]=l}else if((l.tag!==22||l.memoizedState===null)&&I(t,e,l.child,a))return!0;l=l.sibling}return!1}function X(t){switch(t.tag){case 5:case 27:case 6:return t.stateNode;case 3:return t.stateNode.containerInfo;default:throw Error(f(559))}}var ht=null,gt=null;function xt(t,e,l){return t===l?!0:t===e?(ht=t,!0):!1}function Z(t,e,l){return t===l?(gt=t,!1):t===e?(gt!==null&&(ht=t),!0):!1}function Tt(t){if(t===null)return null;do t=t===null?null:t.return;while(t&&t.tag!==5&&t.tag!==27&&t.tag!==3);return t||null}function w(t,e,l){for(var a=0,n=t;n;n=l(n))a++;n=0;for(var u=e;u;u=l(u))n++;for(;0<a-n;)t=l(t),a--;for(;0<n-a;)e=l(e),n--;for(;a--;){if(t===e||e!==null&&t===e.alternate)return t;t=l(t),e=l(e)}return null}var Q=Object.assign,et=Symbol.for("react.element"),zt=Symbol.for("react.transitional.element"),Bt=Symbol.for("react.portal"),vt=Symbol.for("react.fragment"),wt=Symbol.for("react.strict_mode"),q=Symbol.for("react.profiler"),ot=Symbol.for("react.consumer"),rt=Symbol.for("react.context"),G=Symbol.for("react.forward_ref"),N=Symbol.for("react.suspense"),ut=Symbol.for("react.suspense_list"),St=Symbol.for("react.memo"),pt=Symbol.for("react.lazy"),ge=Symbol.for("react.activity"),we=Symbol.for("react.legacy_hidden"),Ce=Symbol.for("react.memo_cache_sentinel"),y=Symbol.for("react.view_transition"),D=Symbol.for("react.recoverable"),ct=Symbol.iterator;function it(t){return t===null||typeof t!="object"?null:(t=ct&&t[ct]||t["@@iterator"],typeof t=="function"?t:null)}var Nt=Symbol.for("react.client.reference");function _t(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===Nt?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case vt:return"Fragment";case q:return"Profiler";case wt:return"StrictMode";case N:return"Suspense";case ut:return"SuspenseList";case ge:return"Activity";case y:return"ViewTransition"}if(typeof t=="object")switch(t.$$typeof){case Bt:return"Portal";case rt:return t.displayName||"Context";case ot:return(t._context.displayName||"Context")+".Consumer";case G:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case St:return e=t.displayName||null,e!==null?e:_t(t.type)||"Memo";case pt:e=t._payload,t=t._init;try{return _t(t(e))}catch{}}return null}var Rt=Array.isArray,P=r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,dt=h.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,ce={pending:!1,data:null,method:null,action:nLine truncated
*/var Yh;function Vy(){if(Yh)return ii;Yh=1;var i=Qy(),r=yo(),h=Ly();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var l=2;l<arguments.length;l++)e+="&args[]="+encodeURIComponent(arguments[l])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function g(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function N(t){for(var e=t,l=e;l&&!l.alternate;)e=l,(e.flags&4098)!==0&&(t=e.return),l=e.return;for(;e.return;)e=e.return;return e.tag===3?t:null}function A(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function S(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function H(t){if(N(t)!==t)throw Error(f(188))}function R(t){var e=t.alternate;if(!e){if(e=N(t),e===null)throw Error(f(188));return e!==t?null:t}for(var l=t,a=e;;){var n=l.return;if(n===null)break;var u=n.alternate;if(u===null){if(a=n.return,a!==null){l=a;continue}break}if(n.child===u.child){for(u=n.child;u;){if(u===l)return H(n),t;if(u===a)return H(n),e;u=u.sibling}throw Error(f(188))}if(l.return!==a.return)l=n,a=u;else{for(var s=!1,o=n.child;o;){if(o===l){s=!0,l=n,a=u;break}if(o===a){s=!0,a=n,l=u;break}o=o.sibling}if(!s){for(o=u.child;o;){if(o===l){s=!0,l=u,a=n;break}if(o===a){s=!0,a=u,l=n;break}o=o.sibling}if(!s)throw Error(f(189))}}if(l.alternate!==a)throw Error(f(190))}if(l.tag!==3)throw Error(f(188));return l.stateNode.current===l?t:e}function Y(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=Y(t),e!==null)return e;t=t.sibling}return null}function x(t,e,l,a,n,u){for(;t!==null;){if((t.tag===5||t.tag===27||t.tag===6)&&l(t,a,n,u)||(t.tag!==22||t.memoizedState===null)&&(e||t.tag!==5&&t.tag!==27)&&x(t.child,e,l,a,n,u))return!0;t=t.sibling}return!1}function k(t){for(t=t.return;t!==null;){if(t.tag===3||t.tag===5||t.tag===27)return t;t=t.return}return null}function W(t){var e=!1;for(t=t.return;t!==null&&(t.tag===4&&(e=!0),!(t.tag===3||t.tag===5||t.tag===27));)t=t.return;return e}function V(t){var e=[null,null],l=k(t);return l===null||I(e,t,l.child,{foundSelf:!1}),e}function I(t,e,l,a){for(;l!==null;){if(l===e)a.foundSelf=!0;else if(l.tag===5||l.tag===27||l.tag===6){if(a.foundSelf)return t[1]=l,!0;t[0]=l}else if((l.tag!==22||l.memoizedState===null)&&I(t,e,l.child,a))return!0;l=l.sibling}return!1}function X(t){switch(t.tag){case 5:case 27:case 6:return t.stateNode;case 3:return t.stateNode.containerInfo;default:throw Error(f(559))}}var ht=null,gt=null;function xt(t,e,l){return t===l?!0:t===e?(ht=t,!0):!1}function Z(t,e,l){return t===l?(gt=t,!1):t===e?(gt!==null&&(ht=t),!0):!1}function Tt(t){if(t===null)return null;do t=t===null?null:t.return;while(t&&t.tag!==5&&t.tag!==27&&t.tag!==3);return t||null}function w(t,e,l){for(var a=0,n=t;n;n=l(n))a++;n=0;for(var u=e;u;u=l(u))n++;for(;0<a-n;)t=l(t),a--;for(;0<n-a;)e=l(e),n--;for(;a--;){if(t===e||e!==null&&t===e.alternate)return t;t=l(t),e=l(e)}return null}var Q=Object.assign,et=Symbol.for("react.element"),zt=Symbol.for("react.transitional.element"),Bt=Symbol.for("react.portal"),vt=Symbol.for("react.fragment"),wt=Symbol.for("react.strict_mode"),q=Symbol.for("react.profiler"),ot=Symbol.for("react.consumer"),rt=Symbol.for("react.context"),G=Symbol.for("react.forward_ref"),C=Symbol.for("react.suspense"),ut=Symbol.for("react.suspense_list"),St=Symbol.for("react.memo"),pt=Symbol.for("react.lazy"),ge=Symbol.for("react.activity"),we=Symbol.for("react.legacy_hidden"),Ce=Symbol.for("react.memo_cache_sentinel"),y=Symbol.for("react.view_transition"),D=Symbol.for("react.recoverable"),ct=Symbol.iterator;function it(t){return t===null||typeof t!="object"?null:(t=ct&&t[ct]||t["@@iterator"],typeof t=="function"?t:null)}var Nt=Symbol.for("react.client.reference");function _t(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===Nt?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case vt:return"Fragment";case q:return"Profiler";case wt:return"StrictMode";case C:return"Suspense";case ut:return"SuspenseList";case ge:return"Activity";case y:return"ViewTransition"}if(typeof t=="object")switch(t.$$typeof){case Bt:return"Portal";case rt:return t.displayName||"Context";case ot:return(t._context.displayName||"Context")+".Consumer";case G:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case St:return e=t.displayName||null,e!==null?e:_t(t.type)||"Memo";case pt:e=t._payload,t=t._init;try{return _t(t(e))}catch{}}return null}var Rt=Array.isArray,P=r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,dt=h.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,ce={pending:!1,data:null,method:null,action:nLine truncated
at`)?" (<anonymous>)":-1<l.stack.indexOf("@")?"@unknown:0:0":""}return`
`+Be+t+se}var rl=!1;function Fl(t,e){if(!t||rl)return"";rl=!0;var l=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(e){var z=function(){throw Error()};if(Object.defineProperty(z.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(z,[])}catch(L){var p=L}Reflect.construct(t,[],z)}else{try{z.call()}catch(L){p=L}z=!1;try{var _=Object.getOwnPropertyDescriptor(t.prototype,"props");Object.defineProperty(t.prototype,"props",{configurable:!0,set:function(){throw Error()}}),z=!0,new t}finally{z&&(_!==void 0?Object.defineProperty(t.prototype,"props",_):delete t.prototype.props)}}}else{try{throw Error()}catch(L){p=L}(z=t())&&typeof z.catch=="function"&&z.catch(function(){})}}catch(L){if(L&&p&&typeof L.stack=="string")return[L.stack,p.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var n=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");n&&n.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var u=a.DetermineComponentFrameRoot(),s=u[0],o=u[1];if(s&&o){var m=s.split(`
`),j=o.split(`
`);for(n=a=0;a<m.length&&!m[a].includes("DetermineComponentFrameRoot");)a++;for(;n<j.length&&!j[n].includes("DetermineComponentFrameRoot");)n++;if(a===m.length||n===j.length)for(a=m.length-1,n=j.length-1;1<=a&&0<=n&&m[a]!==j[n];)n--;for(;1<=a&&0<=n;a--,n--)if(m[a]!==j[n]){if(a!==1||n!==1)do if(a--,n--,0>n||m[a]!==j[n]){var E=`
`+m[a].replace(" at new "," at ");return t.displayName&&E.includes("<anonymous>")&&(E=E.replace("<anonymous>",t.displayName)),E}while(1<=a&&0<=n);break}}}finally{rl=!1,Error.prepareStackTrace=l}return(l=t?t.displayName||t.name:"")?re(l):""}function ma(t,e){switch(t.tag){case 26:case 27:case 5:return re(t.type);case 16:return re("Lazy");case 13:return t.child!==e&&e!==null?re("Suspense Fallback"):re("Suspense");case 19:return re("SuspenseList");case 0:case 15:return Fl(t.type,!1);case 11:return Fl(t.type.render,!1);case 1:return Fl(t.type,!0);case 31:return re("Activity");case 30:return re("ViewTransition");default:return""}}function Dl(t){try{var e="",l=null;do e+=ma(t,l),l=t,t=t.return;while(t);return e}catch(a){return`
Error generating stack: `+a.message+`
`+a.stack}}var te=Object.prototype.hasOwnProperty,Pe=i.unstable_scheduleCallback,qe=i.unstable_cancelCallback,_l=i.unstable_shouldYield,ol=i.unstable_requestPaint,Wt=i.unstable_now,Ul=i.unstable_getCurrentPriorityLevel,mn=i.unstable_ImmediatePriority,Qa=i.unstable_UserBlockingPriority,Se=i.unstable_NormalPriority,Xa=i.unstable_LowPriority,ha=i.unstable_IdlePriority,su=i.log,ru=i.unstable_setDisableYieldValue,Wl=null,Ae=null;function fl(t){if(typeof su=="function"&&ru(t),Ae&&typeof Ae.setStrictMode=="function")try{Ae.setStrictMode(Wl,t)}catch{}}var Oe=Math.clz32?Math.clz32:du,ou=Math.log,fu=Math.LN2;function du(t){return t>>>=0,t===0?32:31-(ou(t)/fu|0)|0}var B=256,U=262144,$=4194304;function at(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&-t;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function O(t,e,l){var a=t.pendingLanes;if(a===0)return 0;var n=0,u=t.suspendedLanes,s=t.pingedLanes;t=t.warmLanes;var o=a&134217727;return o!==0?(a=o&~u,a!==0?n=at(a):(s&=o,s!==0?n=at(s):l||(l=o&~t,l!==0&&(n=at(l))))):(o=a&~u,o!==0?n=at(o):s!==0?n=at(s):l||(l=a&~t,l!==0&&(n=at(l)))),n===0?0:e!==0&&e!==n&&(e&u)===0&&(u=n&-n,l=e&-e,u>=l||u===32&&(l&4194048)!==0)?e:n}function J(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function nt(t,e){(e&8)!==0&&(e|=e&32);var l=t.entangledLanes;if(l!==0)for(t=t.entanglements,l&=e;0<l;){var a=31-Oe(l),n=1<<a;e|=t[a],l&=~n}return e}function qt(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Dt(){var t=$;return $<<=1,($&62914560)===0&&($=4194304),t}function xe(t){for(var e=[],l=0;31>l;l++)e.push(t);return e}function be(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function Dc(t,e,l,a,n,u){var s=t.pendingLanes;t.pendingLanes=l,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=l,t.entangledLanes&=l,t.errorRecoveryDisabledLanes&=l,t.shellSuspendCounter=0;var o=t.entanglements,m=t.expirationTimes,j=t.hiddenUpdates;for(l=s&~l;0<l;){var E=31-Oe(l),z=1<<E;o[E]=0,m[E]=-1;var p=j[E];if(p!==null)for(j[E]=null,E=0;E<p.length;E++){var _=p[E];_!==null&&(_.lane&=-536870913)}l&=~z}a!==0&&ci(t,a,0),u!==0&&n===0&&t.tag!==0&&(t.suspendedLanes|=u&~(s&~e))}function ci(t,e,l){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-Oe(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|l&261930}function hn(t,e){var l=t.entangledLanes|=e;for(t=t.entanglements;l;){var a=31-Oe(l),n=1<<a;n&e|t[a]&e&&(t[a]|=e),l&=~n}}function $l(t,e){var l=e&-e;return l=(l&42)!==0?1:Hl(l),(l&(t.suspendedLanes|e))!==0?0:l}function Hl(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function mu(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function hu(){var t=dt.p;return t!==0?t:(t=window.event,t===void 0?32:Eh(t.type))}function Ze(t,e){var l=dt.p;try{return dt.p=t,e()}finally{dt.p=l}}var ae=Math.random().toString(36).slice(2),ve="__reactFiber$"+ae,Ke="__reactProps$"+ae,vn="__reactContainer$"+ae,So="__reactEvents$"+ae,Ph="__reactListeners$"+ae,tv="__reactHandles$"+ae,xo="__reactResources$"+ae,vu="__reactMarker$"+ae,si="__reactLoad$"+ae;function ri(t){delete t[ve],delete t[Ke],delete t[Ph],delete t[tv]}function La(t){var e;if(e=t[ve])return e;for(var l=t.parentNode;l;){if(e=l[vn]||l[ve]){if(l=e.alternate,e.child!==null||l!==null&&l.child!==null)for(t=uh(t);t!==null;){if(l=t[ve])return l;t=uh(t)}return e}t=l,l=t.parentNode}return null}function yn(t){if(t=t[ve]||t[vn]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function yu(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function gn(t){var e=t[xo];return e||(e=t[xo]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function je(t){t[vu]=!0}function bo(t){t[si]=void 0}var jo=new Set,To={};function Va(t,e){pn(t,e),pn(t+"Capture",e)}function pn(t,Line truncated
`).replace(G0,"")}function qm(t,e){return e=Bm(e),Bm(t)===e}function Vt(t,e,l,a,n,u){switch(l){case"children":if(typeof a=="string")e==="body"||e==="textarea"&&a===""||xn(t,a);else if(typeof a=="number"||typeof a=="bigint")e!=="body"&&xn(t,""+a);else return;break;case"className":fi(t,"class",a);break;case"tabIndex":fi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":fi(t,l,a);break;case"style":Do(t,a,u);return;case"data":if(e!=="object"){fi(t,"data",a);break}case"src":case"href":if(a===""&&(e!=="a"||l!=="href")){t.removeAttribute(l);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=di(a),t.setAttribute(l,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(l,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof u=="function"&&(l==="formAction"?(e!=="input"&&Vt(t,e,"name",n.name,n,null),Vt(t,e,"formEncType",n.formEncType,n,null),Vt(t,e,"formMethod",n.formMethod,n,null),Vt(t,e,"formTarget",n.formTarget,n,null)):(Vt(t,e,"encType",n.encType,n,null),Vt(t,e,"method",n.method,n,null),Vt(t,e,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=di(a),t.setAttribute(l,a);break;case"onClick":a!=null&&(t.onclick=wl);return;case"onScroll":a!=null&&At("scroll",t);return;case"onScrollEnd":a!=null&&At("scrollend",t);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}l=di(a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",l);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,""):t.removeAttribute(l);break;case"capture":case"download":a===!0?t.setAttribute(l,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(l,a):t.removeAttribute(l);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(l):t.setAttribute(l,a);break;case"popover":At("beforetoggle",t),At("toggle",t),oi(t,"popover",a);break;case"xlinkActuate":Il(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":Il(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":Il(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":Il(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":Il(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":Il(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":Il(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":Il(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":Il(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":oi(t,"is",a);break;case"innerText":case"textContent":return;default:if(!(2<l.length)||l[0]!=="o"&&l[0]!=="O"||l[1]!=="n"&&l[1]!=="N")l=iv.get(l)||l,oi(t,l,a);else return}kt=!0}function Hr(t,e,l,a,n,u){switch(l){case"style":Do(t,a,u);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"children":if(typeof a=="string")xn(t,a);else if(typeof a=="number"||typeof a=="bigint")xn(t,""+a);else return;break;case"onScroll":a!=null&&At("scroll",t);return;case"onScrollEnd":a!=null&&At("scroLine truncated
`+a.stack}}var te=Object.prototype.hasOwnProperty,Pe=i.unstable_scheduleCallback,qe=i.unstable_cancelCallback,_l=i.unstable_shouldYield,ol=i.unstable_requestPaint,Wt=i.unstable_now,Ul=i.unstable_getCurrentPriorityLevel,mn=i.unstable_ImmediatePriority,Qa=i.unstable_UserBlockingPriority,Se=i.unstable_NormalPriority,Xa=i.unstable_LowPriority,ha=i.unstable_IdlePriority,su=i.log,ru=i.unstable_setDisableYieldValue,Wl=null,Ae=null;function fl(t){if(typeof su=="function"&&ru(t),Ae&&typeof Ae.setStrictMode=="function")try{Ae.setStrictMode(Wl,t)}catch{}}var Oe=Math.clz32?Math.clz32:du,ou=Math.log,fu=Math.LN2;function du(t){return t>>>=0,t===0?32:31-(ou(t)/fu|0)|0}var B=256,U=262144,$=4194304;function at(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&-t;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function O(t,e,l){var a=t.pendingLanes;if(a===0)return 0;var n=0,u=t.suspendedLanes,s=t.pingedLanes;t=t.warmLanes;var o=a&134217727;return o!==0?(a=o&~u,a!==0?n=at(a):(s&=o,s!==0?n=at(s):l||(l=o&~t,l!==0&&(n=at(l))))):(o=a&~u,o!==0?n=at(o):s!==0?n=at(s):l||(l=a&~t,l!==0&&(n=at(l)))),n===0?0:e!==0&&e!==n&&(e&u)===0&&(u=n&-n,l=e&-e,u>=l||u===32&&(l&4194048)!==0)?e:n}function J(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function nt(t,e){(e&8)!==0&&(e|=e&32);var l=t.entangledLanes;if(l!==0)for(t=t.entanglements,l&=e;0<l;){var a=31-Oe(l),n=1<<a;e|=t[a],l&=~n}return e}function qt(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Dt(){var t=$;return $<<=1,($&62914560)===0&&($=4194304),t}function xe(t){for(var e=[],l=0;31>l;l++)e.push(t);return e}function be(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function Dc(t,e,l,a,n,u){var s=t.pendingLanes;t.pendingLanes=l,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=l,t.entangledLanes&=l,t.errorRecoveryDisabledLanes&=l,t.shellSuspendCounter=0;var o=t.entanglements,m=t.expirationTimes,j=t.hiddenUpdates;for(l=s&~l;0<l;){var E=31-Oe(l),z=1<<E;o[E]=0,m[E]=-1;var p=j[E];if(p!==null)for(j[E]=null,E=0;E<p.length;E++){var _=p[E];_!==null&&(_.lane&=-536870913)}l&=~z}a!==0&&ci(t,a,0),u!==0&&n===0&&t.tag!==0&&(t.suspendedLanes|=u&~(s&~e))}function ci(t,e,l){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-Oe(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|l&261930}function hn(t,e){var l=t.entangledLanes|=e;for(t=t.entanglements;l;){var a=31-Oe(l),n=1<<a;n&e|t[a]&e&&(t[a]|=e),l&=~n}}function $l(t,e){var l=e&-e;return l=(l&42)!==0?1:Hl(l),(l&(t.suspendedLanes|e))!==0?0:l}function Hl(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function mu(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function hu(){var t=dt.p;return t!==0?t:(t=window.event,t===void 0?32:Eh(t.type))}function Ze(t,e){var l=dt.p;try{return dt.p=t,e()}finally{dt.p=l}}var ae=Math.random().toString(36).slice(2),ve="__reactFiber$"+ae,Ke="__reactProps$"+ae,vn="__reactContainer$"+ae,So="__reactEvents$"+ae,Ph="__reactListeners$"+ae,tv="__reactHandles$"+ae,xo="__reactResources$"+ae,vu="__reactMarker$"+ae,si="__reactLoad$"+ae;function ri(t){delete t[ve],delete t[Ke],delete t[Ph],delete t[tv]}function La(t){var e;if(e=t[ve])return e;for(var l=t.parentNode;l;){if(e=l[vn]||l[ve]){if(l=e.alternate,e.child!==null||l!==null&&l.child!==null)for(t=uh(t);t!==null;){if(l=t[ve])return l;t=uh(t)}return e}t=l,l=t.parentNode}return null}function yn(t){if(t=t[ve]||t[vn]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function yu(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function gn(t){var e=t[xo];return e||(e=t[xo]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function je(t){t[vu]=!0}function bo(t){t[si]=void 0}var jo=new Set,To={};function Va(t,e){pn(t,e),pn(t+"Capture",e)}function pn(t,Line truncated
`).replace(G0,"")}function qm(t,e){return e=Bm(e),Bm(t)===e}function Vt(t,e,l,a,n,u){switch(l){case"children":if(typeof a=="string")e==="body"||e==="textarea"&&a===""||xn(t,a);else if(typeof a=="number"||typeof a=="bigint")e!=="body"&&xn(t,""+a);else return;break;case"className":fi(t,"class",a);break;case"tabIndex":fi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":fi(t,l,a);break;case"style":Do(t,a,u);return;case"data":if(e!=="object"){fi(t,"data",a);break}case"src":case"href":if(a===""&&(e!=="a"||l!=="href")){t.removeAttribute(l);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=di(a),t.setAttribute(l,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(l,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof u=="function"&&(l==="formAction"?(e!=="input"&&Vt(t,e,"name",n.name,n,null),Vt(t,e,"formEncType",n.formEncType,n,null),Vt(t,e,"formMethod",n.formMethod,n,null),Vt(t,e,"formTarget",n.formTarget,n,null)):(Vt(t,e,"encType",n.encType,n,null),Vt(t,e,"method",n.method,n,null),Vt(t,e,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=di(a),t.setAttribute(l,a);break;case"onClick":a!=null&&(t.onclick=wl);return;case"onScroll":a!=null&&At("scroll",t);return;case"onScrollEnd":a!=null&&At("scrollend",t);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}l=di(a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",l);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,""):t.removeAttribute(l);break;case"capture":case"download":a===!0?t.setAttribute(l,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(l,a):t.removeAttribute(l);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(l):t.setAttribute(l,a);break;case"popover":At("beforetoggle",t),At("toggle",t),oi(t,"popover",a);break;case"xlinkActuate":Il(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":Il(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":Il(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":Il(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":Il(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":Il(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":Il(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":Il(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":Il(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":oi(t,"is",a);break;case"innerText":case"textContent":return;default:if(!(2<l.length)||l[0]!=="o"&&l[0]!=="O"||l[1]!=="n"&&l[1]!=="N")l=iv.get(l)||l,oi(t,l,a);else return}kt=!0}function Hr(t,e,l,a,n,u){switch(l){case"style":Do(t,a,u);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"children":if(typeof a=="string")xn(t,a);else if(typeof a=="number"||typeof a=="bigint")xn(t,""+a);else return;break;case"onScroll":a!=null&&At("scroll",t);return;case"onScrollEnd":a!=null&&At("scroLine truncated
`;qt&&(xe+=qt+`
`),Dt&&(xe+=(Array.isArray(Dt)?Dt.join(`
`):String(Dt))+`
`),xe+=JSON.stringify(nt,null,2).slice(0,4e3),jl(xe),ce("已返回登录信息,见下方")}catch(nt){ce("发起失败:"+nt.message)}},[]),U=d.useCallback(async()=>{const O=V.trim();if(!O){W("请填要查配额的用户 id");return}try{const J=await tt(K.adminQuotaUser(O));W("用户 "+O.slice(0,8)+":本月用量 "+fo(J.used_usd_micros)+" / 配额 "+fo(J.quota_usd_micros)+(J.remaining_usd_micros!==null&&J.remaining_usd_micros!==void 0?"(剩余 "+fo(J.remaining_usd_micros)+")":"")+" | 月份 "+(J.month||""))}catch(J){W(oo(J))}},[V]),$=d.useCallback(async()=>{if(window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。"))try{const J=(await tt(K.adminSweep,"POST",{})).swept||[];W("sweep 完成,清理 "+J.length+" 个任务"+(J.length?":"+J.join("、"):""))}catch(O){W(oo(O))}},[]),at=d.useCallback(async()=>{try{const O=await tt(K.adminBackup,"POST",{});W("备份完成:backend="+O.backend+" | 路径 "+O.path+" | 大小 "+O.size_bytes+" 字节 | 任务 "+O.task_count+" 行")}catch(O){W(oo(O))}},[]);return{keySaved:i,keyState:h,quotaText:g,healthText:A,adminGate:H,adminBody:Y,adminResult:k,adminUserId:V,setAdminUserId:I,projects:X,projectId:gt,setProjectId:xt,projectMsg:Z,githubRepos:w,workers:et,workersSummary:Bt,workersError:wt,members:ot,memberMsg:G,memberError:ut,skills:pt,apps:we,mcpServers:y,skillMsg:ct,skillError:Nt,mcpMsg:Rt,mcpOauthMsg:dt,mcpOauthBox:Ga,envs:Le,envCount:Gt,envMsg:Jl,envRepoLine:da,envRepoBar:zl,envRepoMsg:Tl,envSelect:se,setEnvSelect:re,models:rl,modelCustomVisible:ma,refreshHealth:qe,loadQuota:_l,probeAdmin:ol,refreshProjects:Wt,loadGithubRepos:Ul,loadRelayModels:mn,refreshWorkers:Qa,refreshEnvs:Se,refreshMembers:Xa,refreshSkills:ha,saveKeyInput:su,clearKeyInput:ru,createProject:Wl,createEnv:Ae,archiveEnv:fl,saveEnvRepo:Oe,inviteMember:ou,changeMemberRole:fu,reloadMcp:du,mcpOauth:B,adminQuota:U,adminSweep:$,adminBackup:at}}const mo=20;function ag(){const[i,r]=d.useState([]),[h,f]=d.useState(""),[g,C]=d.useState(""),[A,S]=d.useState(""),[H,R]=d.useState(""),[Y,x]=d.useState(0),[k,W]=d.useState(0),[V,I]=d.useState({}),X=d.useRef(0),ht=d.useRef({status:"",executor:"",q:""});ht.current={status:g,executor:A,q:H};const gt=d.useRef(""),xt=d.useCallback(async(ot,rt,G)=>{const N=ot!==void 0?ot:rt;if(!N){r([]),W(0),I({});return}gt.current=N;const ut=G!==void 0?G:X.current,St=ht.current;try{const pt=await tt(K.tasksPage(N,mo,ut*mo,St.status,St.executor,St.q));r(pt.tasks||[]),W(pt.total??(pt.tasks||[]).length),I(pt.status_counts||{}),f("")}catch(pt){f("加载失败:"+pt.message)}},[]),Z=d.useCallback(ot=>{const rt=Math.max(0,ot);X.current=rt,x(rt)},[]),Tt=d.useCallback(ot=>{Z(ot),xt(gt.current||void 0,void 0,ot)},[xt,Z]);d.useEffect(()=>{if(!gt.current)return;const ot=setTimeout(()=>{xt(gt.current,void 0,0)},300);return()=>clearTimeout(ot)},[g,A,H]);const w=d.useCallback(ot=>{C(ot),Z(0)},[Z]),Q=d.useCallback(ot=>{S(ot),Z(0)},[Z]),et=d.useCallback(ot=>{R(ot),Z(0)},[Z]),zt=d.useCallback(async ot=>{Z(0),await xt(ot,void 0,0)},[xt,Z]),Bt=Math.max(1,Math.ceil(k/mo)),vt=k===0?"共 0 个任务":"共 "+k+" 个任务,第 "+(Y+1)+" / "+Bt+" 页",wt=i,q=d.useCallback(async(ot,rt)=>{const G={...ot};rt.length&&(G.upload_id=await tg(rt));const N=await tt(K.tasksCreate,"POST",G);return"任务已建:"+Ne(N.id)},[]);return{tasks:i,taskError:h,statusFilter:g,setStatusFilter:w,executorFilter:A,setExecutorFilter:Q,searchText:H,setSearchText:et,filteredTasks:wt,taskCountText:vt,refreshTasks:xt,refreshTasksFirstPage:zt,page:Y,totalPages:Bt,taskTotal:k,taskStatusCounts:V,goToPage:Tt,dispatch:q}}function ng(i,r){const[h,f]=d.useState(null),[g,C]=d.useState(""),[A,S]=d.useState(""),[H,R]=d.useState(""),[Y,x]=d.useState(""),[k,W]=d.useState(void 0),[V,I]=d.useState(!1),[X,ht]=d.useState(""),[gt,xt]=d.useState([]),[Z,Tt]=d.useState([]),[w,Q]=d.useState(""),[et,zt]=d.useState([]),[Bt,vt]=d.useState(""),[wt,q]=d.useState(""),[ot,rt]=d.useState([]),[G,N]=d.useState(""),[ut,St]=d.useState(""),[pt,ge]=d.useState(""),[we,Ce]=d.useState(""),[y,D]=d.useState([]),[ct,it]=d.useState([]),[Nt,_t]=d.useState(""),[Rt,P]=d.useState([]),[dt,ce]=d.useState(""),[Ga,jl]=d.useState(""),[Le,le]=d.useState(""),[Gt,he]=d.useState(""),[Jl,pe]=d.useState([]),[da,Ve]=d.useState(""),[zl,Rl]=d.useState(null),Tl=d.useRef(null),Be=d.useRef(null),se=d.useRef(null);se.current=h;const re=d.useCallback(()=>{if(Tl.current){try{Tl.current.abort()}catch{}Tl.current=null}Be.current&&(clearTimeout(Be.current),Be.current=null)},[]),rl=d.useCallback(async B=>{try{const U=await tt(K.taskAgents(B));Tt(U.agents||[]),Q(U.note||"")}catch(U){Tt([]),Q("子代理链路加载失败:"+U.message)}},[]),Fl=d.useCallback(B=>{Be.current&&clearTimeout(Be.current),Be.current=setTimeout(()=>void rl(B),1e3)},[rl]),ma=d.useCallback(async B=>{if(!B.coordinator_parts){xt([]);return}try{const U=await tt(K.tasksByParent(B.project_id||"",B.id));xt(U.tasks||[])}catch{xt([])}},[]),Dl=d.useCallback(async B=>{try{Line truncated
`),xe+=JSON.stringify(nt,null,2).slice(0,4e3),jl(xe),ce("已返回登录信息,见下方")}catch(nt){ce("发起失败:"+nt.message)}},[]),U=d.useCallback(async()=>{const O=V.trim();if(!O){W("请填要查配额的用户 id");return}try{const J=await tt(K.adminQuotaUser(O));W("用户 "+O.slice(0,8)+":本月用量 "+fo(J.used_usd_micros)+" / 配额 "+fo(J.quota_usd_micros)+(J.remaining_usd_micros!==null&&J.remaining_usd_micros!==void 0?"(剩余 "+fo(J.remaining_usd_micros)+")":"")+" | 月份 "+(J.month||""))}catch(J){W(oo(J))}},[V]),$=d.useCallback(async()=>{if(window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。"))try{const J=(await tt(K.adminSweep,"POST",{})).swept||[];W("sweep 完成,清理 "+J.length+" 个任务"+(J.length?":"+J.join("、"):""))}catch(O){W(oo(O))}},[]),at=d.useCallback(async()=>{try{const O=await tt(K.adminBackup,"POST",{});W("备份完成:backend="+O.backend+" | 路径 "+O.path+" | 大小 "+O.size_bytes+" 字节 | 任务 "+O.task_count+" 行")}catch(O){W(oo(O))}},[]);return{keySaved:i,keyState:h,quotaText:g,healthText:A,adminGate:H,adminBody:Y,adminResult:k,adminUserId:V,setAdminUserId:I,projects:X,projectId:gt,setProjectId:xt,projectMsg:Z,githubRepos:w,workers:et,workersSummary:Bt,workersError:wt,members:ot,memberMsg:G,memberError:ut,skills:pt,apps:we,mcpServers:y,skillMsg:ct,skillError:Nt,mcpMsg:Rt,mcpOauthMsg:dt,mcpOauthBox:Ga,envs:Le,envCount:Gt,envMsg:Jl,envRepoLine:da,envRepoBar:zl,envRepoMsg:Tl,envSelect:se,setEnvSelect:re,models:rl,modelCustomVisible:ma,refreshHealth:qe,loadQuota:_l,probeAdmin:ol,refreshProjects:Wt,loadGithubRepos:Ul,loadRelayModels:mn,refreshWorkers:Qa,refreshEnvs:Se,refreshMembers:Xa,refreshSkills:ha,saveKeyInput:su,clearKeyInput:ru,createProject:Wl,createEnv:Ae,archiveEnv:fl,saveEnvRepo:Oe,inviteMember:ou,changeMemberRole:fu,reloadMcp:du,mcpOauth:B,adminQuota:U,adminSweep:$,adminBackup:at}}const mo=20;function ag(){const[i,r]=d.useState([]),[h,f]=d.useState(""),[g,N]=d.useState(""),[A,S]=d.useState(""),[H,R]=d.useState(""),[Y,x]=d.useState(0),[k,W]=d.useState(0),[V,I]=d.useState({}),X=d.useRef(0),ht=d.useRef({status:"",executor:"",q:""});ht.current={status:g,executor:A,q:H};const gt=d.useRef(""),xt=d.useCallback(async(ot,rt,G)=>{const C=ot!==void 0?ot:rt;if(!C){r([]),W(0),I({});return}gt.current=C;const ut=G!==void 0?G:X.current,St=ht.current;try{const pt=await tt(K.tasksPage(C,mo,ut*mo,St.status,St.executor,St.q));r(pt.tasks||[]),W(pt.total??(pt.tasks||[]).length),I(pt.status_counts||{}),f("")}catch(pt){f("加载失败:"+pt.message)}},[]),Z=d.useCallback(ot=>{const rt=Math.max(0,ot);X.current=rt,x(rt)},[]),Tt=d.useCallback(ot=>{Z(ot),xt(gt.current||void 0,void 0,ot)},[xt,Z]);d.useEffect(()=>{if(!gt.current)return;const ot=setTimeout(()=>{xt(gt.current,void 0,0)},300);return()=>clearTimeout(ot)},[g,A,H]);const w=d.useCallback(ot=>{N(ot),Z(0)},[Z]),Q=d.useCallback(ot=>{S(ot),Z(0)},[Z]),et=d.useCallback(ot=>{R(ot),Z(0)},[Z]),zt=d.useCallback(async ot=>{Z(0),await xt(ot,void 0,0)},[xt,Z]),Bt=Math.max(1,Math.ceil(k/mo)),vt=k===0?"共 0 个任务":"共 "+k+" 个任务,第 "+(Y+1)+" / "+Bt+" 页",wt=i,q=d.useCallback(async(ot,rt)=>{const G={...ot};rt.length&&(G.upload_id=await tg(rt));const C=await tt(K.tasksCreate,"POST",G);return"任务已建:"+Ne(C.id)},[]);return{tasks:i,taskError:h,statusFilter:g,setStatusFilter:w,executorFilter:A,setExecutorFilter:Q,searchText:H,setSearchText:et,filteredTasks:wt,taskCountText:vt,refreshTasks:xt,refreshTasksFirstPage:zt,page:Y,totalPages:Bt,taskTotal:k,taskStatusCounts:V,goToPage:Tt,dispatch:q}}function ng(i,r){const[h,f]=d.useState(null),[g,N]=d.useState(""),[A,S]=d.useState(""),[H,R]=d.useState(""),[Y,x]=d.useState(""),[k,W]=d.useState(void 0),[V,I]=d.useState(!1),[X,ht]=d.useState(""),[gt,xt]=d.useState([]),[Z,Tt]=d.useState([]),[w,Q]=d.useState(""),[et,zt]=d.useState([]),[Bt,vt]=d.useState(""),[wt,q]=d.useState(""),[ot,rt]=d.useState([]),[G,C]=d.useState(""),[ut,St]=d.useState(""),[pt,ge]=d.useState(""),[we,Ce]=d.useState(""),[y,D]=d.useState([]),[ct,it]=d.useState([]),[Nt,_t]=d.useState(""),[Rt,P]=d.useState([]),[dt,ce]=d.useState(""),[Ga,jl]=d.useState(""),[Le,le]=d.useState(""),[Gt,he]=d.useState(""),[Jl,pe]=d.useState([]),[da,Ve]=d.useState(""),[zl,Rl]=d.useState(null),Tl=d.useRef(null),Be=d.useRef(null),se=d.useRef(null);se.current=h;const re=d.useCallback(()=>{if(Tl.current){try{Tl.current.abort()}catch{}Tl.current=null}Be.current&&(clearTimeout(Be.current),Be.current=null)},[]),rl=d.useCallback(async B=>{try{const U=await tt(K.taskAgents(B));Tt(U.agents||[]),Q(U.note||"")}catch(U){Tt([]),Q("子代理链路加载失败:"+U.message)}},[]),Fl=d.useCallback(B=>{Be.current&&clearTimeout(Be.current),Be.current=setTimeout(()=>void rl(B),1e3)},[rl]),ma=d.useCallback(async B=>{if(!B.coordinator_parts){xt([]);return}try{const U=await tt(K.tasksByParent(B.project_id||"",B.id));xt(U.tasks||[])}catch{xt([])}},[]),Dl=d.useCallback(async B=>{try{Line truncated
`);hn=Hl.pop()||"";for(const mu of Hl){const hu=mu.split(`
`).filter(Ze=>Ze.indexOf("data: ")===0).map(Ze=>Ze.slice(6)).join(`
`);if(hu)try{const Ze=JSON.parse(hu);if(Ze.type==="snapshot")at=Ze.entries||[],Object.keys(O).forEach(ae=>delete O[ae]),at.forEach((ae,ve)=>{ae.id&&(O[ae.id]=ve)}),J=Ze.note||"",rt([...at]),N(J),Fl(B);else if(Ze.type==="entry"){const ae=Ze.entry;ae.id&&ae.id in O?at[O[ae.id]]=ae:(ae.id&&(O[ae.id]=at.length),at.push(ae)),rt([...at]),Fl(B)}else Ze.type==="done"&&(nt=!0,qt=Ze.status,St(qt))}catch{}}if(nt)break}}catch(Dt){if(Dt&&Dt.name==="AbortError")return;N("链路流中断:"+Dt.message);return}nt&&r()},[re,Fl,r]),Se=d.useCallback(async B=>{re();try{const U=await tt(K.task(B));f(U);let $="";U.result_text&&($+=`=== result ===
`);if(hu)try{const Ze=JSON.parse(hu);if(Ze.type==="snapshot")at=Ze.entries||[],Object.keys(O).forEach(ae=>delete O[ae]),at.forEach((ae,ve)=>{ae.id&&(O[ae.id]=ve)}),J=Ze.note||"",rt([...at]),C(J),Fl(B);else if(Ze.type==="entry"){const ae=Ze.entry;ae.id&&ae.id in O?at[O[ae.id]]=ae:(ae.id&&(O[ae.id]=at.length),at.push(ae)),rt([...at]),Fl(B)}else Ze.type==="done"&&(nt=!0,qt=Ze.status,St(qt))}catch{}}if(nt)break}}catch(Dt){if(Dt&&Dt.name==="AbortError")return;C("链路流中断:"+Dt.message);return}nt&&r()},[re,Fl,r]),Se=d.useCallback(async B=>{re();try{const U=await tt(K.task(B));f(U);let $="";U.result_text&&($+=`=== result ===
`+U.result_text.slice(0,8e3)+`
`),U.error_text&&($+=`=== error ===
`+U.error_text.slice(0,8e3)+`
`),$||($="(暂无 result/error 输出)"),C($),x(""),W(void 0),Dl(B),I(!1),ht(""),ce(""),he(""),mn(U.status);const at=i(U.project_id),O=at==="maintainer"||at==="owner",J=at==="contributor"||O,nt=uu.includes(U.status),qt=[];["failed","interrupted","merge-failed"].includes(U.status)&&O||qt.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试"+(O?"":",且仅 maintainer 可重试")),!nt&&J||qt.push("取消:不可用——"+(nt?"终态任务不可取消":"仅项目成员(contributor 及以上)可取消")),U.status==="completed"&&O||qt.push("合并:不可用——仅 completed 可合并"+(O?"":",且仅 maintainer 可合并")),U.status==="merged"&&O||qt.push("提 PR:不可用——仅 merged 可提 PR"+(O?"":",且仅 maintainer 可提 PR")),R(qt.join(";")),S(""),Qa(B,U.status),rl(B),ma(U),Pe(B),te(B),ol(B),Wt(B),Ul(B),qe(B),_l(B)}catch(U){f(null),C("加载失败:"+U.message)}},[re,Qa,rl,ma,Pe,te,ol,Wt,Ul,qe,_l,Dl,i]),Xa=d.useCallback(async()=>{const B=se.current;if(B){try{const U=await tt(K.task(B.id));f(U);let $="";U.result_text&&($+=`=== result ===
`),$||($="(暂无 result/error 输出)"),N($),x(""),W(void 0),Dl(B),I(!1),ht(""),ce(""),he(""),mn(U.status);const at=i(U.project_id),O=at==="maintainer"||at==="owner",J=at==="contributor"||O,nt=uu.includes(U.status),qt=[];["failed","interrupted","merge-failed"].includes(U.status)&&O||qt.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试"+(O?"":",且仅 maintainer 可重试")),!nt&&J||qt.push("取消:不可用——"+(nt?"终态任务不可取消":"仅项目成员(contributor 及以上)可取消")),U.status==="completed"&&O||qt.push("合并:不可用——仅 completed 可合并"+(O?"":",且仅 maintainer 可合并")),U.status==="merged"&&O||qt.push("提 PR:不可用——仅 merged 可提 PR"+(O?"":",且仅 maintainer 可提 PR")),R(qt.join(";")),S(""),Qa(B,U.status),rl(B),ma(U),Pe(B),te(B),ol(B),Wt(B),Ul(B),qe(B),_l(B)}catch(U){f(null),N("加载失败:"+U.message)}},[re,Qa,rl,ma,Pe,te,ol,Wt,Ul,qe,_l,Dl,i]),Xa=d.useCallback(async()=>{const B=se.current;if(B){try{const U=await tt(K.task(B.id));f(U);let $="";U.result_text&&($+=`=== result ===
`+U.result_text.slice(0,8e3)+`
`),U.error_text&&($+=`=== error ===
`+U.error_text.slice(0,8e3)+`
`),C($||"(暂无 result/error 输出)")}catch{}te(B.id),Pe(B.id),qe(B.id),_l(B.id),ma(B),rl(B.id),ol(B.id),Wt(B.id),Ul(B.id),Dl(B.id)}},[te,Pe,qe,_l,ma,rl,ol,Wt,Ul,Dl]),ha=d.useCallback(async B=>{const U=se.current;if(!U)return;const $={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!($[B]&&!window.confirm($[B])))try{const at=await tt(K.taskAction(U.id,B),"POST",{});S(B+" 已提交:"+JSON.stringify(at).slice(0,300)),r(),await Se(U.id)}catch(at){S(B+" 失败:"+at.message)}},[Se,r]),su=d.useCallback(async()=>{const B=se.current;if(B)try{const U=await tt(K.taskCost(B.id));x(JSON.stringify(U,null,2).slice(0,4e3))}catch(U){x("查成本失败:"+U.message)}},[]),ru=d.useCallback(async B=>{const U=se.current;if(U){if(!B.trim()){ht("分支消息必填");return}if(!uu.includes(U.status)){ht("仅终态任务可分支");return}try{const $=await tt(K.taskFork(U.id),"POST",{message:B.trim()});ht("分支任务已建:"+$.id.slice(0,8)),r()}catch($){ht("分支失败:"+$.message)}}},[r]),Wl=d.useCallback(async B=>{const U=se.current;if(U)try{await tt(K.taskReviews(U.id),"POST",B),vt("评审已登记(requested)"),await qe(U.id)}catch($){vt("登记评审需求失败:"+$.message)}},[qe]),Ae=d.useCallback(B=>{const U=se.current;if(!U)return;const $=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");$!==null&&tt(K.taskReviewFindings(U.id,B),"POST",{findings:$}).then(()=>void qe(U.id)).catch(at=>vt("写 findings 失败:"+at.message))},[qe]),fl=d.useCallback((B,U)=>{const $=se.current;if(!$)return;const at=U==="approved"?"approve":"deny";tt(K.taskApprovalDecision($.id,B,at),"POST",{}).then(()=>{_t("已记录"+(U==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),_l($.id)}).catch(O=>_t("决定失败:"+O.message))},[_l]),Oe=d.useCallback(async(B,U,$)=>{const at=se.current;if(at){if(!U.trim()){ce("目标任务 id 不能为空");return}if(!$.trim()){ce("消息正文不能为空");return}try{const O=await tt(K.taskMessagesTo(U.trim()),"POST",{from_task_id:at.id,body:$.trim(),mode:B});ce("已发送("+O.delivery+")"),await ol(at.id)}catch(O){ce("发送失败:"+O.message)}}},[ol]),ou=d.useCallback((B,U)=>{const $=se.current;if(!$)return;if(!["completed","failed","interrupted"].includes($.status)){he("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const at={};if(B==="rename"){if(!U.trim()){he("新线程名不能为空");return}at.name=U.trim()}tt(K.taskThreadAction($.id,B),"POST",at).then(O=>{he(B+" 已提交:"+JSON.stringify(O).slice(0,300)),Wt($.id),Ul($.id)}).catch(O=>he(B+" 失败:"+O.message))},[Wt,Ul]),fu=d.useCallback(async B=>{const U=se.current;if(!U)return;const $=B.trim();if(!$){Ve("消息不能为空");return}try{uu.includes(U.status)&&U.status!=="merged"?(await tt(K.taskContinue(U.id),"POST",{message:$}),Ve("已重新排队,同一会话继续"),r(),await Se(U.id)):["queued","running"].includes(U.status)?(await tt(K.taskFollowups(U.id),"POST",{body:$,mode:"direct"}),Ve("已直接发送"),await te(U.id),await Pe(U.id)):Ve("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(at){Ve("发送失败:"+at.message)}},[Se,r,te,Pe]),du=d.useCallback(async()=>{const B=se.current;if(!B){Ce("先选一个任务");return}try{const U=await tt(K.taskTrace(B.id)),$=new Blob([JSON.stringify({task_id:B.id,status:U.status,note:U.note||"",count:(U.entries||[]).length,entries:U.entries||[]},null,2)],{type:"application/json"}),at=document.createElement("a");at.href=URL.createObjectURL($),at.download="trace-"+B.id.slice(0,8)+".json",document.body.appendChild(at),at.click(),at.remove(),setTimeout(()=>URL.revokeObjectURL(at.href),1e3),Ce("已下载完整链路 "+(U.entries||[]).length+" 条")}catch(U){Ce("下载失败:"+U.message)}},[]);return d.useEffect(()=>()=>re(),[re]),{currentTask:h,detailBody:g,actionMsg:A,actionReasons:H,costBox:Y,costSummary:k,forkVisible:V,setForkVisible:I,forkMsg:X,coordChildren:gt,agents:Z,agentsNote:w,reviews:et,reviewMsg:Bt,reviewError:wt,traceEntries:ot,traceNote:G,traceDone:ut,traceKind:pt,setTraceKind:ge,traceMsg:we,followups:y,approvals:ct,approvalMsg:Nt,peerMessages:Rt,peerMsg:dt,threadText:Ga,threadGateNote:Le,threadMsg:Gt,threadList:Jl,chatMsg:da,chatHistory:zl,showDetail:Se,refreshDetailLists:Xa,doAction:ha,taskCost:su,forkTask:ru,postReview:Wl,submitFindings:Ae,decideApproval:fl,postPeer:Oe,threadAction:ou,sendChatMessage:fu,downloadTrace:du,refreshReviews:()=>void qe(),refreshApprovals:()=>void _l()}}function ug(i){const[r,h]=d.useState([]),[f,g]=d.useState(""),[C,A]=d.useState(null),[S,H]=d.useState(""),R=d.useCallback(async V=>{const I=V!==void 0?V:i();if(!I){h([]);return}try{const X=await tt(K.projectAutomations(I));h(X.automations||[])}catch(X){g("自动化加载失败:"+X.message)}}Line truncated
(链路摘要已截断至 300 字,完整回复见右侧任务结果)`:f,label:"助手"}}if(i.kind==="exec"||i.kind==="file"||i.kind==="tool"||i.kind==="call"){const f=i.kind==="exec"?"命令":i.kind==="file"?"文件":i.kind==="tool"?"工具":"调用";return{id:h,role:"tool",kind:i.kind,text:vo(i),label:f}}return i.kind==="plan"?{id:h,role:"status",kind:i.kind,text:i.text||""}:i.kind==="reasoning"?{id:h,role:"status",kind:i.kind,text:"思考:"+(i.summary||"")}:i.kind==="agent-start"?{id:h,role:"status",kind:i.kind,text:"子代理开始:"+(i.name||"")+(i.prompt?"("+i.prompt+")":"")}:i.kind==="agent-done"?{id:h,role:"status",kind:i.kind,text:"子代理结束:"+(i.name||"")+" "+(i.status||"")}:{id:h,role:"status",kind:i.kind,text:vo(i)}}function pg(){const i=Zt(),r=i.currentTask,h=d.useMemo(()=>{if(!r)return[];let H;if(i.chatHistory){H=i.chatHistory.messages.map(V=>({id:V.id,role:V.role,text:V.text,label:V.label,kind:V.kind}));const k=new Set(H.map(V=>V.id)),W=i.traceEntries.filter(V=>V.id&&!k.has(V.id)&&!String(V.id).startsWith("fu-")).map((V,I)=>Xh(V,I));H=[...H,...W]}else{const k=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],W=i.traceEntries.filter(I=>!String(I.id||"").startsWith("fu-")).map((I,X)=>Xh(I,X)),V=i.followups.map((I,X)=>({id:"followup-"+X,role:"user",text:String(I.body||""),label:"后续消息"}));H=[...k,...W,...V]}const R=i.traceKind,Y=R?H.filter(k=>k.role==="user"||!k.kind||k.kind===R):H,x=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...Y,...x]},[r,i.chatHistory,i.traceEntries,i.followups,i.traceKind,i.traceDone]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const f=uu.includes(r.status),g=["queued","running"].includes(r.status),C=f||g,A=f?"终态任务:发送走继续接口(同一会话再跑一轮)":g?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",S=i.chatHistory?i.chatHistory.legacy_continue_count??null:null;return c.jsxs("div",{className:"chat-column",children:[c.jsx("div",{className:"muted",children:"实时对话"}),c.jsxs(Ht,{children:[c.jsxs("select",{"aria-label":"链路类型",value:i.traceKind,onChange:H=>i.setTraceKind(H.target.value),children:[c.jsx("option",{value:"",children:"链路类型:全部"}),["exec","file","call","tool","message","reasoning","plan","agent-start","agent-done"].map(H=>c.jsx("option",{value:H,children:H==="exec"?"命令":H==="file"?"文件":H==="call"?"调用":H==="tool"?"工具":H==="message"?"消息":H==="reasoning"?"思考":H==="plan"?"计划":"子代理"},H))]}),c.jsx("button",{onClick:i.downloadTrace,children:"下载完整链路"}),c.jsx("span",{className:"muted",children:i.traceMsg})]}),i.traceNote?c.jsx("div",{children:c.jsx("span",{className:"err",children:i.traceNote})}):null,S!==null&&S>0?c.jsxs("div",{className:"muted",children:["此前有 ",S," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):null,i.chatHistory&&i.chatHistory.trace_note?c.jsx("div",{className:"muted",children:i.chatHistory.trace_note}):null,c.jsx(yg,{messages:h,empty:"(暂无会话消息)"}),c.jsx(gg,{disabled:!C,placeholder:"消息",onSend:H=>void i.sendChatMessage(H)}),c.jsxs("div",{className:"muted",children:[A,"。",i.chatMsg]}),c.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function Sg(){return Zt().currentTask?c.jsx("div",{className:"conversation",children:c.jsx(pg,{})}):c.jsx("div",{className:"conversation",children:c.jsx("div",{className:"muted",children:"在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。"})})}function xg(){const i=Zt(),r=i.currentTask,[h,f]=d.useState("");if(!r)return null;const g=uu.includes(r.status),C=["failed","interrupted","merge-failed"].includes(r.status);return c.jsxs(c.Fragment,{children:[c.jsxs(Ht,{children:[c.jsx("button",{disabled:!C,title:C?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),c.jsx("button",{className:"danger",disabled:g,title:g?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),children:"取消"}),c.jsx("button",{disabled:r.status!=="completed",title:r.status!=="completed"?"仅 completed 可合并":"",onClick:()=>i.doAction("merge"),children:"合并(completed → merged)"}),c.jsx("button",{disabled:r.status!=="merged",title:r.status!=="merged"?"仅 merged 可提 PR":"",onClick:()=>i.doAction("pr"),children:"提 PR(须 mergedLine truncated
`),N($||"(暂无 result/error 输出)")}catch{}te(B.id),Pe(B.id),qe(B.id),_l(B.id),ma(B),rl(B.id),ol(B.id),Wt(B.id),Ul(B.id),Dl(B.id)}},[te,Pe,qe,_l,ma,rl,ol,Wt,Ul,Dl]),ha=d.useCallback(async B=>{const U=se.current;if(!U)return;const $={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!($[B]&&!window.confirm($[B])))try{const at=await tt(K.taskAction(U.id,B),"POST",{});S(B+" 已提交:"+JSON.stringify(at).slice(0,300)),r(),await Se(U.id)}catch(at){S(B+" 失败:"+at.message)}},[Se,r]),su=d.useCallback(async()=>{const B=se.current;if(B)try{const U=await tt(K.taskCost(B.id));x(JSON.stringify(U,null,2).slice(0,4e3))}catch(U){x("查成本失败:"+U.message)}},[]),ru=d.useCallback(async B=>{const U=se.current;if(U){if(!B.trim()){ht("分支消息必填");return}if(!uu.includes(U.status)){ht("仅终态任务可分支");return}try{const $=await tt(K.taskFork(U.id),"POST",{message:B.trim()});ht("分支任务已建:"+$.id.slice(0,8)),r()}catch($){ht("分支失败:"+$.message)}}},[r]),Wl=d.useCallback(async B=>{const U=se.current;if(U)try{await tt(K.taskReviews(U.id),"POST",B),vt("评审已登记(requested)"),await qe(U.id)}catch($){vt("登记评审需求失败:"+$.message)}},[qe]),Ae=d.useCallback(B=>{const U=se.current;if(!U)return;const $=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");$!==null&&tt(K.taskReviewFindings(U.id,B),"POST",{findings:$}).then(()=>void qe(U.id)).catch(at=>vt("写 findings 失败:"+at.message))},[qe]),fl=d.useCallback((B,U)=>{const $=se.current;if(!$)return;const at=U==="approved"?"approve":"deny";tt(K.taskApprovalDecision($.id,B,at),"POST",{}).then(()=>{_t("已记录"+(U==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),_l($.id)}).catch(O=>_t("决定失败:"+O.message))},[_l]),Oe=d.useCallback(async(B,U,$)=>{const at=se.current;if(at){if(!U.trim()){ce("目标任务 id 不能为空");return}if(!$.trim()){ce("消息正文不能为空");return}try{const O=await tt(K.taskMessagesTo(U.trim()),"POST",{from_task_id:at.id,body:$.trim(),mode:B});ce("已发送("+O.delivery+")"),await ol(at.id)}catch(O){ce("发送失败:"+O.message)}}},[ol]),ou=d.useCallback((B,U)=>{const $=se.current;if(!$)return;if(!["completed","failed","interrupted"].includes($.status)){he("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const at={};if(B==="rename"){if(!U.trim()){he("新线程名不能为空");return}at.name=U.trim()}tt(K.taskThreadAction($.id,B),"POST",at).then(O=>{he(B+" 已提交:"+JSON.stringify(O).slice(0,300)),Wt($.id),Ul($.id)}).catch(O=>he(B+" 失败:"+O.message))},[Wt,Ul]),fu=d.useCallback(async B=>{const U=se.current;if(!U)return;const $=B.trim();if(!$){Ve("消息不能为空");return}try{uu.includes(U.status)&&U.status!=="merged"?(await tt(K.taskContinue(U.id),"POST",{message:$}),Ve("已重新排队,同一会话继续"),r(),await Se(U.id)):["queued","running"].includes(U.status)?(await tt(K.taskFollowups(U.id),"POST",{body:$,mode:"direct"}),Ve("已直接发送"),await te(U.id),await Pe(U.id)):Ve("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(at){Ve("发送失败:"+at.message)}},[Se,r,te,Pe]),du=d.useCallback(async()=>{const B=se.current;if(!B){Ce("先选一个任务");return}try{const U=await tt(K.taskTrace(B.id)),$=new Blob([JSON.stringify({task_id:B.id,status:U.status,note:U.note||"",count:(U.entries||[]).length,entries:U.entries||[]},null,2)],{type:"application/json"}),at=document.createElement("a");at.href=URL.createObjectURL($),at.download="trace-"+B.id.slice(0,8)+".json",document.body.appendChild(at),at.click(),at.remove(),setTimeout(()=>URL.revokeObjectURL(at.href),1e3),Ce("已下载完整链路 "+(U.entries||[]).length+" 条")}catch(U){Ce("下载失败:"+U.message)}},[]);return d.useEffect(()=>()=>re(),[re]),{currentTask:h,detailBody:g,actionMsg:A,actionReasons:H,costBox:Y,costSummary:k,forkVisible:V,setForkVisible:I,forkMsg:X,coordChildren:gt,agents:Z,agentsNote:w,reviews:et,reviewMsg:Bt,reviewError:wt,traceEntries:ot,traceNote:G,traceDone:ut,traceKind:pt,setTraceKind:ge,traceMsg:we,followups:y,approvals:ct,approvalMsg:Nt,peerMessages:Rt,peerMsg:dt,threadText:Ga,threadGateNote:Le,threadMsg:Gt,threadList:Jl,chatMsg:da,chatHistory:zl,showDetail:Se,refreshDetailLists:Xa,doAction:ha,taskCost:su,forkTask:ru,postReview:Wl,submitFindings:Ae,decideApproval:fl,postPeer:Oe,threadAction:ou,sendChatMessage:fu,downloadTrace:du,refreshReviews:()=>void qe(),refreshApprovals:()=>void _l()}}function ug(i){const[r,h]=d.useState([]),[f,g]=d.useState(""),[N,A]=d.useState(null),[S,H]=d.useState(""),R=d.useCallback(async V=>{const I=V!==void 0?V:i();if(!I){h([]);return}try{const X=await tt(K.projectAutomations(I));h(X.automations||[])}catch(X){g("自动化加载失败:"+X.message)}}Line truncated
(链路摘要已截断至 300 字,完整回复见右侧任务结果)`:f,label:"助手"}}if(i.kind==="exec"||i.kind==="file"||i.kind==="tool"||i.kind==="call"){const f=i.kind==="exec"?"命令":i.kind==="file"?"文件":i.kind==="tool"?"工具":"调用";return{id:h,role:"tool",kind:i.kind,text:vo(i),label:f}}return i.kind==="plan"?{id:h,role:"status",kind:i.kind,text:i.text||""}:i.kind==="reasoning"?{id:h,role:"status",kind:i.kind,text:"思考:"+(i.summary||"")}:i.kind==="agent-start"?{id:h,role:"status",kind:i.kind,text:"子代理开始:"+(i.name||"")+(i.prompt?"("+i.prompt+")":"")}:i.kind==="agent-done"?{id:h,role:"status",kind:i.kind,text:"子代理结束:"+(i.name||"")+" "+(i.status||"")}:{id:h,role:"status",kind:i.kind,text:vo(i)}}function pg(){const i=Zt(),r=i.currentTask,h=d.useMemo(()=>{if(!r)return[];let H;if(i.chatHistory){H=i.chatHistory.messages.map(V=>({id:V.id,role:V.role,text:V.text,label:V.label,kind:V.kind}));const k=new Set(H.map(V=>V.id)),W=i.traceEntries.filter(V=>V.id&&!k.has(V.id)&&!String(V.id).startsWith("fu-")).map((V,I)=>Xh(V,I));H=[...H,...W]}else{const k=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],W=i.traceEntries.filter(I=>!String(I.id||"").startsWith("fu-")).map((I,X)=>Xh(I,X)),V=i.followups.map((I,X)=>({id:"followup-"+X,role:"user",text:String(I.body||""),label:"后续消息"}));H=[...k,...W,...V]}const R=i.traceKind,Y=R?H.filter(k=>k.role==="user"||!k.kind||k.kind===R):H,x=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...Y,...x]},[r,i.chatHistory,i.traceEntries,i.followups,i.traceKind,i.traceDone]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const f=uu.includes(r.status),g=["queued","running"].includes(r.status),N=f||g,A=f?"终态任务:发送走继续接口(同一会话再跑一轮)":g?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",S=i.chatHistory?i.chatHistory.legacy_continue_count??null:null;return c.jsxs("div",{className:"chat-column",children:[c.jsx("div",{className:"muted",children:"实时对话"}),c.jsxs(Ht,{children:[c.jsxs("select",{"aria-label":"链路类型",value:i.traceKind,onChange:H=>i.setTraceKind(H.target.value),children:[c.jsx("option",{value:"",children:"链路类型:全部"}),["exec","file","call","tool","message","reasoning","plan","agent-start","agent-done"].map(H=>c.jsx("option",{value:H,children:H==="exec"?"命令":H==="file"?"文件":H==="call"?"调用":H==="tool"?"工具":H==="message"?"消息":H==="reasoning"?"思考":H==="plan"?"计划":"子代理"},H))]}),c.jsx("button",{onClick:i.downloadTrace,children:"下载完整链路"}),c.jsx("span",{className:"muted",children:i.traceMsg})]}),i.traceNote?c.jsx("div",{children:c.jsx("span",{className:"err",children:i.traceNote})}):null,S!==null&&S>0?c.jsxs("div",{className:"muted",children:["此前有 ",S," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):null,i.chatHistory&&i.chatHistory.trace_note?c.jsx("div",{className:"muted",children:i.chatHistory.trace_note}):null,c.jsx(yg,{messages:h,empty:"(暂无会话消息)"}),c.jsx(gg,{disabled:!N,placeholder:"消息",onSend:H=>void i.sendChatMessage(H)}),c.jsxs("div",{className:"muted",children:[A,"。",i.chatMsg]}),c.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function Sg(){return Zt().currentTask?c.jsx("div",{className:"conversation",children:c.jsx(pg,{})}):c.jsx("div",{className:"conversation",children:c.jsx("div",{className:"muted",children:"在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。"})})}function xg(){const i=Zt(),r=i.currentTask,[h,f]=d.useState("");if(!r)return null;const g=uu.includes(r.status),N=["failed","interrupted","merge-failed"].includes(r.status);return c.jsxs(c.Fragment,{children:[c.jsxs(Ht,{children:[c.jsx("button",{disabled:!N,title:N?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),c.jsx("button",{className:"danger",disabled:g,title:g?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),children:"取消"}),c.jsx("button",{disabled:r.status!=="completed",title:r.status!=="completed"?"仅 completed 可合并":"",onClick:()=>i.doAction("merge"),children:"合并(completed → merged)"}),c.jsx("button",{disabled:r.status!=="merged",title:r.status!=="merged"?"仅 merged 可提 PR":"",onClick:()=>i.doAction("pr"),children:"提 PR(须 mergedLine truncated
`);for(;ot>=0;){const rt=vt.slice(0,ot);vt=vt.slice(ot+2);for(const G of rt.split(`
`))if(G.startsWith("data: "))try{const N=JSON.parse(G.slice(6));N.waiting?S(N.task_status==="interrupted"?"任务已中断,等待续跑…":N.task_status==="paused"?"任务已暂停…":"等待会话…"):N.image&&(f("data:image/jpeg;base64,"+N.image),S("直播中"));const ut={};N.holder&&(ut.holder=N.holder),N.url&&(ut.url=N.url),N.title!==void 0&&(ut.title=N.title),Object.keys(ut).length>0&&C(St=>({...St||{},...ut}))}catch{}ot=vt.indexOf(`
`))if(G.startsWith("data: "))try{const C=JSON.parse(G.slice(6));C.waiting?S(C.task_status==="interrupted"?"任务已中断,等待续跑…":C.task_status==="paused"?"任务已暂停…":"等待会话…"):C.image&&(f("data:image/jpeg;base64,"+C.image),S("直播中"));const ut={};C.holder&&(ut.holder=C.holder),C.url&&(ut.url=C.url),C.title!==void 0&&(ut.title=C.title),Object.keys(ut).length>0&&N(St=>({...St||{},...ut}))}catch{}ot=vt.indexOf(`
`)}}S("流已断开")}catch(et){if(et.name==="AbortError")return;R(et instanceof Error?et.message:String(et)),S("未开始")}finally{x(!1)}},[r]),X=d.useCallback(()=>{var w;(w=k.current)==null||w.abort(),x(!1),S("已停止")},[]),ht=d.useCallback(async w=>{if(r)try{const Q=await tt(K.taskBrowserControl(r.id),"POST",{holder:w});C(Q),R("")}catch(Q){R(Q instanceof Error?Q.message:String(Q))}},[r]),gt=d.useCallback(async w=>{if(!r||(g==null?void 0:g.holder)!=="user"||!W.current)return;const Q=W.current.getBoundingClientRect(),et=Math.round((w.clientX-Q.left)/Q.width*1280),zt=Math.round((w.clientY-Q.top)/Q.height*800);try{await tt(K.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:et,y:zt})}catch(Bt){R(Bt instanceof Error?Bt.message:String(Bt))}},[r,g]),xt=d.useCallback(async w=>{if(!(!r||(g==null?void 0:g.holder)!=="user")){w.preventDefault();try{w.key.length===1?await tt(K.taskBrowserInput(r.id),"POST",{type:"type",text:w.key}):await tt(K.taskBrowserInput(r.id),"POST",{type:"key",key:w.key})}catch(Q){R(Q instanceof Error?Q.message:String(Q))}}},[r,g]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const Z=(g==null?void 0:g.holder)||"—",Tt=g!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:A})]}),c.jsxs("span",{className:"muted",children:["会话 ",Tt?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",Z]}),g!=null&&g.url?c.jsx("span",{className:"muted",title:g.url,children:g.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[Y?c.jsx("button",{type:"button",onClick:X,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void I(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void V(),children:"刷新状态"}),Z!=="user"?c.jsx("button",{type:"button",onClick:()=>void ht("user"),disabled:!Tt,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void ht("agent"),children:"交还给 Agent"})]}),H?c.jsx("div",{className:"muted",style:{marginTop:8},children:H}):null,Z==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:W,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:w=>void gt(w),onKeyDown:w=>void xt(w),style:{width:"100%",maxWidth:1280,cursor:Z==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:Tt?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function jg(){const i=Zt(),[r,h]=d.useState("uncommittedChanges"),[f,g]=d.useState(""),[C,A]=d.useState(""),[S,H]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:R=>h(R.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:R=>g(R.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:C,onChange:R=>A(R.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:S,onChange:R=>H(R.target.value)}),c.jsxs(Ht,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:C.trim()||void 0,instructions:S.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(cu,{children:i.reviews.map(R=>{let Y=R.target_type||"";return R.branch&&(Y+=":"+R.branch),R.sha&&(Y+=":"+String(R.sha).slice(0,12)),R.instructions&&(Y+=":"+String(R.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",Ne(R.id)," | ",Y," | ",c.jsx("span",{className:"st-"+(R.status||""),children:RLine truncated
`)}}S("流已断开")}catch(et){if(et.name==="AbortError")return;R(et instanceof Error?et.message:String(et)),S("未开始")}finally{x(!1)}},[r]),X=d.useCallback(()=>{var w;(w=k.current)==null||w.abort(),x(!1),S("已停止")},[]),ht=d.useCallback(async w=>{if(r)try{const Q=await tt(K.taskBrowserControl(r.id),"POST",{holder:w});N(Q),R("")}catch(Q){R(Q instanceof Error?Q.message:String(Q))}},[r]),gt=d.useCallback(async w=>{if(!r||(g==null?void 0:g.holder)!=="user"||!W.current)return;const Q=W.current.getBoundingClientRect(),et=Math.round((w.clientX-Q.left)/Q.width*1280),zt=Math.round((w.clientY-Q.top)/Q.height*800);try{await tt(K.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:et,y:zt})}catch(Bt){R(Bt instanceof Error?Bt.message:String(Bt))}},[r,g]),xt=d.useCallback(async w=>{if(!(!r||(g==null?void 0:g.holder)!=="user")){w.preventDefault();try{w.key.length===1?await tt(K.taskBrowserInput(r.id),"POST",{type:"type",text:w.key}):await tt(K.taskBrowserInput(r.id),"POST",{type:"key",key:w.key})}catch(Q){R(Q instanceof Error?Q.message:String(Q))}}},[r,g]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const Z=(g==null?void 0:g.holder)||"—",Tt=g!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:A})]}),c.jsxs("span",{className:"muted",children:["会话 ",Tt?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",Z]}),g!=null&&g.url?c.jsx("span",{className:"muted",title:g.url,children:g.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[Y?c.jsx("button",{type:"button",onClick:X,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void I(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void V(),children:"刷新状态"}),Z!=="user"?c.jsx("button",{type:"button",onClick:()=>void ht("user"),disabled:!Tt,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void ht("agent"),children:"交还给 Agent"})]}),H?c.jsx("div",{className:"muted",style:{marginTop:8},children:H}):null,Z==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:W,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:w=>void gt(w),onKeyDown:w=>void xt(w),style:{width:"100%",maxWidth:1280,cursor:Z==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:Tt?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function jg(){const i=Zt(),[r,h]=d.useState("uncommittedChanges"),[f,g]=d.useState(""),[N,A]=d.useState(""),[S,H]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:R=>h(R.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:R=>g(R.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:N,onChange:R=>A(R.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:S,onChange:R=>H(R.target.value)}),c.jsxs(Ht,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:N.trim()||void 0,instructions:S.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(cu,{children:i.reviews.map(R=>{let Y=R.target_type||"";return R.branch&&(Y+=":"+R.branch),R.sha&&(Y+=":"+String(R.sha).slice(0,12)),R.instructions&&(Y+=":"+String(R.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",Ne(R.id)," | ",Y," | ",c.jsx("span",{className:"st-"+(R.status||""),children:RLine truncated
<style rel="stylesheet" crossorigin>:root{--text:#1f2328;--muted:#656d76;--subtle:#6e7781;--line:#d0d7de;--line-muted:#d8dee4;--paper:#ffffff;--card:#ffffff;--subtle-bg:#f6f8fa;--inset-bg:#f6f8fa;--accent:#0969da;--accent-emphasis:#0969da;--green:#2da44e;--green-hover:#2c974b;--success:#1a7f37;--danger:#d1242f;--danger-bg:#ffebe9;--attention:#9a6700;--done:#8250df;--selected-bg:#ddf4ff;--radius:6px;--shadow:0 1px 0 rgba(27,31,36,.04);--sans:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;--ctl:32px}*{box-sizing:border-box}html,body,#root{height:100%}body{font-family:var(--sans);margin:0;color:var(--text);background:#fff;line-height:1.5;font-size:14px}a{color:var(--accent);text-underline-offset:3px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}.muted{color:var(--muted);font-size:12px;line-height:1.5}.err{color:var(--danger)}.ok{color:var(--success)}button{cursor:pointer;font-weight:500;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text);box-shadow:var(--shadow)}button:hover{background:var(--subtle-bg)}button:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}button.primary,button.seal{background:var(--green);border-color:#1b1f2426;color:#fff}button.primary:hover,button.seal:hover{background:var(--green-hover)}button.danger{color:var(--danger);border-color:var(--line);background:#fff}button.danger:hover{background:var(--danger-bg);color:var(--danger)}.icon-btn{width:32px;height:32px;padding:0;display:grid;place-items:center;border:1px solid var(--line);border-radius:var(--radius);background:#fff;color:var(--text);font-size:16px;line-height:1}.icon-btn:hover{background:var(--subtle-bg)}.dash-topbar{position:sticky;top:0;z-index:10;background:#24292f;color:#fff;border-bottom:1px solid rgba(255,255,255,.15);padding:10px 20px 8px}.dash-topbar .muted{color:#ffffffbf}.dash-topbar-row{display:flex;gap:14px;align-items:center;flex-wrap:wrap}.dash-brand{font-weight:600;font-size:16px;color:#fff}.dash-status{font-size:13px;font-weight:600;color:#ffffffbf}.dash-status.ok{color:#2da44e}.dash-stamp{margin-left:auto}.dash-nav{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}.dash-nav a{color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.dash-nav a:hover{background:#ffffff1f;text-decoration:none}.tab-nav button{background:transparent;border:0;box-shadow:none;color:#fff;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.tab-nav button:hover{background:#ffffff1f}.tab-nav button.active{background:#ffffff2e}.dash-page-tabs{display:flex}.dash-page-tabs>.tab-content{grid-column:1 / -1;min-width:0;display:flex;flex-direction:column;gap:20px}.tab-workbench{display:grid;grid-template-columns:320px minmax(0,1fr);gap:20px;align-items:start}.tab-workbench>*{min-width:0}@media(max-width:899px){.tab-workbench{grid-template-columns:1fr}.tab-workbench #sec-dispatch{order:1}.tab-workbench #sec-tasks{order:2}.tab-workbench #sec-conv{order:3}}.dash-page{max-width:1180px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:20px}.dash-page>*{min-width:0}.dash-section>*{min-width:0}.dash-section{scroll-margin-top:130px;display:flex;flex-direction:column;gap:16px}.dash-section.card{padding:16px}.dash-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.dash-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px;min-width:0}.dash-card-label{color:var(--muted);font-size:12px;font-weight:600}.dash-card-value{font-size:24px;font-weight:600;line-height:1.3;overflow-wrap:anywhere}.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:none;padding:16px}.card h2{font-size:14px;margin:0 0 12px;font-weight:600}.card h3{font-size:14px;margin:24px 0 12px;padding-top:16px;border-top:1px solid var(--line);font-weight:600}.card h3:first-of-type{border-top:0;padding-top:0}.composer h2{font-size:16px}.composer textarea.prompt{min-height:148px;font-size:14px;padding:12px;border-radius:var(--radius)}.composer textarea.coord{font-family:var(--mono);font-size:13px}.bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}.bar>button,.bar>label{display:inline-flex;align-items:center;height:var(--ctl);min-height:var(--ctl);padding:0 16px;box-sizing:border-box;white-space:nowrap}.bar>input,.bar>select{height:var(--ctl);min-height:var(--ctl)}input,select,textarea,button{font:inherit;font-size:14px;padding:5px 12px;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px #0969da4d}select{-moz-appearance:none;appearance:none;-webkit-appearance:none;cursLine truncated
</head>
<body>
+30
View File
@@ -0,0 +1,30 @@
"""执行器基类(叶子模块,不依赖 cloud.sandbox 与 cloud.chv,
避免循环导入)。
原来在 cloud/sandbox.py 里;拆分后 Local/Docker/Cloud
执行器(sandbox.py 内)与 CloudHypervisorExecutor
(cloud/chv/executor.py)共用这一个类对象,isinstance
判断与旧路径 `from cloud.sandbox import Executor` 都不变
(sandbox.py 会 re-export 它)。
"""
class Executor:
"""执行器基类。
task 是 dict,含 id、project_id、repo、prompt、branch(可选)。
execute(task) -> dict,至少含 executor、task_id、status、stdout。
"""
def execute(self, task) -> dict:
raise NotImplementedError("子类必须实现 execute(task)")
def goal_task(self, task) -> dict:
"""目标循环(host.py goal):turn→验测→喂回重试。
子类实现;默认不支持(CloudExecutor 的通用 HTTP 协议里
没有 goal/验测概念,调它会如实失败,不静默降级成普通
dispatch)。结果 dict 除 execute() 的字段外另含:
goal_met(True/False/None=无结论)、goal_error。
"""
raise NotImplementedError("该执行器不支持 goal 模式")
+195 -123
View File
@@ -11,6 +11,13 @@
时不启动容器);超时与取消时 docker kill + rm -f,不留僵尸
容器。网络白名单不在本类里假装实现(见 cloud/README.md)。
- CloudExecutor:占位,未接通云端执行。
- CloudHypervisorExecutor:生产级 v1,Cloud Hypervisor VM 内经
SSH 调 host.py(已拆到 cloud/chv/,API 唤醒流程见该类
docstring:preflight → vmm.ping → vm.create → vm.boot →
SSH 就绪 → 跑任务 → finally 关 VM;任务内 LLM 绝不能
发现/调用它,执行器选择只在任务创建前由 server 白名单/
环境默认值/worker 认领决定)。本模块只留它的兼容
re-export,旧导入路径仍可用。
"""
import json
@@ -18,8 +25,10 @@ import logging
import os
import re
import shutil
import socket
import subprocess
import sys
import threading
import time
from cloud.marker_tools import extract_quota_status as _extract_quota_status
@@ -68,25 +77,10 @@ def _full_stdout(text):
_DOCKER_LOGGER = logging.getLogger("cloud.docker")
class Executor:
"""执行器基类。
task 是 dict,含 id、project_id、repo、prompt、branch(可选)。
execute(task) -> dict,至少含 executor、task_id、status、stdout。
"""
def execute(self, task) -> dict:
raise NotImplementedError("子类必须实现 execute(task)")
def goal_task(self, task) -> dict:
"""目标循环(host.py goal):turn→验测→喂回重试。
子类实现;默认不支持(CloudExecutor 的通用 HTTP 协议里
没有 goal/验测概念,调它会如实失败,不静默降级成普通
dispatch)。结果 dict 除 execute() 的字段外另含:
goal_met(True/False/None=无结论)、goal_error。
"""
raise NotImplementedError("该执行器不支持 goal 模式")
# 执行器基类已移到叶子模块 cloud/executor_base.py(供
# cloud/chv/executor.py 共用,避免循环导入);这里 re-export,
# 旧路径 `from cloud.sandbox import Executor` 仍可用。
from cloud.executor_base import Executor # noqa: F401
def _clean_usage(raw):
@@ -314,6 +308,133 @@ def _approval_cli_args(approval):
return []
def _host_command_options(model=None, effective_model=None,
model_provider=None, effort=None,
approval=None, sandbox=None):
"""host.py 顶层全局参数(Local/Docker/CHV 三执行器共享,
本轨去重复):顺序固定为 --model → --model-provider →
--effort → approval 参数 → --sandbox,且必须放在子命令
之前。approval=full 自带 danger-full-access,显式 sandbox
在后(argparse 里后出现的 --sandbox 覆盖前面的,见
LocalExecutor._base_command 的旧注释)。"""
cmd = []
effective = model or effective_model
if effective:
cmd += ["--model", effective]
if model_provider:
cmd += ["--model-provider", model_provider]
if effort:
cmd += ["--effort", effort]
cmd += _approval_cli_args(approval)
if sandbox:
cmd += ["--sandbox", sandbox]
return cmd
def _call_runner_with_timeout(runner, cmd, timeout=None):
"""runner 调用共享 helper(CHV 与 Docker 的 env=None 分支
共用):timeout 给定时优先传 timeout,假 runner 只接受
cmd 时 TypeError 回退到只传 cmd(超时护栏丢了,但绝不
因此静默换执行器;worker 层 task_timeout 仍是外层硬护栏)。
Docker 的 env 分支与 Local 的 _run_local 语义不同(env
丢了必须 loud fail,绝不静默降级),所以它们不走这里。"""
if timeout is None:
return runner(cmd)
try:
return runner(cmd, timeout=timeout)
except TypeError:
return runner(cmd)
def parse_goal_result(task_id, result, executor_name):
"""host.py goal 输出解析的正典实现(Local 与 CHV 共享,
本轨去重复;Docker 的 goal_task 仍保留内联版,见下文)。
解析用完整 stdout(结论在末尾),存库的 stdout 才截尾;
returncode 非 0 时:已打印 met/unmet 结论的仍判 failed,
无结论的异常退出判 interrupted(断点在盘上,可续跑,
绝不能一律 failed);host 打印了 interrupted 标记时也判
interrupted。为 0 时无结论仍判 failed,绝不把“没打印
结论”当成达标。host 任务已存在时(同任务 /retry 撞名,
goal 没有 run 续跑语义)错误写明“goal 失败请用继续而
非重试”。断点丢失拒绝(goal-resume 拒绝行)判 failed
终态,不进自动恢复。
Docker 未收敛的原因(有意保留,不是不小心漏掉):
Docker 的 goal-resume 变体故意没有“已存在”分支
(goal-resume 本来就是操作已有 host 任务,不存在
dispatch 撞名问题),且它的超时证据路径与解析绑在
一起;硬收敛会改 Docker 生产路径的语义,本轨不动它。
三执行器在通用输入下结论一致由 test_chv_optimize.py
的矩阵测试钉住(CHV 与 Local 全等;Docker 的通用
goal 路径由既有 docker 测试覆盖)。"""
returncode = getattr(result, "returncode", 1)
stdout_full = getattr(result, "stdout", "") or ""
stderr = getattr(result, "stderr", "") or ""
met, goal_error = _parse_goal_output(stdout_full)
interrupted_reason = _goal_interrupted_reason(stdout_full)
refusal = _goal_resume_refusal(stdout_full)
combined = stdout_full + stderr
perm_kind, perm_snippet = _classify_goal_error(
stdout_full, stderr)
if refusal is not None and met is None:
# 断点丢失拒绝:终态 failed,不进自动恢复。
met, goal_error = None, refusal
status = "failed"
elif "已存在" in combined:
met, goal_error = None, (
"host 任务已存在:goal 失败请用继续而非重试——"
"同任务重试会撞已存在 host-state,worker 会先"
"尝试断点续跑,断点续跑仍失败时请用继续(不要"
"重排同一任务)")
status = "failed"
elif interrupted_reason is not None:
# host 侧已记 interrupted:异常中断不是失败,云端跟
# 着记 interrupted,任务可 /goal-resume 断点续跑。
status = "interrupted"
goal_error = (interrupted_reason
or "host goal 异常中断,断点可续跑")
elif (met is None and returncode != 0
and perm_kind == "permanent"):
# P0 三件其二:永久错误(断点 KeyError、配置缺失等)
# 的无结论异常退出——同代码同断点重试必然再失败,
# 判 failed 终态进死信,绝不判 interrupted 空转。
# host 已明言 interrupted 的在上一分支,不受影响。
status = "failed"
goal_error = perm_snippet or "goal 永久错误"
elif returncode != 0:
if met is None:
# 无结论的异常退出(进程被杀、连接断后恢复时抛错
# 等):断点在盘上,记 interrupted 保住续跑活路,
# 绝不能一律 failed——2026-10-28 长 goal 实弹教训。
status = "interrupted"
goal_error = ((stderr.strip()[-500:] if stderr.strip()
else stdout_full.strip()[-500:])
or "host goal 异常退出(无结论),断点可续跑")
else:
status = "failed"
elif met is True:
status = "completed"
elif met is False:
status = "failed"
else:
status = "failed"
goal_error = goal_error or "host goal 输出无结论"
return {
"executor": executor_name,
"task_id": task_id,
"status": status,
"stdout": _goal_stdout_tail(stdout_full),
"stderr": stderr,
"returncode": returncode,
"host_status": ("goal-met" if met is True else
"goal-unmet" if met is False else None),
"goal_met": met,
"goal_error": goal_error,
"usage": _parse_goal_usage(stdout_full),
}
class LocalExecutor(Executor):
"""本地执行器:调 host.py CLI 子进程做执行封装。
@@ -356,22 +477,17 @@ class LocalExecutor(Executor):
if self.config_path is not None:
cmd += ["--config", self.config_path]
# G9:顶层全局参数,必须放在子命令之前(调用方在
# _base_command 之后才追加子命令)。
# _base_command 之后才追加子命令)。选项顺序与
# Docker/CHV 共享 _host_command_options;显式沙箱放
# 最后:argparse 里后出现的 --sandbox 覆盖前面的
# (approval=full 自带 danger-full-access);server 侧
# 已拒绝 plan+full 同存,这里是第二道防线。
if getattr(self, "ignore_host_budget", False):
cmd += ["--ignore-host-budget"]
effective = model or self.model
if effective:
cmd += ["--model", effective]
if model_provider:
cmd += ["--model-provider", model_provider]
if effort:
cmd += ["--effort", effort]
cmd += _approval_cli_args(approval)
# 显式沙箱放最后:argparse 里后出现的 --sandbox 覆盖前面
# 的(approval=full 自带 danger-full-access);server 侧
# 已拒绝 plan+full 同存,这里是第二道防线。
if sandbox:
cmd += ["--sandbox", sandbox]
cmd += _host_command_options(
model=model, effective_model=self.model,
model_provider=model_provider, effort=effort,
approval=approval, sandbox=sandbox)
return cmd
def _run_local(self, cmd, extra_env):
@@ -711,81 +827,15 @@ class LocalExecutor(Executor):
def _goal_result_from_runner(self, task_id, result):
"""把 host.py goal 的 runner 返回值解析成 goal 结果 dict。
解析用完整 stdout(结论在末尾),存库的 stdout 才截尾;
returncode 非 0 时:已打印 met/unmet 结论的仍判 failed,
无结论的异常退出判 interrupted(断点在盘上,可续跑,
绝不能一律 failed);host 打印了 interrupted 标记时也判
interrupted。为 0 时无结论仍判 failed,绝不把“没打印
结论”当成达标。host 任务已存在时(同任务 /retry 撞名,
goal 没有 run 续跑语义)错误写明“goal 失败请用继续而
非重试”(P1:重试在已存在 host-state 上必失败,断点续
跑才是正确路径,见 worker 的回落逻辑)。
直接委托给共享的 parse_goal_result(正典语义,见该
函数 docstring):解析用完整 stdout(结论在末尾),
存库的 stdout 才截尾;returncode 非 0 时已打印结论
的仍判 failed,无结论的异常退出判 interrupted;为 0
时无结论仍判 failed;“已存在”撞名与断点丢失拒绝判
failed 终态。本方法只定 executor="local",语义与旧
内联版逐行一致(旧版就是正典的来源)。
"""
returncode = getattr(result, "returncode", 1)
stdout_full = getattr(result, "stdout", "") or ""
stderr = getattr(result, "stderr", "") or ""
met, goal_error = _parse_goal_output(stdout_full)
interrupted_reason = _goal_interrupted_reason(stdout_full)
refusal = _goal_resume_refusal(stdout_full)
combined = stdout_full + stderr
perm_kind, perm_snippet = _classify_goal_error(
stdout_full, stderr)
if refusal is not None and met is None:
# 断点丢失拒绝:终态 failed,不进自动恢复(见 helper)。
met, goal_error = None, refusal
status = "failed"
elif "已存在" in combined:
met, goal_error = None, (
"host 任务已存在:goal 失败请用继续而非重试——"
"同任务重试会撞已存在 host-state,worker 会先"
"尝试断点续跑,断点续跑仍失败时请用继续(不要"
"重排同一任务)")
status = "failed"
elif interrupted_reason is not None:
# host 侧已记 interrupted:异常中断不是失败,云端跟
# 着记 interrupted,任务可 /goal-resume 断点续跑。
status = "interrupted"
goal_error = (interrupted_reason
or "host goal 异常中断,断点可续跑")
elif (met is None and returncode != 0
and perm_kind == "permanent"):
# P0 三件其二:永久错误(断点 KeyError、配置缺失等)
# 的无结论异常退出——同代码同断点重试必然再失败,
# 判 failed 终态进死信,绝不判 interrupted 空转。
# host 已明言 interrupted 的在上一分支,不受影响。
status = "failed"
goal_error = perm_snippet or "goal 永久错误"
elif returncode != 0:
if met is None:
# 无结论的异常退出(进程被杀、连接断后恢复时抛错
# 等):断点在盘上,记 interrupted 保住续跑活路,
# 绝不能一律 failed——2026-10-28 长 goal 实弹教训。
status = "interrupted"
goal_error = ((stderr.strip()[-500:] if stderr.strip()
else stdout_full.strip()[-500:])
or "host goal 异常退出(无结论),断点可续跑")
else:
status = "failed"
elif met is True:
status = "completed"
elif met is False:
status = "failed"
else:
status = "failed"
goal_error = goal_error or "host goal 输出无结论"
return {
"executor": "local",
"task_id": task_id,
"status": status,
"stdout": _goal_stdout_tail(stdout_full),
"stderr": stderr,
"returncode": returncode,
"host_status": ("goal-met" if met is True else
"goal-unmet" if met is False else None),
"goal_met": met,
"goal_error": goal_error,
"usage": _parse_goal_usage(stdout_full),
}
return parse_goal_result(task_id, result, "local")
def popen_task(self, task, kind="dispatch", seed_dir=None,
extra_env=None, model_provider=None):
@@ -1179,18 +1229,13 @@ class DockerExecutor(Executor):
cmd += ["--config",
"/app/" + os.path.basename(self.config_path)]
# G9:与 LocalExecutor 同款,顶层全局参数,放子命令之前。
# 选项顺序与 Local/CHV 共享 _host_command_options。
if getattr(self, "ignore_host_budget", False):
cmd += ["--ignore-host-budget"]
effective = model or self.model
if effective:
cmd += ["--model", effective]
if model_provider:
cmd += ["--model-provider", model_provider]
if effort:
cmd += ["--effort", effort]
cmd += _approval_cli_args(approval)
if sandbox:
cmd += ["--sandbox", sandbox]
cmd += _host_command_options(
model=model, effective_model=self.model,
model_provider=model_provider, effort=effort,
approval=approval, sandbox=sandbox)
cmd += list(args)
return cmd
@@ -1206,12 +1251,9 @@ class DockerExecutor(Executor):
丢掉密钥跑任务(那样容器内 key 缺失,任务必失败还难查)。
"""
if env is None:
try:
return self.runner(
cmd,
timeout=self.timeout if timeout is None else timeout)
except TypeError:
return self.runner(cmd)
return _call_runner_with_timeout(
self.runner, cmd,
self.timeout if timeout is None else timeout)
try:
return self.runner(
cmd,
@@ -1554,6 +1596,19 @@ class DockerExecutor(Executor):
"container": self.container_name(task),
"goal_met": None, "goal_error": str(exc)[:500],
}
# feat-chv-fix(P2-2,分叉说明 + 同步检查清单):
# 以下是 Docker goal 解析的内联版,故意不收敛进正典
# parse_goal_result()(本文件约 349 行起):① Docker
# 的 goal-resume 变体没有“已存在”分支(goal-resume
# 操作已有 host 任务,无 dispatch 撞名问题);② 超时
# 证据路径(_docker_timeout_evidence)与本解析绑在
# 一起(见本方法上方的 TimeoutExpired 分支)。
# 同步检查清单——改正典时必须同步核对这里,反之亦然:
# ① 非 0 退出但已打印 met 结论判 failed;② 无结论异常
# 退出判 interrupted(断点可续跑);③ 永久错误无结论判
# failed;④ interrupted 标记判 interrupted;⑤ rc==0
# 无结论判 failed;⑥ “已存在”撞名判 failed 并提示用
# 继续而非重试。改一处漏一处即回归,见 test 矩阵。
returncode = getattr(result, "returncode", 1)
stdout_full = getattr(result, "stdout", "") or ""
stderr = getattr(result, "stderr", "") or ""
@@ -2146,3 +2201,20 @@ class CloudExecutor(Executor):
def run_task(self, task) -> dict:
return self.execute(task)
# -- Cloud Hypervisor 执行器(已拆到 cloud/chv/,这里只留兼容 re-export) --
# #216 的 CHV 类(原 2233-3342 行)与 WarmPool(原 3342-3437 行)、
# _parse_memory_bytes、_unix_socket_api 已分别移到 cloud/chv/
# 的 executor.py / pool.py / config.py / api_client.py;本模块
# 不再保留它们的副本,旧导入路径(from cloud.sandbox import
# CloudHypervisorExecutor / WarmPool / ChvFailure)仍可用,
# 新代码优先从 cloud.chv import。执行器方法内按需 import
# 本模块共享 helper 的原因见 cloud/chv/executor.py 头注释。
from cloud.chv import ChvFailure # noqa: F401
from cloud.chv import CloudHypervisorExecutor # noqa: F401
from cloud.chv import WarmPool # noqa: F401
from cloud.chv.api_client import _unix_socket_api # noqa: F401
from cloud.chv.config import _parse_memory_bytes # noqa: F401
+22 -6
View File
@@ -494,6 +494,9 @@ def _task_worktree_for_files(server, task):
"""
if task.get("executor") == "cloud":
return None, (409, "cloud task worktrees are not on this host")
if task.get("executor") == "cloud-hypervisor":
return None, (409, "cloud-hypervisor task worktrees are "
"inside the VM, not on this host")
worktree_host = task.get("worktree_host")
if worktree_host and worktree_host != socket.gethostname():
return None, (409, "task worktree is on host %s;"
@@ -1550,7 +1553,7 @@ def _default_executor():
raw = raw.strip()
if raw == "":
return None
if raw in ("local", "docker", "cloud"):
if raw in ("local", "docker", "cloud", "cloud-hypervisor"):
return raw
return "docker"
@@ -2492,9 +2495,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(400, {"error": "name too long"})
return
executor = body.get("executor")
if executor is not None and executor not in ("local", "docker"):
if executor is not None and executor not in (
"local", "docker", "cloud-hypervisor"):
self._send_json(400, {
"error": "executor must be local or docker"})
"error": "executor must be local, docker or "
"cloud-hypervisor"})
return
try:
branch = _validate_env_branch(body.get("branch"))
@@ -4248,12 +4253,15 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(400, {"error": "priority must be an integer"})
return
# executor 可选:None 表示任意 worker 可认领;指定时只能是
# local/docker/cloud,非法值 400 且不建任务。
# local/docker/cloud/cloud-hypervisor,非法值 400 且不建任务。
# cloud-hypervisor 是创建层可见的调度选择(任务内 LLM
# 绝不能自选它,见 sandbox.py 该类 docstring)。
executor = body.get("executor")
if executor is not None and executor not in (
"local", "docker", "cloud"):
"local", "docker", "cloud", "cloud-hypervisor"):
self._send_json(400, {
"error": "executor must be local, docker or cloud"})
"error": "executor must be local, docker, cloud or "
"cloud-hypervisor"})
return
# 任务级选项:model 不做白名单(中转目录会变,下拉只是
# 建议值);effort 必须是 codex 的 effort 档位;approval
@@ -4481,6 +4489,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(400, {
"error": "附件不支持 cloud 执行器"})
return
if executor == "cloud-hypervisor":
self._send_json(400, {
"error": "附件不支持 cloud-hypervisor 执行器"})
return
conn0 = self._db()
try:
brow = conn0.execute(
@@ -5893,6 +5905,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if executor == "cloud":
return (409, "thread ops are local-executor only;"
" cloud task threads are not on this host")
if executor == "cloud-hypervisor":
return (409, "thread ops are local-executor only;"
" cloud-hypervisor task threads live inside"
" the VM, not on this host")
worktree_host = task.get("worktree_host")
if worktree_host and worktree_host != socket.gethostname():
return (409, "task worktree is on host %s;"
+594
View File
@@ -0,0 +1,594 @@
"""CHV 审核修复轨测试(堆叠于 #230,base=feat-chv-gaps)。
只用标准库与 mock runner/launcher/api_client,绝不启动真实
VM:当前环境没有可跑真 VM 的宿主。真实 VM 启动、seed 盘
在真机上的 cloud-init 应用、SSH 可达实测一律不在本文件
声称已验证;本文件只证明代码层面的接线(VmConfig 含 seed
盘、restore 不回落、reuse 拦截、SSH 目标解析、配额/快照/
API 完整性等)成立。
"""
import json
import os
import socket
import sys
import tempfile
import threading
import unittest
from unittest import mock
from pathlib import Path
from types import SimpleNamespace
try:
from cloud.chv.api_client import _unix_socket_api
from cloud.chv.disk import check_disk_quota, provision_task_disk
from cloud.chv.hostkeys import build_seed_disk, seed_disk_path
from cloud.chv.pool import WarmPool
from cloud.sandbox import (
ChvFailure,
CloudHypervisorExecutor,
)
from cloud.worker import Worker
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.chv.api_client import _unix_socket_api
from cloud.chv.disk import check_disk_quota, provision_task_disk
from cloud.chv.hostkeys import build_seed_disk, seed_disk_path
from cloud.chv.pool import WarmPool
from cloud.sandbox import (
ChvFailure,
CloudHypervisorExecutor,
)
from cloud.worker import Worker
class FakeResult:
def __init__(self, returncode=0, stdout="", stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
class FakeProc:
def __init__(self, alive=True):
self._alive = alive
def poll(self):
return None if self._alive else 1
def terminate(self):
self._alive = False
def wait(self, timeout=None):
return 0
def kill(self):
self._alive = False
def _write(path, content=b"x"):
with open(path, "wb") as fh:
fh.write(content)
os.chmod(path, 0o755)
return path
class ChvFixCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = self.tmp.name
self.binary = _write(os.path.join(self.dir, "cloud-hypervisor"))
self.ch_remote = _write(os.path.join(self.dir, "ch-remote"))
self.kernel = _write(os.path.join(self.dir, "vmlinux"))
self.rootfs = _write(os.path.join(self.dir, "disk.raw"),
b"R" * 100)
self.ssh_key = _write(os.path.join(self.dir, "id_chv"))
self.kvm = _write(os.path.join(self.dir, "kvm"))
self.cpuinfo = os.path.join(self.dir, "cpuinfo")
with open(self.cpuinfo, "w") as fh:
fh.write("flags : fpu vmx sse\n")
self.state_dir = os.path.join(self.dir, "state")
self.calls = []
self.api_calls = []
self.api_payloads = {}
self.audit = []
def tearDown(self):
self.tmp.cleanup()
def _default_runner(self, cmd, timeout=None):
self.calls.append(list(cmd))
if cmd and cmd[0] == "ssh":
if cmd[-1] == "true":
return FakeResult(0, "", "")
if "goal" in cmd:
return FakeResult(0, "goal met after rounds\n", "")
return FakeResult(0, "", "")
if cmd and cmd[0] == "ssh-keygen":
priv = cmd[cmd.index("-f") + 1]
with open(priv, "w") as fh:
fh.write("-----BEGIN OPENSSH PRIVATE KEY-----\nFAKE\n")
os.chmod(priv, 0o600)
with open(priv + ".pub", "w") as fh:
fh.write("ssh-ed25519 AAAAfake1 vm-key\n")
return FakeResult(0, "", "")
if cmd and cmd[0] == "genisoimage":
out = cmd[cmd.index("-output") + 1]
with open(out, "wb") as fh:
fh.write(b"CIDATA" * 16)
return FakeResult(0, "", "")
return FakeResult(0, "", "")
def _default_api(self, socket_path, method, path, payload=None):
self.api_calls.append((method, path))
if path == "/vm.create":
self.api_payloads["vm.create"] = payload
return (200, {})
def make_exec(self, runner=None, env_extra=None, **overrides):
env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888",
"CHV_SSH_FORWARD": "1"}
if env_extra:
env.update(env_extra)
kwargs = dict(
binary=self.binary, ch_remote=self.ch_remote,
kernel=self.kernel, rootfs=self.rootfs,
ssh_key=self.ssh_key, kvm_path=self.kvm,
cpuinfo_path=self.cpuinfo, state_dir=self.state_dir,
runner=runner or self._default_runner,
launcher=lambda cmd: FakeProc(alive=True),
api_client=self._default_api,
sleeper=lambda s: None, env=env)
kwargs.update(overrides)
ex = CloudHypervisorExecutor(**kwargs)
ex.audit_callback = lambda e, t, d, k=None: self.audit.append(
(e, t.get("id") if isinstance(t, dict) else t, d, k))
return ex
# -- P0-1 seed 磁盘接入 -------------------------------------------------
class SeedDiskTest(ChvFixCase):
def test_vm_config_carries_seed_disk_readonly(self):
ex = self.make_exec()
cfg = ex.build_vm_config("/tmp/x/disk.raw",
seed_disk="/tmp/x/seed.iso")
self.assertEqual(len(cfg["disks"]), 2)
self.assertEqual(cfg["disks"][1]["path"], "/tmp/x/seed.iso")
self.assertTrue(cfg["disks"][1]["readonly"])
cfg2 = ex.build_vm_config("/tmp/x/disk.raw")
self.assertEqual(len(cfg2["disks"]), 1)
def test_provisioned_wake_mounts_seed_disk(self):
ex = self.make_exec(
env_extra={"CHV_HOST_KEY_MODE": "provisioned"})
out = ex.wake_vm({"id": "seed1"})
self.assertEqual(out["state"], "running")
payload = self.api_payloads["vm.create"]
self.assertEqual(len(payload["disks"]), 2)
seed_disk = payload["disks"][1]["path"]
self.assertTrue(seed_disk.endswith("seed.iso"))
self.assertTrue(payload["disks"][1]["readonly"])
self.assertTrue(os.path.exists(seed_disk))
self.assertIn("genisoimage", [c[0] for c in self.calls])
def test_seed_disk_build_failure_is_fail_fast(self):
def bad_runner(cmd, timeout=None):
self.calls.append(list(cmd))
if cmd and cmd[0] == "genisoimage":
return FakeResult(1, "", "no genisoimage")
return self._default_runner(cmd, timeout)
ex = self.make_exec(
runner=bad_runner,
env_extra={"CHV_HOST_KEY_MODE": "provisioned"})
with self.assertRaises(RuntimeError):
ex.wake_vm({"id": "seedfail"})
# 制盘失败在起 VMM 之前,不应走到 vm.create。
self.assertNotIn("/vm.create",
[p for _, p in self.api_calls])
def test_build_seed_disk_rejects_missing_product(self):
with self.assertRaises(RuntimeError):
build_seed_disk(self.dir, os.path.join(self.dir, "no.iso"),
runner=lambda cmd: FakeResult(0, "", ""))
# -- P0-2 restore 不回落 ------------------------------------------------
class RestoreNoFallbackTest(ChvFixCase):
def test_wait_guest_ready_provisioned_missing_known_hosts(self):
ex = self.make_exec(
env_extra={"CHV_HOST_KEY_MODE": "provisioned"})
with self.assertRaises(ChvFailure) as ctx:
ex.wait_guest_ready({"id": "nopin"})
self.assertEqual(ctx.exception.kind, "host_key_not_pinned")
self.assertFalse(any(c[0] == "ssh-keyscan" for c in self.calls))
def test_restore_provisioned_missing_known_hosts_fails_before_launch(self):
ex = self.make_exec(
env_extra={"CHV_HOST_KEY_MODE": "provisioned"})
launches = []
ex.launcher = lambda cmd: launches.append(cmd) or FakeProc(True)
with self.assertRaises(ChvFailure) as ctx:
ex.restore_vm({"id": "r1"}, "file:///snap/x")
self.assertEqual(ctx.exception.kind, "host_key_not_pinned")
self.assertEqual(launches, [])
# -- P0-3 reuse 拦截 ----------------------------------------------------
class ReuseBlockTest(ChvFixCase):
def test_reuse_blocked_without_experiment_flag(self):
ex = self.make_exec(env_extra={"CHV_DISK_MODE": "reuse"})
with self.assertRaises(ChvFailure) as ctx:
ex.preflight()
self.assertEqual(ctx.exception.kind,
"disk_mode_reuse_blocked")
def test_reuse_passes_with_experiment_flag_and_is_audited(self):
ex = self.make_exec(env_extra={
"CHV_DISK_MODE": "reuse",
"CHV_REUSE_SINGLE_TENANT_EXPERIMENT": "1"})
self.assertTrue(ex.preflight())
out = ex.wake_vm({"id": "reuse1"})
self.assertEqual(out["state"], "running")
self.assertTrue(any(e == "disk_reuse_experiment_allowed"
for e, _, _, _ in self.audit))
# -- P0-4 SSH 目标解析 --------------------------------------------------
class SshTargetTest(ChvFixCase):
def test_explicit_host_wins(self):
ex = self.make_exec(ssh_host="10.9.8.7")
self.assertEqual(ex.resolve_ssh_host(), "10.9.8.7")
def test_net_guest_ip_used_when_no_explicit_host(self):
ex = self.make_exec(env_extra={
"CHV_NET_JSON": '[{"tap": "", "ip": "192.168.100.5",'
'"mask": "255.255.255.0"}]',
"CHV_SSH_FORWARD": "0"})
# env_extra 覆盖 forward=0 后仍应走 net ip,不报未声明。
self.assertEqual(ex.resolve_ssh_host(), "192.168.100.5")
cmd = ex.build_ssh_command({"id": "t"}, ["true"])
self.assertIn("root@192.168.100.5", " ".join(cmd))
def test_undeclared_target_is_fail_fast(self):
ex = self.make_exec(env_extra={"CHV_SSH_FORWARD": "0"})
with self.assertRaises(ChvFailure) as ctx:
ex.resolve_ssh_host()
self.assertEqual(ctx.exception.kind, "ssh_target_undeclared")
def test_forward_attestation_allows_loopback(self):
ex = self.make_exec()
self.assertEqual(ex.resolve_ssh_host(), "127.0.0.1")
# -- P1-1 网关 extra_env ------------------------------------------------
class GatewayExtraEnvTest(ChvFixCase):
def test_chv_ssh_command_carries_extra_env(self):
ex = self.make_exec()
cmd = ex.build_ssh_command({"id": "t"}, ["true"],
extra_env={"SUB2API_API_KEY": "k1"})
joined = " ".join(cmd)
self.assertIn("SUB2API_API_KEY=k1", joined)
self.assertIn("HTTPS_PROXY=http://10.0.0.1:8888", joined)
def test_worker_gateway_guard_allows_chv(self):
from cloud.server import init_db
db_path = os.path.join(self.dir, "control.db")
init_db(db_path)
ex = self.make_exec()
worker = Worker(db_path, "/fake/host.py", executor=ex,
env={"XCM_API_KEY": "test-placeholder"})
task = {"id": "gw1", "project_id": "p1", "prompt": "做目标",
"goal_mode": 1,
"_gateway_provider": "sub2api",
"_gateway_env": {"SUB2API_API_KEY": "gwkey",
"SUB2API_BASE_URL": "http://gw/"}}
result, error = worker._run_attempt(task)
self.assertIsNone(error)
self.assertEqual(result["status"], "completed")
ssh_calls = [c for c in self.calls if c and c[0] == "ssh"]
self.assertTrue(any("SUB2API_API_KEY=gwkey" in " ".join(c)
for c in ssh_calls))
# -- P1-2 取消/清理/看门狗 ----------------------------------------------
class CancelCleanupTest(ChvFixCase):
def test_worker_cancel_calls_cancel_vm(self):
from cloud.server import init_db
db_path = os.path.join(self.dir, "control.db")
init_db(db_path)
ex = self.make_exec()
cancelled = []
ex.cancel_vm = lambda name: cancelled.append(name) or True
worker = Worker(db_path, "/fake/host.py", executor=ex,
env={"XCM_API_KEY": "test-placeholder"})
worker._running_set("t-cancel")
import sqlite3, time
conn = sqlite3.connect(db_path)
conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status,"
" idempotency_key, depends_on, priority, created_by,"
" created_at, updated_at, last_heartbeat)"
" VALUES(?, ?, ?, ?, NULL, ?, ?, 'tester', ?, ?, NULL)",
("t-cancel", "p1", "做事", "running", "[]", 0,
time.time(), time.time()))
conn.commit()
conn.close()
self.assertTrue(worker.cancel("t-cancel"))
self.assertEqual(cancelled, ["t-cancel"])
def test_worker_cleanup_calls_cleanup_stale_vms(self):
from cloud.server import init_db
db_path = os.path.join(self.dir, "control.db")
init_db(db_path)
ex = self.make_exec()
ex.cleanup_stale_vms = lambda: ["chv-stale"]
worker = Worker(db_path, "/fake/host.py", executor=ex,
env={"XCM_API_KEY": "test-placeholder"})
self.assertIn("chv-stale", worker.cleanup_stale_containers())
def test_chv_cancel_watchdog_kills_vm_on_cancel(self):
import threading
from cloud.server import init_db
db_path = os.path.join(self.dir, "control.db")
init_db(db_path)
ex = self.make_exec()
killed = []
ex.cancel_vm = lambda name: killed.append(name) or True
worker = Worker(db_path, "/fake/host.py", executor=ex,
env={"XCM_API_KEY": "test-placeholder"})
worker._cancel_requested.add("t-wd")
stop = threading.Event()
worker._chv_cancel_watchdog("t-wd", "chv-t-wd", stop)
self.assertEqual(killed, ["chv-t-wd"])
# -- P1-3 WarmPool acquire resume ---------------------------------------
class PoolAcquireResumeTest(unittest.TestCase):
def test_acquire_resumes_paused_vm(self):
class Stub:
def __init__(self):
self.resumed = []
self.state_dir = None
self.warm_pool_size = 1
self.snapshot_url = "file:///snap/base"
def vm_name(self, task):
raw = task.get("id") if isinstance(task, dict) \
else str(task)
return raw if raw.startswith("chv-") \
else "chv-" + raw
def wake_vm(self, task):
return {"vm": self.vm_name(task)}
def pause_vm(self, task):
return True
def resume_vm(self, task):
self.resumed.append(task["id"])
return True
def shutdown_vm(self, task_or_name):
return True
def _emit(self, event, task, detail="", kind=None):
pass
stub = Stub()
pool = WarmPool(stub, size=1, snapshot_url="file:///snap/base",
registry_path=None)
pool.prewarm()
got = pool.acquire({"id": "t1"})
self.assertEqual(got["mode"], "warm")
self.assertEqual(stub.resumed, ["warm-1"])
def test_acquire_discards_vm_whose_resume_fails(self):
class Stub:
def __init__(self):
self.state_dir = None
self.warm_pool_size = 1
self.snapshot_url = "file:///snap/base"
def vm_name(self, task):
raw = task.get("id") if isinstance(task, dict) \
else str(task)
return raw if raw.startswith("chv-") \
else "chv-" + raw
def wake_vm(self, task):
return {"vm": self.vm_name(task)}
def pause_vm(self, task):
return True
def resume_vm(self, task):
raise RuntimeError("resume 失败")
def shutdown_vm(self, task_or_name):
return True
def _emit(self, event, task, detail="", kind=None):
pass
pool = WarmPool(Stub(), size=1, snapshot_url="file:///snap/base",
registry_path=None)
pool.prewarm()
self.assertEqual(pool.acquire({"id": "t1"})["mode"], "cold")
# -- P1-4 容量持久化计数 -------------------------------------------------
class CapacityPersistentTest(ChvFixCase):
def test_socket_counts_after_restart(self):
ex = self.make_exec(env_extra={"CHV_MAX_VMS": "1"})
os.makedirs(self.state_dir, exist_ok=True)
with open(os.path.join(self.state_dir, "chv-other.sock"),
"w") as fh:
fh.write("")
with self.assertRaises(ChvFailure) as ctx:
ex.wake_vm({"id": "new1"})
self.assertEqual(ctx.exception.kind, "host_capacity_exceeded")
def test_own_socket_does_not_block_rewake(self):
ex = self.make_exec(env_extra={"CHV_MAX_VMS": "1"})
os.makedirs(self.state_dir, exist_ok=True)
with open(os.path.join(self.state_dir, "chv-t1.sock"),
"w") as fh:
fh.write("")
# 同任务重复 wake:自己的残留 socket 不计入容量。
out = ex.wake_vm({"id": "t1"})
self.assertEqual(out["state"], "running")
# -- P1-5 磁盘配额 -------------------------------------------------------
class DiskQuotaTest(ChvFixCase):
def test_unreadable_image_is_not_silently_skipped(self):
with self.assertRaises(ChvFailure) as ctx:
check_disk_quota("/nonexistent/disk.raw", 1.0)
self.assertEqual(ctx.exception.kind, "disk_quota_unverified")
def test_total_usage_over_quota(self):
os.makedirs(self.state_dir, exist_ok=True)
with open(os.path.join(self.state_dir, "old.raw"),
"wb") as fh:
fh.write(b"X" * 100)
# 配额 150 字节:已用 100 + 本次 100 > 150。
with self.assertRaises(ChvFailure) as ctx:
check_disk_quota(self.rootfs, 150 / (1024 ** 3),
state_dir=self.state_dir)
self.assertEqual(ctx.exception.kind, "disk_quota_exceeded")
def test_existing_disk_size_mismatch_is_recopied(self):
task_disk = provision_task_disk(self.state_dir, "chv-a",
self.rootfs, "copy")
with open(task_disk, "wb") as fh:
fh.write(b"Z" * 10)
task_disk2 = provision_task_disk(self.state_dir, "chv-a",
self.rootfs, "copy")
self.assertEqual(task_disk, task_disk2)
with open(task_disk2, "rb") as fh:
self.assertEqual(fh.read(), b"R" * 100)
# -- P1-6 API 截断 -------------------------------------------------------
class ApiTruncationTest(unittest.TestCase):
def _serve(self, raw):
tmp = tempfile.TemporaryDirectory()
sock_path = os.path.join(tmp.name, "vmm.sock")
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
srv.bind(sock_path)
srv.listen(1)
def _run():
conn, _ = srv.accept()
try:
conn.recv(65536)
conn.sendall(raw)
finally:
conn.close()
srv.close()
threading.Thread(target=_run, daemon=True).start()
return tmp, sock_path
def test_truncated_body_raises(self):
body = b'{"ok": true}'
raw = (b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n"
+ body)
tmp, sock_path = self._serve(raw)
with self.assertRaises(RuntimeError):
_unix_socket_api(sock_path, "GET", "/vmm.ping")
tmp.cleanup()
def test_chunked_rejected(self):
raw = (b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
b"5\r\nhello\r\n0\r\n\r\n")
tmp, sock_path = self._serve(raw)
with self.assertRaises(RuntimeError):
_unix_socket_api(sock_path, "GET", "/vmm.ping")
tmp.cleanup()
def test_full_body_parses(self):
body = b'{"ok": true}'
raw = (b"HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n"
% len(body)) + body
tmp, sock_path = self._serve(raw)
status, parsed = _unix_socket_api(sock_path, "GET", "/vmm.ping")
self.assertEqual(status, 200)
self.assertTrue(parsed["ok"])
tmp.cleanup()
# -- P1-7 快照落盘验证 ---------------------------------------------------
class SnapshotPersistTest(ChvFixCase):
def test_snapshot_missing_file_does_not_shutdown(self):
ex = self.make_exec()
with self.assertRaises(ChvFailure) as ctx:
ex.hibernate_vm({"id": "h1"}, "file:///nonexistent/snap")
self.assertEqual(ctx.exception.kind, "snapshot_not_persisted")
kinds = [c[-1] for c in self.calls if c[0] == self.ch_remote]
self.assertIn("pause", kinds)
self.assertNotIn("shutdown", kinds)
# -- P1-8 execute 死端口归类 ---------------------------------------------
class ExecuteDeadPortTest(ChvFixCase):
def test_dead_socket_ssh_failure_is_vm_not_running(self):
def raising_runner(cmd, timeout=None):
self.calls.append(list(cmd))
if cmd and cmd[0] == "ssh":
raise RuntimeError("connection refused")
return FakeResult(0, "", "")
ex = self.make_exec(runner=raising_runner)
os.makedirs(self.state_dir, exist_ok=True)
with open(os.path.join(self.state_dir, "chv-dead.sock"),
"w") as fh:
fh.write("")
ex._vms["chv-dead"] = FakeProc(alive=False)
with self.assertRaises(ChvFailure) as ctx:
ex.execute({"id": "dead"})
self.assertEqual(ctx.exception.kind, "vm_not_running")
# -- P2 配置校验时机 / 文档口径 ------------------------------------------
class ConfigTimingAndDocsTest(ChvFixCase):
def test_preflight_rejects_bad_cpu_and_memory(self):
ex = self.make_exec(cpus="abc")
with self.assertRaises(ChvFailure) as ctx:
ex.preflight()
self.assertEqual(ctx.exception.kind, "config_invalid")
ex2 = self.make_exec(memory="not-a-size")
with self.assertRaises(ChvFailure) as ctx2:
ex2.preflight()
self.assertEqual(ctx2.exception.kind, "config_invalid")
def test_stop_cleanup_failure_is_audited(self):
ex = self.make_exec()
ex.stop_vm({"id": "ck"})
os.makedirs(os.path.join(self.state_dir, "chv-ck", "seed"),
exist_ok=True)
with mock.patch("cloud.chv.executor.shutil.rmtree",
side_effect=OSError("rm failed")):
ex.stop_vm({"id": "ck"})
self.assertTrue(any(e == "vm_key_cleanup_failed"
for e, _, _, _ in self.audit))
def test_readme_sections_in_order_and_buildinfo_honest(self):
readme = Path(__file__).resolve().parents[2] \
/ "chv-guest" / "README.md"
text = readme.read_text()
self.assertLess(text.index("## 5. Host key"),
text.index("## 6. 与执行器的对接"))
self.assertIn("contents: [清单逐项名]", text)
self.assertIn("Node.js 不装", text)
def test_manual_vm_command_is_private(self):
ex = self.make_exec()
self.assertFalse(hasattr(ex, "build_vm_command"))
cmd = ex._build_vm_command({"id": "t"})
self.assertIn("--api-socket", cmd)
if __name__ == "__main__":
unittest.main()
+447
View File
@@ -0,0 +1,447 @@
"""CHV 补缺口轨测试(堆叠于 #221,base=feat-chv-optimize)。
只用标准库与 mock runner/launcher/api_client,绝不启动真实
VM:当前环境没有可跑真 VM 的宿主(生产主机是 CVM、无
/dev/kvm、无嵌套虚拟化;本 VM 容器内同样无 /dev/kvm)。
真实 VM 启动、快照恢复、压测一律不在本文件声称已验证。
"""
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
try:
from cloud.chv.hostkeys import (
build_seed_user_data,
generate_host_keypair,
host_key_paths,
known_hosts_line,
write_seed,
)
from cloud.chv.pool import WarmPool
from cloud.sandbox import (
ChvFailure,
CloudHypervisorExecutor,
_parse_memory_bytes,
)
from cloud.worker import Worker, _call_goal_resume_breakpoint
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.chv.hostkeys import (
build_seed_user_data,
generate_host_keypair,
host_key_paths,
known_hosts_line,
write_seed,
)
from cloud.chv.pool import WarmPool
from cloud.sandbox import (
ChvFailure,
CloudHypervisorExecutor,
_parse_memory_bytes,
)
from cloud.worker import Worker, _call_goal_resume_breakpoint
class FakeResult:
def __init__(self, returncode=0, stdout="", stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
class FakeProc:
def __init__(self, alive=True):
self._alive = alive
def poll(self):
return None if self._alive else 1
def terminate(self):
self._alive = False
def wait(self, timeout=None):
return 0
def kill(self):
self._alive = False
def _write(path, content=b"x"):
with open(path, "wb") as fh:
fh.write(content)
os.chmod(path, 0o755)
return path
class ChvGapCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = self.tmp.name
self.binary = _write(os.path.join(self.dir, "cloud-hypervisor"))
self.ch_remote = _write(os.path.join(self.dir, "ch-remote"))
self.kernel = _write(os.path.join(self.dir, "vmlinux"))
self.rootfs = _write(os.path.join(self.dir, "disk.raw"),
b"R" * 100)
self.ssh_key = _write(os.path.join(self.dir, "id_chv"))
self.kvm = _write(os.path.join(self.dir, "kvm"))
self.cpuinfo = os.path.join(self.dir, "cpuinfo")
with open(self.cpuinfo, "w") as fh:
fh.write("flags : fpu vmx sse\n")
self.state_dir = os.path.join(self.dir, "state")
self.calls = []
self.audit = []
def tearDown(self):
self.tmp.cleanup()
def _default_runner(self, cmd, timeout=None):
self.calls.append(list(cmd))
if cmd and cmd[0] == "ssh":
if cmd[-1] == "true":
return FakeResult(0, "", "")
if "goal-resume" in cmd:
return FakeResult(0, "goal met after rounds\n", "")
if "goal" in cmd:
return FakeResult(0, "goal met after rounds\n", "")
return FakeResult(0, "", "")
return FakeResult(0, "", "")
def _default_api(self, socket_path, method, path, payload=None):
return (200, {})
def _default_launcher(self, cmd):
return FakeProc(alive=True)
def make_exec(self, runner=None, env_extra=None, **overrides):
env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888",
# 实验室宣称:mock runner 假装回环端口已转发到客机
#(feat-chv-fix 起,执行器不再无条件假设回环能通)。
"CHV_SSH_FORWARD": "1"}
if env_extra:
env.update(env_extra)
kwargs = dict(
binary=self.binary, ch_remote=self.ch_remote,
kernel=self.kernel, rootfs=self.rootfs,
ssh_key=self.ssh_key, kvm_path=self.kvm,
cpuinfo_path=self.cpuinfo, state_dir=self.state_dir,
runner=runner or self._default_runner,
launcher=self._default_launcher,
api_client=self._default_api,
sleeper=lambda s: None, env=env)
kwargs.update(overrides)
ex = CloudHypervisorExecutor(**kwargs)
ex.audit_callback = lambda e, t, d, k=None: self.audit.append(
(e, t.get("id") if isinstance(t, dict) else t, d, k))
return ex
# -- (1) worker 断点接线 ---------------------------------------------
class BreakpointWiringTest(ChvGapCase):
def test_call_helper_passes_model_provider_when_accepted(self):
seen = {}
class FakeChv:
def goal_resume_breakpoint_task(self, task, model_provider=None):
seen["provider"] = model_provider
return {"status": "completed", "task_id": task["id"]}
out = _call_goal_resume_breakpoint(
FakeChv(), {"id": "bp1"}, model_provider="prov-x")
self.assertEqual(out["status"], "completed")
self.assertEqual(seen["provider"], "prov-x")
def test_call_helper_single_arg_executor_still_works(self):
class FakeOld:
def goal_resume_breakpoint_task(self, task):
return {"status": "completed", "task_id": task["id"]}
out = _call_goal_resume_breakpoint(
FakeOld(), {"id": "bp2"}, model_provider="prov-x")
self.assertEqual(out["status"], "completed")
def test_chv_breakpoint_accepts_model_provider(self):
ex = self.make_exec()
out = ex.goal_resume_breakpoint_task(
{"id": "g1", "prompt": "续跑"}, model_provider="prov-x")
self.assertEqual(out["status"], "completed")
self.assertTrue(out["goal_met"])
# 客内命令带 --model-provider(网关 key 本身不进命令值)。
ssh_calls = [c for c in self.calls if c and c[0] == "ssh"]
self.assertTrue(any("--model-provider" in c for c in ssh_calls))
def test_worker_breakpoint_branch_calls_generic_executor(self):
# feat-chv-fix(P2-3):不再用源码 grep 充数,改为行为
# 断言——真实 Worker._run_attempt 走通用断点分支,调到
# 非 Local 执行器的 goal_resume_breakpoint_task,并把
# 结果原样返回(参数、守卫顺序、返回值都覆盖)。
from cloud.server import init_db
db_path = os.path.join(self.dir, "control.db")
init_db(db_path)
seen = {}
class FakeGeneric:
def goal_resume_breakpoint_task(self, task,
model_provider=None,
extra_env=None):
seen["provider"] = model_provider
seen["task_id"] = task["id"]
return {"executor": "generic", "task_id": task["id"],
"status": "completed", "stdout": "",
"stderr": "", "returncode": 0,
"host_status": None, "goal_met": True,
"goal_error": None}
worker = Worker(db_path, "/fake/host.py",
executor=FakeGeneric(),
env={"XCM_API_KEY": "test-placeholder"})
task = {"id": "bp-behav", "project_id": "p1",
"prompt": "续跑", "goal_mode": 1,
"_goal_resume_breakpoint": True}
result, error = worker._run_attempt(task)
self.assertIsNone(error)
self.assertEqual(result["status"], "completed")
self.assertTrue(result["goal_met"])
self.assertEqual(seen["task_id"], "bp-behav")
self.assertNotIn("executor 不支持", str(result))
# -- (2) reconcile_orphans 接线 ---------------------------------------
class ReconcileWiringTest(ChvGapCase):
def test_worker_startup_and_close_call_reconcile(self):
from cloud.server import init_db
db_path = os.path.join(self.dir, "control.db")
init_db(db_path)
ex = self.make_exec()
# 手工造一个 orphan:先建池写 registry,再新开池载入。
pool1 = WarmPool(ex)
pool1._entries["chv-old"] = {
"state": "warm", "vm": "chv-old",
"task": {"id": "old"}, "snapshot_url": None}
pool1._save_registry()
self.assertTrue(
os.path.exists(os.path.join(self.state_dir, "warm-pool.json")))
# Worker 构造即 startup reconcile:用假 shutdown 记录。
shutdowns = []
orig_shutdown = ex.shutdown_vm
ex.shutdown_vm = lambda name: shutdowns.append(name) or True
worker = Worker(db_path, "/fake/host.py", executor=ex,
env={"XCM_API_KEY": "test-placeholder"})
self.assertIn("chv-old", shutdowns)
self.assertEqual(worker._warm_pool.orphan_count(), 0)
worker.close()
# close 时再次 reconcile 不炸(池已空,返回 [] 即可)。
self.assertEqual(worker._reconcile_warm_pool("shutdown"), [])
def test_reconcile_failure_does_not_break_close(self):
from cloud.server import init_db
db_path = os.path.join(self.dir, "control.db")
init_db(db_path)
ex = self.make_exec()
worker = Worker(db_path, "/fake/host.py", executor=ex,
env={"XCM_API_KEY": "test-placeholder"})
# 池的 reconcile 抛错时 close 仍不炸。
worker._warm_pool.reconcile_orphans = lambda: (_ for _ in ()).throw(
RuntimeError("boom"))
worker.close() # 不抛即通过
# -- (3) SSH host key 预置 ---------------------------------------------
class HostKeyProvisionTest(ChvGapCase):
def _fake_keygen_runner(self, cmd, timeout=None):
self.calls.append(list(cmd))
if cmd and cmd[0] == "ssh-keygen":
priv = cmd[cmd.index("-f") + 1]
with open(priv, "w") as fh:
fh.write("-----BEGIN OPENSSH PRIVATE KEY-----\nFAKE\n")
os.chmod(priv, 0o600)
with open(priv + ".pub", "w") as fh:
fh.write("ssh-ed25519 AAAAfake1 vm-key\n")
return FakeResult(0, "", "")
return self._default_runner(cmd, timeout)
def test_per_vm_keys_differ_and_known_hosts_pinned(self):
ex = self.make_exec(runner=self._fake_keygen_runner)
r1 = ex.provision_host_key({"id": "vm-a"})
r2 = ex.provision_host_key({"id": "vm-b"})
# 不同 VM 的私钥文件不同路径、known_hosts 不同文件。
self.assertNotEqual(r1["paths"]["private"],
r2["paths"]["private"])
self.assertNotEqual(r1["known_hosts"], r2["known_hosts"])
for r in (r1, r2):
with open(r["known_hosts"]) as fh:
line = fh.read().strip()
self.assertTrue(line.startswith("[127.0.0.1]:"))
self.assertIn("ssh-ed25519 AAAAfake1", line)
self.assertNotIn("PRIVATE KEY", line)
# 同一 VM 再次预置复用已有对(不重生成、不改 known_hosts)。
with open(r1["known_hosts"]) as fh:
before = fh.read()
ex.provision_host_key({"id": "vm-a"})
with open(r1["known_hosts"]) as fh:
self.assertEqual(fh.read(), before)
def test_seed_contains_private_key_and_is_deleted_on_stop(self):
ex = self.make_exec(runner=self._fake_keygen_runner)
r = ex.provision_host_key({"id": "vm-c"})
seed_user = os.path.join(r["seed_dir"], "user-data")
self.assertTrue(os.path.exists(seed_user))
with open(seed_user) as fh:
seed_text = fh.read()
self.assertIn("ssh_host_ed25519_key", seed_text)
self.assertIn("ssh_deletekeys: false", seed_text)
# stop 时 seed 目录与私钥删掉,known_hosts 公钥保留审计。
ex.stop_vm({"id": "vm-c"})
self.assertFalse(os.path.exists(r["seed_dir"]))
self.assertFalse(os.path.exists(r["paths"]["private"]))
self.assertTrue(os.path.exists(r["known_hosts"]))
def test_known_hosts_line_rejects_bad_key(self):
with self.assertRaises(ValueError):
known_hosts_line(2222, "not-a-key")
self.assertEqual(
known_hosts_line(2222, "ssh-ed25519 AAAAx comment"),
"[127.0.0.1]:2222 ssh-ed25519 AAAAx")
def test_seed_user_data_shape(self):
text = build_seed_user_data("PRIV\n", "ssh-ed25519 AAAAx\n")
self.assertIn("/etc/ssh/ssh_host_ed25519_key", text)
self.assertIn("ssh_deletekeys: false", text)
with self.assertRaises(ValueError):
build_seed_user_data("", "ssh-ed25519 AAAAx")
def test_keyscan_is_weak_fallback_only(self):
# provisioned 模式下 wake 前必须预置;keyscan 不应被
# 当成生产路径:配置值本身可解析,且文档/代码里标弱。
ex = self.make_exec(env_extra={"CHV_HOST_KEY_MODE": "provisioned"})
self.assertEqual(ex.host_key_mode, "provisioned")
with self.assertRaises(ValueError):
self.make_exec(env_extra={"CHV_HOST_KEY_MODE": "bogus"})
# -- (4) egress tap 校验 -------------------------------------------------
class EgressValidationTest(ChvGapCase):
def test_direct_egress_without_flag_is_fail_closed(self):
ex = self.make_exec()
ex.egress_proxy = None
ex.allow_direct_egress = False
with self.assertRaises(ChvFailure) as ctx:
ex.preflight()
self.assertEqual(ctx.exception.kind, "egress_not_configured")
def test_direct_egress_with_flag_passes_and_is_audited(self):
ex = self.make_exec(env_extra={"CHV_ALLOW_DIRECT_EGRESS": "1"})
# preflight 本身不拦显式直连(守卫在 wake 的审计里)。
self.assertTrue(ex.preflight())
out = ex.goal_task({"id": "g1", "prompt": "做目标",
"goal_mode": True})
self.assertEqual(out["status"], "completed")
self.assertTrue(any(e == "egress_direct_allowed"
for e, _, _, _ in self.audit))
def test_bad_net_json_is_preflight_error(self):
ex = self.make_exec(env_extra={"CHV_NET_JSON": "{not json"})
with self.assertRaises(ChvFailure) as ctx:
ex.preflight()
self.assertEqual(ctx.exception.kind, "net_config_invalid")
# -- (5) 构建脚本 --------------------------------------------------------
class BuildScriptTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = self.tmp.name
self.bin_dir = os.path.join(self.dir, "bin")
os.makedirs(os.path.join(self.bin_dir, "codex-resources"))
for name in ("codex", "codex-code-mode-host"):
p = os.path.join(self.bin_dir, name)
with open(p, "w") as fh:
fh.write("#!/bin/sh\n")
os.chmod(p, 0o755)
self.repo = os.path.join(self.dir, "repo")
os.makedirs(os.path.join(self.repo, "cloud"))
with open(os.path.join(self.repo, "host.py"), "w") as fh:
fh.write("# host\n")
self.script = str(Path(__file__).resolve().parents[2]
/ "chv-guest" / "build.sh")
def tearDown(self):
self.tmp.cleanup()
def _run(self, args, env_extra=None):
env = dict(os.environ)
env.update({"CODEX_BIN_DIR": self.bin_dir,
"UBUNTU_VERSION": "24.04",
"CODEX_VERSION": "0.164.0-alpha.9",
"REPO_COMMIT": "test123",
"REPO_DIR": self.repo})
if env_extra:
env.update(env_extra)
return subprocess.run(["bash", self.script] + args,
capture_output=True, text=True, env=env)
def test_dry_run_prints_real_steps_and_builds_nothing(self):
out_dir = os.path.join(self.dir, "out")
r = self._run(["--dry-run", out_dir])
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn("qemu-img convert", r.stdout)
self.assertIn("virt-customize", r.stdout)
self.assertIn("virt-get-kernel", r.stdout)
self.assertIn("BUILDINFO", r.stdout)
self.assertIn("chv-guest-24.04-0.164.0-alpha.9-test123", r.stdout)
self.assertFalse(os.path.exists(os.path.join(out_dir, "disk.raw")))
def test_full_mode_without_kvm_fails_fast(self):
# 本 VM 容器内无 /dev/kvm,全量模式必须明确失败,
# 绝不声称已建成镜像。
r = self._run([os.path.join(self.dir, "out2")])
self.assertNotEqual(r.returncode, 0)
self.assertIn("/dev/kvm", r.stderr + r.stdout)
def test_missing_binary_fails_fast(self):
os.remove(os.path.join(self.bin_dir, "codex"))
r = self._run(["--dry-run", os.path.join(self.dir, "out3")])
self.assertNotEqual(r.returncode, 0)
# -- (6) 中途死亡 → interrupted → 断点续跑闭环 ---------------------------
class MidDeathLoopTest(ChvGapCase):
def test_vm_death_is_interrupted_then_breakpoint_resumes(self):
state = {"dead": False}
def runner(cmd, timeout=None):
self.calls.append(list(cmd))
if cmd and cmd[0] == "ssh":
if cmd[-1] == "true":
return FakeResult(0, "", "")
if "goal-resume" in cmd and state["dead"]:
# 第二次断点调用:VM 已换新,健康返回。
return FakeResult(0, "goal met after rounds\n", "")
if "goal" in cmd:
# 第一次 goal:模拟传输中断,并把 VMM 进程标死。
state["dead"] = True
# 找到登记的 VMM 进程并标死(wake 已登记)。
for proc in list(ex._vms.values()):
proc._alive = False
raise RuntimeError("connection lost mid-task")
return FakeResult(0, "", "")
ex = self.make_exec(runner=runner)
# 第一次 goal:中途死应判 interrupted,不是 failed 死信。
out = ex.goal_task({"id": "g9", "prompt": "做目标",
"goal_mode": True})
self.assertEqual(out["status"], "interrupted")
# 断点续跑:同一执行器的断点方法能走通并完成。
state["dead"] = True # 标记已进入断点阶段(runner 按此分支)
out2 = ex.goal_resume_breakpoint_task(
{"id": "g9", "prompt": "续跑"})
self.assertEqual(out2["status"], "completed")
if __name__ == "__main__":
unittest.main()
+589
View File
@@ -0,0 +1,589 @@
"""Cloud Hypervisor 优化轨(feat-chv-optimize,堆叠于 #216 之上)测试。
只用标准库与 mock runner/launcher/api_client,绝不启动真实
VM(与 test_cloud_hypervisor.py 同款边界)。覆盖:
- 拆模块:cloud/chv/* 新路径与 cloud/sandbox 旧路径是同一对象
- 去重复:Local 与 CHV 的 goal 解析在矩阵输入下结论全等;
三执行器 host 顶层参数顺序全等;runner 超时 helper
- 安全:每 VM known_hosts 钉死、ssh-keyscan 首次捕获(TOFU)、
非 root 用户与端口显式配置与收紧校验
- execute 语义:只读已运行 VM,未运行报 vm_not_running
- 预热池:registry 持久化、重启载入只当孤儿、reconcile 清理、
损坏 quarantine、一 次性不复用
- 长任务:VM 中途死亡判 interrupted 而非 failed/抛异常
"""
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
try:
from cloud.chv import ChvFailure
from cloud.chv import CloudHypervisorExecutor
from cloud.chv import WarmPool
from cloud.chv.api_client import _unix_socket_api as _chv_api
from cloud.chv.config import _parse_memory_bytes as _chv_mem
from cloud.sandbox import ChvFailure as _sb_failure
from cloud.sandbox import CloudHypervisorExecutor as _sb_exec
from cloud.sandbox import DockerExecutor
from cloud.sandbox import LocalExecutor
from cloud.sandbox import WarmPool as _sb_pool
from cloud.sandbox import _call_runner_with_timeout
from cloud.sandbox import _parse_memory_bytes as _sb_mem
from cloud.sandbox import _unix_socket_api as _sb_api
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.chv import ChvFailure
from cloud.chv import CloudHypervisorExecutor
from cloud.chv import WarmPool
from cloud.chv.api_client import _unix_socket_api as _chv_api
from cloud.chv.config import _parse_memory_bytes as _chv_mem
from cloud.sandbox import ChvFailure as _sb_failure
from cloud.sandbox import CloudHypervisorExecutor as _sb_exec
from cloud.sandbox import DockerExecutor
from cloud.sandbox import LocalExecutor
from cloud.sandbox import WarmPool as _sb_pool
from cloud.sandbox import _call_runner_with_timeout
from cloud.sandbox import _parse_memory_bytes as _sb_mem
from cloud.sandbox import _unix_socket_api as _sb_api
class FakeResult:
def __init__(self, returncode=0, stdout="", stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
class FakeProc:
def __init__(self, alive=True):
self._alive = alive
self.terminated = False
def poll(self):
return None if self._alive else 1
def terminate(self):
self.terminated = True
self._alive = False
def wait(self, timeout=None):
return 0
def kill(self):
self._alive = False
def _write(path, content=b"x"):
with open(path, "wb") as fh:
fh.write(content)
os.chmod(path, 0o755)
return path
class ChvOptCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = self.tmp.name
self.binary = _write(os.path.join(self.dir, "cloud-hypervisor"))
self.ch_remote = _write(os.path.join(self.dir, "ch-remote"))
self.kernel = _write(os.path.join(self.dir, "vmlinux"))
self.rootfs = _write(os.path.join(self.dir, "disk.raw"),
b"R" * 100)
self.ssh_key = _write(os.path.join(self.dir, "id_chv"))
self.kvm = _write(os.path.join(self.dir, "kvm"))
self.cpuinfo = os.path.join(self.dir, "cpuinfo")
with open(self.cpuinfo, "w") as fh:
fh.write("flags : fpu vmx sse\n")
self.state_dir = os.path.join(self.dir, "state")
self.calls = []
self.api_calls = []
self.launches = []
def tearDown(self):
self.tmp.cleanup()
def make_exec(self, runner=None, api_client=None, launcher=None,
env_extra=None, **overrides):
env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888",
# 实验室宣称:mock runner 假装回环端口已转发到客机
#(feat-chv-fix 起,执行器不再无条件假设回环能通)。
"CHV_SSH_FORWARD": "1"}
if env_extra:
env.update(env_extra)
if runner is None:
runner = self._default_runner
if api_client is None:
api_client = self._default_api
if launcher is None:
launcher = self._default_launcher
kwargs = dict(
binary=self.binary, ch_remote=self.ch_remote,
kernel=self.kernel, rootfs=self.rootfs,
ssh_key=self.ssh_key, kvm_path=self.kvm,
cpuinfo_path=self.cpuinfo, state_dir=self.state_dir,
runner=runner, launcher=launcher, api_client=api_client,
sleeper=lambda s: None, env=env)
kwargs.update(overrides)
return CloudHypervisorExecutor(**kwargs)
def _default_runner(self, cmd, timeout=None):
self.calls.append(list(cmd))
if cmd[0] == "ssh":
if cmd[-1] == "true":
return FakeResult(0, "", "")
if "dispatch" in cmd:
return FakeResult(0, json.dumps(
{"status": "completed", "text": "ok-dispatch"}), "")
if "goal" in cmd:
return FakeResult(0, "goal met after rounds\n", "")
if "status" in cmd:
return FakeResult(0, json.dumps(
{"status": "completed", "text": "ok-status"}), "")
return FakeResult(0, "", "")
return FakeResult(0, "", "")
def _default_api(self, socket_path, method, path, payload=None):
self.api_calls.append((method, path, payload))
return (200, {})
def _default_launcher(self, cmd):
self.launches.append(list(cmd))
return FakeProc(alive=True)
def _task(self, task_id="abc123"):
return {"id": task_id, "prompt": "做一个加法函数",
"project_id": "proj1"}
# -- 拆模块 ---------------------------------------------------------------
class SplitTest(unittest.TestCase):
def test_new_and_old_import_paths_are_same_objects(self):
self.assertIs(CloudHypervisorExecutor, _sb_exec)
self.assertIs(WarmPool, _sb_pool)
self.assertIs(ChvFailure, _sb_failure)
self.assertIs(_chv_mem, _sb_mem)
self.assertIs(_chv_api, _sb_api)
def test_classes_live_in_chv_modules(self):
self.assertEqual(CloudHypervisorExecutor.__module__,
"cloud.chv.executor")
self.assertEqual(WarmPool.__module__, "cloud.chv.pool")
self.assertEqual(ChvFailure.__module__, "cloud.chv.errors")
# -- 去重复:goal 解析与 host 命令 ------------------------------------------
class SharedGoalTest(unittest.TestCase):
CASES = [
# (returncode, stdout, stderr)
(0, "goal met after rounds\n", ""),
(0, "round 1 verify_out=bad\ngoal unmet after rounds\n", ""),
(1, "goal interrupted after rounds: boom\n", ""),
(1, "KeyError: 'x'\n", ""),
(1, "some odd failure\n", ""),
(0, "nothing conclusive\n", ""),
(1, "goal-resume 拒绝:断点丢失\n", ""),
(1, "任务已存在\n", ""),
(1, "goal met after rounds\n", ""),
(1, "goal unmet after rounds\n", ""),
(1, "", "HTTP 503 Service Unavailable\n"),
]
def test_local_and_chv_goal_conclusions_match(self):
local = LocalExecutor("/tmp/host.py")
chv = ChvOptCase()
chv.setUp()
try:
ex = chv.make_exec()
for rc, out, err in self.CASES:
res = FakeResult(rc, out, err)
a = local._goal_result_from_runner("t1", res)
b = ex._goal_result("t1", res)
self.assertEqual(
(a["status"], a["goal_met"]),
(b["status"], b["goal_met"]),
"rc=%s stdout=%r" % (rc, out))
self.assertEqual(a["executor"], "local")
self.assertEqual(b["executor"], "cloud-hypervisor")
finally:
chv.tearDown()
def test_already_exists_message_points_to_continue(self):
chv = ChvOptCase()
chv.setUp()
try:
ex = chv.make_exec()
out = ex._goal_result(
"t1", FakeResult(1, "任务已存在\n", ""))
self.assertEqual(out["status"], "failed")
self.assertIn("继续", out["goal_error"])
finally:
chv.tearDown()
def test_host_command_options_identical_across_executors(self):
chv = ChvOptCase()
chv.setUp()
try:
ex = chv.make_exec()
local = LocalExecutor("/tmp/host.py")
docker = DockerExecutor()
for kwargs in (
{"model": "m1", "effort": "low", "approval": "ask"},
{"model": "m1", "effort": "low", "approval": "full",
"sandbox": "read-only",
"model_provider": "sub2api"},
):
lcmd = local._base_command(**kwargs)
dcmd = docker._inner_host_command("status", **kwargs)
ccmd = ex._guest_host_command("status", **kwargs)
self.assertEqual(lcmd[2:], dcmd[2:-1])
self.assertEqual(lcmd[2:], ccmd[2:-1])
finally:
chv.tearDown()
def test_runner_timeout_helper(self):
seen = []
def only_cmd(cmd):
seen.append(("cmd-only", list(cmd)))
return FakeResult(0, "", "")
out = _call_runner_with_timeout(only_cmd, ["x"], timeout=5)
self.assertEqual(out.returncode, 0)
self.assertEqual(seen, [("cmd-only", ["x"])])
def with_timeout(cmd, timeout=None):
seen.append(("with-timeout", timeout))
return FakeResult(0, "", "")
_call_runner_with_timeout(with_timeout, ["x"], timeout=7)
self.assertEqual(seen[-1], ("with-timeout", 7))
_call_runner_with_timeout(with_timeout, ["x"], timeout=None)
self.assertEqual(seen[-1], ("with-timeout", None))
# -- 安全:known_hosts 钉死 -------------------------------------------------
class SshPinningTest(ChvOptCase):
def test_known_hosts_path_is_per_vm(self):
ex = self.make_exec()
a = ex.known_hosts_path({"id": "t-a"})
b = ex.known_hosts_path({"id": "t-b"})
self.assertNotEqual(a, b)
self.assertEqual(a, os.path.join(self.state_dir, "chv-t-a",
"known_hosts"))
def test_capture_host_key_writes_pinned_file_0600(self):
def keyscan(cmd, timeout=None):
self.calls.append(list(cmd))
return FakeResult(
0, "[127.0.0.1]:2222 ssh-ed25519 AAAAB3test\n"
"evil.example.com ssh-rsa AAAAEvil\n"
"# comment\n", "")
ex = self.make_exec(runner=keyscan)
self.assertTrue(ex.capture_host_key(self._task()))
path = ex.known_hosts_path(self._task())
with open(path) as fh:
content = fh.read()
self.assertIn("[127.0.0.1]:2222 ssh-ed25519 AAAAB3test",
content)
self.assertNotIn("evil.example.com", content)
self.assertEqual(os.stat(path).st_mode & 0o777, 0o600)
def test_capture_host_key_empty_is_false_not_error(self):
ex = self.make_exec(
runner=lambda cmd, timeout=None: FakeResult(0, "", ""))
self.assertFalse(ex.capture_host_key(self._task()))
self.assertFalse(os.path.exists(
ex.known_hosts_path(self._task())))
def test_wait_guest_ready_captures_before_ssh(self):
def runner(cmd, timeout=None):
self.calls.append(list(cmd))
if cmd[0] == "ssh-keyscan":
return FakeResult(
0, "[127.0.0.1]:2222 ssh-ed25519 AAAAB3test\n",
"")
if cmd[0] == "ssh" and cmd[-1] == "true":
return FakeResult(0, "", "")
return FakeResult(0, "", "")
ex = self.make_exec(runner=runner)
self.assertTrue(ex.wait_guest_ready(self._task()))
self.assertTrue(os.path.exists(
ex.known_hosts_path(self._task())))
keyscan_idx = next(i for i, c in enumerate(self.calls)
if c[0] == "ssh-keyscan")
ssh_idx = next(i for i, c in enumerate(self.calls)
if c[0] == "ssh" and c[-1] == "true")
self.assertLess(keyscan_idx, ssh_idx)
def test_ssh_user_port_explicit_config(self):
ex = self.make_exec(ssh_user="codex", ssh_port=2201)
cmd = ex.build_ssh_command(self._task(), ["true"])
joined = " ".join(cmd)
self.assertIn("codex@127.0.0.1", joined)
self.assertNotIn("root@127.0.0.1", joined)
self.assertIn("2201", joined)
def test_ssh_user_port_tightened_validation(self):
with self.assertRaises(ValueError):
self.make_exec(ssh_user="")
with self.assertRaises(ValueError):
self.make_exec(ssh_port=0)
with self.assertRaises(ValueError):
self.make_exec(ssh_port=70000)
with self.assertRaises(ValueError):
self.make_exec(ssh_port="abc")
with self.assertRaises(ValueError):
self.make_exec(env_extra={"CHV_SSH_PORT": "0"})
# -- execute 语义 -------------------------------------------------------------
class ExecuteGateTest(ChvOptCase):
def test_execute_without_running_vm_is_classified(self):
ex = self.make_exec()
with self.assertRaises(ChvFailure) as ctx:
ex.execute(self._task())
self.assertEqual(ctx.exception.kind, "vm_not_running")
self.assertEqual(self.launches, [])
self.assertFalse(any(c[0] == "ssh" for c in self.calls))
def test_execute_with_dead_proc_and_no_socket_is_classified(self):
ex = self.make_exec()
ex._vms["chv-abc123"] = FakeProc(alive=False)
with self.assertRaises(ChvFailure) as ctx:
ex.execute(self._task())
self.assertEqual(ctx.exception.kind, "vm_not_running")
def test_execute_with_socket_but_no_registry_proceeds(self):
ex = self.make_exec()
os.makedirs(self.state_dir, exist_ok=True)
with open(os.path.join(self.state_dir,
"chv-abc123.sock"), "w") as fh:
fh.write("")
out = ex.execute(self._task())
self.assertIn("ok-status", out["stdout"])
self.assertEqual(self.launches, [])
# -- 预热池持久化与孤儿清理 ----------------------------------------------------
class StubExecutor:
"""WarmPool 测试替身:只实现池子调用的鸭子接口。"""
def __init__(self, state_dir):
self.state_dir = state_dir
self.warm_pool_size = 2
self.snapshot_url = "file:///snap/base"
self.wake_calls = []
self.pause_calls = []
self.shutdown_calls = []
self.audit = []
def vm_name(self, task):
raw = task.get("id") if isinstance(task, dict) else str(task)
return raw if raw.startswith("chv-") else "chv-" + raw
def wake_vm(self, task):
self.wake_calls.append(task["id"])
return {"vm": self.vm_name(task), "state": "running"}
def pause_vm(self, task):
self.pause_calls.append(task["id"])
return True
def shutdown_vm(self, task_or_name):
name = task_or_name if isinstance(task_or_name, str) \
else self.vm_name(task_or_name)
self.shutdown_calls.append(name)
return True
def _emit(self, event, task, detail="", kind=None):
self.audit.append(event)
class PoolRegistryTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.state_dir = os.path.join(self.tmp.name, "state")
os.makedirs(self.state_dir, exist_ok=True)
self.registry = os.path.join(self.state_dir, "warm-pool.json")
def tearDown(self):
self.tmp.cleanup()
def test_persist_load_orphan_reconcile(self):
stub = StubExecutor(self.state_dir)
pool = WarmPool(stub, size=2, snapshot_url="file:///snap/base")
added = pool.prewarm()
self.assertEqual(len(added), 2)
self.assertTrue(os.path.exists(self.registry))
with open(self.registry) as fh:
data = json.load(fh)
self.assertEqual(len(data), 2)
# 新池实例载入:上一世条目只当孤儿,绝不发给任务:
stub2 = StubExecutor(self.state_dir)
pool2 = WarmPool(stub2, size=2,
snapshot_url="file:///snap/base")
self.assertEqual(pool2.warm_count(), 0)
self.assertEqual(pool2.orphan_count(), 2)
self.assertEqual(pool2.acquire({"id": "t1"})["mode"], "cold")
cleaned = pool2.reconcile_orphans()
self.assertEqual(len(cleaned), 2)
self.assertEqual(sorted(stub2.shutdown_calls), sorted(cleaned))
self.assertEqual(pool2.orphan_count(), 0)
with open(self.registry) as fh:
self.assertEqual(json.load(fh), {})
# 第三实例:无残留:
pool3 = WarmPool(StubExecutor(self.state_dir), size=2,
snapshot_url="file:///snap/base")
self.assertEqual(pool3.orphan_count(), 0)
def test_corrupt_registry_quarantines_and_starts_empty(self):
with open(self.registry, "w") as fh:
fh.write("{not json")
pool = WarmPool(StubExecutor(self.state_dir), size=2,
snapshot_url="file:///snap/base")
self.assertTrue(pool.registry_corrupt)
self.assertEqual(pool.warm_count(), 0)
self.assertEqual(pool.orphan_count(), 0)
self.assertTrue(os.path.exists(self.registry + ".corrupt"))
self.assertFalse(os.path.exists(self.registry))
# 损坏后仍可正常预热(registry 重建):
self.assertEqual(len(pool.prewarm()), 2)
self.assertTrue(os.path.exists(self.registry))
def test_assigned_entry_on_load_is_never_served(self):
with open(self.registry, "w") as fh:
json.dump({"warm-1": {"state": "assigned",
"vm": "chv-warm-1",
"task_id": "old-task",
"snapshot_url": "file:///snap/base"}},
fh)
pool = WarmPool(StubExecutor(self.state_dir), size=2,
snapshot_url="file:///snap/base")
self.assertEqual(pool.warm_count(), 0)
self.assertEqual(pool.acquire({"id": "t1"})["mode"], "cold")
self.assertEqual(pool.reconcile_orphans(), ["chv-warm-1"])
def test_release_never_rewarms_and_registry_clears(self):
stub = StubExecutor(self.state_dir)
pool = WarmPool(stub, size=1, snapshot_url="file:///snap/base")
added = pool.prewarm()
got = pool.acquire({"id": "t1"})
self.assertEqual(got["mode"], "warm")
self.assertTrue(pool.release({"id": "t1"}, pool_vm=added[0]))
self.assertEqual(pool.warm_count(), 0)
with open(self.registry) as fh:
self.assertEqual(json.load(fh), {})
# -- 长任务中途死亡 --------------------------------------------------------------
class MidDeathTest(ChvOptCase):
def _dispatch_with(self, runner):
ex = self.make_exec(runner=runner)
return ex.dispatch_task(self._task())
def test_failed_result_with_dead_vm_is_interrupted(self):
proc_holder = []
def launcher(cmd):
proc = FakeProc(alive=True)
proc_holder.append(proc)
self.launches.append(list(cmd))
return proc
def runner(cmd, timeout=None):
self.calls.append(list(cmd))
if cmd[0] == "ssh" and "dispatch" in cmd:
proc_holder[0]._alive = False
return FakeResult(1, "",
"Connection closed by remote host")
if cmd[0] == "ssh":
return FakeResult(0, "", "")
return FakeResult(0, "", "")
ex = self.make_exec(runner=runner, launcher=launcher)
out = ex.dispatch_task(self._task())
self.assertEqual(out["status"], "interrupted")
self.assertTrue(out["vm_dead"])
def test_failed_result_with_live_vm_stays_failed(self):
def runner(cmd, timeout=None):
self.calls.append(list(cmd))
if cmd[0] == "ssh" and "dispatch" in cmd:
return FakeResult(1, "", "host dispatch failed")
if cmd[0] == "ssh":
return FakeResult(0, "", "")
return FakeResult(0, "", "")
ex = self.make_exec(runner=runner)
out = ex.dispatch_task(self._task())
self.assertEqual(out["status"], "failed")
self.assertNotIn("vm_dead", out)
def test_exception_with_dead_vm_is_interrupted_not_raised(self):
proc_holder = []
def launcher(cmd):
proc = FakeProc(alive=True)
proc_holder.append(proc)
self.launches.append(list(cmd))
return proc
def runner(cmd, timeout=None):
self.calls.append(list(cmd))
if cmd[0] == "ssh" and "dispatch" in cmd:
proc_holder[0]._alive = False
raise RuntimeError("ssh transport broken")
if cmd[0] == "ssh":
return FakeResult(0, "", "")
return FakeResult(0, "", "")
ex = self.make_exec(runner=runner, launcher=launcher)
out = ex.dispatch_task(self._task())
self.assertEqual(out["status"], "interrupted")
self.assertTrue(out["vm_dead"])
def test_exception_with_live_vm_propagates(self):
def runner(cmd, timeout=None):
self.calls.append(list(cmd))
if cmd[0] == "ssh" and "dispatch" in cmd:
raise RuntimeError("ssh transport broken")
if cmd[0] == "ssh":
return FakeResult(0, "", "")
return FakeResult(0, "", "")
ex = self.make_exec(runner=runner)
with self.assertRaises(RuntimeError):
ex.dispatch_task(self._task())
def test_goal_exception_with_dead_vm_is_interrupted(self):
proc_holder = []
def launcher(cmd):
proc = FakeProc(alive=True)
proc_holder.append(proc)
self.launches.append(list(cmd))
return proc
def runner(cmd, timeout=None):
self.calls.append(list(cmd))
if cmd[0] == "ssh" and "goal" in cmd:
proc_holder[0]._alive = False
raise RuntimeError("ssh transport broken")
if cmd[0] == "ssh":
return FakeResult(0, "", "")
return FakeResult(0, "", "")
ex = self.make_exec(runner=runner, launcher=launcher)
out = ex.goal_task({"id": "g1", "prompt": "做目标",
"goal_mode": True})
self.assertEqual(out["status"], "interrupted")
self.assertIsNone(out["goal_met"])
self.assertTrue(out["vm_dead"])
if __name__ == "__main__":
unittest.main()
+687
View File
@@ -0,0 +1,687 @@
"""Cloud Hypervisor 执行器 v1 + 生产级唤醒/生命周期测试。
只用标准库与 mock runner/launcher/api_client,绝不启动真实
VM:当前环境没有可跑真 VM 的宿主(生产主机是 CVM、无
/dev/kvm、无嵌套虚拟化;本 VM 容器内同样无 /dev/kvm),
所以本文件验证的是:preflight 分类、API 唤醒顺序、VmConfig、
SSH 就绪、暂停/快照/休眠/关闭/清理、预热池、单任务单 VM
隔离、容量门、磁盘门、worker/server/autoscaler 接线。
真实 VM 启动、快照恢复、压测一律不在本文件声称已验证。
"""
import http.client
import json
import os
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from unittest import mock
try:
from cloud.sandbox import (
ChvFailure,
CloudHypervisorExecutor,
WarmPool,
_parse_memory_bytes,
)
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.sandbox import (
ChvFailure,
CloudHypervisorExecutor,
WarmPool,
_parse_memory_bytes,
)
class FakeResult:
def __init__(self, returncode=0, stdout="", stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
class FakeProc:
"""假 VMM 进程:poll() 返回 None=活着,数字=已死。"""
def __init__(self, alive=True):
self._alive = alive
self.terminated = False
self.killed = False
def poll(self):
return None if self._alive else 1
def terminate(self):
self.terminated = True
self._alive = False
def wait(self, timeout=None):
return 0
def kill(self):
self.killed = True
self._alive = False
def _write(path, content=b"x"):
with open(path, "wb") as fh:
fh.write(content)
os.chmod(path, 0o755)
return path
class ChvCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = self.tmp.name
self.binary = _write(os.path.join(self.dir, "cloud-hypervisor"))
self.ch_remote = _write(os.path.join(self.dir, "ch-remote"))
self.kernel = _write(os.path.join(self.dir, "vmlinux"))
self.rootfs = _write(os.path.join(self.dir, "disk.raw"),
b"R" * 100)
self.ssh_key = _write(os.path.join(self.dir, "id_chv"))
self.kvm = _write(os.path.join(self.dir, "kvm"))
self.cpuinfo = os.path.join(self.dir, "cpuinfo")
with open(self.cpuinfo, "w") as fh:
fh.write("flags : fpu vmx sse\n")
self.state_dir = os.path.join(self.dir, "state")
self.calls = [] # runner 调用记录
self.api_calls = [] # api_client 调用记录
self.launches = [] # launcher 调用记录
self.audit = [] # audit 事件记录
def tearDown(self):
self.tmp.cleanup()
def make_exec(self, runner=None, api_client=None, launcher=None,
env_extra=None, **overrides):
env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888",
# 实验室宣称:mock runner 假装回环端口已转发到客机
#(feat-chv-fix 起,执行器不再无条件假设回环能通)。
"CHV_SSH_FORWARD": "1"}
if env_extra:
env.update(env_extra)
if runner is None:
runner = self._default_runner
if api_client is None:
api_client = self._default_api
if launcher is None:
launcher = self._default_launcher
kwargs = dict(
binary=self.binary, ch_remote=self.ch_remote,
kernel=self.kernel, rootfs=self.rootfs,
ssh_key=self.ssh_key, kvm_path=self.kvm,
cpuinfo_path=self.cpuinfo, state_dir=self.state_dir,
runner=runner, launcher=launcher, api_client=api_client,
sleeper=lambda s: None, env=env)
kwargs.update(overrides)
ex = CloudHypervisorExecutor(**kwargs)
ex.audit_callback = lambda e, t, d, k=None: self.audit.append(
(e, t.get("id") if isinstance(t, dict) else t, d, k))
return ex
def _default_runner(self, cmd, timeout=None):
self.calls.append(list(cmd))
if cmd[0] == "ssh":
if cmd[-1] == "true":
return FakeResult(0, "", "")
if "dispatch" in cmd:
return FakeResult(0, json.dumps(
{"status": "completed", "text": "ok-dispatch"}), "")
if "goal" in cmd or "goal-resume" in cmd:
return FakeResult(0, "goal met after rounds\n", "")
if "status" in cmd:
return FakeResult(0, json.dumps(
{"status": "completed", "text": "ok-status"}), "")
return FakeResult(0, "", "")
return FakeResult(0, "", "")
def _default_api(self, socket_path, method, path, payload=None):
self.api_calls.append((method, path, payload))
return (200, {})
def _default_launcher(self, cmd):
self.launches.append(list(cmd))
return FakeProc(alive=True)
def _task(self, task_id="abc123"):
return {"id": task_id, "prompt": "做一个加法函数",
"project_id": "proj1"}
# -- preflight ---------------------------------------------------------
class PreflightTest(ChvCase):
def test_all_present_passes(self):
self.assertTrue(self.make_exec().preflight())
def test_missing_binary_reports_it(self):
ex = self.make_exec(binary="/tmp/no-such-chv-binary")
with self.assertRaises(ChvFailure) as ctx:
ex.preflight()
self.assertEqual(ctx.exception.kind, "binary_missing")
self.assertIn("cloud-hypervisor", str(ctx.exception))
def test_missing_ch_remote_reports_it(self):
ex = self.make_exec(ch_remote="/tmp/no-such-ch-remote")
with self.assertRaises(ChvFailure) as ctx:
ex.preflight()
self.assertEqual(ctx.exception.kind, "binary_missing")
self.assertIn("ch-remote", str(ctx.exception))
def test_missing_kvm_reports_it(self):
ex = self.make_exec(kvm_path="/tmp/no-such-kvm")
with self.assertRaises(ChvFailure) as ctx:
ex.preflight()
self.assertEqual(ctx.exception.kind, "kvm_missing")
self.assertIn("/tmp/no-such-kvm", str(ctx.exception))
def test_missing_cpu_flag_reports_it(self):
with open(self.cpuinfo, "w") as fh:
fh.write("flags : fpu sse\n")
with self.assertRaises(ChvFailure) as ctx:
self.make_exec().preflight()
self.assertEqual(ctx.exception.kind, "cpu_flag_missing")
self.assertIn("vmx", str(ctx.exception))
def test_missing_kernel_reports_it(self):
ex = self.make_exec(kernel="/tmp/no-such-vmlinux")
with self.assertRaises(ChvFailure) as ctx:
ex.preflight()
self.assertEqual(ctx.exception.kind, "kernel_missing")
def test_missing_image_reports_it(self):
ex = self.make_exec(rootfs="/tmp/no-such-disk.raw")
with self.assertRaises(ChvFailure) as ctx:
ex.preflight()
self.assertEqual(ctx.exception.kind, "image_missing")
def test_missing_ssh_key_reports_it(self):
ex = self.make_exec(ssh_key="/tmp/no-such-key")
with self.assertRaises(ChvFailure) as ctx:
ex.preflight()
self.assertEqual(ctx.exception.kind, "ssh_key_missing")
def test_missing_egress_is_fail_closed(self):
ex = self.make_exec(env_extra={"CHV_EGRESS_PROXY": ""})
# env_extra 空值会被 __init__ 的 or 链忽略,故改用显式 env:
ex.egress_proxy = None
ex.allow_direct_egress = False
with self.assertRaises(ChvFailure) as ctx:
ex.preflight()
self.assertEqual(ctx.exception.kind, "egress_not_configured")
def test_multiple_missing_lists_all(self):
ex = self.make_exec(binary="/tmp/no-chv", kernel="/tmp/no-kern")
with self.assertRaises(ChvFailure) as ctx:
ex.preflight()
text = str(ctx.exception)
self.assertIn("cloud-hypervisor", text)
self.assertIn("内核", text)
def test_dispatch_missing_dependency_does_not_call_runner(self):
ex = self.make_exec(binary="/tmp/no-chv")
with self.assertRaises(ChvFailure):
ex.dispatch_task(self._task())
self.assertEqual(self.calls, [])
# -- 配置与命令构造 -----------------------------------------------------
class ConfigCommandTest(ChvCase):
def test_vm_config_shape_and_memory_bytes(self):
ex = self.make_exec()
cfg = ex.build_vm_config("/tmp/x/disk.raw")
self.assertEqual(cfg["cpus"], {"boot_vcpus": 2,
"max_vcpus": 2})
self.assertEqual(cfg["memory"]["size"], 2 * 1024 ** 3)
self.assertEqual(cfg["kernel"]["path"], self.kernel)
self.assertEqual(cfg["disks"], [{"path": "/tmp/x/disk.raw"}])
self.assertEqual(cfg["cmdline"]["args"],
"console=hvc0 root=/dev/vda1 rw")
def test_memory_parsing(self):
self.assertEqual(_parse_memory_bytes("512M"), 512 * 1024 ** 2)
self.assertEqual(_parse_memory_bytes("1024"), 1024)
self.assertEqual(_parse_memory_bytes("1K"), 1024)
with self.assertRaises(ValueError):
_parse_memory_bytes("abc")
def test_rootfs_image_alias(self):
ex = self.make_exec(rootfs=None, env_extra={})
# rootfs=None 时 __init__ 仍从 env 取不到(env 无 CHV_*),
# 故用 env 含 CHV_IMAGE 的构造验证别名:
env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888",
"CHV_IMAGE": self.rootfs}
ex2 = CloudHypervisorExecutor(
binary=self.binary, ch_remote=self.ch_remote,
kernel=self.kernel, ssh_key=self.ssh_key,
kvm_path=self.kvm, cpuinfo_path=self.cpuinfo,
state_dir=self.state_dir, env=env,
sleeper=lambda s: None)
self.assertEqual(ex2.rootfs, self.rootfs)
def test_manual_vm_command_shape(self):
ex = self.make_exec()
self.assertFalse(hasattr(ex, "build_vm_command"))
cmd = ex._build_vm_command(self._task())
self.assertEqual(cmd[0], self.binary)
self.assertIn("--api-socket", cmd)
self.assertIn("--cpus", cmd)
self.assertIn("boot=2", cmd)
self.assertIn("size=2G", cmd)
self.assertIn("--kernel", cmd)
self.assertIn("--disk", cmd)
def test_ssh_command_has_key_path_not_secret_value(self):
ex = self.make_exec()
cmd = ex.build_ssh_command(self._task(), ["true"])
joined = " ".join(cmd)
self.assertIn(self.ssh_key, joined)
self.assertIn("BatchMode=yes", joined)
self.assertNotIn("SECRET", joined)
# egress 代理地址在 env 前缀里(不是密钥),且只在代理已设时出现:
self.assertIn("HTTPS_PROXY=http://10.0.0.1:8888", joined)
# 本轨安全默认变化(#216 的 no + /dev/null 已废除):
# host key 钉死在每 VM 的 known_hosts 文件上:
self.assertIn("StrictHostKeyChecking=yes", joined)
self.assertNotIn("StrictHostKeyChecking=no", joined)
self.assertNotIn("/dev/null", joined)
self.assertIn(os.path.join(self.state_dir,
"chv-abc123", "known_hosts"),
joined)
def test_isolation_names_and_disks_differ(self):
ex = self.make_exec()
a, b = {"id": "task-a"}, {"id": "task-b"}
self.assertNotEqual(ex.vm_name(a), ex.vm_name(b))
self.assertNotEqual(ex.api_socket(a), ex.api_socket(b))
self.assertNotEqual(ex._provision_task_disk(a),
ex._provision_task_disk(b))
# copy 模式:每任务磁盘是基础镜像的真实副本,内容一致、
# 路径不同(两个任务绝不写同一文件):
with open(ex._provision_task_disk(a), "rb") as fh:
self.assertEqual(fh.read(), b"R" * 100)
def test_disk_quota_gate(self):
ex = self.make_exec(env_extra={"CHV_DISK_GB": "0.00000001"})
with self.assertRaises(ChvFailure) as ctx:
ex.wake_vm(self._task())
self.assertEqual(ctx.exception.kind, "disk_quota_exceeded")
def test_capacity_gate_rejects_not_fallback(self):
ex = self.make_exec(env_extra={"CHV_MAX_VMS": "1"})
ex._vms["chv-other"] = FakeProc(alive=True)
with self.assertRaises(ChvFailure) as ctx:
ex.wake_vm(self._task())
self.assertEqual(ctx.exception.kind, "host_capacity_exceeded")
self.assertEqual(self.launches, [])
# -- API 唤醒 -----------------------------------------------------------
class WakeApiTest(ChvCase):
def test_wake_uses_api_mode_not_direct_command(self):
ex = self.make_exec()
out = ex.wake_vm(self._task())
self.assertEqual(out["state"], "running")
self.assertTrue(out["ready"])
self.assertEqual(out["mode"], "cold")
# launcher 只带 --api-socket,不带内核/磁盘(API 模式证明):
self.assertEqual(len(self.launches), 1)
launch = self.launches[0]
self.assertIn("--api-socket", launch)
self.assertNotIn("--kernel", launch)
self.assertNotIn("--disk", launch)
# API 调用顺序:ping → create → boot:
kinds = [(m, p) for m, p, _ in self.api_calls]
self.assertEqual(kinds[0], ("GET", "/vmm.ping"))
self.assertEqual(kinds[1], ("PUT", "/vm.create"))
self.assertEqual(kinds[2], ("PUT", "/vm.boot"))
create_payload = self.api_calls[1][2]
self.assertEqual(create_payload["kernel"]["path"], self.kernel)
self.assertTrue(create_payload["disks"][0]["path"].endswith(
"disk.raw"))
events = [e for e, _, _, _ in self.audit]
self.assertIn("vm_create", events)
self.assertIn("vm_boot", events)
self.assertIn("vm_ready", events)
def test_wake_api_timeout_kills_proc(self):
def never_ok(socket_path, method, path, payload=None):
self.api_calls.append((method, path, payload))
return (500, {})
ex = self.make_exec(api_client=never_ok, api_timeout=3)
proc_holder = []
def launcher(cmd):
proc = FakeProc(alive=True)
proc_holder.append(proc)
self.launches.append(list(cmd))
return proc
ex.launcher = launcher
with self.assertRaises(ChvFailure) as ctx:
ex.wake_vm(self._task())
self.assertEqual(ctx.exception.kind, "api_timeout")
self.assertTrue(proc_holder[0].terminated)
self.assertNotIn("chv-abc123", ex._vms)
def test_guest_ready_timeout_shuts_down(self):
def ssh_never(cmd, timeout=None):
self.calls.append(list(cmd))
if cmd[0] == "ssh":
return FakeResult(1, "", "")
return FakeResult(0, "", "")
ex = self.make_exec(runner=ssh_never, ready_timeout=10,
ready_interval=5)
with self.assertRaises(ChvFailure) as ctx:
ex.wake_vm(self._task())
self.assertEqual(ctx.exception.kind, "guest_not_ready")
# 超时后关 VM:runner 里有 ch-remote shutdown:
self.assertTrue(any("shutdown" in c for c in self.calls))
# -- 暂停 / 快照 / 休眠 / 关闭 / 清理 -----------------------------------
class LifecycleTest(ChvCase):
def test_pause_resume(self):
ex = self.make_exec()
self.assertTrue(ex.pause_vm(self._task()))
self.assertTrue(ex.resume_vm(self._task()))
kinds = [c[-1] for c in self.calls if c[0] == self.ch_remote]
self.assertEqual(kinds, ["pause", "resume"])
def test_restore_then_explicit_resume_order(self):
ex = self.make_exec()
out = ex.restore_vm(self._task(), "file:///snap/1")
self.assertEqual(out["mode"], "restored")
self.assertTrue(out["ready"])
kinds = [c[-1] if c[-1] != "restore" else c[-1]
for c in self.calls if c[0] == self.ch_remote]
# ch-remote 调用里 restore 必须在 resume 之前:
flat = []
for c in self.calls:
if c[0] == self.ch_remote:
flat.append(c[-1] if c[-1].startswith("source_url")
else c[-1])
self.assertIn("resume", flat)
restore_idx = next(i for i, c in enumerate(self.calls)
if c[0] == self.ch_remote
and any(a.startswith("source_url")
for a in c))
resume_idx = next(i for i, c in enumerate(self.calls)
if c[0] == self.ch_remote and c[-1] == "resume")
self.assertLess(restore_idx, resume_idx)
def test_hibernate_is_pause_snapshot_shutdown(self):
ex = self.make_exec()
# feat-chv-fix(P1-7):file:// 快照须真实存在且非空,
# 否则 hibernate 不 shutdown;用临时文件作快照目标。
snap = os.path.join(self.dir, "h1.snap")
with open(snap, "wb") as fh:
fh.write(b"S" * 64)
snap_url = "file://" + snap
out = ex.hibernate_vm(self._task(), snap_url)
self.assertEqual(out["state"], "hibernated")
self.assertEqual(out["snapshot"], snap_url)
kinds = [c[-1] for c in self.calls if c[0] == self.ch_remote]
self.assertEqual(kinds[0], "pause")
self.assertTrue(any(a.startswith("source_url")
for c in self.calls for a in c))
self.assertIn("shutdown", kinds)
# 从休眠唤醒 = restore + resume:
ex2 = self.make_exec()
out2 = ex2.wake_from_hibernate(self._task(), snap_url)
self.assertEqual(out2["mode"], "restored")
def test_stop_dead_proc(self):
ex = self.make_exec()
proc = FakeProc(alive=False)
ex._vms["chv-abc123"] = proc
sock = os.path.join(self.state_dir, "chv-abc123.sock")
os.makedirs(self.state_dir, exist_ok=True)
with open(sock, "w") as _fh:
_fh.write("")
self.assertTrue(ex.stop_vm(self._task()))
self.assertFalse(os.path.exists(sock))
self.assertNotIn("chv-abc123", ex._vms)
def test_stop_already_closed(self):
ex = self.make_exec()
self.assertFalse(ex.stop_vm(self._task()))
# 已关仍尽力调过一次 ch-remote shutdown:
self.assertTrue(any("shutdown" in c for c in self.calls))
def test_stop_socket_residual(self):
ex = self.make_exec()
sock = os.path.join(self.state_dir, "chv-abc123.sock")
os.makedirs(self.state_dir, exist_ok=True)
with open(sock, "w") as _fh:
_fh.write("")
self.assertTrue(ex.stop_vm(self._task()))
self.assertFalse(os.path.exists(sock))
def test_cleanup_skips_live_and_non_chv(self):
ex = self.make_exec()
os.makedirs(self.state_dir, exist_ok=True)
live = os.path.join(self.state_dir, "chv-live.sock")
with open(live, "w") as _fh:
_fh.write("")
ex._vms["chv-live"] = FakeProc(alive=True)
dead = os.path.join(self.state_dir, "chv-dead.sock")
with open(dead, "w") as _fh:
_fh.write("")
other = os.path.join(self.state_dir, "other.sock")
with open(other, "w") as _fh:
_fh.write("")
cleaned = ex.cleanup_stale_vms()
self.assertEqual(cleaned, ["chv-dead"])
self.assertTrue(os.path.exists(live))
self.assertTrue(os.path.exists(other))
self.assertFalse(os.path.exists(dead))
def test_cleanup_missing_state_dir(self):
ex = self.make_exec()
self.assertEqual(ex.cleanup_stale_vms(), [])
def test_recycle_preserves_checkpoints_not_temp(self):
ex = self.make_exec()
out = ex.recycle_vm(self._task())
self.assertEqual(out["state"], "recycled")
self.assertIn("task_checkpoints", out["preserved"])
self.assertIn("task_attempts", out["preserved"])
self.assertIn("guest /tmp", out["lost"])
# -- 预热池 --------------------------------------------------------------
class WarmPoolTest(ChvCase):
def test_prewarm_acquire_release(self):
ex = self.make_exec()
pool = WarmPool(ex, size=2, snapshot_url="file:///snap/base")
added = pool.prewarm()
self.assertEqual(len(added), 2)
self.assertEqual(pool.warm_count(), 2)
first = pool.acquire(self._task("t1"))
self.assertEqual(first["mode"], "warm")
second = pool.acquire(self._task("t2"))
self.assertEqual(second["mode"], "warm")
third = pool.acquire(self._task("t3"))
self.assertEqual(third["mode"], "cold")
# release 一律销毁,不放回池:
self.assertTrue(pool.release(self._task("t1"),
pool_vm=added[0]))
self.assertEqual(pool.warm_count(), 0)
def test_prewarm_without_snapshot_is_empty(self):
ex = self.make_exec()
pool = WarmPool(ex, size=2, snapshot_url=None)
self.assertEqual(pool.prewarm(), [])
self.assertEqual(pool.acquire(self._task())["mode"], "cold")
# -- dispatch / goal(wake → 跑 → finally 关) ---------------------------
class DispatchTest(ChvCase):
def test_dispatch_wakes_runs_shuts_down(self):
ex = self.make_exec()
out = ex.dispatch_task(self._task())
self.assertEqual(out["executor"], "cloud-hypervisor")
self.assertEqual(out["status"], "completed")
self.assertIn("ok-dispatch", out["stdout"])
self.assertEqual(out["vm"], "chv-abc123")
# finally 关 VM:ch-remote shutdown 在 dispatch 之后出现:
shutdown_idx = next(i for i, c in enumerate(self.calls)
if c[0] == self.ch_remote
and c[-1] == "shutdown")
ssh_idx = next(i for i, c in enumerate(self.calls)
if c[0] == "ssh" and "dispatch" in c)
self.assertLess(ssh_idx, shutdown_idx)
self.assertNotIn("chv-abc123", ex._vms)
def test_goal_met(self):
ex = self.make_exec()
out = ex.goal_task({"id": "g1", "prompt": "做目标",
"goal_mode": True})
self.assertEqual(out["status"], "completed")
self.assertTrue(out["goal_met"])
def test_goal_resume_breakpoint(self):
ex = self.make_exec()
out = ex.goal_resume_breakpoint_task({"id": "g1",
"prompt": "续跑"})
self.assertEqual(out["status"], "completed")
self.assertTrue(out["goal_met"])
def test_execute_is_ssh_status_only(self):
ex = self.make_exec()
# 本轨 execute 语义门禁:只读已运行 VM。先登记一个活
# 的 VMM 进程表示 VM 已醒(未登记时 execute 报
# vm_not_running,见 test_chv_optimize.py):
ex._vms["chv-abc123"] = FakeProc(alive=True)
out = ex.execute(self._task())
self.assertEqual(out["executor"], "cloud-hypervisor")
self.assertIn("ok-status", out["stdout"])
# execute 不 wake(launcher 未调用):
self.assertEqual(self.launches, [])
# -- worker / autoscaler / server 接线 -----------------------------------
class WiringTest(ChvCase):
def test_worker_executor_name_and_cli_builder(self):
from cloud import worker as worker_mod
from cloud.sandbox import CloudHypervisorExecutor as CHV
ex = self.make_exec()
w = worker_mod.Worker.__new__(worker_mod.Worker)
w.executor = ex
self.assertEqual(w._executor_name(), "cloud-hypervisor")
class Args:
executor = "cloud-hypervisor"
built = worker_mod._build_cli_executor(Args(), None)
self.assertIsInstance(built, CHV)
def test_autoscaler_passthrough(self):
from cloud.autoscaler import Autoscaler
scaler = Autoscaler("/tmp/no.db", [], executor="cloud-hypervisor")
cmd = scaler._worker_command("w1")
self.assertIn("--executor", cmd)
self.assertEqual(cmd[cmd.index("--executor") + 1],
"cloud-hypervisor")
scaler2 = Autoscaler("/tmp/no.db",
["--executor", "docker"],
executor="cloud-hypervisor")
cmd2 = scaler2._worker_command("w1")
self.assertEqual(cmd2.count("--executor"), 1)
self.assertEqual(cmd2[cmd2.index("--executor") + 1], "docker")
def test_server_default_executor(self):
from cloud.server import _default_executor
with mock.patch.dict(os.environ,
{"CLOUD_DEFAULT_EXECUTOR":
"cloud-hypervisor"}):
self.assertEqual(_default_executor(), "cloud-hypervisor")
with mock.patch.dict(os.environ,
{"CLOUD_DEFAULT_EXECUTOR": "bogus"}):
self.assertEqual(_default_executor(), "docker")
class ServerWhitelistTest(unittest.TestCase):
"""server 白名单端到端:真实 HTTP + 临时库 + 临时端口。"""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
try:
from cloud.auth import AuthStore
from cloud.server import create_server
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.server import create_server
self.auth = AuthStore(self.db_path)
self.owner_id, self.owner_key = self.auth.create_user("owner")
old = os.environ.get("CODEX_GLOBAL_ADMINS")
self._old_admins = old
os.environ["CODEX_GLOBAL_ADMINS"] = self.owner_id
self.server = create_server(self.db_path, "127.0.0.1", 0)
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever, daemon=True)
self.thread.start()
status, self.project = self._req(
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201, self.project)
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
pass
self.tmp.cleanup()
if self._old_admins is None:
os.environ.pop("CODEX_GLOBAL_ADMINS", None)
else:
os.environ["CODEX_GLOBAL_ADMINS"] = self._old_admins
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port,
timeout=10)
hdrs = {}
payload = None
if body is not None:
payload = json.dumps(body).encode("utf-8")
hdrs["Content-Type"] = "application/json"
if key is not None:
hdrs["X-API-Key"] = key
try:
conn.request(method, path, body=payload, headers=hdrs)
resp = conn.getresponse()
data = resp.read()
finally:
conn.close()
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
parsed = None
return resp.status, parsed
def test_task_executor_cloud_hypervisor_accepted(self):
status, task = self._req(
"POST", "/tasks",
{"project_id": self.project["id"], "prompt": "x",
"executor": "cloud-hypervisor"}, self.owner_key)
self.assertEqual(status, 201, task)
self.assertEqual(task["executor"], "cloud-hypervisor")
def test_task_executor_bogus_rejected(self):
status, body = self._req(
"POST", "/tasks",
{"project_id": self.project["id"], "prompt": "x",
"executor": "bogus"}, self.owner_key)
self.assertEqual(status, 400)
self.assertIn("cloud-hypervisor", body["error"])
if __name__ == "__main__":
unittest.main()
+250 -15
View File
@@ -120,6 +120,7 @@ Docker 下 repos_dir 必须放在 extra-mounts 已挂载的目录下,
import argparse
import hashlib
import inspect
import json
import os
from datetime import datetime, timezone
@@ -195,7 +196,8 @@ from cloud.pr_exec import (claim_pr_job, execute_task_pr, finish_pr_job,
mark_pr_job_needs_local_worker)
from cloud.queue import budget_exceeded, ready_tasks
from cloud.repos import RepoError, checkout_dir, resolve_repo
from cloud.sandbox import CloudExecutor, DockerExecutor, LocalExecutor
from cloud.sandbox import CloudExecutor, CloudHypervisorExecutor, \
DockerExecutor, LocalExecutor
from cloud.uploads import upload_root as _upload_root
from cloud.sandbox import _default_runner as _sandbox_default_runner
from cloud.caps import effective_capacity, resolve_capacity
@@ -331,6 +333,30 @@ def _merge_env(base, override):
return merged
def _call_goal_resume_breakpoint(executor, task, model_provider=None,
extra_env=None):
"""通用断点调用:执行器提供 goal_resume_breakpoint_task 时调用它。
Local 仍走原路径(调用方直接传 model_provider,不经过这里,
以免改生产语义);这里只用于非 Local 执行器:签名接受
model_provider 时传它(CHV/Docker 均接受),否则只传 task
(旧假执行器单参)。feat-chv-fix(P1-1):CHV 的
gateway_env 经此进入 SSH env 前缀注入客机环境(不进
镜像/代码/prompt);CHV 没有 Local 那样的进程 env 通道,
SSH env 前缀是唯一通道,值只出现在宿主侧 ssh argv。"""
method = getattr(executor, "goal_resume_breakpoint_task")
try:
params = inspect.signature(method).parameters
except (TypeError, ValueError):
return method(task)
kwargs = {}
if "model_provider" in params:
kwargs["model_provider"] = model_provider
if "extra_env" in params and extra_env is not None:
kwargs["extra_env"] = extra_env
return method(task, **kwargs)
def _resolve_model(model=None, config_path=None):
"""模型名:显式参数 > host 配置文件里的 model > host 默认值。"""
if model:
@@ -506,6 +532,37 @@ class Worker:
self.executor.ignore_host_budget = self.ignore_host_budget
except Exception:
pass
# CHV 审计转发:VM 生命周期事件(启动/暂停/恢复/快照/
# 销毁/失败)经本回调进 AuthStore 审计(_audit 本身
# best-effort,失败不影响任务)。时间线展示 VM 事件时
# 读审计表即可,不另起一套 VM 事件表;成本/配额仍走
# worker 现有 quota 语义,本回调绝不记账。
if isinstance(self.executor, CloudHypervisorExecutor):
def _chv_audit(event, task, detail, kind=None):
try:
project_id = task.get("project_id") \
if isinstance(task, dict) else None
self._audit(project_id, "chv_vm_" + event,
detail or "")
except Exception:
pass
try:
self.executor.audit_callback = _chv_audit
except Exception:
pass
# CHV 补缺口轨:WarmPool 显式接线。池构造本身不自动
# 清理(单活池假设,见 pool.py),worker 启动是显式
# 入口,在这里建池并调 reconcile_orphans();关闭时
# close() 再调一次。调用失败不炸 worker 构造/关闭,
# 但要进审计(_audit 本身 best-effort)。
self._warm_pool = None
if isinstance(self.executor, CloudHypervisorExecutor):
try:
from cloud.chv.pool import WarmPool as _WarmPool
self._warm_pool = _WarmPool(self.executor)
self._reconcile_warm_pool("startup")
except Exception:
pass
self._cost_store = cost_store
self._cancel_requested = set()
# 真取消用:process_factory 可注入(单测用它返回一个假长跑
@@ -740,8 +797,49 @@ class Worker:
self._cost_store = CostStore(self.db_path)
return self._cost_store
def _reconcile_warm_pool(self, when):
"""显式调 WarmPool.reconcile_orphans(),返回已清理 VM 名。
仅 CHV worker 且池已建时有效;池未建时若执行器是 CHV
则现建现调(供测试与手动清理用)。调用失败不炸主流程,
但进审计(失败本身也审计,不静默丢弃)。orphan 条目
绝不发给任务,reconcile 只做清理。"""
pool = getattr(self, "_warm_pool", None)
if pool is None:
executor = getattr(self, "executor", None)
if not isinstance(executor, CloudHypervisorExecutor):
return []
try:
from cloud.chv.pool import WarmPool as _WarmPool
pool = _WarmPool(executor)
self._warm_pool = pool
except Exception:
return []
try:
cleaned = pool.reconcile_orphans() or []
except Exception as exc:
try:
self._audit(None, "chv_warm_pool_reconcile_failed",
"when=%s error=%s" % (when, exc))
except Exception:
pass
return []
if cleaned:
try:
self._audit(None, "chv_warm_pool_reconcile",
"when=%s cleaned=%s" % (when, ",".join(cleaned)))
except Exception:
pass
return cleaned
def close(self):
"""关闭惰性建的成本存储连接(注入的 cost_store 由调用方管)。"""
"""关闭惰性建的成本存储连接(注入的 cost_store 由调用方管)。
CHV worker 关闭时先显式调一次 warm-pool reconcile,
失败不炸关闭流程。"""
try:
self._reconcile_warm_pool("shutdown")
except Exception:
pass
if self._cost_store is not None:
try:
self._cost_store._conn.close()
@@ -2276,6 +2374,12 @@ class Worker:
self.executor.kill_container(entry["container"])
except Exception:
pass
# feat-chv-fix(P1-2):CHV 取消真关 VM,不只改库状态。
if hasattr(self.executor, "cancel_vm"):
try:
self.executor.cancel_vm(task_id)
except Exception:
pass
self._running_clear(task_id)
conn = _db_connect(self.db_path)
try:
@@ -2378,22 +2482,87 @@ class Worker:
pass
return
def _chv_cancel_watchdog(self, task_id, vm_name, stop_event):
"""CHV 同步路径的取消看门狗(feat-chv-fix,P1-2,Docker
取消看门狗同款模式):同步 SSH 阻塞时 worker 的 cancel()
只能改库、清 _running 项,阻塞中的 run 本身不会醒;
本线程每 1 秒查库/取消标志,命中时调 cancel_vm 真关
VM,然后退出。查库失败时当没取消,不误杀。"""
while not stop_event.wait(1.0):
try:
if task_id in self._cancel_requested:
cancelled = True
else:
db_task = self.get_task(task_id)
cancelled = (db_task is not None and
db_task.get("status") == "interrupted")
except Exception:
cancelled = False
if not cancelled:
continue
try:
cancel = getattr(self.executor, "cancel_vm", None)
if cancel is not None and vm_name:
cancel(vm_name)
log_event(self.logger, "worker_chv_killed",
task_id=task_id,
message="cancel watchdog killed VM %s "
"(task interrupted/cancelled)"
% vm_name)
except Exception:
pass
return
def _chv_quota_watchdog(self, task_id, vm_name, stop_event,
quota_check, interval=30.0):
"""CHV 同步路径的配额看门狗(feat-chv-fix,P1-2,Docker
配额看门狗同款):命中时记 _quota_hit_tasks 并调
cancel_vm 真关 VM;_run_attempt 在同步返回后把结果
改写成 quota_exceeded,走统一配额中断收尾。"""
while not stop_event.wait(interval):
try:
over = bool(quota_check()) if quota_check else False
except Exception:
over = False
if not over:
continue
self._mark_quota_hit(task_id)
try:
cancel = getattr(self.executor, "cancel_vm", None)
if cancel is not None and vm_name:
cancel(vm_name)
log_event(self.logger, "worker_chv_killed",
task_id=task_id,
message="quota watchdog killed VM %s "
"(quota exceeded)" % vm_name)
except Exception:
pass
return
def cleanup_stale_containers(self):
"""启动清理兜底:清掉上一轮残留的 codex- 容器。
只在执行器有 cleanup_stale_containers 时做(Docker);
Docker 执行器清容器、CHV 执行器清残留 VM(feat-chv-fix);
列出或删除失败只记日志,绝不抛异常炸掉 worker。
返回已清理的容器名列表(非 Docker 执行器返回 [])。
返回已清理的容器/VM 名列表(两者皆无的执行器返回 [])。
"""
cleaned = []
cleanup = getattr(self.executor, "cleanup_stale_containers", None)
if cleanup is None:
return []
if cleanup is not None:
try:
cleaned = cleanup()
cleaned = list(cleanup() or [])
except Exception as exc:
log_event(self.logger, "worker_cleanup_failed",
message="cleanup stale containers failed: %s" % exc)
return []
# feat-chv-fix(P1-2):CHV 残留 VM 走通用清理路径,
# 不再只靠 worker 启动/关闭的 WarmPool reconcile 兜底。
chv_cleanup = getattr(self.executor, "cleanup_stale_vms", None)
if chv_cleanup is not None:
try:
cleaned = cleaned + list(chv_cleanup() or [])
except Exception as exc:
log_event(self.logger, "worker_cleanup_failed",
message="cleanup stale vms failed: %s" % exc)
if cleaned:
log_event(self.logger, "worker_cleanup",
message="cleaned stale containers: %s"
@@ -2409,12 +2578,15 @@ class Worker:
# -- 缺 key 门 ------------------------------------------------------
def _executor_name(self):
"""执行器名:DockerExecutor -> docker,CloudExecutor -> cloud,
其它 -> local。cloud 绝不能回落成 local,否则 cloud worker
CloudHypervisorExecutor -> cloud-hypervisor,其它 -> local。
cloud/cloud-hypervisor 绝不能回落成 local,否则该 worker
会认领 local 池的任务,池过滤就失效了。"""
if isinstance(self.executor, DockerExecutor):
return "docker"
if isinstance(self.executor, CloudExecutor):
return "cloud"
if isinstance(self.executor, CloudHypervisorExecutor):
return "cloud-hypervisor"
return "local"
def key_available(self):
"""只查存在性:名在环境里且值非空即 True;值本身绝不返回。"""
@@ -4544,6 +4716,29 @@ class Worker:
_watchdog_stop, quota_check),
daemon=True)
_quota_watchdog_thread.start()
# feat-chv-fix(P1-2):CHV 同步路径同款看门狗(VM 名
# 由任务 id 派生)。进程模式不重复起(_execute_via_
# process 已轮询 quota_check)。
if isinstance(self.executor, CloudHypervisorExecutor) \
and hasattr(self.executor, "cancel_vm"):
try:
_chv_vm_name = self.executor.vm_name(task)
except Exception:
_chv_vm_name = None
if _chv_vm_name:
_watchdog_stop = threading.Event()
_watchdog_thread = threading.Thread(
target=self._chv_cancel_watchdog,
args=(task_id, _chv_vm_name, _watchdog_stop),
daemon=True)
_watchdog_thread.start()
if quota_check is not None and not self._use_popen():
_quota_watchdog_thread = threading.Thread(
target=self._chv_quota_watchdog,
args=(task_id, _chv_vm_name,
_watchdog_stop, quota_check),
daemon=True)
_quota_watchdog_thread.start()
# 启动前分段日志(2026-10-29 任务 A):认领后 61 秒中断,
# 但阻塞的 docker run 中途没有任何标记、容器 --rm 后输出
# 也没了,事后完全无法定位卡在哪一段。派发开始/结束进
@@ -4679,8 +4874,12 @@ class Worker:
# Docker 路径上面已有 is_docker 守卫,不走这里。
# 网关任务在非 Local 执行器上无法注入 key 与
# provider,必须 loud fail,不静默跑错网关。
# feat-chv-fix(P1-1):守卫收窄——CHV 已补
# extra_env SSH 注入路径,不再被“非 Local 即
# 拦”;无注入能力的执行器仍 loud fail。
if gateway_provider and not isinstance(
self.executor, LocalExecutor):
self.executor,
(LocalExecutor, CloudHypervisorExecutor)):
raise RuntimeError(
"sub2api 网关不支持该执行器:%s"
% type(self.executor).__name__)
@@ -4690,6 +4889,19 @@ class Worker:
self.executor, LocalExecutor):
result = self.executor.goal_resume_breakpoint_task(
task, model_provider=gateway_provider)
elif task.get("_goal_resume_breakpoint") and hasattr(
self.executor,
"goal_resume_breakpoint_task"):
# CHV 补缺口轨:任何提供断点方法的执行器
# 都可走断点(CHV 的 goal_resume_breakpoint_task
# 已接线;Docker 走上面 is_docker 分支,不到
# 这里)。网关任务在进入这里之前已被
# “网关不支持该执行器”守卫 loud fail,
# 故这里不放宽网关规则。
result = _call_goal_resume_breakpoint(
self.executor, task,
model_provider=gateway_provider,
extra_env=gateway_env)
elif task.get("_goal_resume_breakpoint"):
# 假执行器/CloudExecutor 没有断点方法时
# 绝不回落成 goal(会撞已存在)或
@@ -4718,6 +4930,11 @@ class Worker:
task, seed_dir=seed_host_dir,
extra_env=gateway_env,
model_provider=gateway_provider)
elif isinstance(
self.executor, CloudHypervisorExecutor):
result = self.executor.goal_task(
task, extra_env=gateway_env,
model_provider=gateway_provider)
else:
result = self.executor.goal_task(task)
else:
@@ -4726,6 +4943,11 @@ class Worker:
task, seed_dir=seed_host_dir,
extra_env=gateway_env,
model_provider=gateway_provider)
elif isinstance(
self.executor, CloudHypervisorExecutor):
result = self.executor.dispatch_task(
task, extra_env=gateway_env,
model_provider=gateway_provider)
else:
result = self.executor.dispatch_task(task)
combined = (result.get("stdout", "") or "") + \
@@ -4749,14 +4971,18 @@ class Worker:
quota_check)
else:
if gateway_provider and not isinstance(
self.executor, LocalExecutor):
self.executor,
(LocalExecutor,
CloudHypervisorExecutor)):
raise RuntimeError(
"sub2api 网关不支持该执行器:%s"
% type(self.executor).__name__)
result = self.executor.run_task(
task, extra_env=gateway_env,
model_provider=gateway_provider) \
if isinstance(self.executor, LocalExecutor) \
if isinstance(self.executor,
(LocalExecutor,
CloudHypervisorExecutor)) \
else self.executor.run_task(task)
except Exception as exc: # runner 异常也如实写回 failed
result = {"executor": "local", "task_id": task_id,
@@ -6756,7 +6982,12 @@ def _build_cli_executor(args, host_py):
docker 时返回 DockerExecutor(env_key 名用 Worker 的解析规则,
值本身绝不从这里传——DockerExecutor 只查存在性,-e 只传名),
cloud 时返回 CloudExecutor(通用 HTTP runner,未配 endpoint
时执行即明确失败,绝不降级)。"""
时执行即明确失败,绝不降级),cloud-hypervisor 时返回
CloudHypervisorExecutor(配置走 CHV_* 环境变量,缺依赖时
执行即明确失败,绝不降级;任务内 LLM 不可自选它,见
sandbox.py 该类 docstring)。"""
if getattr(args, "executor", "local") == "cloud-hypervisor":
return CloudHypervisorExecutor()
if getattr(args, "executor", "local") == "cloud":
return CloudExecutor()
if getattr(args, "executor", "local") != "docker":
@@ -6834,11 +7065,15 @@ def main(argv=None):
"的 max_daily_turns 预算照常生效(双重门)")
parser.add_argument("--max-tasks", type=int, default=None)
parser.add_argument("--executor", default="local",
choices=["local", "docker", "cloud"],
choices=["local", "docker", "cloud",
"cloud-hypervisor"],
help="执行器:local 调 host.py 子进程;docker"
" 调 DockerExecutor(无 docker 时任务"
"明确失败,不静默降级);cloud 调通用"
" HTTP runner(需 CLOUD_RUNNER_ENDPOINT)")
" HTTP runner(需 CLOUD_RUNNER_ENDPOINT);"
"cloud-hypervisor 调 CloudHypervisorExecutor"
"(VM 内经 SSH 调 host.py,配置走 CHV_*"
" 环境变量,缺依赖时任务明确失败)")
parser.add_argument("--worker-id", default=None,
help="多 worker 池身份(默认:主机名-进程号;"
"生产部署应配稳定 id,否则重启后旧行变 stale)")
+249
View File
@@ -0,0 +1,249 @@
# Cloud Hypervisor 执行器生产就绪清单
> 状态图例:✅ 代码已完成 · 🧪 mock 测试已覆盖(无真 VM) ·
> ⏳ 需 KVM 宿主实测 · 🏗️ 需镜像构建 · 📈 需压测 · 🔒 需安全审计
>
> 本清单是 PR 的一部分,不是事后补的口号。凡是标 ⏳/🏗️/📈/🔒
> 的项,在本轨内一律不声称已验证、已建成、已通过。
## 0. 交付点卡在哪一关(必读,先于逐项清单)
- **CI 门禁(未过,交付点 = PR OPEN)**:CI 必须通过才能合并。
此前全仓 CI 因 GitHub 账单问题 0 步骤失败;若账单未恢复、
CI 仍失败,本 PR 只到 OPEN,不合并,更不为变绿改 CI 配置
或绕过检查。
- **真 VM 门禁(未过,执行器默认关闭)**:生产启用(把真实任务
路由到 cloud-hypervisor、部署到生产主机)必须先有带 /dev/kvm
的宿主并完成真实 VM 启动、快照恢复、任务端到端验证。
当前事实:生产主机 124.222.165.170 本身就是 CVM
(systemd-detect-virt=kvm,DMI product=CVM),/dev/kvm 不存在、
无 vmx/svm 标志、无 kvm 模块、未装 cloud-hypervisor,无嵌套
虚拟化透传;本 VM 容器内同样无 /dev/kvm。
故真 VM、快照恢复、压测在本轨内一律不声称已验证;执行器
默认关闭(server 默认仍是 docker,worker 默认仍是 local,
CHV 只在任务创建时显式选 `executor=cloud-hypervisor` 且有
CHV worker 认领时才跑),不进入生产任务路由。
- **还差什么才能真正上生产**(按顺序):① 用户侧开通带 /dev/kvm
的裸金属或开启嵌套虚拟化的实例;② 在该宿主上构建并发布
chv-guest 镜像(见 §4);③ 在该宿主上跑通真实 VM 启动、
快照恢复、任务端到端、预热池与压测;④ 安全审计通过(§6);
⑤ CI 账单恢复且本 PR CI 变绿后,按当时 main 头复核 diff 再合并、
再部署。缺任何一项,都不把 CHV 推上生产。
## 1. 单任务单 VM 隔离
- ✅🧪 一个任务一个 VM:dispatch/run/goal 均走 wake → 跑 → finally
shutdown,同一任务重复 wake 先关旧 VM;VM 名、api-socket、
每任务磁盘路径均按任务派生,互不相同(测试断言)。
- ✅🧪 每任务磁盘独立:copy 模式复制基础镜像为每任务磁盘;
reuse 模式仅单租户实验,preflight 拦截(须显式实验二次确认并审计放行,feat-chv-fix 起代码真正拦截;此前“代码与文档双写”的说法是错的,代码当时并不拦截)。
- ✅🧪 租户无共享路径:执行器不挂载宿主根、宿主代码目录或含
密钥目录;客机代码在镜像内 `/app`,不在宿主共享挂载上。
- ✅🧪 密钥不进镜像/代码/prompt:SSH argv 只有 key 文件路径;
模型 key 由客机 cloud-init 预置的环境变量名注入;测试断言
命令中不含哨兵密钥值。
- ✅🧪 SSH host key 按 VM 钉死(优化轨,#216 的 no + /dev/null
已废除):每 VM 在 state_dir/<vm>/known_hosts 有独立文件,
首次启动经 ssh-keyscan 在解析出的 SSH 目标上捕获 key 写入该文件,
此后 SSH 一律 StrictHostKeyChecking=yes + 该文件校验;
非 root 客机用户(CHV_SSH_USER,显式空串直接拒绝)与端口
(CHV_SSH_PORT,1-65535)为显式配置。mock 只验证捕获→
写文件→钉死选项的管线,不验证首次密钥真实性。
- ✅🧪 Host key 预置方案(补缺口轨,替代 TOFU 为生产路径):
宿主在启动前为每 VM 生成一次性 ed25519 host key 对,
公钥立即写进该 VM 的 known_hosts 钉死,私钥只进 per-VM
cloud-init seed(随 VM 关闭/回收而删),客机镜像硬性
要求见 chv-guest/README.md §5(cloud-init NoCloud、
首启先应用 seed 再起 sshd、不得无条件重生成 host key)。
ssh-keyscan 首次捕获只保留为弱回落(CHV_HOST_KEY_MODE
=keyscan),生产部署必须用 provisioned 模式;TOFU 首次
真实性仍待预置 key 方案在真机上验证,安全审计时须单独立项确认。
- 🔒 需安全审计:VM 逃逸面(virtio 设备、tap 桥接、SSH 配置)、
客机 root 提权面、租户间东西向隔离的宿主网络配置,须由
安全审计逐项确认,代码自查不能代替审计。
## 2. 快速启动:预热快照池 + 镜像缓存
- ✅🧪 快照/恢复接口:snapshot_vm / restore_vm(restore 后显式
resume,见 §3),WarmPool 接口与状态机(cold → prewarming →
warm → assigned → running → recycled/destroyed)。
- ✅🧪 冷启动回落:池空时 acquire 返回 cold,调用方回落 wake_vm
冷启动,绝不降级 Docker/Local。
- ✅🧪 预热条目一次性:acquire 消耗、release 一律销毁,绝不跨
任务复用脏 VM。
- ✅🧪 预热池 registry 与孤儿清理(优化轨):池状态落
state_dir/warm-pool.json(原子写),worker 重启后载入的
条目一律当孤儿、只能 reconcile_orphans() 清理,绝不重新
发给任务;registry 损坏时 quarantine 成 .corrupt 并从空池
开始;registry 之外的残留 socket 仍由 cleanup_stale_vms()
扫描处理。
- ⏳ 需 KVM 宿主实测:预热池的真实冷启动耗时、resume 耗时、
池大小与命中率、快照来源一致性,均需真机数据,mock 数字
不能当性能结论。
- 📈 需压测:多 VM 并行预热/分配/销毁的宿主 CPU/内存/磁盘
压力、快照存储增长,均未测。
## 3. 完整生命周期
- ✅🧪 create / boot:API 模式 wake_vm(vmm.ping → vm.create →
vm.boot → SSH 就绪)。
- ✅🧪 pause-resume(sleep-wake):pause_vm / resume_vm(ch-remote)。
- ✅🧪 snapshot-restore:restore_vm(restore + 显式 resume +
SSH 就绪),恢复后默认 paused 的坑已在代码与测试里钉住。
- ✅🧪 hibernate:CHV 无原生 hibernate 端点,本轨用 pause +
snapshot + shutdown 等价实现,文档与代码双写“等价实现”,
不声称接了原生端点;唤醒走 restore_vm。
- ✅🧪 shutdown-destroy / recycle:shutdown_vm(ch-remote
shutdown + 杀进程 + 删 socket)、recycle_vm(保留
task_checkpoints/task_attempts,丢失 guest /tmp 与未拷出
的 worktree 临时物,绝不声称恢复临时文件)。
- ✅🧪 execute 只读已运行 VM(优化轨):execute() 先查 VM
是否在运行(登记活进程或 api-socket 存在),未运行报
ChvFailure(kind=vm_not_running),不为一次 status 起 VM,
也不向不存在的端口发 SSH 再拿连接失败冒充任务状态。
- ✅🧪 长任务中途死亡判 interrupted(优化轨):客内命令抛
传输异常、或非 goal 结果已 failed 且登记 VMM 进程已死时,
_run_with_vm 改判 interrupted(断点可续跑)而非笼统
failed;VM 还活着时的异常/失败原样处理。中途心跳未做:
host.py 单次 SSH 长回合没有客内进度信号可采,另起一路
SSH 轮询只能证明 VM 活着、不能证明任务在进展,评估后
认为风险大于收益,宁可只做死亡分类。
- ⏳ 需 KVM 宿主实测:上述全部生命周期在真机上的状态转换、
快照恢复后 guest 状态一致性、休眠后文件/worktree 保留,
均未在真机验证。
## 4. 完整开发环境(客机镜像)
- ✅ 客机镜像内容清单:`chv-guest/README.md` 按
`cloud/docker/Dockerfile` 能力对齐(Python 3.12、git、
pandoc/poppler/latex、python-docx/openpyxl/pptx/PyMuPDF/
Pillow、codex 0.164.0-alpha.9 成套、Playwright + Chromium、
openssh-server、cloud-init)。
- ✅ 构建脚本实做(补缺口轨):`chv-guest/build.sh`(参数校验、
KVM 宿主检查、下载 Ubuntu 云镜像、qemu-img 转 raw、
virt-customize 装清单、拷入 /app 代码、virt-get-kernel、
BUILDINFO.json 生成与 tag 校验;--dry-run 只打印命令)。
- 🏗️ 需镜像构建:镜像本身未构建、未发布;BUILDINFO 版本核对、
浏览器版本钉死、中文 LaTeX 单独一版,均待构建时做。
- ⏳ 需 KVM 宿主实测:镜像在真 VM 里启动、codex/Playwright/
git/文档工具链逐项跑通,均未验证。
## 5. 资源与成本
- ✅🧪 每 VM 配额:CPU/内存进 VmConfig(CHV_CPU/CHV_MEM),
磁盘配额门(CHV_DISK_GB,超限拒绝),宿主容量门
(CHV_MAX_VMS,超限明确拒绝排队,不降级)。
- ✅ 超时与最大轮次:SSH 单次超时(CHV_TASK_TIMEOUT)+
worker task_timeout 外层护栏;goal 最大轮次 100(与 Local
同款),执行器内部不重试(重试是 worker max_retries 语义)。
- ✅ 成本复用:不另起一套计费;worker 现有 quota/成本语义
(per-user USD 配额、quota 看门狗、成本写库)仍是唯一口径,
执行器只把 host.py 用量原样返回。
- ✅ 空闲休眠省成本:hibernate_vm(pause + snapshot +
shutdown)+ WarmPool 释放即销毁,空闲 VM 不占 vCPU/内存
(快照只占磁盘,见 §2 压测项)。
- ✅ autoscaler:按 executor 过滤队列深度(已有)+ 本轨透传
`--executor` 给拉起的 worker(否则 CHV 池永远饿死)。
- 📈 需压测:配额下的真实资源占用、超限拒绝的排队行为、
成本与配额在长任务下的实际表现,均未测。
## 6. 网络与治理
- ✅🧪 受控 egress:默认 fail-closed(CHV_EGRESS_PROXY 未设且
未显式允许直连时 preflight 直接失败);客机命令加 env
代理前缀,出口只走白名单代理(Tinyproxy 思路复用)。
- ⏳🔒 需宿主网络配置 + 安全审计:tap 桥接是否真的只路由到
白名单代理、VM 能否绕过代理直连外网,是宿主网络配置问题,
执行器代码不能自证,必须在 KVM 宿主上实测并审计。
- ✅ 审批门:任务 approval 经 `_approval_cli_args` 进客内 host.py
顶层参数,VM 内 agent 不能自改;delete/deploy/spend 类动作
仍走 server 的 approval/merge_gate,不由 VM 内 agent 自批
(本轨不改 approval/merge_gate 语义,只复用)。
- ✅ 工具与系统调用范围收敛:VM 边界本身就是系统调用边界
(客机独立内核);plan 任务走 read-only 沙箱参数;最大步数
(goal 100 轮)、时间(task_timeout)、重试(worker 1 次)
超限即停并审计(vm_failed 带 kind)。
## 7. 可观测与审计
- ✅🧪 VM 事件审计:create/boot/ready/pause/resume/snapshot/
restore/shutdown/destroy/failed/recycled/warm_pool_acquire/
release 均经 audit_callback 发出;worker 接线后转 AuthStore
审计(_audit 本身 best-effort)。
- ✅🧪 失败分类:ChvFailure 带 kind(kvm_missing/binary_missing/
kernel_missing/image_missing/ssh_key_missing/cpu_flag_missing/
egress_not_configured/api_timeout/guest_not_ready/vm_create_failed/
vm_boot_failed/host_capacity_exceeded/disk_quota_exceeded),
不笼统 failed。
- ✅ 断点对齐(补缺口轨已接线):VM 快照/休眠与现有
task_checkpoints/task_attempts 语义对齐(recycle 保留两者,
丢失临时文件);worker 通用分支对任何提供
goal_resume_breakpoint_task 的执行器调用它(Local 仍走
原路径,网关任务仍被 loud fail 守卫拦住);WarmPool
reconcile_orphans 已由 worker 启动与关闭时显式调用,
失败不炸主流程但进审计;VM 中途死亡改判 interrupted
后走同一断点路径,不再落 failed 死信(见 §3 与新增测试)。
- ⏳ 需 KVM 宿主实测:审计事件在真机任务 timeline 里的完整
呈现、失败分类与真实故障的对应关系,均未验证。
## 8. 多 VM 并发与容量
- ✅🧪 worker 注册:`_executor_name()` 返回 cloud-hypervisor,
worker register/heartbeat 已上报 executor + capacity(现有
机制复用,无需新表)。
- ✅🧪 多 VM 并行:`_vms` 登记 + 锁保护,wake 按任务名登记,
不同任务互不覆盖;容量门在 wake 前检查。
- ✅🧪 残留 VM 清理:cleanup_stale_vms 处理已死/已关/socket
残留三种情况,只清 chv- 前缀 socket。
- ✅🧪 容量不足明确拒绝:kind=host_capacity_exceeded,调用方
排队/拒绝,绝不降级 Docker/Local(测试断言 launcher 未调用)。
- 📈 需压测:多 VM 并行下的宿主容量、VM 启动并发上限、残留
清理在高压下的表现,均未测。
## 9. 默认关闭与生产启用条件(重申)
- ✅ 默认关闭:server 默认执行器仍是 docker,worker 默认 local;
CHV 只在显式选 executor 且有 CHV worker 时才跑;无 CHV_*
配置时 preflight 直接失败。
- ⏳ 生产启用前置(全部未完成):带 /dev/kvm 的宿主开通 →
chv-guest 镜像构建发布 → 真实 VM 启动/快照恢复/任务端到端
验证 → 压测 → 安全审计 → CI 变绿后按当时 main 头复核 diff
再合并部署。缺一不可。
## 10. 审核修复轨 feat-chv-fix(堆叠于 #230 之上)
- ✅🧪 P0 已修:seed 磁盘(CIDATA ISO,第二块只读盘)挂进
VmConfig;restore 不回落 keyscan(provisioned 下复用已钉
死 known_hosts、缺失 fail-fast,快照内 host key 不重预置);
reuse 由 preflight 拦截(显式实验二次确认 + 审计放行);
SSH 目标选客机 IP 方案(显式 host > net 静态 ip > 显式
转发宣称下的回环,三者皆无 fail-fast;vsock 与纯 NAT 方案
未选,理由见 config.py)。
- ✅🧪 P1 已修或有结论:网关 extra_env 经 SSH env 前缀注入,
worker 守卫收窄到 Local/CHV;取消/清理/看门狗接 CHV 通用
路径;WarmPool acquire 内 resume(但 worker dispatch 前查
池仍未接线,预热收益仍不是已交付特性);容量门改持久化
计数(socket 联合计数);磁盘配额改总量+余量、已存在磁盘
校验重拷;API 客户端按 Content-Length 验完整性;快照
file:// 目标验存在且非空再 shutdown;execute 死端口归类
vm_not_running。
- ⚠️ 仍未接线/未验证(不得声称已交付):worker dispatch 前查
WarmPool;CHV + 网关真实任务;真机 wake/boot/SSH(含 seed
注入与 NAT/客机 IP 实测);快照恢复端到端;压测;安全审计;
CI 账单恢复。另:P0-2 审计原说法“restore 须把 seed 接入
VmConfig”已纠正——restore 不走 vm.create,seed 无从挂盘,
正确不变量是复用钉死 known_hosts。
- ✅🧪 P2 已做(范围已恢复为全做,上一条收窄令作废):
test_chv_gaps.py 的 grep 文本断言改成真实 Worker._run_attempt
行为断言;stop_vm 删除失败记 vm_key_cleanup_failed 审计
(同名复用旧 key 的彻底根治“清理失败即拒起”列为遗留,不
假装已根治);配置校验时机统一到 preflight(CPU/内存非法
进 preflight,kind=config_invalid);build.sh 与 README 口径
对齐(章节顺序 §5 host key → §6 对接、BUILDINFO contents
纠正为名字列表无逐项版本、Node.js 不装写实);第二启动路径
清理(build_vm_command 降级为私有 _build_vm_command,手动
等价写法保留;Docker goal 内联解析顶部加指向正典
parse_goal_result 的分叉说明 + 同步检查清单);长函数只做
小提取(preflight 的 reuse/CPU/内存校验块抽成
_preflight_config_checks,语义不变;config.py 字段解析与
其余长函数深耦合,本轨如实停、不硬拆)。