"""Cloud Hypervisor 执行器(从 cloud/sandbox.py 巨类拆出)。 模块分工(cloud/chv/): - errors.py:ChvFailure 分类失败 - config.py:配置解析(参数/env/默认值)与内存字符串解析 - api_client.py:Unix socket 上的 VMM REST API 传输与归一化 - ssh_transport.py:SSH 命令构造、每 VM known_hosts 钉死、 ssh-keyscan 首次捕获(TOFU 边界见该模块) - disk.py:每任务磁盘供给与磁盘配额门 - orphans.py:生产残留清理唯一入口(registry 文件直读清理) - experimental_pool.py:WarmPool 预热池(纯实验,生产禁 import) - executor.py(本模块):执行器类本体——生命周期方法 (wake/pause/resume/snapshot/restore/hibernate/stop/ cleanup)与任务执行方法(dispatch/run/goal/execute)。 类体仍较长是故意的:生命周期与执行入口共享同一份 _vms 登记与审计状态,再按方法拆 mixin 只会把状态机 藏进多文件继承链;真正的可拆部分(配置/传输/磁盘/ 池)已经拆出去了。 循环导入说明(必读):cloud/sandbox.py 顶层 re-export 本模块(保证 `from cloud.sandbox import CloudHypervisorExecutor` 旧路径可用),所以本模块顶层 绝不能 import cloud.sandbox;凡是原来 sandbox.py 里的 共享 helper(goal 解析、host 命令选项、runner 超时、 _parse_host_result),一律在方法内按需 import——调用时 sandbox 早已加载完成,不会撞上半初始化的模块。 """ import json import logging import os import re import shutil import socket import subprocess import threading import time from collections.abc import Callable from cloud.chv.api_client import _unix_socket_api, normalize_api_result from cloud.chv.atomic_file import atomic_write_text from cloud.chv.config import _parse_memory_bytes, build_chv_config from cloud.chv.disk import ( check_disk_quota, check_snapshot_limits, clear_persistent_vm, data_disk_path, delete_data_disk, delete_task_disk, find_snapshot_entry, has_recorded_snapshots, overlay_disk_path, provision_data_disk, provision_task_disk, read_persistent_vm, read_vm_tenant, record_snapshot, task_disk_path, task_disk_paths, write_persistent_vm, write_vm_tenant, ) from cloud.chv.errors import ChvFailure from cloud.chv.hardening import ( ensure_secure_state_dir, parse_net_entries, socket_is_live, validate_egress_proxy, validate_net_entries, validate_snapshot_url, ) from cloud.chv.hybrid_events import ( RESIDENT_UPGRADE_HINT, SNAPSHOT_TENANT_MISMATCH, VM_RESIDENT_DEMOTED, VM_RESIDENT_PROMOTED, VM_RESIDENT_SUSPENDED, VM_RESTORE_DENIED, WARM_POOL_ACQUIRE, WARM_POOL_MISS, WARM_POOL_RELEASE, ) from cloud.chv.ssh_transport import build_ssh_command as _build_ssh_cmd from cloud.chv.ssh_transport import known_hosts_path as _known_hosts_path from cloud.chv.ssh_transport import ( parse_keyscan_output, split_guest_env, split_secret_env, write_known_hosts, ) from cloud.executor_base import Executor _LOGGER = logging.getLogger("cloud.cloud_hypervisor") # -- 混合模式 B 轨(feat-chv-hybrid-b,设计 DESIGN-HYBRID.md §9.1 # 第 5–9 项;已定值直接用,不许改回提案口径)------------------- # 常驻资格档位(§5):只有 Paid/Heavy 有常驻资格,Free 无论多 # 高频都没有。 # 档位显示名(正字法);比较一律走 _norm_tier + # HYBRID_RESIDENT_TIERS(小写),不要直接拿它做 in 比较。 RESIDENT_TIERS = ("Paid", "Heavy") def _norm_tier(tier): """档位名大小写归一(集成统一):A 轨用小写 paid/heavy, B 轨用 Paid/Heavy;比较时一律归一为小写,fail-closed 语义不变(Free/None/未知仍不合格)。""" return str(tier or "").strip().lower() # 常驻租约状态(§4.1):登记 state 字段取值。legacy 的 # paused/running 是阶段 1 持久 VM 的旧状态,不是常驻态。 RESIDENT_STATES = ("resident-active", "resident-paused", "hibernated", "demoted") # 常驻有效态(占槽、续期、认领、网关放行都只认这三个): # demoted 已收回租约,不占槽、不续期、不复活。 RESIDENT_ACTIVE_STATES = ("resident-active", "resident-paused", "hibernated") # 认领时可 resume 的登记状态:legacy paused + 常驻态里 VM 已 # 暂停的两种(resident-active 的 VM 在任务间隙同样被 # suspend_after_task 暂停;hibernated 的 VM 已停机,须走 # restore,不进 resume 认领;demoted 已收回租约,不认领)。 RESIDENT_CLAIMABLE_STATES = ("paused", "resident-active", "resident-paused") # 租约阈值(已定值,§4.2):闲置 30 分钟挂起,挂起后再闲置 # 24 小时降级(累计 24.5 小时无活动)。生产默认值不变; # 测试用环境变量注入短阈值(见 _resident_thresholds)。 RESIDENT_IDLE_SUSPEND_SECONDS = 30 * 60 RESIDENT_IDLE_DEMOTE_SECONDS = 24 * 3600 # 每宿主常驻上限(已定值,§4.1):宿主 VM 槽位的 50%。 RESIDENT_MAX_FRACTION = 0.5 # 排队超时(已定值,§3.4):无可用宿主时等 300 秒再明确拒绝。 QUEUE_TIMEOUT_SECONDS = 300 QUEUE_POLL_SECONDS = 5 # 升级提示点(已定值,§5):30 天内池任务 ≥100 次,或连续 7 天 # 每天 ≥3 次;一周内同一租户最多记一次。 UPGRADE_HINT_WINDOW_DAYS = 30 UPGRADE_HINT_MIN_TASKS = 100 UPGRADE_HINT_STREAK_DAYS = 7 UPGRADE_HINT_MIN_PER_DAY = 3 UPGRADE_HINT_DEDUP_DAYS = 7 # 网关 provider(§6):目前唯一的长连接网关是 sub2api。 GATEWAY_PROVIDERS = ("sub2api",) def _pid_gone(pid): """进程是否已不在(/proc 消失,或已成僵尸:父进程未收, 但内存/tap 等资源已释放,等同已死)。""" try: with open("/proc/%d/stat" % pid, "rb") as fh: stat = fh.read() except OSError: return True try: state = stat.rsplit(b")", 1)[1].split()[0] except (IndexError, ValueError): return False return state == b"Z" def _terminate_pid(pid, sigterm_timeout=2.0): """SIGTERM→等→SIGKILL 杀一个进程:best-effort 不抛异常。 僵尸态即算已死(资源已释放),不等父进程收尸。""" import signal try: os.kill(pid, signal.SIGTERM) except OSError: return deadline = time.time() + sigterm_timeout while time.time() < deadline: if _pid_gone(pid): return time.sleep(0.05) try: os.kill(pid, signal.SIGKILL) except OSError: return deadline = time.time() + sigterm_timeout while time.time() < deadline: if _pid_gone(pid): return time.sleep(0.05) def _find_vmm_pids_by_socket(socket_path, binary_basename): """扫描 /proc,找命令行以 --api-socket 带该 socket 路径、 且 argv[0] 是给定 VMM 二进制的进程 pid 列表。三道门: 精确 socket 路径(每 VM 唯一)、VMM 二进制名(ch-remote 等客户端绝不认)、跳过自身与 pid 1。读不到 cmdline 的 进程跳过,不抛异常。""" if not socket_path or not binary_basename: return [] try: entries = os.listdir("/proc") except OSError: return [] me = os.getpid() found = [] for entry in entries: if not entry.isdigit(): continue pid = int(entry) if pid in (1, me): continue try: with open("/proc/%d/cmdline" % pid, "rb") as fh: raw = fh.read() except OSError: continue if not raw: continue tokens = [t.decode("utf-8", "surrogateescape") for t in raw.split(b"\0") if t] if not tokens: continue if os.path.basename(tokens[0]) != binary_basename: continue if socket_path not in tokens: continue if "--api-socket" not in tokens \ and not any(t.startswith("--api-socket=") for t in tokens): continue found.append(pid) return found def _assert_snapshot_dir_persisted(snapshot_url): """file:// 快照的落盘校验(v54 起产物是目录):目标须是 已生成的非空目录,否则 ChvFailure snapshot_not_persisted。 非 file:// 目标无法从宿主验证,只认 ch-remote returncode。""" if not snapshot_url.startswith("file://"): return snap_path = snapshot_url[len("file://"):] if not os.path.isdir(snap_path) or not os.listdir(snap_path): raise ChvFailure( "snapshot_not_persisted", "快照未落盘或为空目录:%s" % snapshot_url) # SSH 连接建立阶段瞬时 reset 的 stderr 标记(V-7/F1): # kex 阶段被 reset 时 OpenSSH 报 kex_exchange_identification # + Connection reset,远端命令根本没开始执行。只认这组 # 标记:host key 拒绝(同为 rc=255)与远端命令失败不在此列。 _SSH_TRANSIENT_RESET_MARKERS = ( "kex_exchange_identification", "Connection reset", "Connection closed by", ) # CHV 混合模式已定值(DESIGN-HYBRID.md §8,2026-11-08 定; # feat-chv-hybrid-a A 轨只用常量/注释承载时延目标,不在单元 # 测试里硬测时延)。W=每宿主干净池预热数;池上限=低频并发 # 峰值的 50%,超上限走冷启不扩池;常驻上限=宿主 VM 槽位 # 的 50%;排队超时后明确拒绝 host_capacity_exceeded。 HYBRID_WARM_POOL_PER_HOST = 2 HYBRID_POOL_CAP_FRACTION = 0.5 HYBRID_RESIDENT_CAP_FRACTION = 0.5 HYBRID_QUEUE_TIMEOUT_SECONDS = 300 HYBRID_QUEUE_POLL_INTERVAL_SECONDS = 5 HYBRID_WARM_RESUME_TARGET_SECONDS = 2 HYBRID_COLD_RESTORE_TARGET_SECONDS = 30 HYBRID_SNAPSHOT_PER_TENANT = 1 HYBRID_SNAPSHOT_RETENTION_DAYS = 30 HYBRID_RESIDENT_TIERS = ("paid", "heavy") CLEAN_POOL_DIR_NAME = "clean_pool" # 干净池预热时拒绝的租户同步键(Gitea 同步变量):池 VM 出生 # 时不带租户,显式带这些键进预热即调用方错误,fail-closed。 _CLEAN_POOL_REFUSED_SYNC_KEYS = ( "GITEA_URL", "GITEA_USER", "GITEA_TOKEN", "SYNC_REPO", "SYNC_BRANCH") def _is_transient_ssh_reset(result) -> bool: """单次 SSH 是否撞上连接建立阶段的瞬时 reset:returncode 255 + stderr 含 kex/reset 标记。stderr 可能是 bytes, 按文本解码后比对;无 stderr 即不认,不猜。""" if getattr(result, "returncode", 0) != 255: return False stderr = getattr(result, "stderr", "") or "" if isinstance(stderr, bytes): stderr = stderr.decode("utf-8", "replace") return any(marker in stderr for marker in _SSH_TRANSIENT_RESET_MARKERS) def _ensure_snapshot_dir(snapshot_url, snapshot_path) -> None: """file:// 快照目标目录建好(V-7/D1):服务端要求目标是 已存在的目录,fresh URL 直接 snapshot 会 rc=1 (Destination is not a directory),mkdir 后同一 URL 即 通。非 file:// 目标(snapshot_path 为 None)跳过。目标 已存在且不是目录、或建目录失败时报 snapshot_not_persisted (ch-remote 之前 fail-fast,不调 ch-remote)。""" if snapshot_path is None: return if os.path.exists(snapshot_path) \ and not os.path.isdir(snapshot_path): raise ChvFailure( "snapshot_not_persisted", "快照目标已存在且不是目录:%s" % snapshot_url) try: os.makedirs(snapshot_path, exist_ok=True) except OSError as exc: raise ChvFailure( "snapshot_not_persisted", "快照目录无法创建:%s:%s" % (snapshot_url, exc)) def _disk_image_type(path: str) -> str: """VmConfig 磁盘 image_type 枚举原名(feat-chv-opt-landing): .qcow2 后缀即 Qcow2,其余即 Raw(raw 无魔数、JSON 里写 小写 raw 会报 unknown variant,见 _vm_config_disks)。""" return "Qcow2" if str(path).endswith(".qcow2") else "Raw" def _default_popen_launcher(cmd): """默认 VMM 启动器:独立会话子进程,输出丢弃(纯移动)。""" return subprocess.Popen( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) class CloudHypervisorExecutor(Executor): """Cloud Hypervisor 执行器 v1(命名:cloud-hypervisor,绝不用 cloud/ch 缩写——cloud 已是通用 HTTP runner 占位,撞名会让 认领过滤与白名单全部错乱)。 调度层选择,不是任务内工具(补充口径,必读): - 任务内的 LLM 绝不能发现、调用本执行器:执行器是在任务 创建前由 server 白名单(POST /tasks 的 executor 字段)、 环境默认值(CLOUD_DEFAULT_EXECUTOR)或 worker CLI --executor 认领决定的。任务内 agent 若能自选沙箱,等 于能从隔离强的执行器降级逃逸到隔离弱的执行器。 - 本模块不提供任何 MCP/工具/skill 给任务内 agent;需要可 发现的是创建任务那一层:POST /tasks 的 executor 可选值、 cloud/README.md 说明、控制台选项(只改文档与白名单, 不新增任务内调用入口)。 客内通道:只选 SSH,不做 vsock 第二套。 - 选 SSH 的理由:客内 host.py 的调用就是一条普通命令, SSH 直接复用 OpenSSH 的密钥认证与端口转发,云镜像自带 sshd,无需在客内再写一个 vsock agent、也无需在宿主再写 一个 vsock 代理去翻译 host.py 协议。 - 放弃 vsock 的代价:需要运维提供从宿主到客机的 SSH 通路 (tap/桥接 + 端口转发,见下文“VM 生命周期”)。 - Host key 钉死(本轨 #216 后的加固,默认行为变化): 不再用 StrictHostKeyChecking=no + UserKnownHostsFile= /dev/null。每 VM 在 state_dir//known_hosts 有独立 文件;首次启动时 wait_guest_ready 经 ssh-keyscan 在本 机回环端口上捕获当时呈现的 host key 写入该文件(首次 信任 TOFU:只防首次之后的替换,不验证首次本身,边界 见 cloud/chv/ssh_transport.py),此后同一 VM 的每次 SSH 都用 StrictHostKeyChecking=yes + 该文件校验,换 key 直接拒绝。客机用户与端口是显式配置:CHV_SSH_USER (默认 root,显式空字符串直接 ValueError,绝不静默 fallback 回 root)、CHV_SSH_PORT(默认 2222,必须在 1-65535 内);非 root 用户要求客机镜像里该用户已配 sshd 与 authorized_keys(运维责任)。 - 密钥红线:SSH 只传 -i 密钥文件路径,绝不把任何 key 值、 模型 key 值放进 argv、日志或返回结果;客内 host.py 的 模型 key 由客机镜像/cloud-init 预置(运维责任),v1 不 在 SSH 命令里注入任务级 key。feat-chv-secfix 起网关 密钥改走 seed 盘客内文件投递(见 wake_vm 与 hostkeys.write_seed),keyscan 模式下密钥投递 fail-closed;非密钥 extra_env 仍走 env 前缀。 VM 唤醒流程(写进代码,不是只留文档;API 模式为准): - 主唤醒路径是 API 模式,不是“一条直接启动命令就完事”: wake_vm() 先 preflight,再经 launcher 以 --api-socket 只启动 VMM 进程(CLI 上不带内核/磁盘配置),等 vmm.ping 通,再 PUT vm.create(VmConfig)+ PUT vm.boot。 _build_vm_command() 保留的直接启动命令只是文档里的手动 等价写法(运维手工起 VM 时用),执行器代码里不走它。 - 就绪判定只选 SSH,不做 vsock 第二套:boot 后轮询 SSH “true”命令,直到 CHV_READY_TIMEOUT(默认 120 秒, 轮询间隔 CHV_READY_INTERVAL,默认 5 秒)内成功才跑 host.py/任务;超时则关 VM 并报失败,绝不在未就绪时 就派任务。vsock 放弃理由同客内通道一节(需客内 agent + 宿主代理两套新代码,v1 不拼半吊子)。 - 暂停与快照唤醒是两条独立代码分支: pause_vm()/resume_vm() 走 ch-remote pause/resume; restore_vm() 先 ch-remote restore(source_url=...), 再显式 resume_vm()——恢复后的 VM 默认是 paused,不 resume 就不算醒,绝不能只 restore 就返回成功。 - 关闭与清理:任务结束/失败/取消时走 shutdown_vm()/ stop_vm():ch-remote shutdown + 终止登记进程 + 删 socket。dispatch/run/goal 均走“wake → 跑任务 → finally shutdown”的每任务 VM 生命周期;execute() 是 只读 status,仍走 SSH 直连(要求 VM 已醒,调用方先 wake_vm),不为一次 status 就起一台 VM。本轨把这条 从“文档约定”收成“代码门禁”:execute() 先查 VM 是否 在运行(本实例登记了活进程,或 api-socket 文件存在 视为带外已醒的 VM),未运行直接报 ChvFailure (kind=vm_not_running),绝不向一个不存在的端口发 SSH 再拿连接失败冒充任务状态(execute 语义二选一 的结论:选“只读已运行 VM + 未运行报分类失败”, 不选“execute 先唤醒”——唤醒是 dispatch/run/goal 的职责,status 不应有起 VM 的副作用)。 - 清理要能处理三种情况:已死进程(poll() 非 None)、 已关 VM(无登记进程、无 socket,stop 返回 False)、 socket 残留(有 socket、无登记进程,照常删 socket 并 计入清理)。cleanup_stale_vms() 只清 chv- 前缀的 socket,绝不按前缀全删其它执行器的东西。 生产级口径(对齐公开沙箱保障口径,不声称复刻某家内部架构): - 单任务单 VM:wake → 跑 → finally 关,同一任务不共享 VM; 每任务磁盘 copy 模式独立,租户无共享路径、无东西向互访; VM 内 root 只能影响本 VM。 - 快速启动:持久 VM + 预热池(feat-chv-opt-landing, 阶段 1,生产路径,见 pause/resume 与 dispatch_task_persistent/prewarm_persistent_vm): 同一租户的任务共用一台持久 VM(overlay 系统盘 + 独立数据盘),任务间隙 pause、到达时 resume; CHV_WARM_POOL_SIZE>=1 时控制面常备 1 台已开机、已 完成 Gitea 首次同步的 paused 持久 VM,任务到达先 领它、结束暂停回池,池空回落冷启不报错;为 0(默认) 时全部退化为冷启旧语义。WarmPool(experimental_pool.py) 仍是纯实验(ADR-5/ADR-12),生产 dispatch 绝不查它, 阶段 1 的池不经过它;池状态另落 state_dir/persistent/.json 登记,worker 重启后载入的条目须经 vm.info=Paused + socket 活性 + tenant 归属三重核对才认领,否则只当孤儿清理, 绝不直接发给任务(见 orphans.py 与 acquire_for_task)。 混合模式(feat-chv-hybrid-a,DESIGN-HYBRID.md §9.1 第 1–4 项):dispatch_task_hybrid 按 tier 分流(Paid/Heavy 常驻、 其余走池);CHV_WARM_POOL_SIZE 的计数口径从“每租户 1 台” 改为“每宿主 2 台干净池”(W=2);干净池 VM 出生时不带租户, 分配时才挂载租户数据盘(任务盘来源不变量见 _verify_task_disk_source);restore_vm 在混合模式启用时 严格校验快照 tenant_id(无 tenant 的登记即拒绝)。 - 长任务中途死亡:_run_with_vm 在客内命令抛传输异常、 或非 goal 结果已 failed 且 VM 登记进程已死时,改判 interrupted(断点可续跑)而不是笼统 failed;VM 还活 着时的异常/失败原样处理,不误判。中途心跳未做:评估 结论是 host.py 单次 SSH 长回合没有客内进度信号可采, 另起一路 SSH 轮询只能证明“VM 活着”不能证明“任务 在进展”,徒增连接 churn,不做(见 PR 描述)。 - 与 #215 收敛模块的关系:#215 未合并,base 里没有 timeouts/gates/quota/sandbox_policy 模块,本轨不复制 它们;#215 合并后应再复用的点清单见 cloud/chv/ config.py 头注释与 PR 描述。 - 资源与成本:CPU/内存/磁盘配额门、宿主容量门(CHV_MAX_VMS)、 SSH 超时、goal 100 轮上限、执行器内部不重试;成本与配额 复用 worker 现有 quota 语义,不另起计费。 - 网络与治理:出口默认 fail-closed(必须过 CHV_EGRESS_PROXY 白名单代理);审批仍走任务 approval 与 server merge_gate, VM 内 agent 不能自批。 - 可观测:VM 生命周期事件经 audit_callback 发出(worker 接线 后转 AuthStore 审计),失败带 ChvFailure.kind 分类;断点 与 task_checkpoints/task_attempts 语义对齐(recycle 保留 两者,丢失临时文件)。 - 默认关闭:server 默认仍 docker、worker 默认仍 local,CHV 只在显式选择且有 CHV worker 时才跑;无 CHV_* 配置时 preflight 直接失败。完整清单见 docs/chv-production-readiness.md。 - worker 层现状:通用路径已能走 dispatch_task/goal_task; 断点续跑(goal-resume-breakpoint)已由补缺口轨接线: worker 通用分支对任何提供该方法的执行器调用它, Local 仍走原路径;网关任务的 worker 侧守卫(P1-1 起) 已放行 CHV,网关分流改由本执行器按 VM 类 enforcement (feat-chv-hybrid-b,见 gateway_allowed_for_task): 常驻 VM 可接网关、池 VM 不接(ChvFailure gateway_pool_denied),改之前网关不上 CHV 的前置 依赖即由此落地。 - 网关现状:worker 通用路径在 sub2api 网关下对非 Local 执行器直接 loud fail(与 CloudExecutor 同款),v1 不 改这条门,所以 CHV + sub2api 组合目前在 worker 层就 明确失败,不宣称生产可用。——本段是 v1 旧口径,已被 feat-chv-hybrid-b 取代:worker 守卫自 P1-1 起已放行 CHV,网关分流现由本执行器的 gateway_allowed_for_task 按常驻/池分流(常驻可接、池不接);旧文案留此作 演变记录,执行以 gateway_allowed_for_task 为准。 配置(只走构造参数或环境变量,不写文件;参数优先): - CHV_BINARY:cloud-hypervisor 二进制(默认同名 PATH 查找) - CHV_KERNEL:客机内核路径(必填,文件必须存在) - CHV_ROOTFS:客机根文件系统镜像路径(必填,文件必须 存在;CHV_IMAGE 是同义别名,仅当 CHV_ROOTFS 未设时用) - CHV_CPU:vCPU 数(默认 2,命令里写 boot=N) - CHV_MEM:内存(默认 2G,命令里写 size=2G) - CHV_STATE_DIR:api-socket 目录(默认 ~/.local/share/codex-app-server/chv-vms,0700; feat-chv-secfix 起不再默认 /tmp/chv-vms) - CHV_SSH_KEY:SSH 私钥文件路径(必填,文件必须存在) - CHV_SSH_USER:SSH 用户(默认 root) - CHV_SSH_PORT:宿主侧转发到客机的 SSH 端口(默认 2222) - CHV_CMDLINE:客机内核命令行(默认 "console=hvc0 root=/dev/vda1 rw") - CHV_NET:直接启动命令里的 --net 原样透传值(例如 tap=,mac=...,运维按实际 tap 配置给;不给则命令里不带 --net)。API 模式不用它,API 的网络配置走 CHV_NET_JSON。 - CHV_NET_JSON:API 模式 VmConfig 的 net 列表 JSON(例如 '[{"tap":"","mac":"12:34:56:78:90:01"}]',运维按实际 tap 给;不给则 VmConfig 不带 net,客机无网卡、SSH 就 绪必然超时失败——这是 fail-fast 前置条件,不是静默成功) - CHV_REMOTE:ch-remote 二进制(默认同名 PATH 查找; preflight 必查,缺失即 fail-fast;stop/cleanup 里调用 失败时尽力而为不炸) - CHV_API_TIMEOUT:等 vmm.ping 通的上限秒数(默认 30) - CHV_READY_TIMEOUT:等 guest SSH 就绪的上限秒数(默认 120) - CHV_READY_INTERVAL:SSH 就绪轮询间隔秒数(默认 5) 前置校验(preflight,执行入口必调,缺一即 fail-fast): 收集全部缺失项一次报出具体缺哪一项,绝不笼统报错: cloud-hypervisor 与 ch-remote 二进制存在、/dev/kvm 存在 且可读写、CPU 有 vmx/svm 标志、内核与磁盘镜像路径存在 且可读、SSH key 存在且可读。任一项缺失都 raise RuntimeError,绝不静默回落 Docker/Local。_validate() 即 preflight(旧名保留,供旧调用点用)。 命令构造(_build_vm_command/build_ssh_command/ build_vm_config)是纯函数,不做文件存在校验,单测可 直接断言命令与 VmConfig 形状。 真实验证边界(写进 PR 描述,不可删): 未做真实 Cloud Hypervisor 启动验证。当前环境没有可跑真 VM 的宿主:生产主机 124.222.165.170 本身就是 CVM (systemd-detect-virt=kvm,DMI product=CVM),/dev/kvm 不存在、无 vmx/svm 标志、无 kvm 模块、未装 cloud-hypervisor,无嵌套虚拟化透传;本 VM 容器内同样 无 /dev/kvm。v1 验证只能是 mock runner + 干跑命令构造 + 单元测试。真 VM 验证需另备带 /dev/kvm 的裸金属或 开启嵌套虚拟化的实例,那是用户侧开通决策,不在本轨内。 """ # to10:mypy 属性声明(仅注解、不赋值,运行时仍由 # _copy_config_fields/_init_runtime 抄写)。没有这组声明, # 动态 setattr 会让 mypy 认为全部配置属性不存在(基线 # 115 个报错里近 90 个属此类),类型检查等于没跑。 binary: str | None kernel: str | None rootfs: str | None cpus: str memory: str state_dir: str ssh_key: str | None ssh_user: str ssh_port: int guest_host_py: str cmdline: str net: str | None net_json: str | None kvm_path: str cpuinfo_path: str ch_remote: str model: str | None max_vms: int disk_mode: str disk_gb: float egress_proxy: str | None allow_direct_egress: bool task_timeout: float | None snapshot_url: str | None warm_pool_size: int api_timeout: float ready_timeout: float ready_interval: float host_key_mode: str ssh_host: str | None ssh_forward: bool reuse_experiment: bool single_tenant_project: str | None snapshot_max_count: int snapshot_max_total_gb: float snapshot_retention_days: float snapshot_dir: str | None allow_remote_snapshot: bool runner: "Callable[..., object]" launcher: "Callable[..., object]" api_client: "Callable[..., object]" sleeper: "Callable[..., object]" audit_callback: object audit_failure_count: int _vms: dict _guest_env_delivered: dict def __init__(self, binary: str | None = None, kernel: str | None = None, rootfs: str | None = None, image: str | None = None, cpus: str | int | None = None, memory: str | None = None, state_dir: str | None = None, runner: Callable[..., object] | None = None, launcher: Callable[..., object] | None = None, ssh_key: str | None = None, ssh_user: str | None = None, ssh_port: int | str | None = None, guest_host_py: str = "/app/host.py", cmdline: str | None = None, net: str | None = None, net_json: str | None = None, ssh_host: str | None = None, kvm_path: str = "/dev/kvm", cpuinfo_path: str = "/proc/cpuinfo", env: dict | None = None, model: str | None = None, ch_remote: str | None = None, api_client: Callable[..., object] | None = None, sleeper: Callable[..., object] | None = None, api_timeout: float | None = None, ready_timeout: float | None = None, ready_interval: float | None = None, host_key_mode: str | None = None) -> None: self._env: dict = env if env is not None else os.environ # type: ignore[assignment] # 配置解析已拆到 cloud/chv/config.py(参数优先于环境 # 变量,默认值与 #216 一致;显式空用户/越界端口的收紧 # 见 config.py 头注释)。字段名与原属性一一对应,旧 # 测试读属性的方式全部不变。 _cfg = build_chv_config({ "binary": binary, "kernel": kernel, "rootfs": rootfs, "image": image, "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, "ssh_host": ssh_host, "kvm_path": kvm_path, "cpuinfo_path": cpuinfo_path, "ch_remote": ch_remote, "model": model, "api_timeout": api_timeout, "ready_timeout": ready_timeout, "ready_interval": ready_interval, "host_key_mode": host_key_mode}, self._env) self._copy_config_fields(_cfg) self._init_runtime(runner, launcher, api_client, sleeper) def _init_runtime(self, runner: Callable[..., object] | None, launcher: Callable[..., object] | None, api_client: Callable[..., object] | None, sleeper: Callable[..., object] | None) -> None: # to10:runner/launcher/api_client/sleeper 均为可调用注入对象; # 形参保持 object 以兼容旧测试的任意 lambda,调用点用 # getattr 可调用检查后调用(见 _call_runner 等),mypy # 的 object-not-callable 在 _call_runner 处以 Callable 局部 # 变量处理。 """运行时注入:seed 投递集合、API 客户端、睡眠器、 runner/launcher 默认值(纯移动)。""" # feat-chv-secfix(H2):本实例已 seed 投递密钥的 VM # 名 → 已投递密钥名集合。_run_guest 只对集合内的密钥 # 名 source 客内文件,未投递的密钥名一律拒绝(防止 # “wake 没投递、执行时却按名 source 一个旧文件”)。 self._guest_env_delivered = {} # feat-chv-closeup(P2-20):_guest_env_delivered 是 # 跨 wake/build_ssh_command/stop 的共享字典,wake 与 # 执行可并发在不同线程——此前无锁读写,set/get/pop # 交错会丢投递记录(密钥被误判未投递而拒跑,或已清 # 的记录被误读为已投递)。专用锁护住全部访问(与 # _vms_lock 分开:投递记录与进程登记是两把不同的锁, # 合一把会在 stop 路径上不必要地串行);所有访问点 # 必须走下面三个小方法,禁止直接读写字典(新方法 # 是唯一入口,回归测试钉死它)。 self._guest_env_lock = threading.Lock() self.api_client = api_client if api_client is not None \ else _unix_socket_api self.sleeper = sleeper if sleeper is not None else time.sleep self.audit_callback = None self.audit_failure_count = 0 self._init_hybrid_b_runtime() self._vms_lock = threading.Lock() if runner is not None: self.runner = runner else: # 方法内 import:sandbox 顶层 re-export 本模块, # 顶层 import 会循环;调用时 sandbox 早已加载完成。 from cloud.sandbox import _default_runner self.runner = _default_runner if launcher is not None: self.launcher = launcher else: self.launcher = _default_popen_launcher self._vms = {} def _init_hybrid_b_runtime(self): """B 轨运行期状态(_init_runtime 的一段,拆出只为 满足 50 行函数上限,语义不变):排队等待计数 (_wait_for_capacity 在等待期间 +1,退出时 -1; queue_depth() 供 L1 扩缩读队列深度)与租约降级前 的同步钩子(见 _sync_resident_before_demote; None 即默认口径)。""" self._queue_waiters = 0 self._queue_lock = threading.Lock() self.resident_sync_hook = None def _delivered_secrets(self, name: str) -> set: """P2-20 唯一读入口:该 VM 已 seed 投递的密钥名集合。""" with self._guest_env_lock: return set(self._guest_env_delivered.get(name, set())) def _mark_guest_env_delivered(self, name: str, secrets) -> None: """P2-20 唯一写入口:记下该 VM 已投递的密钥名集合 (secrets 为空即清记录,与旧语义一致)。""" with self._guest_env_lock: if secrets: self._guest_env_delivered[name] = set(secrets) else: self._guest_env_delivered.pop(name, None) def _clear_guest_env_delivered(self, name: str) -> None: """P2-20 唯一清除入口:stop 时清掉该 VM 的投递记录。""" with self._guest_env_lock: self._guest_env_delivered.pop(name, None) def _copy_config_fields(self, _cfg) -> None: """把 build_chv_config 的字段抄到 self(纯移动,字段集不变)。""" for _field in ( "binary", "kernel", "rootfs", "cpus", "memory", "state_dir", "ssh_key", "ssh_user", "ssh_port", "guest_host_py", "cmdline", "net", "net_json", "kvm_path", "cpuinfo_path", "ch_remote", "model", "max_vms", "disk_mode", "disk_gb", "egress_proxy", "allow_direct_egress", "task_timeout", "snapshot_url", "warm_pool_size", "api_timeout", "ready_timeout", "ready_interval", "host_key_mode", "ssh_host", "ssh_forward", "reuse_experiment", "single_tenant_project", "snapshot_max_count", "snapshot_max_total_gb", "snapshot_retention_days", "snapshot_dir", "allow_remote_snapshot", "gitea_sync_env", "seed_user_data_extra_file", "seed_network_config_file"): setattr(self, _field, getattr(_cfg, _field)) # -- 名字 / 路径 --------------------------------------------------- def _host_name(self, task): return re.sub(r"[^A-Za-z0-9_.-]", "-", str(task.get("id"))) def vm_name(self, task_or_name: dict | str) -> str: """VM 名:chv-<安全字符任务名>(与 Docker 的 codex- 同款 派生,同一任务每次算出同一名字,清理才能按名找)。 幂等:传入的已是 chv- 前缀名时不再二次加前缀(清理 扫描 socket 文件名时传的就是 chv- 名,二次加前缀会 把 chv-dead 算成 chv-chv-dead、永远清不掉)。""" # to10:id 按契约是 str;cast 只给 mypy 用,运行时原样 # 传给 re.sub(id 缺失时仍按旧行为 TypeError,不静默 # 编成 "None")。 from typing import cast as _cast raw = _cast(str, task_or_name.get("id")) if isinstance(task_or_name, dict) \ else str(task_or_name) safe = re.sub(r"[^A-Za-z0-9_.-]", "-", raw) if safe.startswith("chv-"): return safe return "chv-" + safe def api_socket(self, task_or_name: dict | str) -> str: """该 VM 的 api-socket 路径:state_dir/.sock。""" return os.path.join(self.state_dir, self.vm_name(task_or_name) + ".sock") def _tenant_id(self, task) -> str | None: """归属租户 id(feat-chv-opt-landing):任务 dict 的 tenant_id > project_id > 单租户声明 CHV_SINGLE_TENANT_PROJECT;三者皆无即 None(无归属 可标,快照/overlay 按 legacy 处理,不伪造)。""" if isinstance(task, dict): tenant = task.get("tenant_id") or task.get("project_id") if tenant: return str(tenant) return getattr(self, "single_tenant_project", None) def persistent_vm_name(self, tenant_id: str | None) -> str: """该租户的持久 VM 名:chv-persist-<安全字符租户> (阶段 1:同一租户的连续任务共用这一台 VM,任务间 暂停不断电、不删盘;租户为空即 default 单租户)。""" safe = re.sub(r"[^A-Za-z0-9_.-]", "-", str(tenant_id or "default")) return "chv-persist-" + safe # -- 前置校验 ------------------------------------------------------ @staticmethod def _binary_ok(path): if "/" in path: return os.path.isfile(path) and os.access(path, os.X_OK) return shutil.which(path) is not None def _preflight_config_checks(self, missing: list) -> None: """preflight 的配置校验块(feat-chv-fix,P2-1 小提取: 仅把 reuse 拦截与 CPU/内存校验从 preflight() 里抽出, 语义逐行不变;其余 preflight 检查项与 config.py 的 字段解析深耦合,本轨如实停、不硬拆)。""" # P0-3:reuse 生产拦截。一行配置再也不能击穿租户隔离: # 只有显式实验二次确认 CHV_REUSE_SINGLE_TENANT_ # EXPERIMENT=1 时才放行,且放行本身在 wake_vm 里审计; # 否则 preflight 直接拒绝(kind=disk_mode_reuse_blocked)。 if getattr(self, "disk_mode", "copy") == "reuse" \ and not getattr(self, "reuse_experiment", False): missing.append(("disk_mode_reuse_blocked", "CHV_DISK_MODE=reuse 未经显式实验确认:" "须同时设 CHV_REUSE_SINGLE_TENANT_" "EXPERIMENT=1(仅单租户实验)")) # feat-chv-secfix(M6):实验放行还须声明单租户项目。 # 代码无法从环境变量验证数据库里是否真的只有一个 # 租户——声明 + 审计就是本项的机制,文档明写该边界, # 不假装已校验多租户状态。 if getattr(self, "disk_mode", "copy") == "reuse" \ and getattr(self, "reuse_experiment", False) \ and not getattr(self, "single_tenant_project", None): missing.append(("reuse_tenant_undeclared", "CHV_DISK_MODE=reuse 实验放行须同时声明" " CHV_SINGLE_TENANT_PROJECT(单租户" "项目 id,仅部署声明 + 审计,不验证" "数据库多租户状态)")) # P2-6:配置校验时机统一到 preflight。CPU 非整数、内存 # 字符串非法过去只在 build_vm_config 才炸,现在 preflight # 一次报出(kind=config_invalid)。 try: int(self.cpus) except (TypeError, ValueError): missing.append(("config_invalid", "CHV_CPU 必须是整数:%r" % (self.cpus,))) try: _parse_memory_bytes(self.memory) except ValueError: missing.append(("config_invalid", "CHV_MEM 格式非法:%r" % (self.memory,))) self._preflight_numeric_checks(missing) def _preflight_numeric_checks(self, missing: list) -> None: """preflight 数值面:端口/配额/超时/快照统一校验,纯移动。 构造时已校验正常值;这里兜底属性事后被改写的情形。""" # feat-chv-arch95(配置统一校验):构造时 build_chv_config # 已校验过这些字段的正常值;这里再以 config_invalid # 统一兜底属性被事后改写/注入非法值的情形,preflight # 是生产路径最后一道统一校验,不再等到 build_vm_config # 或 wake 时才在不同位置以不同文案报错。 try: _port = int(getattr(self, "ssh_port", 2222)) if not 1 <= _port <= 65535: raise ValueError("ssh_port") except (TypeError, ValueError): missing.append(("config_invalid", "CHV_SSH_PORT 必须在 1-65535 之间")) try: _max_vms = int(getattr(self, "max_vms", 4)) if _max_vms < 0: raise ValueError("max_vms") except (TypeError, ValueError): missing.append(("config_invalid", "CHV_MAX_VMS 必须是非负整数")) try: _disk_gb = float(getattr(self, "disk_gb", 50.0)) if _disk_gb <= 0: raise ValueError("disk_gb") except (TypeError, ValueError): missing.append(("config_invalid", "CHV_DISK_GB 必须是大于 0 的数字")) try: if float(getattr(self, "api_timeout", 30)) <= 0 \ or float(getattr(self, "ready_timeout", 120)) <= 0 \ or float(getattr(self, "ready_interval", 5)) <= 0: raise ValueError("timeout") except (TypeError, ValueError): missing.append(("config_invalid", "CHV_*_TIMEOUT/INTERVAL 必须是正数")) try: if int(getattr(self, "warm_pool_size", 0)) < 0: raise ValueError("warm_pool_size") except (TypeError, ValueError): missing.append(("config_invalid", "CHV_WARM_POOL_SIZE 必须是非负整数")) self._preflight_snapshot_checks(missing) def _preflight_snapshot_checks(self, missing: list) -> None: """preflight 快照/host-key 面:统一校验,纯移动。""" try: if int(getattr(self, "snapshot_max_count", 10)) < 0 \ or float(getattr(self, "snapshot_max_total_gb", 20.0)) < 0 \ or float(getattr(self, "snapshot_retention_days", 30.0)) < 0: raise ValueError("snapshot") except (TypeError, ValueError): missing.append(("config_invalid", "CHV_SNAPSHOT_* 不能为负数")) if getattr(self, "host_key_mode", "provisioned") \ not in ("provisioned", "keyscan"): missing.append(("config_invalid", "CHV_HOST_KEY_MODE 只能是 provisioned 或 keyscan")) def _preflight_host_checks(self, missing: list) -> None: """preflight 主机面:二进制/KVM/CPU 标志,纯移动。""" if not self._binary_ok(self.binary): missing.append(("binary_missing", "cloud-hypervisor 二进制缺失或不可执行:%s" % self.binary)) if not self._binary_ok(self.ch_remote): missing.append(("binary_missing", "ch-remote 二进制缺失或不可执行:%s" % self.ch_remote)) if not os.path.exists(self.kvm_path): missing.append(("kvm_missing", "/dev/kvm 不存在:%s,宿主无 KVM" % self.kvm_path)) else: if not os.access(self.kvm_path, os.R_OK): missing.append(("kvm_missing", "/dev/kvm 不可读:%s" % self.kvm_path)) if not os.access(self.kvm_path, os.W_OK): missing.append(("kvm_missing", "/dev/kvm 不可写:%s" % self.kvm_path)) cpuinfo_ok = False try: with open(self.cpuinfo_path, "r") as fh: for line in fh: if line.startswith("flags"): flags = line.split(":", 1)[1].split() if "vmx" in flags or "svm" in flags: cpuinfo_ok = True break except OSError: missing.append(("cpu_flag_missing", "CPU 信息文件缺失:%s" % self.cpuinfo_path)) else: if not cpuinfo_ok: missing.append(("cpu_flag_missing", "CPU 无虚拟化标志(无 vmx/svm):%s" % self.cpuinfo_path)) def _preflight_file_checks(self, missing: list) -> None: """preflight 文件面:内核/镜像/SSH key,纯移动。""" if not self.kernel: missing.append(("kernel_missing", "CHV_KERNEL 未设")) elif not os.path.isfile(self.kernel): missing.append(("kernel_missing", "内核文件不存在:%s" % self.kernel)) elif not os.access(self.kernel, os.R_OK): missing.append(("kernel_missing", "内核文件不可读:%s" % self.kernel)) if not self.rootfs: missing.append(("image_missing", "CHV_ROOTFS 未设")) elif not os.path.isfile(self.rootfs): missing.append(("image_missing", "磁盘镜像文件不存在:%s" % self.rootfs)) elif not os.access(self.rootfs, os.R_OK): missing.append(("image_missing", "磁盘镜像文件不可读:%s" % self.rootfs)) if not self.ssh_key: missing.append(("ssh_key_missing", "CHV_SSH_KEY 未设")) elif not os.path.isfile(self.ssh_key): missing.append(("ssh_key_missing", "SSH key 文件不存在:%s" % self.ssh_key)) elif not os.access(self.ssh_key, os.R_OK): missing.append(("ssh_key_missing", "SSH key 文件不可读:%s" % self.ssh_key)) def _preflight_net_checks(self, missing: list) -> None: """preflight 网络/目录面:egress/安全目录/net,纯移动。""" if not self.egress_proxy and not self.allow_direct_egress: missing.append(("egress_not_configured", "CHV_EGRESS_PROXY 未设且未显式允许直连:" "VM 出口必须过白名单代理,默认不给直连外网")) if self.egress_proxy: try: validate_egress_proxy(self.egress_proxy) except ChvFailure as exc: missing.append((exc.kind, str(exc))) # feat-chv-secfix(H6):state_dir 安全目录门。 # 默认值已出 /tmp(见 config.py);这里再校验一次: # 目录须存在且 0700,否则 preflight 直接失败。 try: ensure_secure_state_dir(self.state_dir) except ChvFailure as exc: missing.append((exc.kind, str(exc))) # 补缺口轨 (4):tap/网络配置校验前置到 preflight。 # CHV_NET_JSON 非法 JSON 或非 list/dict 时直接报 # net_config_invalid,不等到 vm.create 才炸;直连 # 只在显式 CHV_ALLOW_DIRECT_EGRESS=1 时放行,且 # wake_vm 里发 egress_direct_allowed 审计。tap 桥接 # 本身是否真只走代理仍需宿主实测,代码不声称已验证。 # feat-chv-secfix(L4):再加字段白名单与条目内重复 # 校验;CHV_NET 与 CHV_NET_JSON 双设时只发警告审计 # (net_config_dual),不硬拒——CHV_NET 是手动直接 # 启动命令的透传值,双设不一定是误配,硬拒会断掉 # 手动运维路径;误配的代价是“API 模式忽略 CHV_NET”, # 警告 + 文档已写清。 if self.net and self.net_json: self._emit("net_config_dual", None, "CHV_NET 与 CHV_NET_JSON 双设:API 模式只用" " CHV_NET_JSON,CHV_NET 仅手动直接启动命令用") if self.net_json: try: _entries = parse_net_entries(self.net_json) validate_net_entries(_entries, []) except ChvFailure as exc: missing.append((exc.kind, str(exc))) def preflight(self) -> bool: """预检(缺一不可则 fail-fast):收集全部缺失项一次报出 具体缺哪一项,绝不笼统报错;失败为 ChvFailure(带 kind, 供审计与失败分类用),绝不回落 Docker/Local。 检查项与 kind: - binary_missing:cloud-hypervisor 二进制不存在/不可执行 - binary_missing:ch-remote 二进制不存在/不可执行 - kvm_missing:/dev/kvm 不存在 - kvm_missing:/dev/kvm 不可读/不可写(分开报) - cpu_flag_missing:/proc/cpuinfo 无 vmx/svm 标志 - kernel_missing:CHV_KERNEL 未设/文件不存在/不可读 - image_missing:CHV_ROOTFS 未设/文件不存在/不可读 - ssh_key_missing:CHV_SSH_KEY 未设/文件不存在/不可读 - egress_not_configured:CHV_EGRESS_PROXY 未设且未显式 允许直连(默认 fail-closed,VM 出口必须过白名单代理) """ missing: list = [] # (kind, 文本) 列表,全部收集完再一次抛 self._preflight_host_checks(missing) self._preflight_file_checks(missing) self._preflight_net_checks(missing) self._preflight_config_checks(missing) if missing: kind = missing[0][0] detail = "; ".join(text for _, text in missing) # feat-chv-secfix(M5/L3):preflight 失败本身也 # 审计(此前 try 块之前失败一律无审计事件)。 self._emit("preflight_failed", None, "preflight 失败:%s" % detail, kind) raise ChvFailure( kind, "cloud-hypervisor preflight 失败:%s" % detail) return True def _validate(self): """旧名保留:即 preflight。""" return self.preflight() # -- 审计 ---------------------------------------------------------- def _emit(self, event, task, detail="", kind=None): """VM 生命周期审计事件:有 audit_callback 时调它 (worker 接线后转 AuthStore 审计,即任务 timeline 的 审计来源),无 callback 时只记日志。 feat-chv-secfix(M5):审计回调失败不再双重静默 pass——记宿主 error 日志(审计完整性本身没有保证, 但失败至少可见;任务仍不因此炸,审计失败不改变 任务状态是 worker 层的既定语义)。feat-chv-arch95plus 取舍(见 ADR-9):不改成“审计失败即中断任务”—— 审计是可观测性不是门禁,DB 短暂不可用时硬中断会把 一次审计失败放大成任务失败,且与 worker 全局 best-effort 语义冲突;改为“失败记日志 + 计数 + 不 中断”,计数见 self.audit_failure_count(宿主可观测, 非任务状态)。审计 detail 里绝不含密钥值(调用方 负责:本类所有 _emit 的 detail 只含 VM 名、路径、 URL、kind,不含任何 key 值)。""" try: if self.audit_callback is not None: self.audit_callback(event, task, detail, kind) else: _LOGGER.info("chv_audit event=%s kind=%s %s", event, kind, detail) except Exception as exc: self.audit_failure_count = getattr( self, "audit_failure_count", 0) + 1 _LOGGER.error("chv_audit_failed event=%s kind=%s error=%s", event, kind, exc) # to10(ADR-14):回调失败的事件再落盘一份—— # 宿主日志可能轮转、计数只剩数字,落盘文件是 # 唯一能事后找回“哪次事件丢了”的取证。落盘 # 本身 best-effort,失败只记日志,绝不炸任务。 self._spill_audit(event, task, detail, kind) def _spill_audit(self, event, task, detail, kind) -> None: """审计失败事件落盘(to10,见 ADR-14 取舍): state_dir/chv-audit-spill.jsonl,每行一 JSON {ts, event, kind, detail, task_id};文件超 1MB 时 先轮转成 .old 再写。detail 沿用 _emit 契约,绝不 含密钥值。落盘不是 DB 替代:timeline 查询看不到它, 需宿主人工查阅;轮转只留最新两代,旧事件会丢。""" try: path = os.path.join(self.state_dir, "chv-audit-spill.jsonl") if os.path.exists(path) \ and os.path.getsize(path) > 1024 * 1024: os.replace(path, path + ".old") task_id = task.get("id") if isinstance(task, dict) \ else str(task) line = json.dumps({"ts": time.time(), "event": event, "kind": kind, "detail": detail, "task_id": task_id}) with open(path, "a") as fh: fh.write(line + "\n") # feat-chv-closeup(P2-18):spill 是审计丢事 # 件后的唯一取证,写完即 fsync,否则宿主崩溃 # 后 spill 本身也可能丢。失败只记日志,不炸任务。 fh.flush() os.fsync(fh.fileno()) except Exception as exc: _LOGGER.error("chv_audit_spill_failed error=%s", exc) # -- 命令构造(纯函数,不做文件校验) ------------------------------- def _build_vm_command(self, task): """构造 cloud-hypervisor 启动命令(干跑可测,仅私有)。 feat-chv-fix(P2-2):原公开 build_vm_command() 降级为 私有——执行器唯一启动路径是 API 模式(wake_vm),公开 的第二启动路径容易被后来人误当成可走的启动入口;手动 等价写法保留在本方法与类 docstring 里,运维手工起 VM 时仍可按此形状拼命令,不再经公开方法调用。 顺序固定:<二进制> --api-socket --cpus boot=N --memory size=M --kernel <内核> --cmdline <命令行> --disk path=<镜像> [--net <运维透传值>]。 --net 不给时不带:tap/桥接是运维侧配置,本类不编造 一个不存在的 tap 名。 """ cmd = [self.binary, "--api-socket", self.api_socket(task), "--cpus", "boot=%s" % self.cpus, "--memory", "size=%s" % self.memory, "--kernel", self.kernel, "--cmdline", self.cmdline, "--disk", "path=%s" % self.rootfs] if self.net: cmd += ["--net", self.net] return cmd def known_hosts_path(self, task_or_name: dict | str) -> str: """该 VM 的 known_hosts 文件:state_dir//known_hosts (与每任务磁盘同目录,见 ssh_transport.py 的钉死模型)。""" return _known_hosts_path(self.state_dir, self.vm_name(task_or_name)) def _net_guest_ip(self) -> str | None: """VmConfig net 首个带 ip 的条目(静态 tap 客机 IP)。""" if not self.net_json: return None try: net = json.loads(self.net_json) except ValueError: return None entries = net if isinstance(net, list) else [net] for entry in entries: if isinstance(entry, dict) and entry.get("ip"): return str(entry["ip"]) return None def resolve_ssh_host(self) -> str: """SSH 目标解析(feat-chv-fix,P0-4 选定方案:客机 IP): 显式 CHV_SSH_HOST > net 静态客机 IP > 显式转发宣称下 的回环;三者皆无时报 ChvFailure(ssh_target_undeclared), 绝不无条件假设 127.0.0.1:2222 能通。""" _ssh_host = self.ssh_host if _ssh_host: return _ssh_host guest_ip = self._net_guest_ip() if guest_ip: return guest_ip if getattr(self, "ssh_forward", False): return "127.0.0.1" raise ChvFailure( "ssh_target_undeclared", "SSH 目标未声明:须设 CHV_SSH_HOST、或在 CHV_NET_JSON " "里给 net 条目配 ip(静态 tap 客机 IP)、或显式设 " "CHV_SSH_FORWARD=1 宣称宿主 NAT 已转发回环端口") def _read_keypair_texts(self, paths: dict) -> tuple: """读 key 对文本(to10 提取,provision_host_key 降行用)。""" with open(paths["public"], "r") as fh: public_text = fh.read() with open(paths["private"], "r") as fh: private_text = fh.read() return public_text, private_text def _write_provisioned_known_hosts(self, paths: dict, public_text: str) -> None: """写预置 known_hosts(to10 提取)。主机部分跟 SSH 目标走(客机 IP 时不再写死回环,否则钉死校验永远 对不上);解析失败时回环仅作钉死写入,不放宽校验。""" from cloud.chv.hostkeys import known_hosts_line from cloud.chv.ssh_transport import write_known_hosts try: ssh_host = self.resolve_ssh_host() except ChvFailure: ssh_host = "127.0.0.1" write_known_hosts( paths["known_hosts"], [known_hosts_line(self.ssh_port, public_text, host=ssh_host)]) def provision_host_key(self, task: dict, secret_env: dict | None = None, guest_env: dict | None = None, user_data_extra: str | None = None, network_config: str | None = None) -> dict: """按 VM 预置 host key(补缺口轨强方案,见 hostkeys.py): 启动前为该 VM 生成一次性 ed25519 host key 对,公钥 立即写进该 VM 的 known_hosts(钉死),私钥只写进 per-VM seed 目录的 cloud-init user-data,随 VM 销毁 而删。返回 {paths, seed_dir, known_hosts}。keygen 失败即 raise,provisioned 模式下绝不回落 keyscan; 不同 VM 的 key 对不同(state_dir/ 隔离),同一 VM 重启复用已有对(否则钉死立即失效)。 feat-chv-secfix(H2/M2):secret_env 给定时写进 seed 的客内密钥文件(0600);guest_env 给定时与 secret_env 合并写同一客内文件(boot 时要用的非密钥 同步变量,见 gitea_sync);上次清理失败留下的 脏标记存在时直接拒绝(清理失败即拒起,不再复用 旧 key 对)。成功后审计 host_key_generated(只含 VM 名与密钥名,不含任何 key 值)。""" # to10:known_hosts 写入已抽到 _write_provisioned_known_hosts, # 此处只留生成与 seed 写入的 import(删后不再有未用 import)。 from cloud.chv.hostkeys import ( dirty_marker_path, generate_host_keypair, write_seed, ) vm = self.vm_name(task) if os.path.exists(dirty_marker_path(self.state_dir, vm)): raise ChvFailure( "vm_dirty_refused", "VM %s 上次清理失败(脏标记存在),拒绝复用旧 " "host key 启动:请人工清理 state_dir 后删脏标记" % vm) paths = generate_host_keypair( self.state_dir, vm, runner=lambda cmd: self._call_runner(cmd)) public_text, private_text = self._read_keypair_texts(paths) self._write_provisioned_known_hosts(paths, public_text) seed_dir = write_seed(paths["seed_dir"], vm, private_text, public_text, secret_env=secret_env, guest_env=guest_env, user_data_extra=user_data_extra, network_config=network_config, ssh_user=getattr(self, "ssh_user", None)) self._emit("host_key_generated", task, "vm=%s secret_names=%s" % (vm, ",".join(sorted(secret_env or {})))) return {"paths": paths, "seed_dir": seed_dir, "known_hosts": paths["known_hosts"]} def capture_host_key(self, task: dict) -> bool: """首次捕获客机 host key(TOFU,弱回落,边界见 ssh_transport.py 与 hostkeys.py):ssh-keyscan 经本机 回环端口抓 key 行,写入该 VM 的 known_hosts 文件 (0600)。生产部署必须用 provisioned 预置模式, 本方法只在 CHV_HOST_KEY_MODE=keyscan 时作回落, 它只能防首次之后的替换,不能验证首次本身。 抓不到可用 key 行时返回 False(不写文件、不报错), 由 wait_guest_ready 下一轮再试;sshd 还没起来时 这就是常态,不是失败。""" try: ssh_host = self.resolve_ssh_host() except ChvFailure: return False try: result = self._call_runner( ["ssh-keyscan", "-p", str(self.ssh_port), ssh_host], timeout=10) stdout = getattr(result, "stdout", "") or "" except Exception: return False key_lines = parse_keyscan_output(stdout, host=ssh_host) if not key_lines: return False try: write_known_hosts(self.known_hosts_path(task), key_lines) except OSError: return False # feat-chv-secfix(M5):捕获成功独立审计(只含 VM # 名与主机,不含 key 行本身)。 self._emit("host_key_captured", task, "vm=%s host=%s" % (self.vm_name(task), ssh_host)) return True def build_ssh_command(self, task: dict, inner_cmd: list, extra_env: dict | None = None) -> list: """构造 SSH 命令:ssh -p <端口> -i + 钉死选项 + user@ + 客内命令。argv 里只有 key 文件路径, 绝无任何 key 值。egress_proxy 已设时客内命令前加 env 前缀(HTTPS_PROXY/HTTP_PROXY/NO_PROXY,代理地址本身 不是密钥;客机出口只走该白名单代理,见类 docstring)。 Host key 选项是钉死式(StrictHostKeyChecking=yes + 每 VM known_hosts 文件),#216 的 no + /dev/null 已 在本轨废除,见类 docstring 与 ssh_transport.py。 feat-chv-secfix(H2):extra_env 先拆密钥子集与非 密钥子集。密钥子集须已在本实例的 wake 里 seed 投递 (_guest_env_delivered 有记录),否则直接 ChvFailure (secret_env_not_delivered),绝不回落 argv;已投递 时远程命令 source 客内密钥文件。keyscan 模式下 wake 根本不建 seed,密钥子集到这里必然未投递—— 即 CHV + 网关密钥现在要求 provisioned 模式(已是 默认),keyscan 模式下的网关任务 fail-closed,不再 把密钥值拼进 argv 将就跑。""" try: ssh_host = self.resolve_ssh_host() except ChvFailure: # 纯命令构造(旧测试/干跑)不炸:回环缺省仅作 # 构造兼容,wake/wait 路径仍严格解析。 ssh_host = "127.0.0.1" secrets, plain = split_secret_env(extra_env) guest_env_file = None if secrets: from cloud.chv.hostkeys import GUEST_ENV_PATH delivered = self._delivered_secrets(self.vm_name(task)) missing: list = [name for name in secrets if name not in delivered] if missing: raise ChvFailure( "secret_env_not_delivered", "密钥环境变量 %s 未经 seed 投递,拒绝进 ssh " "argv:provisioned 模式下 wake 时投递," "keyscan 模式不支持密钥投递" % ",".join(sorted(missing))) guest_env_file = GUEST_ENV_PATH return _build_ssh_cmd( self.ssh_port, self.ssh_key, self.ssh_user, self.egress_proxy, self.known_hosts_path(task), inner_cmd, ssh_host=ssh_host, extra_env=plain or None, guest_env_file=guest_env_file) # -- API 模式:VmConfig / API 调用 / 就绪轮询 ----------------------- def _vm_config_disks(self, task_disk: str | None, seed_disk: str | None, data_disk: str | None = None) -> list: """VmConfig 磁盘列表(to10 提取,含 L2 白名单校验)。 任务盘 + 可选 seed 只读盘(CIDATA ISO,cloud-init NoCloud 数据源)+ 可选数据盘(overlay 模式第二块盘, 工作区/浏览器 profile 载体,客内按 LABEL=CHVDATA 挂 载到 /home/chv/workspace);显式给的盘若不是基础 镜像本身,须在 state_dir 内,否则 VmConfig 不能挂 进宿主任意文件。 image_type 必须显式给:CHV 54 的磁盘类型靠文件 魔数探测,raw 盘无魔数、探测失败即 vm.boot 400 "Image type required for disk"(2026-11-07 WSL 真机 CHV 54.0.0 实测);值须是枚举原名 Raw(CLI 的小写 raw 是另一层映射,JSON 里写 raw 会报 unknown variant); CLI 各轮一直传 image_type=raw(seed ISO 亦然,客内 仍以 /dev/vdb 读到 CIDATA)。 feat-chv-opt-landing:.qcow2 后缀的盘(overlay 与 数据盘)image_type 为 Qcow2(qcow2 有魔数可探测, 但与 raw 同款必须显式给枚举原名,不依赖探测)。""" # 旧文案逐字保留:任务盘“任务磁盘须在…”,seed 盘 # “seed 磁盘须在…”(label 本身含“磁盘”与空格)。 for path, label in ((task_disk, "任务磁盘"), (seed_disk, "seed 磁盘"), (data_disk, "数据磁盘")): if path and path != self.rootfs: if not os.path.realpath(path).startswith( os.path.realpath(self.state_dir) + os.sep): raise ChvFailure( "disk_path_not_allowed", "%s须在 state_dir 内:%s" % (label, path)) disks: list = [{"path": task_disk or self.rootfs, "image_type": _disk_image_type( task_disk or self.rootfs)}] if seed_disk: disks.append({"path": seed_disk, "readonly": True, "image_type": "Raw"}) if data_disk: disks.append({"path": data_disk, "image_type": _disk_image_type(data_disk)}) return disks def build_vm_config(self, task_disk: str | None = None, seed_disk: str | None = None, data_disk: str | None = None) -> dict: """API 模式的 VmConfig(vm.create 请求体,纯函数): cpus{boot_vcpus,max_vcpus}、memory{size: 字节}、 payload{kernel, cmdline}、disks[{path: 每任务磁盘}]、 rng{/dev/urandom},以及 CHV_NET_JSON 给出的 net 列表 (不给则不带 net——客机无网卡时 SSH 就绪会超时失败, 这是 fail-fast 前置条件,见类 docstring)。 task_disk 不给时用基础镜像路径(仅 lab/reuse 模式)。 payload 形态(CHV 54 API):内核与命令行须放在 payload 对象里(kernel/cmdline 均为直接字符串), 旧版顶层 kernel{path}/cmdline{args} 会被服务端忽略, vm.create 照常 204、vm.boot 报 400 "No bootitem provided: neither firmware nor kernel"(2026-11-07 WSL 真机 CHV 54.0.0 实测)。""" try: cpus = int(self.cpus) except ValueError: raise ValueError("CHV_CPU 必须是整数:%r" % (self.cpus,)) payload: dict = {} if self.kernel: payload["kernel"] = self.kernel if self.cmdline: payload["cmdline"] = self.cmdline config = { "cpus": {"boot_vcpus": cpus, "max_vcpus": cpus}, "memory": {"size": _parse_memory_bytes(self.memory)}, "payload": payload, "disks": self._vm_config_disks(task_disk, seed_disk, data_disk), "rng": {"src": "/dev/urandom"}, } if self.net_json: try: net = json.loads(self.net_json) except ValueError: raise ValueError("CHV_NET_JSON 不是合法 JSON") config["net"] = net if isinstance(net, list) else [net] return config def _api_call(self, socket_path, method, path, payload=None): """API 调用归一化(实现在 cloud/chv/api_client.py)。""" return normalize_api_result( self.api_client(socket_path, method, path, payload)) def _call_runner(self, cmd, timeout=None): """runner 调用(共享 helper 在 cloud/sandbox.py): timeout 给定时优先传 timeout,假 runner 只接受 cmd 时 TypeError 回退到只传 cmd(超时护栏丢了,但绝不因此静默 换执行器;worker 层 task_timeout 仍是外层硬护栏)。""" from cloud.sandbox import _call_runner_with_timeout return _call_runner_with_timeout(self.runner, cmd, timeout) def _drop_registered_proc(self, name): """取走并杀掉本实例登记的旧 VMM 进程(wake 与 restore 的共用准备步):返回旧进程或 None。""" old = None with self._vms_lock: if name in self._vms: old = self._vms.pop(name, None) if old is not None: self._kill_proc(old) return old def _kill_vmm_by_socket(self, socket_path): """按 api-socket 杀未登记的 VMM 进程(F2 跨实例 stop 与 V-3 restore 的共用机制):扫描 /proc 找命令行里 以 --api-socket 带该 socket 路径、且 argv[0] 是本执行 器 VMM 二进制的进程,SIGTERM→等→SIGKILL。 绝不误杀的三道门(见 _find_vmm_pids_by_socket): 精确 socket 路径、VMM 二进制名、跳过自身与 pid 1。 扫描/杀失败一律 best-effort 不抛异常——stop_vm “尽力而为、不抛异常”的契约不变。返回杀掉的 pid 列表 (调用方审计用,测试断言用)。""" try: own_binary = os.path.basename(self.binary or "") except Exception: return [] killed = [] for pid in _find_vmm_pids_by_socket(socket_path, own_binary): _terminate_pid(pid) killed.append(pid) return killed def _kill_proc(self, proc): """终止登记进程:terminate → 等 5 秒 → kill,全部 best-effort,绝不抛异常。""" if proc is None: return try: proc.terminate() except Exception: pass try: proc.wait(timeout=5) except Exception: try: proc.kill() except Exception: pass def _active_count(self): with self._vms_lock: return len(self._vms) def _persistent_vm_count(self, exclude=None): """跨重启容量计数(feat-chv-fix,P1-4 + feat-chv-secfix M7):本实例登记的活进程名 ∪ state_dir 下 chv-*.sock 对应的 VM 名。worker 重启后 _vms 为空,但旧 VMM 的 socket 仍在,仍计入容量——重启不再绕过 CHV_MAX_VMS。 M7 修正:残留 socket 不再无条件计入——只有 socket 真能连通(有监听者)才计数;空文件/死 socket 不计, 否则 CHV_MAX_VMS 会被崩溃残留占满、新任务全部被 拒(可用性 DoS)。exclude:调用方自己的 VM 名(同 任务重复 wake 时先关旧 VM,不应被自己的残留 socket 误拒)。""" names = set() with self._vms_lock: for name, proc in self._vms.items(): try: alive = proc.poll() is None except Exception: alive = False if alive: names.add(name) try: entries = os.listdir(self.state_dir) except Exception: entries = [] for entry in entries: if entry.endswith(".sock") and entry[:-5].startswith("chv-"): sock_path = os.path.join(self.state_dir, entry) if socket_is_live(sock_path): names.add(entry[:-5]) if exclude: names.discard(exclude) return len(names) def _check_capacity(self, exclude=None): """宿主容量门:持久化计数(见 _persistent_vm_count) 达到 max_vms 即明确拒绝(kind=host_capacity_exceeded, 调用方应排队/拒绝,绝不降级 Docker/Local)。""" count = self._persistent_vm_count(exclude=exclude) if count >= self.max_vms: raise ChvFailure( "host_capacity_exceeded", "宿主 CHV 容量已满:已计数 %d 台,上限 %d," "请排队或拒绝本任务" % (count, self.max_vms)) def _check_disk_quota(self): """磁盘配额门(实现在 cloud/chv/disk.py,feat-chv-fix 口径:总量 + 本次 + 宿主余量,镜像不可读不静默跳过)。 feat-chv-opt-landing:overlay 模式本次增量为 0(只 建空 overlay,不拷 base),配额仍按 base 大小硬帽。""" increment = 0 if getattr(self, "disk_mode", "copy") \ == "overlay" else None check_disk_quota(self.rootfs, self.disk_gb, state_dir=self.state_dir, increment_bytes=increment) def _provision_task_disk(self, task): """每任务磁盘供给(实现在 cloud/chv/disk.py): - copy(默认):把基础镜像复制到 state_dir//disk.raw,VmConfig 用该路径;复制 慢但两个任务绝不写同一文件,测试用小文件可直接验证。 - reuse(仅单租户实验):直接返回基础镜像路径,磁盘 不独立;preflight 拦截(须显式实验二次确认并审计 放行,见 preflight,feat-chv-fix)。 - overlay(feat-chv-opt-landing,阶段 1):在 state_dir//disk.qcow2 建空 overlay,backing file 指向只读 base。 数据盘与 tenant 标记与 overlay 解耦(2026-11-07 真机 实测:CHV v54 的 vm.boot 对任何带 backing file 的 qcow2 都报 Maximum disk nesting depth exceeded, overlay 在本 VMM 上不可用——见阶段 1 报告 §overlay; 但数据盘(工作区/浏览器 profile 载体,不进快照)与 tenant.json(持久 VM 归属)是独立持久/归属机制, 不依赖 backing file):copy 与 overlay 都供给独立 数据盘 state_dir//data.raw 并写 tenant 归属 标记 tenant.json;只有 reuse 不写(盘就是共享 base, 标给某租户是错的)。 返回每任务磁盘路径。""" disk = provision_task_disk( self.state_dir, self.vm_name(task), self.rootfs, self.disk_mode) if getattr(self, "disk_mode", "copy") in ("overlay", "copy"): provision_data_disk(self.state_dir, self.vm_name(task)) write_vm_tenant(self.state_dir, self.vm_name(task), self._tenant_id(task), disk=disk) return disk def _provision_data_disk_path(self, task): """数据盘路径(VmConfig 第三块盘):copy 与 overlay 都 挂(见 _provision_task_disk 的解耦说明);只有 reuse 返回 None(共享 base,不挂私有数据盘)。""" if getattr(self, "disk_mode", "copy") == "reuse": return None return data_disk_path(self.state_dir, self.vm_name(task)) def wait_api_ready(self, task: dict) -> bool: """等 vmm.ping 通:按次数轮询(api_timeout/1 秒取整, 至少 1 次),不用墙钟死循环——fake sleeper 不推进墙钟 时墙钟循环会空转,次数循环在单测里是确定性的。 超时 raise ChvFailure(kind=api_timeout)。""" socket_path = self.api_socket(task) attempts = max(1, int(self.api_timeout)) for attempt in range(attempts): try: status, _ = self._api_call( socket_path, "GET", "/vmm.ping") if status == 200: return True except Exception: pass if attempt + 1 < attempts: self.sleeper(1) raise ChvFailure( "api_timeout", "vmm.ping 超时:%d 次内 API 未通,socket=%s" % (attempts, socket_path)) def _provisioned_key_guard(self, task): """provisioned 下缺 known_hosts 即 fail-fast,绝不 keyscan。 返回 True 表示已拦截(调用方须先关 VM 再抛 host_key_not_pinned);False 表示可继续轮询。""" if getattr(self, "host_key_mode", "provisioned") == "provisioned" \ and not os.path.exists(self.known_hosts_path(task)): return True return False def _check_host_key_mismatch(self, task, result): """SSH stderr 含钉死拒绝标记时审计并返回 True(须 fail-fast)。 MITM 尝试是独立安全事件,不轮询成普通超时;审计 detail 只含 VM 名与标记,不含 key 值。""" stderr = getattr(result, "stderr", "") or "" if "Host key verification failed" in stderr \ or "REMOTE HOST IDENTIFICATION" in stderr: self._emit("host_key_mismatch", task, "vm=%s ssh host key 被拒绝(钉死不匹配)" % self.vm_name(task), "host_key_mismatch") return True return False def _call_ssh_with_transient_retry(self, task, ssh_cmd, timeout=None): """任务 SSH 的连接建立阶段瞬时 reset 重试(V-7/F1, 执行器内部唯一允许的重试——§5“执行器内部不重试” 的例外,见 _run_with_vm 的注): 崩溃后重 wake 的首条任务 SSH 曾撞 1 次 kex 阶段 Connection reset(rc=255),下一条即通;与防火墙 冒烟第一轮 cloud-init 收尾重启 sshd 的瞬态故障同款 签名。wait_guest_ready 的轮询本就容忍这类抖动,但 首条任务 SSH 是单次调用:不重试就把一次秒级抖动 放大成 worker 级重试 = 整台 VM 冷启动重跑(分钟级 + 重复计费)。故只对“连接未建立即 reset”(rc=255 + kex/reset 标记,见 _is_transient_ssh_reset)重试 恰好 1 次,重试前 sleeper(1);第二次仍失败即原样 返回,不再追。host key 拒绝、认证失败、远端命令 失败一律不重试;误重试的最坏后果也被 host.py 兜住: dispatch 同名任务已存在即 loud fail(task 已存在), 不会静默重跑一轮 turn。重试发 vm_ssh_transient_retry 审计。""" result = self._call_runner(ssh_cmd, timeout=timeout) if _is_transient_ssh_reset(result): self._emit("vm_ssh_transient_retry", task, "vm=%s ssh 瞬时 reset(kex 阶段),1 秒后重试 1 次" % self.vm_name(task), "ssh_transient_retry") self.sleeper(1) result = self._call_runner(ssh_cmd, timeout=timeout) return result def wait_guest_ready(self, task: dict) -> bool: """等 guest SSH 就绪:轮询 ssh "true",returncode 0 即就绪; 每轮先尽力捕获 host key(keyscan 模式),超时先关 VM 再 raise guest_not_ready,绝不在未就绪时派任务。""" # P0-2:provisioned 下缺 known_hosts 即 fail-fast,绝不 # 调用 capture_host_key 回落 TOFU(keyscan 才走捕获分支)。 if self._provisioned_key_guard(task): try: self.stop_vm(task) except Exception: pass raise ChvFailure( "host_key_not_pinned", "provisioned 模式下 known_hosts 缺失,拒绝 keyscan " "回落:VM %s 未钉死 host key" % self.vm_name(task)) attempts = max(1, int(self.ready_timeout / self.ready_interval)) for attempt in range(attempts): try: if not os.path.exists(self.known_hosts_path(task)): self.capture_host_key(task) result = self._call_runner( self.build_ssh_command(task, ["true"])) if getattr(result, "returncode", 1) == 0: return True # M5:host key 不匹配是独立安全事件,见 # _check_host_key_mismatch(审计 + fail-fast)。 if self._check_host_key_mismatch(task, result): try: self.stop_vm(task) except Exception: pass raise ChvFailure( "host_key_mismatch", "SSH host key 不匹配(钉死拒绝):VM %s," "可能被替换或中间人,已关 VM" % self.vm_name(task)) except ChvFailure: raise except Exception: pass if attempt + 1 < attempts: self.sleeper(self.ready_interval) try: self.stop_vm(task) except Exception: pass raise ChvFailure( "guest_not_ready", "guest SSH 就绪超时:%d 次轮询内 sshd 未通,已关 VM" % attempts) def _wait_guest_env_usable(self, task: dict) -> None: """密钥已投递时,等客内 guest-env 对 SSH 用户可读再返回: write_files 以 root:root 写文件(见 hostkeys.py—— write_files 在 users-groups 之前跑,owner 只能写 root), 客内 final 阶段的 99-chv-sync.cfg runcmd 才 chown 给 SSH 用户;SSH 就绪(vm_ready)比 final 阶段早约 30 秒 (V-9 真机:runcmd 脚本在 vm_ready 后约 31 秒才执行), 派发若在这窗口内 source 文件会 Permission denied。 判定用 `test -r`(SSH 用户本人可读与否,正是派发 source 的前置条件),不比对 owner 名。无密钥投递时 不等待、不轮询。超时先关 VM 再抛 guest_env_not_ready, 绝不带着读不到的密钥去跑任务。""" if not self._delivered_secrets(self.vm_name(task)): return from cloud.chv.hostkeys import GUEST_ENV_PATH attempts = max(1, int(self.ready_timeout / self.ready_interval)) for attempt in range(attempts): try: result = self._call_runner(self.build_ssh_command( task, ["test", "-r", GUEST_ENV_PATH])) except Exception: result = None if (result is not None and getattr(result, "returncode", 1) == 0): return if attempt + 1 < attempts: self.sleeper(self.ready_interval) try: self.stop_vm(task) except Exception: pass raise ChvFailure( "guest_env_not_ready", "guest-env 在 %d 次轮询内仍对 SSH 用户不可读,已关 VM:" "runcmd chown 未生效或 cloud-init 未完成" % attempts) def _guest_host_command(self, *args, model=None, effort=None, approval=None, sandbox=None, model_provider=None): """客内 host.py 命令:python3 /app/host.py [顶层全局参数] [子命令...],顶层参数顺序与 Local/Docker 同款(必须在 子命令之前,共享 helper 在 cloud/sandbox.py)。 guest_host_py 默认 /app/host.py:客机镜像须把 host.py 放在该路径(与 Docker 的 /app:ro 同款约定,只是载体 从容器换成 VM 镜像)。""" from cloud.sandbox import _host_command_options cmd = ["python3", self.guest_host_py] 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 # -- 生命周期:API 唤醒 / 暂停恢复 / 快照 / 关闭 -------------------- def _launch_api_only(self, task): """只启动 VMM 进程(API 模式第一步):CLI 上只带 --api-socket,不带内核/磁盘配置;VM 配置随后走 vm.create 请求体。返回登记的进程。""" socket_path = self.api_socket(task) cmd = [self.binary, "--api-socket", socket_path] proc = self.launcher(cmd) with self._vms_lock: self._vms[self.vm_name(task)] = proc return proc def _ch_remote(self, task_or_name, *args): """ch-remote 调用:[ch-remote --api-socket= args...]。 returncode 非 0 即 raise RuntimeError(fail-fast), runner 本身抛异常时原样传播。返回 runner 结果。""" name = self.vm_name(task_or_name) socket_path = os.path.join(self.state_dir, name + ".sock") result = self.runner([self.ch_remote, "--api-socket=%s" % socket_path] + list(args)) if getattr(result, "returncode", 0) not in (0, None): # feat-chv-opt-landing(阶段 2):stderr 尾巴进报错, # 否则 tap-busy(Resource busy / os error 16)这类 # 原因只剩 returncode,调用方无法区分瞬时竞态与 # 真失败、重试 wrapper 无从下手。截断 300 字符。 tail = (getattr(result, "stderr", "") or "").strip() tail = tail.splitlines()[-1] if tail else "" raise RuntimeError( "ch-remote %s 失败:returncode=%s%s" % (" ".join(args), getattr(result, "returncode"), (" stderr=%s" % tail[:300]) if tail else "")) return result # -- tap 空闲轮询与退避重试(阶段 2,§6.1/§6.2 遗留) ------------ def _tap_wait_seconds(self): try: return max(0.0, float( self._env.get("CHV_TAP_WAIT_TIMEOUT", 30))) except (TypeError, ValueError): return 30.0 def _tap_wait_interval(self): try: return max(0.5, float( self._env.get("CHV_TAP_WAIT_INTERVAL", 2))) except (TypeError, ValueError): return 2.0 @staticmethod def _is_tap_busy_error(exc): text = str(exc) lowered = text.lower() return ("resource busy" in lowered or "os error 16" in lowered or ("tap" in lowered and "busy" in lowered)) def _tap_names_for_task(self): """该任务 net 条目的 tap 名(去重去空)。""" if not self.net_json: return [] try: entries = parse_net_entries(self.net_json) except Exception: return [] taps = [] for entry in entries: tap = (entry.get("tap") or "").strip() if tap and tap not in taps: taps.append(tap) return taps def _wait_tap_idle(self, taps, timeout=None): """等 tap 空闲:`ip link show ` 不存在即空闲; state DOWN / NO-CARRIER 即空闲;否则轮询到超时。 无 tap 配置、ip 不可用、探查抛错、输出为空时返回 True (按旧行为直接尝试,重试 wrapper 仍会接住 busy 再 等一次)。诊断一律不用 pgrep -x cloud-hypervisor (15 字符上限永远匹配不到),只看 tap 设备状态, 或 ps -eo pid,cmd / 直接读 /proc。""" if not taps: return True if timeout is None: timeout = self._tap_wait_seconds() interval = self._tap_wait_interval() deadline = time.monotonic() + max(0.0, timeout) while True: idle = True for tap in taps: try: result = self._call_runner( ["ip", "link", "show", tap], timeout=10) except Exception: continue if getattr(result, "returncode", 0) != 0: continue # tap 不存在 = 空闲 out = getattr(result, "stdout", "") or "" if not out.strip(): continue # 无输出 = 探查无果,按空闲处理 if "state DOWN" in out or "NO-CARRIER" in out: continue idle = False if idle: return True if time.monotonic() >= deadline: return False self.sleeper(interval) def _retry_on_tap_busy(self, task, fn, what): """tap-busy 退避重试:fn 抛含 Resource busy 的错时, 等 tap 空闲(CHV_TAP_WAIT_TIMEOUT,默认 30 秒)后 重试一次;第二次仍 busy 即原样抛(调用方失败路径 处理,restore 失败路径现已保留 seed.iso 可再试)。""" try: return fn() except Exception as exc: if not self._is_tap_busy_error(exc): raise taps = self._tap_names_for_task() self._emit("vm_tap_busy_retry", task, "vm=%s %s tap busy,等 tap 空闲后重试一次" % (self.vm_name(task), what)) self._wait_tap_idle(taps) return fn() def _registered_net_entries(self, exclude_vm=None): """已登记 VM 的 net 条目(H7):扫描 state_dir/*/net.json (wake 成功后写,stop 时删)。文件损坏/缺失即跳过该 VM——登记缺失只会漏判冲突,不会误拒;漏判的兜底是 宿主防火墙部署清单里的“每 VM 独立 tap”检查项。""" registered = [] try: entries = os.listdir(self.state_dir) except Exception: return registered for entry in entries: if entry == exclude_vm: continue path = os.path.join(self.state_dir, entry, "net.json") try: with open(path, "r") as fh: data = json.load(fh) except Exception: continue if not isinstance(data, list): continue for item in data: if isinstance(item, dict): row = dict(item) row["vm"] = entry registered.append(row) return registered def _record_net_entries(self, vm, entries): """wake 成功后登记本 VM 的 net 条目(P2-19 收尾)。 返回 True=登记成功,False=登记失败。失败时三件事 同时做:warning 日志(旧行为保留)+ 独立审计事件 net_registry_write_failed(调用方与运维都能看见, 不再只有一条日志)+ 返回 False 让调用方感知。 调用方 _boot_vm 见 False 即拒绝启动(ChvFailure, wake 随后 stop_vm 清掉半醒 VM):登记缺失的后果 是后来的 wake 漏判 tap/mac/ip 冲突(H7 的第二道 防线没了),继续启动等于带着缺口跑,不如不起; state_dir 可写本就是 preflight 的 0700 门,走到 这里才写失败是真故障,不是预期内的可容忍情形。 写路径走 atomic_file(P2-18):随机 tmp 名 + 双 fsync。 """ try: vm_dir = os.path.join(self.state_dir, vm) os.makedirs(vm_dir, exist_ok=True) atomic_write_text( os.path.join(vm_dir, "net.json"), json.dumps(entries)) return True except OSError as exc: _LOGGER.warning("chv_net_registry_write_failed vm=%s " "error=%s", vm, exc) self._emit("net_registry_write_failed", None, "vm=%s net 登记写失败:%s" % (vm, exc), "net_registry_write_failed") return False def _check_api_socket_permissions(self, socket_path): """VMM api-socket 权限门(H6):socket 文件存在时, 组/其它可写(mode & 0o022)即 fail-fast——Unix socket 的 connect 需要写权限,可写即任何同组/其它用户都 能调 VMM API。仅禁写位,不禁读/执行位:VMM 默认 umask 下的 0755 类权限不误杀(VMM 默认值代码控制 不了,真机上的实际权限仍须按部署清单核对)。 socket 文件不存在时(mock/带外)跳过,不假装已验。 普通文件(mock 占位)也跳过——只对真正的 socket 文件校验权限。""" if not os.path.exists(socket_path): return try: import stat as _stat st = os.stat(socket_path) if not _stat.S_ISSOCK(st.st_mode): return mode = st.st_mode & 0o777 except OSError: return if mode & 0o022: raise ChvFailure( "socket_permissions_insecure", "VMM api-socket 组/其它可写(%o),拒绝启动:%s" % (mode, socket_path)) def _seed_extras(self) -> tuple: """读 seed 附加项文件(CHV_SEED_* 配置的路径): 返回 (user_data_extra, network_config),皆空即 (None, None)。路径已配但文件缺失/读不出时 ChvFailure(seed_extra_missing),wake 在起 VMM 之前拒绝——缺 users 块的 VM 起了也登不进去,缺 network-config 的 VM 在无 DHCP 的 tap 上拿不到 IP, 不如不起。错误只含路径,不含文件内容。""" out = [] for attr in ("seed_user_data_extra_file", "seed_network_config_file"): path = getattr(self, attr, None) if not path: out.append(None) continue try: with open(path) as fh: out.append(fh.read()) except OSError as exc: raise ChvFailure( "seed_extra_missing", "seed 附加文件读不出:%s:%s" % (path, exc)) return out[0], out[1] def _effective_extra_env(self, task: dict, extra_env: dict | None, skip_gitea_sync: bool = False) -> dict | None: """wake 生效的 extra_env:把 Gitea 同步变量并进来。 skip_gitea_sync=True(干净池出生,feat-chv-hybrid-a): 原样返回 extra_env,不并宿主配置的 Gitea 同步变量—— 池 VM 出生时不带租户,同步变量只能在分配时随租户绑定 给,不在出生时给。 优先级(低→高):执行器宿主配置 CHV_GITEA_*(构造时 已映射成 gitea_sync_env)< 任务 dict 的 gitea_sync < 显式 extra_env 同名键(见 gitea_sync.extract_...)。 同步已请求(任一同步键出现)但必填键不全时,在起 VMM 之前就 ChvFailure(gitea_sync_incomplete, fail-closed;错误只含缺失键名,不含值)——半份同步 配置绝不能让 VM 带着“以为同步了”的状态启动。无同步 变量时原样返回(本次启动不同步)。 必须在 _wake_prechecks 之前调:任务/配置来源的 GITEA_TOKEN 也要受 keyscan 模式 fail-closed 检查。""" if skip_gitea_sync: return extra_env from cloud.chv.gitea_sync import ( extract_gitea_sync_env, validate_gitea_sync_env, ) sync = extract_gitea_sync_env( task=task, extra_env=extra_env, config_sync=getattr(self, "gitea_sync_env", None)) try: validate_gitea_sync_env(sync) except ValueError as exc: raise ChvFailure("gitea_sync_incomplete", str(exc)) if not sync: return extra_env merged = dict(extra_env or {}) merged.update(sync) return merged def _wake_prechecks(self, task: dict, name: str, extra_env) -> tuple: """wake 前置:preflight/容量/配额/net/密钥模式,纯移动。 失败时发 vm_failed 审计并原样抛 ChvFailure。""" try: self.preflight() # feat-chv-fix:SSH 目标与 reuse 放行在启动前就定性。 # resolve 失败即 fail-fast(不先起 VMM 再超时); # reuse 实验放行必须审计,留痕给运维复核。 self.resolve_ssh_host() self._check_capacity(exclude=name) self._check_disk_quota() # feat-chv-secfix(H7):跨 VM tap/mac/ip 冲突在 # 起 VMM 前拒绝(preflight 只做了条目内校验)。 net_entries = [] if self.net_json: net_entries = validate_net_entries( parse_net_entries(self.net_json), self._registered_net_entries(exclude_vm=name)) # feat-chv-secfix(H2):密钥子集的投递能力在起 # VM 前就定性——keyscan 模式无 seed 可投递,有 # 密钥即 fail-closed,不等到执行时才在 argv 前 # 被拦(那时 VM 已起,失败语义更脏)。 secrets, _plain = split_secret_env(extra_env) if secrets and getattr(self, "host_key_mode", "provisioned") != "provisioned": raise ChvFailure( "secret_env_requires_provisioned", "密钥环境变量 %s 须走 seed 文件投递,仅 " "provisioned 模式支持(当前 %s)" % (",".join(sorted(secrets)), getattr(self, "host_key_mode", "provisioned"))) except ChvFailure as exc: self._emit("vm_failed", task, "vm=%s wake 前置失败 kind=%s" % (name, exc.kind), exc.kind) raise return secrets, net_entries def _provision_seed_disk(self, task: dict, name: str, secrets, guest_env: dict | None = None, seed_extras: tuple | None = None) -> str | None: """provisioned 模式的 seed 盘供给:生成→制盘→挂盘, 任一步失败即抛(调用方 stop_vm 清理),纯移动。""" seed_disk = None if getattr(self, "host_key_mode", "provisioned") == "provisioned": # 强方案:启动前预置每 VM host key 并钉死, # 再把 seed 目录做成 CIDATA seed 磁盘并挂进 # VmConfig(P0-1 闭环:生成→制盘→挂盘三步缺 # 一即 fail-fast,绝不回落 keyscan、绝不只 # 生成不注入)。 # feat-chv-secfix(H2/H5):secret_env 随 seed # 写进客内密钥文件;seed.iso 建好(0600,见 # build_seed_disk)后立即删宿主侧私钥——此后 # 宿主上只剩公钥 + known_hosts,私钥只活在客 # 机与 seed.iso(随 VM 销毁而删)里。删私钥 # 失败即 fail-fast(VM 尚未启动,此时拒绝最 # 安全,except 里 stop_vm 会清掉 seed)。 from cloud.chv.hostkeys import ( build_seed_disk, delete_host_private_key, seed_disk_path, ) extra, netcfg = seed_extras or (None, None) provisioned = self.provision_host_key( task, secret_env=secrets or None, guest_env=guest_env or None, user_data_extra=extra, network_config=netcfg) seed_disk = seed_disk_path(self.state_dir, name) build_seed_disk( provisioned["seed_dir"], seed_disk, runner=lambda cmd: self._call_runner(cmd)) delete_host_private_key(self.state_dir, name) self._mark_guest_env_delivered(name, secrets) return seed_disk def _create_vm(self, task, name, task_disk, seed_disk, attach_data_disk=True) -> None: """PUT vm.create(_boot_vm 的建盘段):VmConfig 挂任务盘 + 可选 seed 盘 + 可选数据盘;attach_data_disk=False 时 不挂数据盘(干净池出生)。失败即 ChvFailure。""" socket_path = self.api_socket(task) status, _body = self._api_call( socket_path, "PUT", "/vm.create", self.build_vm_config( task_disk, seed_disk=seed_disk, data_disk=self._provision_data_disk_path(task) if attach_data_disk else None)) if status not in (200, 204): raise ChvFailure( "vm_create_failed", "vm.create 失败:status=%s" % status) self._emit("vm_create", task, "vm=%s disk=%s" % (name, task_disk)) def _boot_vm(self, task, name, task_disk, seed_disk, net_entries, attach_data_disk=True) -> None: """VMM 启动→vm.create→vm.boot→SSH 就绪→密钥已投递时 等 guest-env 对 SSH 用户可读(V-9,见 _wait_guest_env_usable),纯移动。attach_data_disk=False (干净池出生,feat-chv-hybrid-a):VmConfig 不挂数据盘, 池 VM 出生时只有干净系统盘,数据盘在分配时才挂载。""" socket_path = self.api_socket(task) self._launch_api_only(task) self.wait_api_ready(task) self._check_api_socket_permissions(socket_path) self._create_vm(task, name, task_disk, seed_disk, attach_data_disk=attach_data_disk) if net_entries: # feat-chv-closeup(P2-19):登记失败即拒起, # 见 _record_net_entries(审计 + 返回 False)。 if not self._record_net_entries(name, net_entries): raise ChvFailure( "net_registry_write_failed", "vm=%s net 登记写失败,拒绝启动(缺登记会" "漏判 tap/mac/ip 冲突)" % name) # feat-chv-opt-landing(阶段 2):vm.boot 触 tap,停机 # →再启窗口的 tap 释放竞态在此处报 Resource busy; # 响应体进报错(否则只剩 status,分不清瞬时竞态), # busy 时等 tap 空闲后重试一次,不立即失败。 def _do_boot(): _status, _body = self._api_call( socket_path, "PUT", "/vm.boot") if _status not in (200, 204): raise ChvFailure( "vm_boot_failed", "vm.boot 失败:status=%s body=%s" % (_status, _body)) return _status self._retry_on_tap_busy(task, _do_boot, "vm.boot") self._emit("vm_boot", task, "vm=%s" % name) self.wait_guest_ready(task) # V-9:密钥已投递时等 guest-env 对 SSH 用户可读再报 # 就绪(runcmd chown 比 SSH 就绪晚约 30 秒)。 self._wait_guest_env_usable(task) def _wake_prepare_clean(self, name: str) -> str: """干净池出生的任务盘供给(_wake_prepare 的干净分支): 只供给任务系统盘(不建数据盘、不写 tenant 标记);上一 租户的 tenant.json 若残留,先清掉,干净 VM 绝不继承归属。""" _stale_tenant = os.path.join(self.state_dir, name, "tenant.json") try: if os.path.exists(_stale_tenant): os.remove(_stale_tenant) except OSError: pass return provision_task_disk( self.state_dir, name, self.rootfs, self.disk_mode) def _wake_prepare(self, task: dict, name: str, clean_pool: bool = False) -> tuple: """wake 准备:reuse 审计、旧 VM 清理、任务盘供给、 直连 egress 审计,纯移动。返回 (task_disk, socket_path)。 clean_pool=True(干净池出生,feat-chv-hybrid-a):只供 给任务系统盘,不建数据盘、不写 tenant 归属标记,并清掉 上一租户可能留下的 tenant.json——干净池 VM 出生时不带 任何租户。 feat-chv-v3(F1):旧 VM 在时先删旧任务盘再供给新盘。 根因:H5 在 seed 建好后删宿主私钥,re-wake 必然重新 生成 key 对;旧盘若复用,cloud-init 以 instance-id (=VM 名)已应用为由不再重应用新 seed,sshd 仍呈旧 key,known_hosts 却钉了新 key → host_key_mismatch。 wake 按设计是冷启动(单任务单 VM,重复 wake 即重启), 旧盘从来不是要保留的状态(restore 另走 restore_vm, 暂停唤醒另走 resume);删盘失败即 ChvFailure fail-fast, 绝不带着旧盘 + 新 key 对去启动。reuse 模式不删(盘 就是基础镜像本身,删不得)。""" if getattr(self, "disk_mode", "copy") == "reuse": self._emit("disk_reuse_experiment_allowed", task, "vm=%s CHV_REUSE_SINGLE_TENANT_EXPERIMENT=1 " "explicit single_tenant_project=%s" % (name, getattr(self, "single_tenant_project", None))) self._drop_registered_proc(name) # 旧盘不论旧 VMM 是否登记在册(活着重 wake / 崩溃后 # 重 wake)一律先删:delete_task_disk 无盘时返回 False, # 不 extra 判断。 if getattr(self, "disk_mode", "copy") != "reuse": try: delete_task_disk(self.state_dir, name) except OSError as exc: raise ChvFailure( "stale_disk_cleanup_failed", "旧任务盘删不掉,拒绝复用旧盘启动:VM %s:%s" % (name, exc)) if clean_pool: task_disk = self._wake_prepare_clean(name) else: task_disk = self._provision_task_disk(task) socket_path = self.api_socket(task) if self.allow_direct_egress: self._emit("egress_direct_allowed", task, "vm=%s CHV_ALLOW_DIRECT_EGRESS=1 explicit" % name) return task_disk, socket_path def _wake_boot(self, task, name, task_disk, secrets, guest_env, seed_extras, net_entries, clean_pool=False) -> None: """wake 的启动段(seed 盘供给 + _boot_vm):任一步失败都 关 VM(stop_vm)再抛,绝不带着半醒的 VM 去跑任务。""" try: seed_disk = self._provision_seed_disk( task, name, secrets, guest_env=guest_env or None, seed_extras=seed_extras) self._boot_vm(task, name, task_disk, seed_disk, net_entries, attach_data_disk=not clean_pool) except Exception: try: self.stop_vm(task) except Exception: pass if isinstance(task, dict): self._emit("vm_failed", task, "vm=%s wake 失败" % name) raise def wake_vm(self, task: dict, extra_env: dict | None = None, clean_pool: bool = False) -> dict: """主唤醒路径(API 模式,dispatch/run/goal 的前置步骤): preflight → 容量门 → 磁盘配额门 → 每任务磁盘供给 → launcher 只起 VMM(--api-socket)→ 等 vmm.ping 通 → PUT vm.create(VmConfig)→ PUT vm.boot → 等 guest SSH 就绪 → 密钥已投递时再等 guest-env 对 SSH 用户可读 (见 _wait_guest_env_usable,V-9)。任一步失败都 关 VM(stop_vm)再抛 ChvFailure,绝不带着半醒的 VM 去跑任务。 成功返回 {vm, api_socket, state: "running", ready: True, mode: "cold"},并审计 vm_create/vm_boot/vm_ready。 单任务单 VM:同一任务重复 wake 时先关旧 VM 再起新 的,绝不两个 VM 共用一个任务名。 feat-chv-secfix:extra_env(_run_with_vm 透传)里的 密钥子集在 provisioned 模式下写进 seed 客内文件, keyscan 模式下有密钥子集即 fail-closed;起 VM 前的 preflight/容量/配额失败也发 vm_failed 审计(L3); net 隔离在起 VMM 前校验(H7),成功后登记 net.json。 clean_pool=True(干净池出生):不并 Gitea 同步变量、 不建数据盘、不写 tenant 标记、VmConfig 不挂数据盘; host-key 预置仍按 provisioned 模式走(SSH 钉死所需, 与租户 seed 不同——租户密钥/Gitea 一律不带)。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") name = self.vm_name(task) try: extra_env = self._effective_extra_env( task, extra_env, skip_gitea_sync=clean_pool) seed_extras = self._seed_extras() except ChvFailure as exc: self._emit("vm_failed", task, "vm=%s wake 失败 kind=%s" % (name, exc.kind), exc.kind) raise secrets, net_entries = self._wake_prechecks(task, name, extra_env) guest_env, _plain = split_guest_env(extra_env) task_disk, socket_path = self._wake_prepare( task, name, clean_pool=clean_pool) self._wake_boot(task, name, task_disk, secrets, guest_env, seed_extras, net_entries, clean_pool=clean_pool) self._emit("vm_ready", task, "vm=%s" % name) return {"vm": name, "api_socket": socket_path, "state": "running", "ready": True, "mode": "cold"} def start_vm(self, task: dict) -> dict: """旧名保留:即 wake_vm(API 模式)。直接启动命令 _build_vm_command() 只作手动等价写法(私有),不再是执行器 的启动路径——两条启动路径并存是上一版的半吊子, 本版只认 API 模式。""" return self.wake_vm(task) def pause_vm(self, task_or_name: dict | str) -> bool: """暂停 VM:ch-remote pause。成功审计 vm_pause。""" self._ch_remote(task_or_name, "pause") task = task_or_name if isinstance(task_or_name, dict) \ else {"id": task_or_name} self._emit("vm_pause", task, "vm=%s" % self.vm_name(task_or_name)) return True def resume_vm(self, task_or_name: dict | str) -> bool: """唤醒已暂停的 VM:ch-remote resume。成功审计 vm_resume。 注意:这是暂停唤醒的唯一路径,恢复后的 VM 也走它 (见 restore_vm),绝不能只 restore 就算醒。""" self._ch_remote(task_or_name, "resume") task = task_or_name if isinstance(task_or_name, dict) \ else {"id": task_or_name} self._emit("vm_resume", task, "vm=%s" % self.vm_name(task_or_name)) return True # -- 持久 VM 与预热池(feat-chv-opt-landing,阶段 1)----------- # 模型(单租户灰度,CHV_WARM_POOL_SIZE=1 时启用): # 同一租户的任务共用一台持久 VM(chv-persist-, # overlay 系统盘 + 独立数据盘);任务间隙 pause 存盘, # 下个任务 resume,不走完整开机。预热池 = 常备 1 台已 # 开机、已完成 Gitea 首次同步、处于 paused 的持久 VM: # 任务到达时先领它顶上,任务结束暂停回池(即补建回池); # 池空(无 paused 登记)自动回落冷启,不报错。 # 与 experimental_pool.WarmPool 的区别(ADR-5 的缺口 # 在此补上):预热 VM 的盘从生下来就是该租户的持久盘 #(overlay + 数据盘 + tenant 标记),任务直接跑在它 # 自己的盘上,不存在“干净快照盘当任务盘用”的隔离击穿, # 故 acquire 不需要热换盘。WarmPool 本身仍是实验模块, # 生产路径不 import 它(ADR-12);本节是生产路径。 # 开关:CHV_WARM_POOL_SIZE(默认 0)——为 0 时本节全部 # 入口退化为冷启旧语义(dispatch_task_persistent 直接 # 调 dispatch_task;prewarm/refill 为 no-op),绝不硬 # 编码改默认值偷换全局行为;阶段 1 验证用 # CHV_WARM_POOL_SIZE=1。 def _vm_info_state(self, task_or_name) -> str | None: """GET /vm.info 取 VM 状态(Running/Paused/...): 取不到/无 state 字段即 None(调用方按未知处理,绝 不猜它是 paused 还是 running)。""" try: status, body = self._api_call( self.api_socket(task_or_name), "GET", "/vm.info") except Exception: return None if status != 200 or not isinstance(body, dict): return None state = body.get("state") return state if isinstance(state, str) else None def _persistent_task(self, tenant_id): """持久 VM 的合成任务 dict:id 固定为 persist-<租户>, 故 vm 名恒为 chv-persist-<租户>;gitea_sync 等任务 级同步变量由调用方经 extra_env 给,不进这里。""" return {"id": "persist-%s" % (tenant_id or "default"), "project_id": tenant_id} def _wait_sync_workspace(self, vm_task, sync_workspace) -> bool: """等 Gitea 首次同步完成:轮询客内 test -d /.git(clone 落盘的硬证据);每轮 间隔 ready_interval、上限 ready_timeout;超时即 False(调用方 fail-closed,不把未同步的 VM 当预热 机发给任务)。""" attempts = max( 1, int(self.ready_timeout / self.ready_interval)) for attempt in range(attempts): try: result = self._call_runner(self.build_ssh_command( vm_task, ["test", "-d", sync_workspace + "/.git"])) if getattr(result, "returncode", 1) == 0: return True except Exception: pass if attempt + 1 < attempts: self.sleeper(self.ready_interval) return False def _prewarm_build_new(self, tenant_id, extra_env, sync_workspace): """预热新建段(prewarm_persistent_vm 的后半段):冷启 一台持久 VM;sync_workspace 给出时等首次同步完成, 等不到即关 VM 报 warm_pool_sync_incomplete(绝不把 未同步的 VM 记成预热机)。返回 (vm_task, gitea_synced)。""" vm_task = self._persistent_task(tenant_id) self.wake_vm(vm_task, extra_env=extra_env) synced = False if sync_workspace: synced = self._wait_sync_workspace( vm_task, sync_workspace) if not synced: try: self.stop_vm(vm_task) except Exception: pass raise ChvFailure( "warm_pool_sync_incomplete", "预热 VM %s 在 %s 秒内未完成 Gitea 首次同步" "(%s/.git 未出现),已关 VM,不进池" % (self.vm_name(vm_task), self.ready_timeout, sync_workspace)) else: synced = bool(getattr(self, "gitea_sync_env", None)) return vm_task, synced def prewarm_persistent_vm(self, tenant_id: str | None, extra_env: dict | None = None, sync_workspace: str | None = None) -> dict: """预热池进池(幂等):CHV_WARM_POOL_SIZE>=1 才生效, 为 0 即 {pooled: False, reason: warm_pool_disabled}。 已有 paused 登记且 socket 活着即返回它(already-warm, 不重建);否则冷启一台持久 VM(overlay 盘 + Gitea 同步 env),sync_workspace 给出时等首次同步完成 (等不到即关 VM 报 warm_pool_sync_incomplete,绝不 把未同步的 VM 记成预热机),再 pause、写登记。 返回 {pooled, vm, mode, gitea_synced}。""" if int(getattr(self, "warm_pool_size", 0)) <= 0: return {"pooled": False, "vm": None, "reason": "warm_pool_disabled"} vm = self.persistent_vm_name(tenant_id) record = read_persistent_vm(self.state_dir, tenant_id) if record is not None and record.get("state") == "paused" \ and record.get("vm") == vm \ and os.path.exists(self.api_socket({"id": vm})): return {"pooled": True, "vm": vm, "mode": "already-warm", "gitea_synced": bool( record.get("gitea_synced"))} vm_task, synced = self._prewarm_build_new( tenant_id, extra_env, sync_workspace) self.pause_vm(vm_task) import time as _time # feat-chv-hybrid-b:合并写(tier/last_active_at 等 # 不再被字面量覆盖丢掉);已有常驻态登记时保持其 # 状态(预热不降级常驻租约),否则记 legacy paused。 _prior = read_persistent_vm(self.state_dir, tenant_id) or {} _state = _prior.get("state") \ if _prior.get("state") in RESIDENT_STATES else "paused" self._write_resident_record(tenant_id, { "vm": vm, "tenant_id": tenant_id, "state": _state, "origin": "prewarm", "gitea_synced": synced}, now=_time.time()) self._emit("warm_pool_prewarm", vm_task, "vm=%s gitea_synced=%s" % (vm, synced)) return {"pooled": True, "vm": vm, "mode": "prewarmed", "gitea_synced": synced} def refill_warm_pool(self, tenant_id: str | None, extra_env: dict | None = None, sync_workspace: str | None = None) -> dict: """后台补建回池(调用方自行起线程跑,本方法本身 同步):池里已有 paused 登记即 no-op;池空即 prewarm 一台。warm_pool_size==0 时 no-op。""" record = read_persistent_vm(self.state_dir, tenant_id) if record is not None and record.get("state") == "paused" \ and os.path.exists(self.api_socket( {"id": record.get("vm") or ""})): return {"pooled": True, "vm": record.get("vm"), "mode": "already-warm", "gitea_synced": bool(record.get("gitea_synced"))} return self.prewarm_persistent_vm( tenant_id, extra_env=extra_env, sync_workspace=sync_workspace) def _acquire_paused_candidate(self, tenant): """认领的登记核对(acquire_for_task 的前半段):登记 是 paused、socket 存在、tenant.json 归属一致才返回 (vm, record);任一不符即清登记、返回 (None, None) (调用方回落冷启)。归属不符与 socket 消失是登记 本身已坏,清掉;状态未知(vm.info 取不到)不在此 判——那是认领时的第二道门,见 acquire_for_task。 feat-chv-hybrid-b:可认领态扩展为 legacy paused + 常驻态里 VM 已暂停的两种(resident-active / resident-paused,见 RESIDENT_CLAIMABLE_STATES); 常驻态登记新增 tier 校验——tier 不是 Paid/Heavy 即不认领(登记本身是坏的/被降档的,留给清理,不 静默按旧语义 resume;legacy paused 登记无 tier 字段,走旧语义,不因此拒认)。""" record = read_persistent_vm(self.state_dir, tenant) if record is None \ or record.get("state") not in RESIDENT_CLAIMABLE_STATES: return None, None vm = record.get("vm") if not vm: return None, None if record.get("state") in RESIDENT_ACTIVE_STATES \ and not self.resident_tier_eligible( tenant, record.get("tier")): # 常驻态却无常驻资格:不认领、不 resume;登记留给 # 租约计时器/清理处理(不静默清掉——删租约是降级 # 流程的职责,不是认领的职责)。 self._emit("resident_tier_denied", {"id": "persist-%s" % tenant, "project_id": tenant}, "host_id=%s tenant=%s vm=%s tier=%s 常驻态登记" "无常驻资格,不认领、回落冷启" % (self._host_id(), tenant, vm, record.get("tier")), "tier_denied") return None, None owner = read_vm_tenant(self.state_dir, vm) if owner is not None and owner != tenant: # 盘的归属与请求方不一致:这台不是你的 VM,绝不 # resume,清登记回落冷启(legacy 未标记盘 owner # 为 None 时放行,见 _check_snapshot_tenant 同款)。 clear_persistent_vm(self.state_dir, tenant) return None, None if not os.path.exists(self.api_socket({"id": vm})): clear_persistent_vm(self.state_dir, tenant) return None, None return vm, record def acquire_for_task(self, task: dict, extra_env: dict | None = None) -> dict: """取本租户的可用 VM(任务到达时的认领): - 持久登记是 paused、socket 活着、vm.info 确认 Paused、tenant.json 归属一致 → resume + 等 SSH 就绪,返回 {mode: resumed, vm, resume_seconds, warm_hit}(warm_hit 表示这台来自 prewarm, 不是上个任务暂停留下的)。 - 其它情形(无登记 / VM 已死 / 状态未知 / resume 或 SSH 就绪失败)→ 丢弃该登记(若有), 返回 {mode: cold, vm: None},调用方回落冷启, 不报错。warm_pool_size==0 时直接 cold。 resume_seconds 只计 resume+SSH 就绪,不含任务 自身启动。""" if int(getattr(self, "warm_pool_size", 0)) <= 0: return {"mode": "cold", "vm": None} tenant = self._tenant_id(task) vm, record = self._acquire_paused_candidate(tenant) if vm is None: return {"mode": "cold", "vm": None} state = self._vm_info_state({"id": vm}) if state != "Paused": # 状态未知或非 paused 时不猜:不是 paused 就不 # 认领(running 的 VM resume 会错、死了的 VM # resume 也会错),回落冷启,登记留给清理。 return {"mode": "cold", "vm": None} return self._acquire_resume_recorded(task, tenant, vm, record) def _acquire_resume_recorded(self, task, tenant, vm, record): """认领的 resume 段(acquire_for_task 的后半段): resume + 等 SSH 就绪,计时写回登记(state=running) 并返回 resumed;resume/就绪失败即清登记、回落 cold。 resume_seconds 只计 resume+SSH 就绪。""" import time as _time t0 = _time.time() try: self.resume_vm({"id": vm}) self.wait_guest_ready({"id": vm}) except Exception as exc: self._emit("warm_pool_acquire_failed", task, "vm=%s resume/就绪失败,回落冷启:%s" % (vm, exc), "warm_pool_acquire_failed") clear_persistent_vm(self.state_dir, tenant) return {"mode": "cold", "vm": None} resume_seconds = _time.time() - t0 # feat-chv-hybrid-b:合并写——常驻态登记 resume 后仍 # 保持常驻态(不写回 legacy running),只续期;legacy # 登记走旧语义(state=running)。tier 等字段不再被 # 字面量覆盖丢掉。 if record.get("state") in RESIDENT_ACTIVE_STATES: self._write_resident_record( tenant, {"last_active_at": _time.time()}, now=_time.time()) else: self._write_resident_record(tenant, { "vm": vm, "tenant_id": tenant, "state": "running", "origin": record.get("origin") or "task", "gitea_synced": bool(record.get("gitea_synced"))}, now=_time.time()) self._emit(WARM_POOL_ACQUIRE, task, "vm=%s mode=resumed resume_seconds=%.2f" " warm_hit=%s" % (vm, resume_seconds, record.get("origin") == "prewarm")) return {"mode": "resumed", "vm": vm, "resume_seconds": resume_seconds, "warm_hit": record.get("origin") == "prewarm"} def suspend_after_task(self, task_or_name: dict | str, tenant_id: str | None = None) -> bool: """任务间隙暂停:pause + 写持久登记(state=paused, origin=task)。pause 失败或 VM 已死即清登记并返回 False(调用方不得把死 VM 记成可用预热机)。 feat-chv-hybrid-b:常驻租户的任务结束只暂停 VM、 只续期(last_active_at=now),不改租约状态—— resident-active → resident-paused 只由租约计时器 在闲置满阈值时转(见 sweep_resident_leases),任务 结束不是降级信号;登记写改走 _write_resident_record 合并写,tier/last_active_at 等字段不再被字面量覆盖丢掉。""" vm = self.vm_name(task_or_name) tenant = tenant_id or self._tenant_id( task_or_name if isinstance(task_or_name, dict) else {"id": task_or_name}) try: self.pause_vm(task_or_name) except Exception as exc: self._emit("vm_suspend_failed", task_or_name if isinstance(task_or_name, dict) else {"id": task_or_name}, "vm=%s 暂停失败,清持久登记:%s" % (vm, exc), "vm_suspend_failed") if tenant: clear_persistent_vm(self.state_dir, tenant) return False if tenant: import time as _time now = _time.time() record = read_persistent_vm(self.state_dir, tenant) or {} if record.get("state") in RESIDENT_STATES: self._write_resident_record( tenant, {"last_active_at": now}, now=now) else: self._write_resident_record(tenant, { "vm": vm, "tenant_id": tenant, "state": "paused", "origin": "task", "gitea_synced": bool(record.get("gitea_synced"))}, now=now) return True # -- 混合模式 B 轨(feat-chv-hybrid-b,DESIGN-HYBRID.md §9.1 # 第 5–9 项)------------------------------------------------- # 计时器驱动方式(实施时定,§4.2 二选一):worker 心跳轮询。 # executor 暴露 sweep_resident_leases() 做一轮扫描,worker # 心跳 tick 调一次(接线点见汇报;本轨不改 worker.py,由 # 协调员在集成时接一行调用)。不复用 P8 cron 语义:P8 是 # 任务级定时调度(每项目每 tick 上限、指数退避重试),租约 # 扫描是宿主级状态机轮询、无任务可调度,套 P8 只会把简单 # 的扫描复杂化;心跳轮询与 _reconcile_warm_pool 的调用形态 # 同款(worker 启动/心跳时调执行器方法)。 # 审计:状态转换记 vm_resident_suspended / vm_resident_demoted / # vm_resident_promoted、升级提示记 resident_upgrade_hint, # 全部带 host_id + tenant_id,绝不带密钥值。 def _hybrid_env_float(self, name, default): """读 B 轨阈值类环境变量(测试用短阈值注入): 非法/缺失/<=0 即回生产默认值,绝不因配错而停表。""" try: value = float(self._env.get(name) or default) except (TypeError, ValueError): return float(default) return value if value > 0 else float(default) def _resident_thresholds(self): """租约阈值(秒):(挂起阈值, 降级阈值)。生产默认值 30 分钟 / 24 小时;测试经 CHV_RESIDENT_IDLE_SUSPEND_SECONDS / CHV_RESIDENT_IDLE_DEMOTE_SECONDS 注入短阈值(构造时 env 或事后改 self._env 均可,调用时现读)。""" return ( self._hybrid_env_float( "CHV_RESIDENT_IDLE_SUSPEND_SECONDS", RESIDENT_IDLE_SUSPEND_SECONDS), self._hybrid_env_float( "CHV_RESIDENT_IDLE_DEMOTE_SECONDS", RESIDENT_IDLE_DEMOTE_SECONDS)) def resident_tier_eligible(self, tenant_id: str | None, tier: str | None = None) -> bool: """常驻资格(权益门,fail-closed,设计 §5): 只有 Paid/Heavy 有常驻资格;Free 无论多高频都没有; 无 tier 行 = 无常驻资格(拒绝常驻,走池)。 不对称说明(有意为之,设计 §5):P11 的既定语义是 “无 tier 行的已有用户不受 tier 门约束(只受原有 USD 配额约束)”——那是用量门。常驻是权益门,语义相反: 无 tier 行 = 不授予新权益。两者不冲突,因为一个是 “不限制已有用量”,一个是“不授予新权益”;实施时不许 把 P11 的豁免复用到常驻门上。 tier 参数优先,否则读登记的 tier 字段;两者皆无即 不合格。""" if tier is None: record = read_persistent_vm(self.state_dir, tenant_id) \ if tenant_id else None tier = record.get("tier") if record else None return _norm_tier(tier) in HYBRID_RESIDENT_TIERS def _write_resident_record(self, tenant, updates, now=None): """合并写持久登记:保留旧字段,只更新给定字段 + updated_at;新登记补 tenant_id/vm 默认值。阶段 1 的 三处字面量写登记(prewarm/acquire/suspend)至此都 经本方法,tier/state/last_active_at/lease_expires_at 不再被字面量覆盖丢掉。""" record = read_persistent_vm(self.state_dir, tenant) or {} record.update(updates) record.setdefault("tenant_id", tenant) record.setdefault("vm", self.persistent_vm_name(tenant)) record["updated_at"] = time.time() if now is None else now write_persistent_vm(self.state_dir, tenant, record) return record def touch_resident_activity(self, tenant_id: str | None, now: float | None = None) -> bool: """租约续期(设计 §4.1):每次任务/消息/网关活动即续期 (last_active_at=now)。仅当登记存在且为常驻有效态 (resident-active/resident-paused/hibernated)时写; 无登记不凭空造租约,demoted 不静默复活(复活走 promote_to_resident)。lease_expires_at 是闲置规则 的派生值(last_active_at + 挂起阈值 + 降级阈值,即 再无活动时的降级点),不是日历到期日,续期即重算。 返回是否续期。""" if not tenant_id: return False record = read_persistent_vm(self.state_dir, tenant_id) if record is None \ or record.get("state") not in RESIDENT_ACTIVE_STATES: return False now = time.time() if now is None else now suspend, demote = self._resident_thresholds() self._write_resident_record(tenant_id, { "last_active_at": now, "lease_expires_at": now + suspend + demote}, now=now) return True def _resident_records(self): """扫描 state_dir/persistent/*.json,返回常驻有效态 登记 [(tenant, record)](demoted 已收回租约、不占槽, 不在内;损坏/非 dict 的登记跳过)。""" out = [] pdir = os.path.join(self.state_dir, "persistent") try: entries = os.listdir(pdir) except OSError: return out for entry in entries: if not entry.endswith(".json"): continue try: with open(os.path.join(pdir, entry), "r") as fh: record = json.load(fh) except (OSError, ValueError): continue if isinstance(record, dict) \ and record.get("state") in RESIDENT_ACTIVE_STATES: tenant = record.get("tenant_id") \ or entry[:-len(".json")] out.append((tenant, record)) return out def _resident_count(self): """当前常驻有效租约数(占槽数)。""" return len(self._resident_records()) def resident_slot_cap(self) -> int: """每宿主常驻上限(已定值,§4.1):VM 槽位的 50% (CHV_MAX_VMS × 50%,向下取整)。resident_cap 是 本方法的别名,两值恒等。""" try: max_vms = int(getattr(self, "max_vms", 4)) except (TypeError, ValueError): max_vms = 4 return max(0, int(max_vms * RESIDENT_MAX_FRACTION)) def promote_to_resident(self, tenant_id: str, tier: str | None = None) -> dict: """新建/恢复常驻租约(§4.1/§4.4 执行层语义): - tier 校验 fail-closed:tier 须显式为 Paid/Heavy; 缺失/不符即 ChvFailure tier_denied(平台层路由时 已拦一次,这里是执行层 preflight 第二道)。 - 常驻上限:已常驻数达上限时不抢占已有常驻,返回 {promoted: False, queued: True}(新常驻申请排队)。 - 成功:登记 tier/state=resident-active/last_active_at/ lease_expires_at,审计 vm_resident_promoted。 已是常驻有效态时幂等续期(不重复占槽)。 返回 {promoted, queued, vm, tier, state, reason?}。""" if not tenant_id: raise ValueError("tenant_id 不能为空") if tier is None: record = read_persistent_vm(self.state_dir, tenant_id) tier = record.get("tier") if record else None if _norm_tier(tier) not in HYBRID_RESIDENT_TIERS: raise ChvFailure( "tier_denied", "常驻资格须为 Paid/Heavy 档位:租户 %s tier=%s," "无常驻资格(Free 无论多高频都没有;无 tier 行" "即无资格,走池)" % (tenant_id, tier)) now = time.time() record = read_persistent_vm(self.state_dir, tenant_id) if record is not None \ and record.get("state") in RESIDENT_ACTIVE_STATES: self.touch_resident_activity(tenant_id, now=now) return {"promoted": True, "queued": False, "vm": record.get("vm"), "tier": tier, "state": record.get("state")} if self._resident_count() >= self.resident_slot_cap(): return {"promoted": False, "queued": True, "reason": "resident_capacity_exceeded", "tier": tier} suspend, demote = self._resident_thresholds() record = self._write_resident_record(tenant_id, { "tier": tier, "state": "resident-active", "last_active_at": now, "lease_expires_at": now + suspend + demote}, now=now) self._emit(VM_RESIDENT_PROMOTED, {"id": "persist-%s" % tenant_id, "project_id": tenant_id}, "host_id=%s tenant=%s vm=%s tier=%s" % (self._host_id(), tenant_id, record.get("vm"), tier)) return {"promoted": True, "queued": False, "vm": record.get("vm"), "tier": tier, "state": "resident-active"} def _suspend_resident_vm(self, tenant, record): """租约挂起的 VM 动作(sweep 的一步):VM 已是 Paused (任务间隙 suspend_after_task 已暂停)即直接转态, 不调 pause;Running 即 pause_vm;状态未知且 socket 已不在时视为 VM 已不在、无须 pause,直接转态;其余 未知时试调 pause_vm,失败即本租户中止(返回 False, 租约状态不动)。""" vm = record.get("vm") or self.persistent_vm_name(tenant) state = self._vm_info_state({"id": vm}) if state == "Paused": return True if state is None and not os.path.exists( self.api_socket({"id": vm})): return True try: self.pause_vm({"id": vm}) return True except Exception as exc: self._emit("vm_suspend_failed", {"id": vm, "project_id": tenant}, "vm=%s 租约挂起 pause 失败:%s" % (vm, exc), "vm_suspend_failed") return False def sweep_resident_leases(self, now: float | None = None) -> dict: """租约计时器一轮扫描(设计 §4.2,worker 心跳轮询驱动): - resident-active 且闲置 >= 挂起阈值 → pause_vm(已 Paused 的直接转态)→ state=resident-paused,审计 vm_resident_suspended(带 host_id + tenant_id)。 - resident-paused 且闲置 >= 挂起阈值 + 降级阈值(即 累计 24.5 小时无活动)→ demote_resident,审计 vm_resident_demoted。 单个租户失败(pause 失败/降级中止)不中断整轮,记入 aborted。返回 {suspended, demoted, aborted}(租户 id 列表)。""" now = time.time() if now is None else now suspend_s, demote_s = self._resident_thresholds() out = {"suspended": [], "demoted": [], "aborted": []} for tenant, record in self._resident_records(): try: last = record.get("last_active_at") \ or record.get("updated_at") or now idle = now - float(last) state = record.get("state") if state == "resident-active" and idle >= suspend_s: if self._suspend_resident_vm(tenant, record): self._write_resident_record( tenant, {"state": "resident-paused"}, now=now) self._emit( VM_RESIDENT_SUSPENDED, {"id": "persist-%s" % tenant, "project_id": tenant}, "host_id=%s tenant=%s vm=%s idle_seconds=%.0f" % (self._host_id(), tenant, record.get("vm"), idle)) out["suspended"].append(tenant) else: out["aborted"].append(tenant) elif state == "resident-paused" \ and idle >= suspend_s + demote_s: result = self.demote_resident(tenant, now=now) if result.get("demoted"): out["demoted"].append(tenant) else: out["aborted"].append(tenant) except Exception as exc: _LOGGER.error( "chv_resident_sweep_failed tenant=%s error=%s", tenant, exc) out["aborted"].append(tenant) return out def _sync_resident_before_demote(self, tenant_id, vm): """降级前的同步步(设计 §4.3 第 1 步): 权威同步点在任务结束时(§3.3:任务完成即 Gitea push + 记忆/技能落盘,之后 VM 才暂停),降级时盘上已是同步后 状态;因此默认本步直接通过。若部署配了 resident_sync_hook(例如先 resume 再跑一次 chv-sync push 的回调),则以钩子返回值为准:False 即同步失败, 降级中止。demote_resident 的 sync_fn 参数优先于钩子。""" hook = getattr(self, "resident_sync_hook", None) if hook is None: return True return bool(hook(tenant_id, vm)) # 降级流程细则(设计 §4.3,注释放方法外、不计入 50 行上限): # 同步 → 快照 → 清 seed → 卸盘 → 销毁 → 登记 demoted。 # - 同步失败即降级中止、VM 保留、记审计 # (vm_resident_demote_aborted),绝不丢改动后降级; # 快照失败同样中止。 # - 清 seed:stop_vm 删 seed 目录 + 宿主私钥 + 已投递密钥 # 记录;seed.iso 随 keep_disk=True 保留——它是快照 # config.json 引用的 restore 输入(hibernate 的 V-7/D2 # 同款),删了快照即不可恢复;“清 seed”指清 live seed, # 不指毁快照输入。 # - 卸盘/销毁:stop_vm(keep_disk=True) 关 VM;任务盘与 # seed.iso 留作快照输入,数据盘 data.raw 是用户数据、 # 永不随降级删(只走显式回收)。 # - 登记:state=demoted,保留 last_active_at 与快照 URL # (供下次唤醒时从 Gitea 重建或快照恢复),审计 # vm_resident_demoted(带 host_id + tenant_id)。 # - VM 已不在时跳过快照(无物可拍),只做登记。 def demote_resident(self, tenant_id: str | None, snapshot_url: str | None = None, sync_fn: Callable[..., bool] | None = None, now: float | None = None) -> dict: """降级流程(设计 §4.3):同步 → 快照 → 清 seed → 卸盘 → 销毁 → 登记 demoted。同步/快照失败即中止、VM 保留;仅 resident-paused/hibernated 可降级,其余返回 {demoted: False, reason: not_demotable}。 返回 {demoted, vm, snapshot, reason?}。""" now = time.time() if now is None else now record = read_persistent_vm(self.state_dir, tenant_id) if record is None or record.get("state") not in ( "resident-paused", "hibernated"): return {"demoted": False, "reason": "not_demotable", "state": record.get("state") if record else None} vm = record.get("vm") or self.persistent_vm_name(tenant_id) task = {"id": vm, "project_id": tenant_id} if not self._demote_sync_ok(task, tenant_id, vm, sync_fn): return {"demoted": False, "reason": "sync_failed", "vm": vm} snapshot, failed = self._demote_snapshot( task, vm, snapshot_url) if failed: return {"demoted": False, "reason": "snapshot_failed", "vm": vm} try: self.stop_vm(task, keep_disk=True) except Exception: pass updates = {"state": "demoted"} if snapshot: updates["snapshot_url"] = snapshot self._write_resident_record(tenant_id, updates, now=now) self._emit(VM_RESIDENT_DEMOTED, task, "host_id=%s tenant=%s vm=%s snapshot=%s" % (self._host_id(), tenant_id, vm, snapshot)) return {"demoted": True, "vm": vm, "snapshot": snapshot} def _demote_sync_ok(self, task, tenant_id, vm, sync_fn): """降级同步步(demote_resident 的一步):失败记 vm_resident_demote_aborted 审计并返回 False(VM 保留, 绝不丢改动后降级);成功返回 True。""" sync_callable = sync_fn if sync_fn is not None \ else self._sync_resident_before_demote try: synced = bool(sync_callable(tenant_id, vm)) except Exception: synced = False if not synced: self._emit("vm_resident_demote_aborted", task, "host_id=%s tenant=%s vm=%s 同步失败,降级中止、" "VM 保留" % (self._host_id(), tenant_id, vm), "resident_demote_sync_failed") return synced def _demote_snapshot(self, task, vm, snapshot_url): """降级快照步(demote_resident 的一步):VM 活着即 snapshot_vm(失败记中止审计);VM 已不在时跳过快照 (无物可拍)。返回 (snapshot_url_or_None, failed)。""" if not (os.path.exists(self.api_socket({"id": vm})) or self._vm_info_state({"id": vm}) is not None): return None, False if snapshot_url is None: snapshot_url = "file://%s" % os.path.join( self.state_dir, vm, "snapshot-demote") try: self.snapshot_vm(task, snapshot_url) except Exception as exc: self._emit("vm_resident_demote_aborted", task, "host_id=%s tenant=%s vm=%s 快照失败,降级" "中止、VM 保留:%s" % (self._host_id(), task.get("project_id"), vm, exc), "resident_demote_snapshot_failed") return None, True return snapshot_url, False # -- 排队(设计 §3.4)-------------------------------------------- def queue_depth(self) -> int: """当前排队等待的任务数(L1 扩缩输入;等待期间计数, 退出时减回)。""" with self._queue_lock: return self._queue_waiters def _queue_attempts(self): """排队轮次(次数制,与 wait_api_ready 同款口径:fake sleeper 不推进墙钟时墙钟循环会空转,次数循环在单测里 是确定性的;真实 sleeper 下总时长约等于超时)。 超时经 CHV_QUEUE_TIMEOUT_SECONDS 注入(默认 300 秒), 轮询间隔经 CHV_QUEUE_POLL_SECONDS(默认 5 秒)。""" timeout = self._hybrid_env_float( "CHV_QUEUE_TIMEOUT_SECONDS", QUEUE_TIMEOUT_SECONDS) poll = self._hybrid_env_float( "CHV_QUEUE_POLL_SECONDS", QUEUE_POLL_SECONDS) return max(1, int(timeout / poll)), poll def _wait_for_capacity(self, task, exclude=None): """排队等宿主容量(设计 §3.4):容量有空即直接过; 满时按到达顺序等(本方法是调用方线程内顺序等待,无 优先队列、不插队)、不抢占(只等同一容量门放行,不踢 别的 VM)、不升/降档位(只等槽位,不改路由)。 超时(默认 300 秒)后 ChvFailure host_capacity_exceeded 明确拒绝,不无限积压。等待期间 queue_depth() +1。""" try: self._check_capacity(exclude=exclude) return except ChvFailure: pass attempts, poll = self._queue_attempts() with self._queue_lock: self._queue_waiters += 1 try: for _ in range(attempts): self.sleeper(poll) try: self._check_capacity(exclude=exclude) return except ChvFailure: continue raise ChvFailure( "host_capacity_exceeded", "排队超时:%d 秒内无可用宿主(容量仍满),明确拒绝," "不无限积压" % int(attempts * poll)) finally: with self._queue_lock: self._queue_waiters -= 1 def _wait_for_resident_slot(self, tenant_id): """常驻租户的任务排队时仍等常驻槽位(设计 §3.4): 只等常驻计数 < 上限,不挪去池里跑(挪了等于改隔离 与计费口径),不抢占已有常驻。超时同样 host_capacity_exceeded。已有常驻登记的租户不等待—— 它已占一个槽位,再等等于把自己的槽位算成满(集成 发现:A 轨 test_resident_cap_blocks_new_resident 的 第二腿,已有常驻 r1 resume 时被自己的槽位挡住)。""" _record = read_persistent_vm(self.state_dir, tenant_id) \ if tenant_id else None if _record is not None and _record.get("state") \ in RESIDENT_ACTIVE_STATES: return if self._resident_count() < self.resident_slot_cap(): return attempts, poll = self._queue_attempts() with self._queue_lock: self._queue_waiters += 1 try: for _ in range(attempts): self.sleeper(poll) if self._resident_count() < self.resident_slot_cap(): return raise ChvFailure( "host_capacity_exceeded", "排队超时:常驻槽位仍满(%d/%d),明确拒绝" % (self._resident_count(), self.resident_slot_cap())) finally: with self._queue_lock: self._queue_waiters -= 1 # -- 升级提示点(设计 §5)---------------------------------------- def _pool_task_file(self, tenant_id): """池任务时间戳文件:state_dir/pool-tasks/.json (执行层任务登记,不是平台层账本;只存时间戳与上次 提示时间,不存任务内容、不存密钥)。""" safe = re.sub(r"[^A-Za-z0-9_.-]", "-", str(tenant_id or "default")) return os.path.join(self.state_dir, "pool-tasks", safe + ".json") def _load_pool_tasks(self, tenant_id): """读池任务登记:{tasks: [ts...], last_hint_at: ts|None}; 无文件/损坏即空登记(不猜)。""" try: with open(self._pool_task_file(tenant_id), "r") as fh: data = json.load(fh) except (OSError, ValueError): return {"tasks": [], "last_hint_at": None} if not isinstance(data, dict): return {"tasks": [], "last_hint_at": None} tasks = data.get("tasks") or [] if not isinstance(tasks, list): tasks = [] return {"tasks": [t for t in tasks if isinstance(t, (int, float))], "last_hint_at": data.get("last_hint_at")} def _save_pool_tasks(self, tenant_id, data): """写池任务登记(atomic 写,与持久登记同款)。""" path = self._pool_task_file(tenant_id) os.makedirs(os.path.dirname(path), exist_ok=True) atomic_write_text(path, json.dumps(data)) def _has_7day_streak(self, tasks, now): """连续 7 天每天 ≥3 次(UTC 日桶;含今天往前 7 天)。""" today = int(now // 86400) days = {} for ts in tasks: day = int(ts // 86400) days[day] = days.get(day, 0) + 1 for offset in range(UPGRADE_HINT_STREAK_DAYS): if days.get(today - offset, 0) < UPGRADE_HINT_MIN_PER_DAY: return False return True def _upgrade_hint_metric(self, tasks, now): """本次提示的频率度量(进审计 detail,供平台层消费)。""" if len(tasks) >= UPGRADE_HINT_MIN_TASKS: return "30d_tasks=%d" % len(tasks) return "7d_streak_%d_per_day" % UPGRADE_HINT_MIN_PER_DAY def _upgrade_hint_due(self, tasks, last_hint_at, now): """是否该记升级提示:先去重(一周内同一租户最多一次), 再判高频定义(30 天 ≥100 次,或连续 7 天每天 ≥3 次)。""" if last_hint_at is not None \ and now - float(last_hint_at) \ < UPGRADE_HINT_DEDUP_DAYS * 86400: return False if len(tasks) >= UPGRADE_HINT_MIN_TASKS: return True return self._has_7day_streak(tasks, now) def record_pool_task(self, tenant_id: str | None, tier: str | None = None, now: float | None = None) -> dict: """记一次池任务并在达高频定义时发升级提示点(设计 §5): 只对 Free 租户记(Paid/Heavy 已有常驻资格,无须提示; tier 未知时按 Free 记——提示只是事件,平台层消费时 再核档位);频率只用于产生提示点,绝不用于改路由 (本方法不返回路由决策、不改任何登记的状态字段)。 频率度量从本文件里的任务时间戳算,不另建账本。 返回 {recorded, hinted, metric}。""" now = time.time() if now is None else now if tier is None: record = read_persistent_vm(self.state_dir, tenant_id) \ if tenant_id else None tier = record.get("tier") if record else None if tier is not None and tier != "Free": return {"recorded": False, "hinted": False, "reason": "not_free_tier"} data = self._load_pool_tasks(tenant_id) cutoff = now - UPGRADE_HINT_WINDOW_DAYS * 86400 tasks = [t for t in data["tasks"] if t >= cutoff] tasks.append(now) hinted = False metric = None if self._upgrade_hint_due(tasks, data.get("last_hint_at"), now): metric = self._upgrade_hint_metric(tasks, now) data["last_hint_at"] = now self._emit( RESIDENT_UPGRADE_HINT, {"id": "pool-%s" % tenant_id, "project_id": tenant_id}, "host_id=%s tenant=%s tier=Free metric=%s " "window_days=%d" % (self._host_id(), tenant_id, metric, UPGRADE_HINT_WINDOW_DAYS)) hinted = True data["tasks"] = tasks self._save_pool_tasks(tenant_id, data) return {"recorded": True, "hinted": hinted, "metric": metric} # -- 网关分流(设计 §6)------------------------------------------ def gateway_allowed_for_task(self, task: dict, model_provider: str | None = None) -> bool: """网关分流(设计 §6;worker 侧守卫自 P1-1 起已放行 CHV, 分流 enforcement 落在本执行器——改之前网关不上 CHV 的 前置依赖即由此落地): - 非网关任务(model_provider 不是网关 provider)→ True(不拦;本方法不作常驻判定用)。 - 网关任务:常驻 VM 可接(登记为常驻有效态且 tier 校验通过)、池 VM 不接。常驻有效态含 hibernated (可 restore + resume 续跑);demoted、无登记、 legacy 态一律按池算。""" provider = model_provider if provider is None and isinstance(task, dict): provider = task.get("_gateway_provider") if provider not in GATEWAY_PROVIDERS: return True tenant = self._tenant_id(task) if isinstance(task, dict) \ else None if not tenant: return False record = read_persistent_vm(self.state_dir, tenant) if record is None \ or record.get("state") not in RESIDENT_ACTIVE_STATES: return False return self.resident_tier_eligible(tenant, record.get("tier")) def _check_gateway_vm_class(self, task, model_provider=None): """网关任务的 VM 类门:池 VM 以 gateway_pool_denied 明确拒绝(ChvFailure,worker 现有 except 路径照常 写回 failed);常驻 VM 放行。""" if not self.gateway_allowed_for_task(task, model_provider): tenant = self._tenant_id(task) \ if isinstance(task, dict) else None raise ChvFailure( "gateway_pool_denied", "网关任务只接常驻 VM:租户 %s 当前走池(无常驻" "租约或档位无常驻资格),拒绝上 CHV;池 VM 用完" "即销毁,长连接在上面活不过一个任务周期" % tenant) def _hybrid_pool_preamble(self, task, model_provider=None): """B 轨池路径前置(_run_with_vm 的开头,拆出只为满足 50 行函数上限):任务到达即活动(续期租约)、网关 VM 类门(常驻可接、池不接)、排队等容量(超时 host_capacity_exceeded)、池任务记一次(Free 高频 提示点原料)。续期与记数失败不挡任务;网关门与排队 拒绝直接抛。""" try: _tenant = self._tenant_id(task) if _tenant: self.touch_resident_activity(_tenant) except Exception: pass self._check_gateway_vm_class(task, model_provider) self._wait_for_capacity(task, exclude=self.vm_name(task)) try: _tenant = self._tenant_id(task) if _tenant: self.record_pool_task( _tenant, task.get("tier") if isinstance(task, dict) else None) except Exception: pass def _hybrid_persistent_preamble(self, task, model_provider=None): """B 轨持久路径前置(_run_with_persistent_vm 的开头, 拆出只为满足 50 行函数上限):任务到达即续期租约 (仅常驻登记写,失败不挡任务);网关任务先过 VM 类门 (常驻可接、池不接)。""" try: _tenant = self._tenant_id(task) if _tenant: self.touch_resident_activity(_tenant) except Exception: pass self._check_gateway_vm_class(task, model_provider) def _wait_for_persistent_slot(self, task, tenant, vm): """持久路径冷启需新占槽位时先排队(_run_with_persistent_vm 的一段,拆出只为满足 50 行函数上限):常驻租户等常驻 槽位(不挪去池里跑),其余等通用宿主容量;超时 host_capacity_exceeded 明确拒绝。""" _record = read_persistent_vm(self.state_dir, tenant) \ if tenant else None if _record is not None and _record.get("state") \ in RESIDENT_ACTIVE_STATES: self._wait_for_resident_slot(tenant) else: self._wait_for_capacity(task, exclude=vm) def _run_guest_on_vm(self, task, vm, inner_args, goal, extra_env=None, **kwargs): """在指定 VM 上跑客内命令并做中途死亡分类( _run_with_persistent_vm 的执行段):登记进程已死即 interrupted(断点可续跑),VM 活着时的异常/失败原 样处理,不误判。返回结果 dict(未附 vm_mode,调用 方贴)。""" try: if goal: result = self._run_guest_goal( task, inner_args, extra_env=extra_env, vm_name=vm, **kwargs) else: result = self._run_guest( task, inner_args, extra_env=extra_env, vm_name=vm, **kwargs) except Exception as exc: if self._vm_proc_dead({"id": vm}): return self._vm_death_result( task, goal, str(exc)[:500]) raise if not goal and result.get("status") == "failed" \ and self._vm_proc_dead({"id": vm}): result = dict(result) result["status"] = "interrupted" result["vm_dead"] = True result["interrupted_reason"] = ( "VM 中途死亡,任务未完成、断点可续跑:" "客内命令已 failed 且登记的 VMM 进程已死") return result def _ensure_resident_capacity(self, tenant) -> None: """常驻容量门(_run_with_persistent_vm 的常驻腿前置): 无 tenant 即拒绝(无归属不能占常驻槽位);已有常驻登记 的租户直接放行;否则常驻数达上限时排队等槽位,超时仍满 即明确拒绝 host_capacity_exceeded,不抢占已有常驻。""" if not tenant: raise ChvFailure( "resident_tenant_missing", "常驻路由须带 tenant_id(无归属的任务不能占常驻" "槽位),请走池路由") _existing = read_persistent_vm(self.state_dir, tenant) if _existing is not None \ and (_existing.get("resident") is True or _existing.get("state") in RESIDENT_ACTIVE_STATES): return if self._resident_count() >= self.resident_cap(): if not self._wait_for_resident_capacity(): raise ChvFailure( "host_capacity_exceeded", "常驻槽位已满:已有 %d 台,上限 %d,排队 %d 秒" "后仍无槽位,明确拒绝" % (self._resident_count(), self.resident_cap(), HYBRID_QUEUE_TIMEOUT_SECONDS)) def _run_with_persistent_vm(self, task, inner_args, goal=False, extra_env=None, hybrid_resident=False, hybrid_tier=None, **kwargs): """持久 VM 任务生命周期(阶段 1,与 _run_with_vm 对 照):acquire(resumed)或冷启持久 VM → 跑客内命令 → finally 暂停回池(suspend_after_task),不关机、 不删盘。warm_pool_size==0 时本方法不被调(调用方 直接走 _run_with_vm)。冷启路径:wake 持久 VM (合成任务 persist-<租户>),任务本身的 host 任务 名仍按真实任务 id 派生。中途死亡分类见 _run_guest_on_vm(登记进程已死即 interrupted)。 hybrid_resident=True(feat-chv-hybrid-a):先过常驻 容量门,再把 tier/resident 写进持久登记;结果附 route/allocation。""" tenant = self._tenant_id(task) if hybrid_resident: self._ensure_resident_capacity(tenant) vm_task = self._persistent_task(tenant) self._hybrid_persistent_preamble( task, kwargs.get("model_provider")) acquired = self.acquire_for_task(task, extra_env=extra_env) vm = acquired.get("vm") or self.vm_name(vm_task) mode = acquired["mode"] resume_seconds = acquired.get("resume_seconds") if mode == "cold": self._wait_for_persistent_slot(vm_task, tenant, vm) self.wake_vm(vm_task, extra_env=extra_env) if hybrid_resident: self._mark_resident_record(tenant, hybrid_tier, vm) try: result = self._run_guest_on_vm( task, vm, inner_args, goal, extra_env=extra_env, **kwargs) if isinstance(result, dict): result = dict(result) result["vm_mode"] = mode if hybrid_resident: result["route"] = "resident" result["allocation"] = "resident-" + mode if resume_seconds is not None: result["resume_seconds"] = resume_seconds return result finally: try: if self._vm_proc_dead({"id": vm}): # VM 已死:暂停无意义,清登记,免得下次 # acquire 把死 VM 当可用预热机。 if tenant: clear_persistent_vm(self.state_dir, tenant) else: self.suspend_after_task({"id": vm}, tenant_id=tenant) except Exception: pass def dispatch_task_persistent(self, task: dict, extra_env: dict | None = None, model_provider: str | None = None) -> dict: """持久 VM 任务入口(阶段 1):warm_pool_size>=1 时 走 _run_with_persistent_vm(resume/冷启持久 VM → 跑 → 暂停回池);==0 时退化为 dispatch_task(冷启 → 跑 → 关机),与旧行为一致。结果 dict 附 vm_mode (resumed/cold)与 resume_seconds(resumed 时)。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") if not task.get("prompt"): raise ValueError("任务缺少 prompt") if int(getattr(self, "warm_pool_size", 0)) <= 0: return self.dispatch_task( task, extra_env=extra_env, model_provider=model_provider) args = ["dispatch", "--name", self._host_name(task), "--task", task["prompt"]] repo = task.get("repo") if repo: args += ["--repo", repo] return self._run_with_persistent_vm( task, args, extra_env=extra_env, model=task.get("model"), effort=task.get("effort"), approval=task.get("approval"), model_provider=model_provider, sandbox=("read-only" if task.get("plan") else None)) # -- 混合模式(feat-chv-hybrid-a,DESIGN-HYBRID.md §9.1 第 1–4 项) # 路由:常驻租户(tier Paid/Heavy 且有 tenant)走常驻 VM, # 其余走共享干净池(命中预热 / 冷启 / 排队)。池 VM 出生时 # 不带租户(无数据盘、无 seed、无 Gitea 同步等待),只 boot # 到 SSH 就绪 + pause;租户绑定延后到 acquire 时(数据盘 # 挂载 + seed 注入 + 首次同步等待)。用过的池 VM 一律销毁, # 绝不回池。CHV_WARM_POOL_SIZE 的计数口径从“每租户 1 台” # 改为“每宿主 W 台干净池”(W=HYBRID_WARM_POOL_PER_HOST=2); # 阶段 1 的每租户持久 VM 路径(prewarm_persistent_vm / # acquire_for_task)原样保留,只作常驻腿的底座。 # 审计事件名按设计原文,detail 一律带 host_id,绝不带密钥值。 def _host_id(self) -> str: """本宿主 id(审计用):显式 CHV_HOST_ID > 主机名。 审计 detail 里只放这个 id,不放密钥、不放快照内容。""" host_id = "" try: host_id = str(self._env.get("CHV_HOST_ID") or "").strip() except Exception: host_id = "" if host_id: return host_id try: return socket.gethostname() except Exception: return "unknown" # -- 口径与容量 ------------------------------------------------------ def clean_pool_target(self) -> int: """干净池目标台数(本宿主):CHV_WARM_POOL_SIZE 的新口径 ——每宿主 W 台干净池(W 定值见 warm_pool_per_host_target, 部署上把 CHV_WARM_POOL_SIZE 设成 W 即 2);为 0 即池关闭, 混合路由整体退化为阶段 1 旧语义。旧口径“每租户 1 台” 已废止:该值不再按租户计数。""" try: return max(0, int(getattr(self, "warm_pool_size", 0))) except (TypeError, ValueError): return 0 def warm_pool_per_host_target(self) -> int: """每宿主干净池预热数 W(已定值 2 台/宿主)。""" return HYBRID_WARM_POOL_PER_HOST def warm_pool_cap(self, low_freq_peak: float | None = None) \ -> int | None: """池上限 = 低频并发峰值的 50%(已定口径):超上限的低频 任务走冷启,不扩池。峰值未给(参数与 CHV_LOW_FREQ_PEAK_ CONCURRENCY 环境变量皆无)即 None(不封顶,由目标数定)。 20 并发 → 10 台,50 并发 → 25 台。""" if low_freq_peak is None: raw = self._env.get("CHV_LOW_FREQ_PEAK_CONCURRENCY") if raw is None: return None try: low_freq_peak = float(raw) except (TypeError, ValueError): return None try: peak = float(low_freq_peak) except (TypeError, ValueError): return None if peak <= 0: return 0 return max(0, int(peak * HYBRID_POOL_CAP_FRACTION)) def clean_pool_effective_target( self, low_freq_peak: float | None = None) -> int: """干净池生效目标 = min(配置目标,池上限):上限只会 压小池子,绝不会把它扩过配置目标。""" target = self.clean_pool_target() cap = self.warm_pool_cap(low_freq_peak) if cap is None: return target return min(target, cap) def resident_cap(self) -> int: """每宿主常驻上限 = VM 槽位的 50%(已定值):48 槽位 → 24 台;超上限的新常驻申请排队,不抢占已有常驻。 别名:唯一事实源是 resident_slot_cap(B 轨),两值 恒等(同为 0.5),此处只转发、不另算,避免漂移。""" return self.resident_slot_cap() def _mark_resident_record(self, tenant, tier, vm) -> None: """把 tier/resident 写进该租户的持久登记(常驻腿的租约 标记,租约计时本身是另一轨,不在这里):state/origin/ gitea_synced 沿用旧登记,不丢。统一模型(集成时定, 设计 §4.1):标记常驻即进 state 模型——已有常驻态 (resident-active/paused/hibernated/demoted)保持原态, 其余一律置 resident-active,使 B 轨的 _resident_count (只认常驻有效态)能数到它;resident: True 旗标仍保留 供旧读取路径识别。""" record = read_persistent_vm(self.state_dir, tenant) or {} _state = record.get("state") if _state not in RESIDENT_STATES: _state = "resident-active" write_persistent_vm(self.state_dir, tenant, { "vm": vm, "tenant_id": tenant, "state": _state, "origin": record.get("origin") or "task", "gitea_synced": bool(record.get("gitea_synced")), "tier": tier, "resident": True, "updated_at": time.time()}) def _hybrid_route(self, task: dict) -> str: """混合路由判定(只看档位 + 租户归属,不看频率): tier Paid/Heavy 且带 tenant_id → resident;其余(Free、 无 tier、无 tenant)一律 pool。无 tier 行 = 无常驻资格, fail-closed 走池(与 P11 用量门的豁免语义相反,常驻是 权益门,不许把 P11 的豁免复用到常驻门上)。""" tier = "" if isinstance(task, dict): tier = str(task.get("tier") or "").strip().lower() if tier in HYBRID_RESIDENT_TIERS and self._tenant_id(task): return "resident" return "pool" def _poll_capacity(self, timeout_seconds=None) -> bool: """排队等可用宿主(§3.4,bool 版):轮询 _persistent_vm_count, 有空槽即 True;超时(已定 300 秒,CHV_QUEUE_TIMEOUT 可覆盖,测试用 0 即不等待)仍满即 False,调用方明确 拒绝 host_capacity_exceeded,绝不静默降级。轮询按 次数封顶(timeout/interval),fake sleeper 不推进墙钟 时也不会空转。与 B 轨的 _wait_for_capacity(raise 版) 同名不同约:本方法是 bool 返回、只供 dispatch 池冷启 腿的容量门使用;B 版在超时时直接抛 ChvFailure。""" if timeout_seconds is None: try: timeout_seconds = float(self._env.get( "CHV_QUEUE_TIMEOUT", HYBRID_QUEUE_TIMEOUT_SECONDS)) except (TypeError, ValueError): timeout_seconds = float(HYBRID_QUEUE_TIMEOUT_SECONDS) timeout_seconds = max(0.0, float(timeout_seconds)) interval = HYBRID_QUEUE_POLL_INTERVAL_SECONDS attempts = max(1, int(timeout_seconds / interval)) \ if timeout_seconds > 0 else 1 for _ in range(attempts): if self._persistent_vm_count() < self.max_vms: return True self.sleeper(interval) return self._persistent_vm_count() < self.max_vms def _wait_for_resident_capacity(self, timeout_seconds=None) -> bool: """排队等常驻槽位(§4.1):轮询 _resident_count,有常驻 槽位即 True;超时仍满即 False,调用方明确拒绝 host_capacity_exceeded,不抢占已有常驻。与 _wait_for_capacity 的区别:等的是常驻上限(槽位 50%), 不是通用 VM 容量——通用槽位空闲时常驻仍可能已满。""" if timeout_seconds is None: try: timeout_seconds = float(self._env.get( "CHV_QUEUE_TIMEOUT", HYBRID_QUEUE_TIMEOUT_SECONDS)) except (TypeError, ValueError): timeout_seconds = float(HYBRID_QUEUE_TIMEOUT_SECONDS) timeout_seconds = max(0.0, float(timeout_seconds)) interval = HYBRID_QUEUE_POLL_INTERVAL_SECONDS attempts = max(1, int(timeout_seconds / interval)) \ if timeout_seconds > 0 else 1 for _ in range(attempts): if self._resident_count() < self.resident_cap(): return True self.sleeper(interval) return self._resident_count() < self.resident_cap() # -- 干净池登记 ------------------------------------------------------ def _clean_pool_vm_name(self, slot: int) -> str: """干净池第 slot 台 VM 名:chv-pool-(与租户无关, 租户绑定只写登记 + tenant.json,不进 VM 名)。""" return "chv-pool-%d" % int(slot) def _clean_pool_record_path(self, slot: int) -> str: return os.path.join(self.state_dir, CLEAN_POOL_DIR_NAME, "slot-%d.json" % int(slot)) def _read_clean_pool_record(self, slot: int): """读干净池登记:无文件/损坏/VM 名不符即 None(不猜)。""" try: with open(self._clean_pool_record_path(slot), "r") as fh: data = json.load(fh) except (OSError, ValueError): return None if not isinstance(data, dict) \ or data.get("vm") != self._clean_pool_vm_name(slot): return None return data def _write_clean_pool_record(self, slot: int, record: dict) -> str: path = self._clean_pool_record_path(slot) os.makedirs(os.path.dirname(path), exist_ok=True) atomic_write_text(path, json.dumps(record)) return path def _clear_clean_pool_record(self, slot: int) -> bool: path = self._clean_pool_record_path(slot) if os.path.exists(path): os.remove(path) return True return False def _list_clean_pool_records(self): """全部干净池登记,按 slot 排序:[(slot, record)]。""" out = [] try: entries = os.listdir( os.path.join(self.state_dir, CLEAN_POOL_DIR_NAME)) except Exception: return out for entry in sorted(entries): if not entry.startswith("slot-") \ or not entry.endswith(".json"): continue try: slot = int(entry[len("slot-"):-len(".json")]) except ValueError: continue record = self._read_clean_pool_record(slot) if record is not None: out.append((slot, record)) return out def _clean_pool_slot_for_vm(self, vm): """该 VM 占的干净池 slot,无登记即 None。""" for slot, record in self._list_clean_pool_records(): if record.get("vm") == vm: return slot return None def _clean_pool_warm_vm(self, target): """已暖的干净池 VM 名(paused 登记 + socket 活着),无即 None。""" for i in range(target): record = self._read_clean_pool_record(i) if record is not None \ and record.get("state") == "paused" \ and os.path.exists(self.api_socket( {"id": record.get("vm") or ""})): return record.get("vm") return None def _pick_clean_pool_slot(self, target): """挑一个可建的干净池 slot:无登记 / 残留登记的 slot; 正跑任务的 slot 绝不重建;全部无槽即 None。""" for i in range(target): record = self._read_clean_pool_record(i) if record is None: return i if record.get("state") == "running": continue if record.get("state") == "paused" and os.path.exists( self.api_socket({"id": record.get("vm") or ""})): continue return i return None def _check_clean_prewarm_seed(self, extra_env) -> None: """干净池预热的 seed 门禁:显式带密钥或 Gitea 同步变量 即 ChvFailure clean_pool_seed_refused(出生时带租户态 是调用方错误,同步只能在分配时随租户绑定给)。""" secrets, _plain = split_secret_env(extra_env) if secrets: raise ChvFailure( "clean_pool_seed_refused", "干净池预热不带租户 seed:密钥 %s 只能在分配时" "随租户绑定给,不能在出生时给" % ",".join(sorted(secrets))) if extra_env and any( key in extra_env for key in _CLEAN_POOL_REFUSED_SYNC_KEYS): raise ChvFailure( "clean_pool_seed_refused", "干净池预热不带 Gitea 同步变量:同步只能在分配" "时随租户绑定给,不能在出生时给") def _prewarm_clean_build(self, slot, vm, extra_env): """干净池预热的建造段:冷启干净 VM → pause → 写登记 → 发 warm_pool_prewarm 审计。返回 prewarmed 结果。""" vm_task = {"id": "pool-%d" % slot} self.wake_vm(vm_task, extra_env=extra_env, clean_pool=True) self.pause_vm(vm_task) self._write_clean_pool_record(slot, { "vm": vm, "slot": slot, "tenant_id": None, "state": "paused", "origin": "clean-prewarm", "gitea_synced": False, "data_disk": None, "updated_at": time.time()}) self._emit("warm_pool_prewarm", vm_task, "vm=%s clean=true host_id=%s gitea_synced=false" % (vm, self._host_id())) return {"pooled": True, "vm": vm, "mode": "prewarmed", "gitea_synced": False, "clean": True, "tenant_id": None} def prewarm_clean_pool(self, slot: int | None = None, extra_env: dict | None = None) -> dict: """干净池预热(从 prewarm_persistent_vm 拆出,§9.1 第 2 项): 池 VM 出生时不带租户——无数据盘、无 seed、无 Gitea 同步 等待,只 boot 到 SSH 就绪 + pause;租户绑定延后到 acquire 时。幂等:已有 paused 登记且 socket 活着即 already-warm。 显式带密钥或 Gitea 同步变量进预热即 ChvFailure clean_pool_seed_refused(出生时带租户态是调用方错误)。 host-key 预置仍按 provisioned 模式走(SSH 钉死所需, 与租户 seed 不同)。返回 {pooled, vm, mode, gitea_synced: False, clean: True, tenant_id: None}。""" target = self.clean_pool_target() if target <= 0: return {"pooled": False, "vm": None, "reason": "warm_pool_disabled"} if slot is None: warm = self._clean_pool_warm_vm(target) if warm: return {"pooled": True, "vm": warm, "mode": "already-warm", "gitea_synced": False, "clean": True, "tenant_id": None} slot = self._pick_clean_pool_slot(target) if slot is None: # 全部 slot 不是已暖就是正跑任务:无槽可建。 return {"pooled": False, "vm": None, "reason": "clean_pool_all_busy"} if int(slot) < 0 or int(slot) >= target: return {"pooled": False, "vm": None, "reason": "clean_pool_slot_out_of_range"} slot = int(slot) vm = self._clean_pool_vm_name(slot) record = self._read_clean_pool_record(slot) if record is not None and record.get("state") == "paused" \ and record.get("vm") == vm \ and os.path.exists(self.api_socket({"id": vm})): return {"pooled": True, "vm": vm, "mode": "already-warm", "gitea_synced": False, "clean": True, "tenant_id": None} if record is not None and record.get("state") == "running": # 正跑任务的 slot 绝不重建(重建等于杀掉租户任务)。 return {"pooled": False, "vm": vm, "reason": "clean_pool_slot_busy"} self._check_clean_prewarm_seed(extra_env) return self._prewarm_clean_build(slot, vm, extra_env) def refill_clean_pool(self, extra_env: dict | None = None) -> dict: """干净池补建(后台调用方自行起线程跑,本方法本身同步): 逐 slot 预热,already-warm 的 slot 不重建;单 slot 失败 记 reason,不挡其它 slot。warm_pool_size==0 时空跑。""" target = self.clean_pool_target() results = [] for i in range(target): try: results.append(self.prewarm_clean_pool( slot=i, extra_env=extra_env)) except Exception as exc: results.append({"pooled": False, "vm": self._clean_pool_vm_name(i), "reason": "refill_failed", "error": str(exc)[:200]}) return {"target": target, "slots": results} # -- 租户数据盘:挂载 / 卸载(§9.1 第 3 项,ADR-5 正面实现)------ def _tenant_data_disk_name(self, tenant) -> str: """该租户的数据盘目录名:tenant-data-<安全字符租户>。 数据盘住在 VM 外(state_dir/tenant-data-<租户>/data.raw), 与任何一台池 VM 的自带盘都不是同一个文件。""" safe = re.sub(r"[^A-Za-z0-9_.-]", "-", str(tenant or "default")) return "tenant-data-" + safe def _tenant_data_disk_path(self, tenant) -> str: """该租户的数据盘路径(唯一合法的任务数据盘来源)。""" return data_disk_path( self.state_dir, self._tenant_data_disk_name(tenant)) def _provision_tenant_data_disk(self, tenant) -> str: """租户数据盘供给:已存在即复用(旧盘里可能已有用户数据, 不 resize、不重建);不存在即新建空稀疏盘并写 tenant 归属 标记。首次使用的租户到这里才有数据盘(§3.1 回落:尚无 数据盘时分配时新建 + 首次同步等待)。""" name = self._tenant_data_disk_name(tenant) path = provision_data_disk(self.state_dir, name) write_vm_tenant(self.state_dir, name, tenant, disk=path) return path def _verify_task_disk_source(self, vm, tenant, data_disk) -> None: """任务盘来源不变量(ADR-5 的正面实现,测试钉死): 任务数据盘必须是该租户自己的数据盘,绝不能是干净快照盘 (池 VM 自带盘)、基础镜像或其它租户的盘。任一不符即 ChvFailure task_disk_source_invalid(fail-closed,不补 默认值、不静默换盘、不把干净盘当任务盘用)。""" if not tenant: raise ChvFailure( "task_disk_source_invalid", "任务盘归属校验须带 tenant_id:无归属的任务不能" "绑定数据盘") if not data_disk: raise ChvFailure( "task_disk_source_invalid", "任务数据盘缺失:租户 %s 没有可挂载的数据盘" % tenant) expected = os.path.realpath( self._tenant_data_disk_path(tenant)) actual = os.path.realpath(data_disk) if actual != expected: raise ChvFailure( "task_disk_source_invalid", "任务数据盘 %s 不是租户 %s 的数据盘(应为 %s)," "拒绝挂载" % (data_disk, tenant, expected)) if self.rootfs and actual == os.path.realpath(self.rootfs): raise ChvFailure( "task_disk_source_invalid", "任务数据盘不能是基础镜像:租户 %s" % tenant) pool_data = os.path.realpath( data_disk_path(self.state_dir, vm)) if actual == pool_data: raise ChvFailure( "task_disk_source_invalid", "任务数据盘不能是干净池 VM 自带的盘:vm=%s," "租户 %s" % (vm, tenant)) owner = read_vm_tenant( self.state_dir, self._tenant_data_disk_name(tenant)) if owner is not None and owner != tenant: raise ChvFailure( "task_disk_source_invalid", "租户数据盘的归属标记是 %s,与请求方 %s 不一致," "拒绝挂载" % (owner, tenant)) def attach_tenant_data_disk(self, task: dict, vm: str, data_disk: str | None = None) -> dict: """分配时挂载租户数据盘(§3.1 首选方案):供给/复用该租户 的数据盘 → 过任务盘来源不变量 → CHV 热挂载(PUT /vm.add-disk,CHV 54 的热插盘端点)→ 客内挂载脚本 (chv-data-mount,按 LABEL=CHVDATA 挂到 /home/chv/workspace)→ 写 VM 归属标记。任一步失败即 ChvFailure(fail-closed,调用方回落冷启或拒绝,不把 未挂盘的 VM 当已绑定发给任务)。返回 {vm, tenant_id, data_disk}。""" tenant = self._tenant_id(task) if not tenant: raise ChvFailure( "task_disk_source_invalid", "挂载数据盘须带 tenant_id:无归属的任务不能绑盘") disk = data_disk or self._provision_tenant_data_disk(tenant) self._verify_task_disk_source(vm, tenant, disk) status, _body = self._api_call( self.api_socket({"id": vm}), "PUT", "/vm.add-disk", {"path": disk}) if status not in (200, 204): raise ChvFailure( "data_disk_attach_failed", "数据盘热挂载失败:vm=%s status=%s" % (vm, status)) result = self._call_runner(self.build_ssh_command( {"id": vm}, ["/usr/local/bin/chv-data-mount"])) if getattr(result, "returncode", 1) != 0: raise ChvFailure( "data_disk_mount_failed", "客内挂载数据盘失败:vm=%s rc=%s" % (vm, getattr(result, "returncode", None))) write_vm_tenant(self.state_dir, vm, tenant, disk=disk) return {"vm": vm, "tenant_id": tenant, "data_disk": disk} def detach_tenant_data_disk(self, task: dict, vm: str) -> bool: """客内卸载租户数据盘(umount /home/chv/workspace,落盘 回外部存储):成功 True;失败 False(调用方不得继续销毁 VM,见 release_pool_vm——同步/卸载失败的 VM 不回收, 等人工/重试,绝不丢改动后静默回收)。""" try: result = self._call_runner(self.build_ssh_command( {"id": vm}, ["umount", "/home/chv/workspace"])) except Exception: return False return getattr(result, "returncode", 1) == 0 def release_pool_vm(self, task: dict, vm: str, sync_workspace: str | None = None) -> dict: """池 VM 回收(§3.3):同步回外部存储(sync_workspace 给 出时客内 chv-sync push,失败即不回收)→ 卸载租户数据盘 (失败即不回收)→ 销毁 VM(stop_vm,用过的池 VM 一律 不回池)→ 清归属标记与干净池登记。返回 {released, vm, tenant_id, reason?}。""" tenant = self._tenant_id(task) if sync_workspace: try: result = self._call_runner(self.build_ssh_command( {"id": vm}, ["chv-sync", "push", sync_workspace])) pushed = getattr(result, "returncode", 1) == 0 except Exception: pushed = False if not pushed: self._emit("warm_pool_release_failed", task, "vm=%s host_id=%s tenant=%s " "reason=sync_failed" % (vm, self._host_id(), tenant), "warm_pool_release_failed") return {"released": False, "vm": vm, "tenant_id": tenant, "reason": "sync_failed"} if not self.detach_tenant_data_disk(task, vm): self._emit("warm_pool_release_failed", task, "vm=%s host_id=%s tenant=%s " "reason=unmount_failed" % (vm, self._host_id(), tenant), "warm_pool_release_failed") return {"released": False, "vm": vm, "tenant_id": tenant, "reason": "unmount_failed"} try: self.stop_vm({"id": vm}) except Exception: pass _tenant_path = os.path.join(self.state_dir, vm, "tenant.json") try: if os.path.exists(_tenant_path): os.remove(_tenant_path) except OSError: pass slot = self._clean_pool_slot_for_vm(vm) if slot is not None: self._clear_clean_pool_record(slot) self._emit(WARM_POOL_RELEASE, task, "vm=%s host_id=%s tenant=%s" % (vm, self._host_id(), tenant)) return {"released": True, "vm": vm, "tenant_id": tenant} # -- 干净池分配 ------------------------------------------------------ def _clean_pool_candidate(self): """找一台可分配的干净池 VM:登记 paused + socket 存在 + vm.info 确认 Paused。socket 消失的登记当场清掉;状态非 Paused 的跳过(不猜,留给清理)。返回 (slot, vm, record) 或 None。""" for slot, record in self._list_clean_pool_records(): if record.get("state") != "paused": continue vm = record.get("vm") if not vm: continue if not os.path.exists(self.api_socket({"id": vm})): self._clear_clean_pool_record(slot) continue if self._vm_info_state({"id": vm}) != "Paused": continue return slot, vm, record return None def _clean_pool_binding(self, task, vm, tenant): """干净池分配的绑盘段:供给/复用租户数据盘 → 过任务盘 来源不变量。失败即发 warm_pool_acquire_failed 审计并 返回 None(调用方回落冷启,不报错)。""" try: data_disk = self._provision_tenant_data_disk(tenant) self._verify_task_disk_source(vm, tenant, data_disk) except ChvFailure as exc: self._emit("warm_pool_acquire_failed", task, "vm=%s host_id=%s tenant=%s kind=%s" % (vm, self._host_id(), tenant, exc.kind), "warm_pool_acquire_failed") return None return data_disk def _resume_clean_pool(self, task, vm, slot, tenant, data_disk): """干净池分配的唤醒段:resume + 等 SSH 就绪 + 挂载租户 数据盘,计时返回 resume_seconds。失败即发 warm_pool_acquire_failed 审计、销毁该 VM、清登记并 返回 None(调用方回落冷启)。""" t0 = time.time() try: self.resume_vm({"id": vm}) self.wait_guest_ready({"id": vm}) self.attach_tenant_data_disk(task, vm, data_disk=data_disk) except Exception as exc: self._emit("warm_pool_acquire_failed", task, "vm=%s host_id=%s tenant=%s error=%s" % (vm, self._host_id(), tenant, str(exc)[:200]), "warm_pool_acquire_failed") try: self.stop_vm({"id": vm}) except Exception: pass self._clear_clean_pool_record(slot) return None return time.time() - t0 def _check_clean_pool_sync(self, vm, slot, sync_workspace) -> None: """干净池分配的同步检查点(fail-closed 在此,不在预热时): sync_workspace 给出时等首次同步,等不到即解绑销毁该 VM (已绑过租户态的 VM 绝不回干净池)再抛 warm_pool_sync_incomplete,任务失败。""" if not sync_workspace: return if not self._wait_sync_workspace({"id": vm}, sync_workspace): try: self.stop_vm({"id": vm}) except Exception: pass self._clear_clean_pool_record(slot) raise ChvFailure( "warm_pool_sync_incomplete", "池 VM %s 分配时在 %s 秒内未完成 Gitea 首次同步" "(%s/.git 未出现),已解绑销毁,不派任务" % (vm, self.ready_timeout, sync_workspace)) def acquire_clean_pool(self, task: dict, extra_env: dict | None = None, sync_workspace: str | None = None) -> dict: """取一台干净池 VM(任务到达时的认领,§3.2 (a)): 命中 → resume + 等 SSH 就绪 + 挂载租户数据盘 + 首次同步 等待(sync_workspace 给出时),返回 {mode: resumed, vm, data_disk, resume_seconds, warm_hit: True}; 池空 / 无租户 → 发 warm_pool_miss 审计,返回 {mode: cold, vm: None},调用方回落冷启,不报错。 resume_seconds 只计 resume+SSH 就绪+挂盘,不含租户态 同步时间(热恢复 ≤2 秒口径,§2.1)。 warm_pool_sync_incomplete 的 fail-closed 语义保留,但 检查点从“预热时”移到“分配时”:同步等不到即解绑销毁 该 VM(已绑过租户态的 VM 绝不回干净池)再抛 warm_pool_sync_incomplete,任务失败,不派给未同步的 VM。""" if self.clean_pool_target() <= 0: return {"mode": "cold", "vm": None} tenant = self._tenant_id(task) candidate = self._clean_pool_candidate() if candidate is None or not tenant: self._emit(WARM_POOL_MISS, task, "host_id=%s tenant=%s reason=%s" % (self._host_id(), tenant, "pool_empty" if candidate is None else "tenant_missing")) return {"mode": "cold", "vm": None} slot, vm, record = candidate data_disk = self._clean_pool_binding(task, vm, tenant) if data_disk is None: return {"mode": "cold", "vm": None} resume_seconds = self._resume_clean_pool( task, vm, slot, tenant, data_disk) if resume_seconds is None: return {"mode": "cold", "vm": None} self._write_clean_pool_record(slot, { "vm": vm, "slot": slot, "tenant_id": tenant, "state": "running", "origin": "clean-prewarm", "gitea_synced": False, "data_disk": data_disk, "updated_at": time.time()}) self._check_clean_pool_sync(vm, slot, sync_workspace) self._emit(WARM_POOL_ACQUIRE, task, "vm=%s host_id=%s tenant=%s mode=resumed " "resume_seconds=%.2f warm_hit=true" % (vm, self._host_id(), tenant, resume_seconds)) return {"mode": "resumed", "vm": vm, "data_disk": data_disk, "resume_seconds": resume_seconds, "warm_hit": True, "clean": True} # -- 池任务生命周期与混合入口 ---------------------------------------- def _run_with_pool_vm(self, task, inner_args, goal=False, extra_env=None, **kwargs): """池任务生命周期(§3.2/§3.3):命中预热 → 跑客内命令 → finally 回收销毁(release_pool_vm,用过的池 VM 一律不 回池);池空 → 冷启(_run_with_vm,跑完关机销毁); 无可用宿主 → 排队等槽位,300 秒超时后明确拒绝 host_capacity_exceeded。warm_pool_sync_incomplete 从 acquire 抛出时 fail-closed,不回落冷启。""" acquired = self.acquire_clean_pool( task, extra_env=extra_env, sync_workspace=task.get("sync_workspace")) if acquired["mode"] == "resumed": vm = acquired["vm"] try: result = self._run_guest_on_vm( task, vm, inner_args, goal, extra_env=extra_env, **kwargs) if isinstance(result, dict): result = dict(result) result["route"] = "pool" result["allocation"] = "pool-hit" result["vm_mode"] = "resumed" result["warm_hit"] = True result["resume_seconds"] = acquired.get( "resume_seconds") result["data_disk"] = acquired.get("data_disk") return result finally: try: self.release_pool_vm( task, vm, sync_workspace=task.get("sync_workspace")) except Exception: pass if self._persistent_vm_count() >= self.max_vms: if not self._poll_capacity(): raise ChvFailure( "host_capacity_exceeded", "无可用宿主:已计数 %d 台,上限 %d,排队 %d 秒" "后仍无槽位,明确拒绝" % (self._persistent_vm_count(), self.max_vms, HYBRID_QUEUE_TIMEOUT_SECONDS)) result = self._run_with_vm( task, inner_args, goal=goal, extra_env=extra_env, **kwargs) if isinstance(result, dict): result = dict(result) result["route"] = "pool" result["allocation"] = "pool-cold" result["vm_mode"] = "cold" return result def dispatch_task_hybrid(self, task: dict, extra_env: dict | None = None, model_provider: str | None = None) -> dict: """混合路由任务入口(§9.1 第 1 项):按 tier + 租约状态 分流——常驻租户走常驻 VM(resume/冷启常驻,任务间隙 暂停回常驻登记,不删盘),池租户走三路分配(命中预热 / 冷启 / 排队)。warm_pool_size==0 时退化为 dispatch_task_persistent(阶段 1 旧语义),与旧行为 一致。结果 dict 附 route(resident/pool)与 allocation (resident-resumed/resident-cold/pool-hit/pool-cold)。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") if not task.get("prompt"): raise ValueError("任务缺少 prompt") if int(getattr(self, "warm_pool_size", 0)) <= 0: return self.dispatch_task_persistent( task, extra_env=extra_env, model_provider=model_provider) args = ["dispatch", "--name", self._host_name(task), "--task", task["prompt"]] repo = task.get("repo") if repo: args += ["--repo", repo] route = self._hybrid_route(task) if route == "resident": return self._run_with_persistent_vm( task, args, extra_env=extra_env, model=task.get("model"), effort=task.get("effort"), approval=task.get("approval"), model_provider=model_provider, sandbox=("read-only" if task.get("plan") else None), hybrid_resident=True, hybrid_tier=str(task.get("tier") or "").lower()) return self._run_with_pool_vm( task, args, extra_env=extra_env, model=task.get("model"), effort=task.get("effort"), approval=task.get("approval"), model_provider=model_provider, sandbox=("read-only" if task.get("plan") else None)) def recycle_persistent_vm(self, task_or_name: dict | str, tenant_id: str | None = None, extra_env: dict | None = None, sync: bool = True) -> dict: """显式回收持久 VM(唯一删用户盘的入口):先 shutdown (stop_vm,keep_disk=False),再删 overlay 与数据盘、 清 tenant 标记与持久登记。返回 {vm, state: recycled, deleted_disks}。与 recycle_vm 的区别:后者只关 VM 不删持久盘(持久盘须显式回收才删,用户数据不静默丢)。 C 轨新增(§3.3 同款回收同步门,见 recycle_vm):sync 默认开;配了 Gitea 同步且 push 失败时不 shutdown、 不删盘,返回 sync_failed(vm_retained=True, deleted_disks=[]),等人工/重试。 """ vm = self.vm_name(task_or_name) tenant = tenant_id or self._tenant_id( task_or_name if isinstance(task_or_name, dict) else {"id": task_or_name}) if sync: task_dict = task_or_name if isinstance( task_or_name, dict) else {"id": task_or_name} ok, sync_detail = self._recycle_sync_gate( task_dict, extra_env=extra_env) if not ok: return self._sync_failed_result(vm, sync_detail, []) else: sync_detail = "sync skipped by caller" self.shutdown_vm(task_or_name) deleted = [] if delete_task_disk(self.state_dir, vm): deleted.append("task") if delete_data_disk(self.state_dir, vm): deleted.append("data") _tenant_path = os.path.join(self.state_dir, vm, "tenant.json") if os.path.exists(_tenant_path): os.remove(_tenant_path) if tenant: clear_persistent_vm(self.state_dir, tenant) self._emit("vm_persistent_recycled", task_or_name if isinstance(task_or_name, dict) else {"id": task_or_name}, "vm=%s deleted_disks=%s" % (vm, deleted)) return {"vm": vm, "state": "recycled", "deleted_disks": deleted, "sync_ok": True, "sync_detail": sync_detail, "vm_retained": False} def snapshot_vm(self, task_or_name: dict | str, snapshot_url: str) -> str: """给(已暂停的)VM 做快照:ch-remote snapshot <裸 URL>。 注意:snapshot 的参数是裸 URL,不是 source_url=... (那是 restore 的键);传 source_url=... 会被服务端 当成 URL 本身,报 Could not extract path from URL (2026-11-07 WSL 真机 V-3 实测)。snapshot_url 为空 即 ValueError;成功审计 vm_snapshot。快照本身不改变 VM 状态(仍 paused,需 resume 才醒)。 v54 起快照产物是目录(含 config.json/state.json/ memory-ranges),不是文件:file:// 目标须是已生成 的非空目录,文件/空目录/缺失一律报 snapshot_not_persisted(旧 isfile 校验会把真快照 误判为未落盘)。 feat-chv-secfix(L2/M3):ch-remote 之前先过 URL 白名单(file:// 须在 state_dir/CHV_SNAPSHOT_DIR 内, 远端须显式放行)与数量/总量/保留期上限;成功后登 记并在总量超帽时按最旧优先剪枝(删动作审计)。 feat-chv-v3-repair(V-7/D1):file:// 目标目录在 配额预检后、ch-remote 前建好(见 _ensure_snapshot_dir)。""" if not snapshot_url: raise ValueError("snapshot_url 不能为空") snapshot_path = validate_snapshot_url( snapshot_url, self.state_dir, snapshot_dir=getattr(self, "snapshot_dir", None), allow_remote=getattr(self, "allow_remote_snapshot", False)) vm_name = self.vm_name(task_or_name) check_snapshot_limits( self.state_dir, vm_name, getattr(self, "snapshot_max_count", 10), getattr(self, "snapshot_max_total_gb", 20.0), getattr(self, "snapshot_retention_days", 30.0)) # feat-chv-v3-repair(V-7/D1):建目录放在配额预检 # 之后——配额拒绝先于任何文件改动,且目标是文件时 # 也不抢占配额错误的 kind;只在 snapshot_vm 这一处 # 建,hibernate_vm 经此调用,不重不漏。 _ensure_snapshot_dir(snapshot_url, snapshot_path) self._ch_remote(task_or_name, "snapshot", snapshot_url) # feat-chv-fix(P1-7)/feat-chv-v3:只查 returncode # 不够,见 _assert_snapshot_dir_persisted——file:// # 快照须验证目标目录已生成且非空,否则报 # snapshot_not_persisted,调用方(hibernate_vm)据此 # 不 shutdown、不销毁原 VM;非 file:// 目标无法从宿 # 主验证落盘,仍只认 returncode,但文档明写该边界。 _assert_snapshot_dir_persisted(snapshot_url) return self._record_snapshot_and_emit( task_or_name, vm_name, snapshot_url) def _record_snapshot_and_emit(self, task_or_name, vm_name, snapshot_url): """快照成功后登记 + 总量超帽时最旧优先剪枝(删动作 审计)+ vm_snapshot 审计,返回 snapshot_url。 feat-chv-opt-landing:登记条目带 tenant 字段(归属 标记,见 disk.record_snapshot);审计 detail 同样 带 tenant,便于事后核对谁的快照。""" task = task_or_name if isinstance(task_or_name, dict) \ else {"id": task_or_name} tenant = self._tenant_id(task) _entries, _removed = record_snapshot( self.state_dir, vm_name, snapshot_url, getattr(self, "snapshot_max_total_gb", 20.0), tenant_id=tenant) for _old in _removed: self._emit("vm_snapshot_pruned", task, "vm=%s pruned snapshot=%s(总量超帽,最旧优先)" % (vm_name, _old.get("url"))) self._emit("vm_snapshot", task, "vm=%s snapshot=%s tenant=%s" % (self.vm_name(task_or_name), snapshot_url, tenant)) return snapshot_url def _snapshot_strict_tenant(self) -> bool: """快照 tenant 严格校验是否生效(feat-chv-hybrid-a, DESIGN-HYBRID.md §2.2):登记必填,无 tenant_id 的快照 视为非法登记,恢复时拒绝、不补默认值。**默认严格**: 显式 CHV_SNAPSHOT_STRICT_TENANT=0 才关闭(legacy 环境 的显式逃生口);显式设 1 同样开启。旧 restore 测试 (test_chv_closeup RestoreSnapshotTest、test_chv_fix RestoreNoFallbackTest)的 fixture 已按此更新为登记带 tenant_id,不再依赖 legacy 警告放行。""" raw = self._env.get("CHV_SNAPSHOT_STRICT_TENANT") if raw == "0": return False return True def _deny_restore(self, task, reason: str) -> None: """严格模式的恢复拒绝(fail-closed):审计 vm_restore_denied 只带 host_id + 请求租户 + 原因,不带快照内容(不带 URL、 不带归属 tenant 值);错误码一律 snapshot_tenant_mismatch。""" requester = self._tenant_id(task) self._emit(VM_RESTORE_DENIED, task, "host_id=%s tenant=%s reason=%s" % (self._host_id(), requester, reason), SNAPSHOT_TENANT_MISMATCH) raise ChvFailure( SNAPSHOT_TENANT_MISMATCH, "快照 tenant 校验失败(%s),拒绝恢复:请求方 " "tenant=%s" % (reason, requester)) def _warn_restore_unmarked(self, task, snapshot_url, what: str) -> None: """未登记 / 登记无 tenant 的恢复警告(legacy 放行路径): 发 vm_restore_tenant_unmarked 审计警告,不拒绝。what 为 "unregistered"(未在登记中找到)或 "unmarked"(登记无 tenant 字段)。""" if what == "unregistered": detail = ("vm=%s snapshot=%s 未在登记中找到,归属无法" "核对(未登记快照,放行并警告)" % (self.vm_name(task), snapshot_url)) else: detail = ("vm=%s snapshot=%s 登记无 tenant 字段" "(legacy 旧快照,放行并警告)" % (self.vm_name(task), snapshot_url)) self._emit("vm_restore_tenant_unmarked", task, detail, "snapshot_tenant_unmarked") def _deny_cross_tenant_restore(self, task, snapshot_url, marked, requester) -> None: """跨租户恢复拒绝(legacy 路径的旧审计名):发 vm_restore_tenant_denied 审计并抛 snapshot_tenant_mismatch。""" self._emit("vm_restore_tenant_denied", task, "vm=%s snapshot=%s 归属 tenant=%s,请求方" " tenant=%s,拒绝跨用户恢复" % (self.vm_name(task), snapshot_url, marked, requester), SNAPSHOT_TENANT_MISMATCH) raise ChvFailure( SNAPSHOT_TENANT_MISMATCH, "快照归属 tenant=%s,与请求方 tenant=%s 不一致," "拒绝跨用户恢复:%s" % (marked, requester, snapshot_url)) def _check_snapshot_tenant(self, task, snapshot_url, strict: bool | None = None) -> None: """restore 的归属校验(feat-chv-opt-landing,阶段 1 必须补的代码项):按 URL 在全部 VM 的快照登记里找 条目;条目带 tenant 且与请求方 tenant 不一致时, 直接拒绝(ChvFailure snapshot_tenant_mismatch)并 发 vm_restore_tenant_denied 审计——A 的快照绝不能 恢复到 B 名下。条目无 tenant 是阶段 1 之前的 legacy 登记:放行但发 vm_restore_tenant_unmarked 审计警告 (单租户灰度下的旧快照,不静默当作已验);条目根本 未登记:同样放行 + 警告(未登记快照无从核对归属, 登记才是归属的唯一账本——生产须要求快照必登记, 见报告残留)。请求方无 tenant(None)而条目有 tenant 时同样拒绝(无法证明归属即 fail-closed)。 strict=True(feat-chv-hybrid-a,§2.2):登记必填—— 未登记 / 登记无 tenant / 归属不符一律拒绝 (snapshot_tenant_mismatch),审计 vm_restore_denied (带 host_id + 请求租户,不带快照内容),不补默认值。 strict=None 时按 _snapshot_strict_tenant 自动判定。""" if strict is None: strict = self._snapshot_strict_tenant() _owner_vm, entry = find_snapshot_entry( self.state_dir, snapshot_url) if entry is None: if strict: self._deny_restore(task, "unregistered") self._warn_restore_unmarked(task, snapshot_url, "unregistered") return marked = entry.get("tenant") if marked is None: if strict: self._deny_restore(task, "unmarked") self._warn_restore_unmarked(task, snapshot_url, "unmarked") return requester = self._tenant_id(task) if requester != marked: if strict: self._deny_restore(task, "mismatch") self._deny_cross_tenant_restore(task, snapshot_url, marked, requester) def _validate_restore(self, task, snapshot_url, strict_tenant: bool | None = None): """restore 前置校验:返回(归一化后的 snapshot_url, 归一化后的 net 条目)。 restore 不走 vm.create、没有 VmConfig 可挂 seed: 快照内已固化原 VM host key,重预置反而破坏钉死, 故 provisioned 下复用已钉死的 known_hosts、缺失 fail-fast、绝不 keyscan、绝不重生成。restore 时不 重复验快照文件存在(远端/对象存储源合法,ch-remote 本身会在源缺失时失败)。 feat-chv-cover100:restore 也走 H7 net 隔离——此前 restore 路径既不校验冲突、也不登记 net.json:已登 记 VM 的 tap/mac/ip 会被恢复出的 VM 静默复用,且 恢复出的 VM 自身不登记、后来的 wake 也看不到它, 两边都漏判。与 wake 的 _wake_prechecks 同款:冲突 在起 VMM 前拒绝(net_tap_conflict),登记失败见 _restore_boot(P2-19 语义)。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") if not snapshot_url: raise ValueError("snapshot_url 不能为空") if "://" not in snapshot_url: snapshot_url = "file://%s" % snapshot_url validate_snapshot_url( snapshot_url, self.state_dir, snapshot_dir=getattr(self, "snapshot_dir", None), allow_remote=getattr(self, "allow_remote_snapshot", False)) self._check_snapshot_tenant(task, snapshot_url, strict=strict_tenant) self._check_snapshot_disk(snapshot_url) self.preflight() self.resolve_ssh_host() if self._provisioned_key_guard(task): raise ChvFailure( "host_key_not_pinned", "provisioned 模式下 restore 缺 known_hosts,拒绝 " "keyscan 回落:VM %s" % self.vm_name(task)) return snapshot_url, self._validate_restore_net(task) def _validate_restore_net(self, task): """restore 的 net 隔离段(_validate_restore 的尾段): 与 wake 的 _wake_prechecks 同款,冲突在起 VMM 前 拒绝(net_tap_conflict);net 未配即空列表。""" if not self.net_json: return [] try: return validate_net_entries( parse_net_entries(self.net_json), self._registered_net_entries( exclude_vm=self.vm_name(task))) except ChvFailure as exc: self._emit("vm_failed", task, "vm=%s restore 前置失败 kind=%s" % (self.vm_name(task), exc.kind), exc.kind) raise def _check_snapshot_disk(self, snapshot_url): """restore 前的快照磁盘预检(feat-chv-v3,hibernate 评估结论):快照 config.json 引用原任务盘路径, 盘缺失时 restore 必在 ch-remote 侧失败;起 VMM 前 先 fail-fast(snapshot_disk_missing),不让调用方 拿一个含糊的 ch-remote 失败去猜。只对本地目录快照 且 config.json 可读可解析时校验:远端源、旧格式 无 config.json、解析失败时跳过(ch-remote 本身会 在源缺失时失败,不在这里多一层误拒)。""" if not snapshot_url.startswith("file://"): return snap_dir = snapshot_url[len("file://"):] if not os.path.isdir(snap_dir): return config_path = os.path.join(snap_dir, "config.json") try: with open(config_path, "r") as fh: config = json.load(fh) except (OSError, ValueError): return if not isinstance(config, dict): return disks = config.get("disks") if not isinstance(disks, list): return missing = [] for disk in disks: if not isinstance(disk, dict): continue path = disk.get("path") if isinstance(path, str) and path \ and not os.path.exists(path): missing.append(path) if missing: raise ChvFailure( "snapshot_disk_missing", "快照引用的任务盘缺失,restore 无法进行:%s" % ", ".join(missing)) def _restore_prepare(self, task, name): """restore 准备(feat-chv-v3,与 _wake_prepare 对等): 杀旧 VMM(登记的 + 未登记跨实例的),清旧 socket。 restore 须落到全新 VMM 上:旧 VMM 还活着时新 VMM 抢不到 tap/socket,ch-remote restore 会被旧 VM 拒 掉(2026-11-07 WSL 真机 V-3 实测)。""" socket_path = self.api_socket(task) self._drop_registered_proc(name) self._kill_vmm_by_socket(socket_path) try: if os.path.exists(socket_path): os.remove(socket_path) except OSError: pass def _restore_boot(self, task, snapshot_url, net_entries): """restore 的启动段:起 VMM → 等 ping → restore → 显式 resume → 等 SSH 就绪(恢复后默认 paused,不 resume 就不算醒)。""" name = self.vm_name(task) self._restore_prepare(task, name) self._launch_api_only(task) self.wait_api_ready(task) self._check_api_socket_permissions(self.api_socket(task)) # feat-chv-opt-landing(阶段 2):restore 触 tap,停机 # →恢复窗口的 tap 释放竞态在此处报 Resource busy; # 等 tap 空闲后重试一次,不立即失败(见 _retry_on_tap_busy)。 self._retry_on_tap_busy( task, lambda: self._ch_remote(task, "restore", "source_url=%s" % snapshot_url), "restore") # feat-chv-cover100:与 _boot_vm 同款——恢复成功后登记 # net.json,登记失败即拒恢复(P2-19 语义:审计 + # 返回 False + 调用方 ChvFailure);restore_vm 的 # except 会 stop_vm 清掉半恢复 VM。 if net_entries and not self._record_net_entries( name, net_entries): raise ChvFailure( "net_registry_write_failed", "vm=%s net 登记写失败,拒绝恢复(缺登记会" "漏判 tap/mac/ip 冲突)" % name) self._emit("vm_restore", task, "vm=%s snapshot=%s" % (name, snapshot_url)) self.resume_vm(task) self.wait_guest_ready(task) def restore_vm(self, task: dict, snapshot_url: str) -> dict: """快照恢复唤醒:校验 → 只起 VMM → restore → 显式 resume → 等 SSH 就绪。恢复后默认 paused,不 resume 就不算醒。任一步失败都关 VM 再抛;成功返回 {vm, api_socket, state: "running", ready: True, mode: "restored"}。恢复前校验 net 冲突、恢复成功后 登记 net.json(与 wake 的 H7/P2-19 同款,见 _validate_restore/_restore_boot)。""" snapshot_url, net_entries = self._validate_restore( task, snapshot_url) name = self.vm_name(task) self._check_capacity(exclude=name) try: self._restore_boot(task, snapshot_url, net_entries) except Exception: try: # feat-chv-opt-landing(阶段 2,§6.2 遗留缺陷): # 失败路径原先 stop_vm(task)(keep_disk=False) # 会删 seed.iso,而快照 config.json 的 disks # 同时引用 disk.raw 与 seed.iso,删了之后任何 # 重试 restore 都报 seed.iso ENOENT——失败清理 # 毁掉了恢复输入。现改 keep_disk=True:任务盘 # 与 seed.iso 都留,重试成为可能;残留盘由 # GC(有登记快照的受保护、无登记的按孤儿清) # 与显式回收接手,不长期滞留。 self.stop_vm(task, keep_disk=True) except Exception: pass raise return {"vm": name, "api_socket": self.api_socket(task), "state": "running", "ready": True, "mode": "restored"} def hibernate_vm(self, task_or_name: dict | str, snapshot_url: str) -> dict: """休眠(CHV 无原生 hibernate 端点,等价实现): pause + snapshot + shutdown。休眠后文件与 worktree 保留在快照里(快照是暂停时刻的完整状态),临时文件 不在恢复保证内(见 recycle_vm 的说明)。 成功返回 {vm, state: "hibernated", snapshot: ...}。 本方法明写“等价实现”,绝不声称接了原生 hibernate。 feat-chv-v3(V-3 附带评估,结论:必须留盘): 快照 config.json 引用原任务盘路径(state_dir// disk.raw),stop_vm 默认会删盘——修前 hibernate 的 stop 直接删掉了自己快照的输入,restore 时盘已不在、 必失败。所以休眠的 stop 用 keep_disk=True 留盘; 留下的盘由 gc_stale_disks 的快照登记保护(有登记 快照的 VM 不 GC 盘),快照被剪干净后盘才成孤儿可删。 restore 成功后 VM 再走正常 stop,盘照常删,不留 长期残留。 feat-chv-v3-repair(V-7/D2):keep_disk=True 同样留 seed.iso——config.json 的 disks 同时引用 disk.raw 与 seed.iso(readonly),只留盘不留 seed.iso 时 restore 仍在服务端失败(Cannot open disk path …/seed.iso), 预检 snapshot_disk_missing 先拒掉不是过严;有登记 快照的 VM 的 seed.iso 也不被 cleanup_stale_secrets 当孤儿清掉(见 _clean_one_vm_secrets)。""" self.pause_vm(task_or_name) self.snapshot_vm(task_or_name, snapshot_url) self.stop_vm(task_or_name, keep_disk=True) # to10:删死赋值(task 建出后未用,F841;返回值直接 # 用 task_or_name 算 VM 名,行为不变)。 return {"vm": self.vm_name(task_or_name), "state": "hibernated", "snapshot": snapshot_url} def wake_from_hibernate(self, task: dict, snapshot_url: str) -> dict: """从休眠唤醒:即 restore_vm(restore + 显式 resume + SSH 就绪),语义与 restore_vm 完全一致,只是名字 说明来源是休眠快照。""" return self.restore_vm(task, snapshot_url) def _stop_registered_proc(self, name, proc): """清登记进程:已死直接清,活着先杀再清(纯移动)。""" if proc is None: return with self._vms_lock: self._vms.pop(name, None) try: alive = proc.poll() is None except Exception: alive = False if alive: self._kill_proc(proc) def _remove_vm_seed_files(self, name, keep_disk): """删 seed 目录 + 宿主私钥 + seed.iso(stop 的第一块 清理,见 _cleanup_vm_files):keep_disk=True 时 seed.iso 留——快照 config.json 的 disks 含 seed.iso(readonly), 删了 restore 必在服务端失败(Cannot open disk path …/seed.iso / No such file or directory),预检 snapshot_disk_missing 不是过严(V-7/D2)。seed 目录 与宿主私钥仍删,config.json 不引用它们。""" seed_dir = os.path.join(self.state_dir, name, "seed") if os.path.isdir(seed_dir): shutil.rmtree(seed_dir) for _suffix in ("ssh_host_ed25519_key", "ssh_host_ed25519_key.pub", "seed.iso"): if keep_disk and _suffix == "seed.iso": continue _p = os.path.join(self.state_dir, name, _suffix) if os.path.exists(_p): os.remove(_p) def _persistent_record_for_vm(self, name): """该 VM 是否被某租户的持久登记认领:读 tenant.json 得租户,再读该租户的持久登记、核对 vm 名一致。 返回登记 dict 或 None(未认领:冷路径 VM 或旧 VM, stop/GC 按旧语义处理)。""" tenant = read_vm_tenant(self.state_dir, name) if not tenant: return None record = read_persistent_vm(self.state_dir, tenant) if record is not None and record.get("vm") == name: return record return None def _overlay_disk_protected(self, name) -> bool: """该 VM 的任务盘是否受保护(stop/GC 不删): 有持久登记认领,或有登记快照(hibernate 的 restore 输入)。不分模式(2026-11-07:copy 模式也要能跑持 久 VM;无登记/无快照的盘仍按旧语义删除)。 数据盘 data.raw 永远不受 GC/stop 删除(另有显式 回收入口),本方法不覆盖它。""" if self._persistent_record_for_vm(name) is not None: return True return has_recorded_snapshots(self.state_dir, name) def _cleanup_vm_task_disk(self, name, task, keep_disk) -> bool: """stop 的任务盘删除块(_cleanup_vm_files 的中段): keep_disk 或受保护盘(持久登记认领/有登记快照)即 跳过;删掉返回 False(无失败),删失败记审计并返回 True(脏标记由调用方写)。保护不分模式(2026-11-07: copy 模式也要能跑持久 VM;无登记/无快照的 copy 盘 仍按旧语义删除,只有被认领/被快照引用的盘才留)。""" _protected = self._overlay_disk_protected(name) try: if not keep_disk and not _protected \ and delete_task_disk(self.state_dir, name): self._emit("vm_disk_deleted", task, "vm=%s disk deleted on stop" % name) except Exception as _disk_exc: self._emit("vm_disk_delete_failed", task, "vm=%s error=%s" % (name, _disk_exc)) return True return False def _cleanup_vm_files(self, name, task, keep_disk=False): """stop 的三块文件清理:seed/私钥、任务磁盘、net 登记。 任一块失败记对应审计并返回 True(脏标记由调用方写); seed/私钥与磁盘删除的审计名与文案逐字不变。 keep_disk=True 时跳过任务盘删除,并跳过 seed.iso 删除(hibernate 专用:快照 config.json 引用原盘路径 与 seed.iso,任一删了 restore 即失败——见 hibernate_vm 的评估注释与 V-7/D2;seed 目录与宿主 私钥仍删,config.json 不引用它们,私钥明文已嵌进 seed.iso、宿主侧不留)。 feat-chv-opt-landing:overlay 模式下,任务盘是持久 用户盘——该 VM 有持久登记认领或有登记快照时,stop 不删 overlay(删了等于销毁用户 VM / 毁掉快照输入); 删盘只走显式回收(recycle_persistent_vm)。无认领 又无快照的 overlay VM(冷路径残留)仍按旧语义删。""" from cloud.chv.hostkeys import dirty_marker_path # noqa: F401 failed = False try: self._remove_vm_seed_files(name, keep_disk) except Exception as _cleanup_exc: failed = True self._emit("vm_key_cleanup_failed", task, "vm=%s error=%s" % (name, _cleanup_exc)) # feat-chv-opt-landing:overlay 模式下受保护的盘(持久 # 登记认领或有登记快照)stop 不删;copy 模式一律旧语义。 # 见 _cleanup_vm_task_disk。 if self._cleanup_vm_task_disk(name, task, keep_disk): failed = True try: _net_path = os.path.join(self.state_dir, name, "net.json") if os.path.exists(_net_path): os.remove(_net_path) except Exception as _net_exc: # feat-chv-closeup(P2-17):net.json 删失败 # 此前只记 failed=True、无独立审计——删不掉的 # 登记会让后来的 wake 误判该 tap/mac/ip 仍被 # 占用(误拒)或与脏标记混在一起查不出;现发 # 独立审计 vm_net_registry_delete_failed。 failed = True self._emit("vm_net_registry_delete_failed", task, "vm=%s error=%s" % (name, _net_exc)) self._clear_guest_env_delivered(name) return failed def _write_dirty_marker(self, name, failed): """M2 脏标记:清理失败写标记、成功清标记(P2-17 收尾)。 返回 True=标记状态已如愿落盘(失败时标记已写、 成功时标记已清/本来就没有),False=写/删标记 本身失败。失败时发独立审计 dirty_marker_write_failed + error 日志,不再全静默——标记写不下去时拒起链 本身就不存在(provision/wake 读不到标记),部署 必须保证 state_dir 可写(preflight 的 0700 门) 并靠审计告警发现;stop 路径仍不抛错(stop_vm “尽力而为、不抛异常”的契约不变,False 只作 调用方可观测的信号)。写路径走 atomic_file (P2-18):随机 tmp 名 + 双 fsync。 """ from cloud.chv.hostkeys import dirty_marker_path try: _marker = dirty_marker_path(self.state_dir, name) if failed: os.makedirs(os.path.dirname(_marker), exist_ok=True) atomic_write_text(_marker, "cleanup failed\n") elif os.path.exists(_marker): os.remove(_marker) return True except Exception as exc: _LOGGER.error("chv_dirty_marker_write_failed vm=%s " "failed=%s error=%s", name, failed, exc) self._emit("dirty_marker_write_failed", None, "vm=%s 脏标记写失败(failed=%s):%s" % (name, failed, exc), "dirty_marker_write_failed") return False def stop_vm(self, task_or_name: dict | str, keep_disk: bool = False) -> bool: """停止 VM(尽力而为,不抛异常):已死进程直接清登记, 已关 VM 仍尽力调一次 ch-remote(返回 False),socket 残留删 socket(返回 True)。ch-remote shutdown 失败 不炸,但命令本身仍会发出(测试据此断言关闭路径)。 feat-chv-v3(F2):新实例的 _vms 里无登记时,旧 VMM 也要按 socket 进程扫描清掉(_kill_vmm_by_socket), 否则 socket/磁盘已删、VMM 空壳进程仍活着占 tap。 keep_disk=True 时不删任务盘与 seed.iso(hibernate 专用, 见 hibernate_vm:快照 config.json 的 disks 同时引用 两者)。""" name = self.vm_name(task_or_name) socket_path = os.path.join(self.state_dir, name + ".sock") with self._vms_lock: had_proc = name in self._vms proc = self._vms.get(name) had = had_proc or os.path.exists(socket_path) try: self.runner([self.ch_remote, "--api-socket=%s" % socket_path, "shutdown"]) except Exception: pass self._stop_registered_proc(name, proc) self._kill_vmm_by_socket(socket_path) try: if os.path.exists(socket_path): os.remove(socket_path) except Exception: pass # per-VM seed 目录与 host 私钥随 VM 关闭而删,绝不留给 # 下一个 VM 复用;known_hosts 公钥保留供事后审计。 # 三块清理独立记账 + 脏标记(H3/M2,见 _cleanup_vm_files)。 _task = task_or_name if isinstance(task_or_name, dict) \ else {"id": task_or_name} _cleanup_failed = self._cleanup_vm_files(name, _task, keep_disk=keep_disk) self._write_dirty_marker(name, _cleanup_failed) task = task_or_name if isinstance(task_or_name, dict) \ else {"id": task_or_name} if had: self._emit("vm_shutdown", task, "vm=%s" % name) self._emit("vm_destroy", task, "vm=%s" % name) _LOGGER.info("chv_vm_stop vm=%s had=%s", name, had) return had def gc_stale_disks(self) -> dict: """残留盘 GC(H3):扫描 state_dir/*/disk.raw 与 disk.qcow2,VM 非 live(无 api-socket 文件且无登记 活进程)即删盘并审计;删失败审计 vm_disk_delete_failed 并计入 failed。返回 {deleted, failed}。 feat-chv-v3:有登记快照的 VM 不 GC 盘——那是休眠 VM 的盘,restore 还要读它(见 hibernate_vm)。 feat-chv-opt-landing:有持久登记认领的 VM 同样不 GC(那是用户的持久盘);数据盘 data.raw 永不进 GC(用户数据,只走显式回收 recycle_persistent_vm)。""" deleted: list = [] failed: list = [] try: entries = os.listdir(self.state_dir) except Exception: return {"deleted": deleted, "failed": failed} for entry in entries: if not entry.startswith("chv-"): continue if not task_disk_paths(self.state_dir, entry): continue if has_recorded_snapshots(self.state_dir, entry): continue if self._persistent_record_for_vm(entry) is not None: continue live = os.path.exists( os.path.join(self.state_dir, entry + ".sock")) with self._vms_lock: proc = self._vms.get(entry) if proc is not None: try: live = live or proc.poll() is None except Exception: pass if live: continue try: delete_task_disk(self.state_dir, entry) deleted.append(entry) self._emit("vm_disk_gc", {"id": entry}, "vm=%s stale disk deleted" % entry) except Exception as exc: failed.append(entry) self._emit("vm_disk_delete_failed", {"id": entry}, "vm=%s gc error=%s" % (entry, exc)) return {"deleted": deleted, "failed": failed} def _is_live_vm(self, entry): """VM 是否活着:socket 存在或登记进程未死即活。""" if os.path.exists(os.path.join(self.state_dir, entry + ".sock")): return True with self._vms_lock: proc = self._vms.get(entry) if proc is not None: try: return proc.poll() is None except Exception: pass return False def _clean_one_vm_secrets(self, entry, vm_dir): """单 VM 残留清理:私钥一律删(live VM 私钥在 seed 盘 建好后即删,残留只可能是崩溃遗留);非 live VM 的 seed.iso/seed 目录同理删,但有登记快照的 VM 除外 (feat-chv-v3-repair,V-7/D2:那是休眠 VM 的 restore 输入,seed.iso 被快照 config.json 的 disks 引用,与 gc_stale_disks 的盘保护同款逻辑;私钥仍删,restore 不读宿主私钥文件)。返回 (删私钥, 删 seed)。""" removed_priv = False priv = os.path.join(vm_dir, "ssh_host_ed25519_key") if os.path.exists(priv): try: os.remove(priv) removed_priv = True except Exception: pass removed_seed = False if not self._is_live_vm(entry) \ and not has_recorded_snapshots(self.state_dir, entry): for _suffix in ("seed.iso", "seed"): _p = os.path.join(vm_dir, _suffix) try: if os.path.isdir(_p): shutil.rmtree(_p) removed_seed = True elif os.path.exists(_p): os.remove(_p) removed_seed = True except Exception: pass return removed_priv, removed_seed def cleanup_stale_secrets(self) -> list: """崩溃残留扫描(H5):私钥一律删、非 live VM 的 seed 同理删并审计;有登记快照的 VM 的 seed 不删(V-7/D2, 休眠 VM 的 restore 输入,见 _clean_one_vm_secrets); 返回已清理 VM 名。worker 启动与 cleanup_stale_vms 时调它,不依赖 socket 是否存在。""" cleaned: list = [] try: entries = os.listdir(self.state_dir) except Exception: return cleaned for entry in entries: if not entry.startswith("chv-"): continue vm_dir = os.path.join(self.state_dir, entry) if not os.path.isdir(vm_dir): continue removed_priv, removed_seed = \ self._clean_one_vm_secrets(entry, vm_dir) if removed_priv or removed_seed: cleaned.append(entry) self._emit("host_secret_residue_cleaned", {"id": entry}, "vm=%s private_key=%s seed=%s" % (entry, removed_priv, removed_seed)) return cleaned def shutdown_vm(self, task_or_name: dict | str) -> bool: """关闭入口的正式名:即 stop_vm(ch-remote shutdown)。 任务结束/失败/取消时调它。""" return self.stop_vm(task_or_name) def cancel_vm(self, task_or_name: dict | str) -> bool: """取消入口:即 shutdown_vm。worker 的取消路径对 Docker 调 kill_container,对 CHV 调本方法——取消绝不 只改数据库状态,必须真关 VM。""" return self.shutdown_vm(task_or_name) def sync_guest_to_gitea(self, task: dict, extra_env: dict | None = None) -> tuple[bool, str]: """回收前把客内已提交的工作区改动 push 回 Gitea (DESIGN-HYBRID.md §3.3 回收流程第 1 步)。 复用客内 chv-sync 的 push 腿(chv-guest/chv-sync.sh): 经 SSH 跑 `chv-sync push`;非 fast-forward 即失败, 绝不 --force、不自动 rebase(与 chv-sync.sh 的 fail-closed 语义同款)。返回 (ok, detail): - 未配 Gitea 同步(extract_gitea_sync_env 为空)→ (True, "no gitea sync configured"),无物可推, 不拦回收; - VM 已不运行 → (True, "vm not running"):死掉的 客机没有可 push 的活工作树,拦回收也推不出去, 不拿已死的 VM 卡清理(其盘仍在 state_dir 里, 由显式回收/GC 按原口径处理); - push rc==0 → (True, ...);rc!=0 或 SSH 本身失败 → (False, 末行输出截断 200 字符)。chv-sync 的 remote URL 是干净 https、token 只走 credential store 文件,所以输出里没有 token(与 syncd 的 _run_sync 同款截断口径)。 边界(诚实写明):push 只推已提交的提交,未提交的 工作树改动不参与同步——与客内 syncd 常驻循环同款 口径,本方法不自动 commit(自动 commit 是新语义, 设计未定);同步失败的判定只看 push 本身。 本方法不重试:push 是幂等的,失败后调用方 (recycle_vm / 人工)重新调回收即重试。 """ # 方法内 import:与 wake_vm 同款,避免顶层循环导入。 from cloud.chv.gitea_sync import extract_gitea_sync_env sync = extract_gitea_sync_env( task, extra_env, config_sync=getattr(self, "gitea_sync_env", None)) if not sync: return True, "no gitea sync configured" if not self._vm_is_running(task): return True, "vm not running: nothing to sync" try: ssh_cmd = self.build_ssh_command(task, ["chv-sync", "push"]) result = self._call_runner(ssh_cmd, timeout=self.task_timeout) except Exception as exc: return False, "ssh failed: %s" % str(exc)[:200] if getattr(result, "returncode", 1) == 0: return True, "chv-sync push ok" tail = ((getattr(result, "stdout", "") or "") + (getattr(result, "stderr", "") or "")).strip() tail = tail.splitlines()[-1] if tail else "" return False, "chv-sync push failed: %s" % tail[:200] def _emit_recycle_sync_failed(self, task, vm, reason): """回收同步失败的审计(§3.3“同步失败记审计”): 事件 vm_recycle_sync_failed(见 hybrid_events), 带 host_id + vm + tenant_id(有则带),绝不带密钥值。 审计本身 best-effort:格式化失败时退化成裸 vm 明细, 绝不因此掩盖 sync_failed 的返回。""" task_dict = task if isinstance(task, dict) else {"id": task} try: from cloud.chv.hybrid_events import ( VM_RECYCLE_SYNC_FAILED, format_detail) fields = {"vm": vm, "reason": reason} tenant = self._tenant_id(task_dict) if tenant: fields["tenant_id"] = tenant detail = format_detail(VM_RECYCLE_SYNC_FAILED, fields) except Exception: detail = "vm=%s reason=%s" % (vm, str(reason)[:200]) try: self._emit(VM_RECYCLE_SYNC_FAILED, task_dict, detail, kind="sync_failed") except Exception: pass @staticmethod def _sync_failed_result(vm, detail, deleted_disks=None): """同步失败的回收返回(§3.3):state/status 均为 sync_failed,VM 保留。recycle_vm 与 recycle_persistent_vm 共用,免得两处拼同一份 dict; 各自再补自己的字段(preserved/lost 或 deleted_disks)。""" out = {"vm": vm, "state": "sync_failed", "status": "sync_failed", "sync_ok": False, "sync_error": detail, "vm_retained": True} if deleted_disks is not None: out["deleted_disks"] = deleted_disks return out def _recycle_sync_gate(self, task, extra_env=None): """回收同步门(§3.3 新增语义的共用判定): 同步失败 → (False, detail);可回收 → (True, detail)。 调用方在 False 时必须不 shutdown、不删盘、不发 recycled 审计,只记 sync_failed 审计并返回 state/status 均为 sync_failed 的结果。""" ok, detail = self.sync_guest_to_gitea(task, extra_env=extra_env) if ok: return True, detail self._emit_recycle_sync_failed(task, self.vm_name(task), detail) return False, detail def recycle_vm(self, task: dict, extra_env: dict | None = None, sync: bool = True) -> dict: """回收 VM(任务结束后的销毁语义): 先 shutdown,再返回保留/丢失清单。保留的只有宿主侧 的 task_checkpoints / task_attempts(数据库里的断点与 尝试账本,VM 销毁不影响它们,goal 断点续跑据此接续); 丢失的是客机内的临时文件与未拷出的 worktree 临时物 ——recycle 绝不声称“恢复临时文件”。需要拷出的工件 必须在 shutdown 之前由调用方经 SSH 拷出,本方法不 假装替调用方拷了。 C 轨新增(DESIGN-HYBRID.md §3.3):sync=True(默认) 时先过回收同步门——配了 Gitea 同步且 `chv-sync push` 失败时,任务标同步失败(返回 state/status 均为 sync_failed,sync_ok=False,vm_retained=True),VM 不回收、不复用,等人工/重试,绝不丢改动后静默回收; 审计记 vm_recycle_sync_failed。未配同步、VM 已死、 或 sync=False(调用方显式跳过)时走旧回收路径。 """ if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") if sync: ok, sync_detail = self._recycle_sync_gate( task, extra_env=extra_env) if not ok: out = self._sync_failed_result(self.vm_name(task), sync_detail) out["preserved"] = ["task_checkpoints", "task_attempts"] out["lost"] = [] return out else: sync_detail = "sync skipped by caller" self.shutdown_vm(task) self._emit("vm_recycled", task, "vm=%s" % self.vm_name(task)) return { "vm": self.vm_name(task), "state": "recycled", "preserved": ["task_checkpoints", "task_attempts"], "lost": ["guest /tmp", "guest worktree temp files"], "sync_ok": True, "sync_detail": sync_detail, "vm_retained": False, } def _stop_dead_socket_vms(self, entries): """socket 扫描:登记进程仍活的跳过,其余走 stop_vm。 返回已清理 VM 名;非 chv- 前缀 socket 一律跳过。""" cleaned = [] for entry in entries: if not entry.endswith(".sock"): continue name = entry[:-len(".sock")] if not name.startswith("chv-"): continue proc = self._vms.get(name) if proc is not None: try: alive = proc.poll() is None except Exception: alive = False if alive: continue if self.stop_vm(name): cleaned.append(name) return cleaned def _purge_dead_registry(self): """登记进程已死但 socket 已被删的登记项顺手清掉 (没有 socket 可删,不计入清理数)。""" for name in list(self._vms.keys()): proc = self._vms.get(name) try: dead = proc is not None and proc.poll() is not None except Exception: dead = True if dead: self._vms.pop(name, None) def cleanup_stale_vms(self) -> list: """清理残留 VM:先跑残留盘 GC 与残留密钥扫描,再走 socket 扫描与登记清理。只清 socket 残留与已死进程 对应的 VM;state_dir 不存在返回 [],绝不抛异常。""" try: self.cleanup_stale_secrets() except Exception: pass try: self.gc_stale_disks() except Exception: pass try: entries = os.listdir(self.state_dir) except Exception: return [] cleaned = self._stop_dead_socket_vms(entries) self._purge_dead_registry() return cleaned # -- 客内执行 ------------------------------------------------------ def _run_guest(self, task, inner_args, extra_env=None, vm_name=None, **kwargs): """SSH 调客内 host.py,返回 Local/Docker 同款结果 dict (executor 字段改写为 cloud-hypervisor,并附 vm 名与 api-socket)。runner 异常即原样传播,由 worker 侧统一 写回 failed——本类不在这里把异常吞成假成功。 extra_env(P1-1):经 SSH env 前缀进客机环境。 feat-chv-opt-landing:vm_name 给出时 SSH 目标是该 VM(持久 VM 路径:任务 id ≠ VM 名,host 任务名仍按 任务 id 派生);不给时目标即 vm_name(task),与旧 行为一致。""" self._validate() vm_task = {"id": vm_name} if vm_name else task ssh_cmd = self.build_ssh_command( vm_task, self._guest_host_command(*inner_args, **kwargs), extra_env=extra_env) result = self._call_ssh_with_transient_retry( task, ssh_cmd, timeout=self.task_timeout) # 方法内 import:见模块头注释的循环导入说明。 from cloud.sandbox import SimpleNamespaceShim, _parse_host_result parsed = _parse_host_result(task["id"], SimpleNamespaceShim(result)) parsed["executor"] = "cloud-hypervisor" parsed["vm"] = self.vm_name(vm_task) parsed["api_socket"] = self.api_socket(vm_task) return parsed def _goal_result(self, task_id, result): """客内 host.py goal 输出解析:直接委托给三执行器共享 的 parse_goal_result(cloud/sandbox.py,Local 的 _goal_result_from_runner 同款正典语义),executor 字段为 cloud-hypervisor。 本轨对齐说明(#216 的 CHV 与正典有两处分叉,本轨 收敛,PR 描述里明写):① 非 0 退出但已打印 met 结论 时,正典判 failed(结论不可信),CHV 旧代码判 completed;② 输出含“已存在”撞名时,正典判 failed 并提示“用继续而非重试”,CHV 旧代码判 interrupted。 其余分支(met/unmet、interrupted 标记、永久错误、 无结论异常退出)本来就一致,不变。""" from cloud.sandbox import parse_goal_result return parse_goal_result(task_id, result, "cloud-hypervisor") def _run_guest_goal(self, task, inner_args, extra_env=None, vm_name=None, **kwargs): self._validate() vm_task = {"id": vm_name} if vm_name else task ssh_cmd = self.build_ssh_command( vm_task, self._guest_host_command(*inner_args, **kwargs), extra_env=extra_env) return self._goal_result( task["id"], self._call_ssh_with_transient_retry( task, ssh_cmd, timeout=self.task_timeout)) def _vm_proc_dead(self, task_or_name): """该 VM 的登记进程是否已死:只认本实例登记的进程 (poll() 非 None 即死);未登记时返回 False(不认 socket 残留——崩掉的 VMM 也会留下 socket 文件,单凭 socket 在不能断定 VM 活着还是死了,中途死亡判定只 用登记进程这个硬证据)。""" name = self.vm_name(task_or_name) with self._vms_lock: proc = self._vms.get(name) if proc is None: return False try: return proc.poll() is not None except Exception: return False def _vm_is_running(self, task_or_name): """execute() 的“VM 在运行”判定:本实例登记了活进程, 或 api-socket 文件存在(带外已醒的 VM,例如另一进 程 restore 的,登记不在本实例里)。两者皆无即未运行。""" name = self.vm_name(task_or_name) with self._vms_lock: proc = self._vms.get(name) if proc is not None: try: if proc.poll() is None: return True except Exception: pass return os.path.exists(self.api_socket(task_or_name)) def _vm_death_result(self, task, goal, evidence): """VM 中途死亡的 interrupted 结果:goal 形态带 goal_met/goal_error(断点可续跑),非 goal 形态带 vm_dead 标记与原因,status 一律 interrupted,绝不 笼统 failed。""" reason = "VM 中途死亡,任务未完成、断点可续跑:%s" \ % (evidence or "客机连接在任务进行中丢失") if goal: return { "executor": "cloud-hypervisor", "task_id": task["id"], "status": "interrupted", "stdout": "", "stderr": evidence or "", "returncode": None, "host_status": None, "goal_met": None, "goal_error": reason, "usage": None, "vm": self.vm_name(task), "vm_dead": True, } return { "executor": "cloud-hypervisor", "task_id": task["id"], "status": "interrupted", "stdout": "", "stderr": evidence or "", "returncode": None, "host_status": None, "vm": self.vm_name(task), "vm_dead": True, "interrupted_reason": reason, } def _run_with_vm(self, task, inner_args, goal=False, extra_env=None, **kwargs): """每任务 VM 生命周期(冷启动正式路径,feat-chv-arch95 定案):wake_vm(API 模式 + SSH 就绪)→ 跑客内命令 → finally shutdown_vm。本方法绝不查 WarmPool:预热池是 实验性的(见 experimental_pool.py 头定案与 ADR-5),dispatch 永远 冷启动,warm_pool_wired_to_dispatch 恒 False。 wake 失败时不跑 任务、直接抛分类失败。本方法不做内部重试:重试是 worker 的 max_retries 语义,执行器内部重试会把一次 失败放大成多次计费与多次 VM 启动。唯一例外:首条任务 SSH 的连接建立阶段瞬时 reset 由 _run_guest 经 _call_ssh_with_transient_retry 重试 1 次(传输抖动,非任务重试)。 中途死亡分类(本轨新增):客内命令抛传输异常、或 非 goal 结果已 failed 时,若登记的 VMM 进程已死, 判 interrupted(断点可续跑)而不是笼统 failed 或 原样抛异常;VM 还活着时的异常/失败原样处理,不 误判。goal 结果本身已有 interrupted 分类(见 parse_goal_result),这里不再二次改写 goal 结论。 feat-chv-secfix(H2):extra_env 透传给 wake_vm(密钥 子集在 provisioned 模式下随 seed 投递),再透传给 _run_guest(远程命令 source 客内密钥文件)。 feat-chv-hybrid-b:wake 之前先走 _hybrid_pool_preamble (续期 + 网关门 + 排队 + 池任务记数)。""" self._hybrid_pool_preamble(task, kwargs.get("model_provider")) self.wake_vm(task, extra_env=extra_env) try: return self._run_guest_with_death_classification( task, inner_args, goal, extra_env, kwargs) finally: try: self.shutdown_vm(task) except Exception: pass def _run_guest_with_death_classification(self, task, inner_args, goal, extra_env, kwargs): """客内执行 + 中途死亡分类(_run_with_vm 的一段,拆出 只为满足 50 行函数上限):客内命令抛传输异常、或非 goal 结果已 failed 时,若登记的 VMM 进程已死,判 interrupted(断点可续跑);VM 还活着时的异常/失败 原样处理,不误判。""" try: if goal: return self._run_guest_goal( task, inner_args, extra_env=extra_env, **kwargs) result = self._run_guest(task, inner_args, extra_env=extra_env, **kwargs) except Exception as exc: if self._vm_proc_dead(task): return self._vm_death_result( task, goal, str(exc)[:500]) raise if not goal and result.get("status") == "failed" \ and self._vm_proc_dead(task): result = dict(result) result["status"] = "interrupted" result["vm_dead"] = True result["interrupted_reason"] = ( "VM 中途死亡,任务未完成、断点可续跑:" "客内命令已 failed 且登记的 VMM 进程已死") return result def execute(self, task: dict) -> dict: """只读执行:客内 host.py status,不直接跑模型回合 (与 Local/Docker 的 execute 同款语义)。 execute 语义二选一结论(本轨落代码):只用于已运行 VM 的只读 status。VM 未运行时报分类失败 ChvFailure(kind=vm_not_running),绝不为一次 status 起一台 VM,也绝不向不存在的端口发 SSH 再拿连接 失败冒充任务状态。需要跑任务时调 dispatch/run/goal, 它们自带 wake_vm。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") if not self._vm_is_running(task): raise ChvFailure( "vm_not_running", "execute() 只读已运行 VM 的 status:VM %s 未运行," "请先 wake_vm() 再调 execute(),不要用 execute " "唤醒 VM" % self.vm_name(task)) try: return self._run_guest( task, ("status", "--task", self._host_name(task))) except Exception as exc: # feat-chv-fix(P1-8):socket 残留但 VMM 已死时, # _vm_is_running 会因 socket 存在而放行,随后的 # SSH 失败不得以原始传输异常抛出——登记进程已死 # 或 socket 已消失即统一归类 vm_not_running;VM # 仍活着时的 SSH 失败原样传播(那是客内 status # 失败,不是 VM 未运行)。 if self._vm_proc_dead(task) \ or not os.path.exists(self.api_socket(task)): raise ChvFailure( "vm_not_running", "execute() 时 VM 已死(socket 残留/进程已死)," "SSH 失败归类为未运行:%s" % exc) raise def dispatch_task(self, task: dict, extra_env: dict | None = None, model_provider: str | None = None) -> dict: """客内 host.py dispatch:建 host 任务并跑一轮 turn。 混合模式接线(集成时补,设计 §9.1 第 1 项):warm_pool_size>=1 时任务入口走混合路由(dispatch_task_hybrid,按 tier + 租约 状态分流,常驻/池三路);==0 时保持旧冷启语义。不成环: dispatch_task_hybrid 的 <=0 分支退化为 dispatch_task_persistent, 后者 <=0 时调回本方法——本方法只在 >=1 时分流。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") if not task.get("prompt"): raise ValueError("任务缺少 prompt") if int(getattr(self, "warm_pool_size", 0)) >= 1: return self.dispatch_task_hybrid( task, extra_env=extra_env, model_provider=model_provider) args = ["dispatch", "--name", self._host_name(task), "--task", task["prompt"]] repo = task.get("repo") if repo: args += ["--repo", repo] return self._run_with_vm( task, args, extra_env=extra_env, model=task.get("model"), effort=task.get("effort"), approval=task.get("approval"), model_provider=model_provider, sandbox=("read-only" if task.get("plan") else None)) def run_task(self, task: dict, extra_env: dict | None = None, model_provider: str | None = None) -> dict: """客内 host.py run:在已有 host 任务上再跑一轮 turn。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") if not task.get("prompt"): raise ValueError("任务缺少 prompt") return self._run_with_vm( task, ("run", "--task", self._host_name(task), "--prompt", task["prompt"]), extra_env=extra_env, model=task.get("model"), effort=task.get("effort"), approval=task.get("approval"), model_provider=model_provider) def goal_task(self, task: dict, extra_env: dict | None = None, model_provider: str | None = None) -> dict: """客内 host.py goal:目标循环,轮次规则与 Local 同款 (缺省 3 轮,>100 轮 loud fail,旧验收形态 0 轮禁用)。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") goal_text = task.get("goal") or task.get("prompt") if not goal_text: raise ValueError("任务缺少 prompt") verify = task.get("verify_command") rounds = task.get("goal_max_rounds") if rounds is None: rounds = 3 if rounds is not None and rounds > 100: raise ValueError("goal_max_rounds 超过上限 100 轮") if rounds == 0 and verify and not task.get("goal_mode"): raise ValueError( "旧验收形态 goal_max_rounds=0 表示不限轮次,已禁用") args = ["goal", "--name", self._host_name(task), "--task", goal_text] if verify: args += ["--verify", verify] args += ["--max-rounds", str(rounds)] repo = task.get("repo") if repo: args += ["--repo", repo] return self._run_with_vm( task, args, goal=True, extra_env=extra_env, model=task.get("model"), effort=task.get("effort"), approval=task.get("approval"), model_provider=model_provider) def goal_resume_task(self, task: dict, goal_text: str | None = None, max_rounds: int | None = None, extra_env: dict | None = None, model_provider: str | None = None) -> dict: """客内 host.py goal-resume:goal_text=None 为断点续跑 (不带 --goal-text,沿用原 goal+verify),给 goal_text 为开新目标(带 --goal-text 与 --max-rounds,默认 0)。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") args = ["goal-resume", "--task", self._host_name(task)] if goal_text: args += ["--goal-text", goal_text, "--max-rounds", str(max_rounds if max_rounds is not None else 0)] elif max_rounds is not None: args += ["--max-rounds", str(max_rounds)] return self._run_with_vm( task, args, goal=True, extra_env=extra_env, model=task.get("model"), effort=task.get("effort"), approval=task.get("approval"), model_provider=model_provider) def goal_resume_breakpoint_task(self, task: dict, model_provider: str | None = None, extra_env: dict | None = None) -> dict: """客内 host.py goal-resume 断点续跑:无 --goal-text、 无 --max-rounds,host 沿用原任务从断点轮次续跑。 worker 层已接线(补缺口轨):非网关的 CHV 断点任务 经 worker 的通用断点分支调到这里,model_provider 按名透传给客内 host.py(与 Local/Docker 同款口径, 首轮 thread 的 provider 必须一致);网关任务的分流 由 _run_with_vm 开头的 _check_gateway_vm_class 按 VM 类 enforcement(常驻 VM 可接、池 VM 以 gateway_pool_denied 拒绝)。模型 key 本身绝不经本方法传值,只按环境变量名注入。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") return self._run_with_vm( task, ("goal-resume", "--task", self._host_name(task)), goal=True, extra_env=extra_env, model=task.get("model"), effort=task.get("effort"), approval=task.get("approval"), model_provider=model_provider)