diff --git a/cloud/chv/__init__.py b/cloud/chv/__init__.py index f60164d..110a65a 100644 --- a/cloud/chv/__init__.py +++ b/cloud/chv/__init__.py @@ -1,18 +1,33 @@ """Cloud Hypervisor 执行器包(从 cloud/sandbox.py 巨类拆出)。 -公开名与 #216 一致,cloud/sandbox.py 会 re-export 它们, -保证旧导入路径可用: +公开名与 #216 一致,cloud/sandbox.py 会 re-export 生产 +接口,保证旧导入路径可用——但 WarmPool 除外(本轨破环 +性清理,见下): - CloudHypervisorExecutor:执行器本体(executor.py) -- WarmPool:预热池 + registry 持久化与孤儿清理(pool.py) - ChvFailure:分类失败(errors.py) +- reconcile_orphans_for_executor:生产残留清理唯一入口 + (orphans.py,只读 registry 文件做清理,不构造池) -子模块分工见 executor.py 头注释。本包顶层 import 不触 -cloud.sandbox(执行器方法内按需 import 共享 helper), -所以任何导入顺序都不会循环。 +破环性清理(feat-chv-arch-maint-fe,未合并前即生效, +合并时按 PR 描述的清单检查调用方):WarmPool 已从 +cloud/chv/pool.py 移到 cloud/chv/experimental_pool.py, +本包与 cloud/sandbox.py 不再导出它。旧路径 +`from cloud.chv import WarmPool`、 +`from cloud.chv.pool import WarmPool`、 +`from cloud.sandbox import WarmPool` 一律 ImportError; +实验用法只走 +`from cloud.chv.experimental_pool import WarmPool`。 +清理理由:CHV 尚未合并、尚无生产用户,兼容保留只是 +“怕断 import”,留着反而让生产代码有路可走进实验接口。 + +子模块分工见 executor.py 头注释与 docs/chv-architecture.md +ADR-12。本包顶层 import 不触 cloud.sandbox(执行器方法 +内按需 import 共享 helper),所以任何导入顺序都不会循环。 """ from cloud.chv.errors import ChvFailure from cloud.chv.executor import CloudHypervisorExecutor -from cloud.chv.pool import WarmPool +from cloud.chv.orphans import reconcile_orphans_for_executor -__all__ = ["ChvFailure", "CloudHypervisorExecutor", "WarmPool"] +__all__ = ["ChvFailure", "CloudHypervisorExecutor", + "reconcile_orphans_for_executor"] diff --git a/cloud/chv/api_client.py b/cloud/chv/api_client.py index 6cae561..0ad0365 100644 --- a/cloud/chv/api_client.py +++ b/cloud/chv/api_client.py @@ -128,7 +128,7 @@ def _unix_socket_api(socket_path, method, path, payload=None): return _parse_response(header, body) -def normalize_api_result(result) -> tuple: +def normalize_api_result(result: dict) -> tuple: """API 调用归一化:fake api_client 可返回 (status, body) 或 {"status":..., "body":...} 或带 .status 的对象,本 函数统一成 (status:int, body)。""" diff --git a/cloud/chv/compat215.py b/cloud/chv/compat215.py index 35c4adb..c89c777 100644 --- a/cloud/chv/compat215.py +++ b/cloud/chv/compat215.py @@ -23,12 +23,67 @@ cloud/gates.py、cloud/quota.py、cloud/sandbox_policy.py。 不提供,测试钉住“CHV 不 import 它们”。 本模块只依赖标准库,是叶子模块,不 import cloud.sandbox。 + +合并后替换清单(REPLACEMENT_POINTS,本轨从“接口形状” +推进到可机械执行的清单;#215 未合并前一律不 import +cloud.gates / cloud.sandbox_policy / cloud.timeouts / +cloud.quota,测试钉住这些模块在本基线里不存在): +- R1 gates.resolve_inside:唯一生产调用方是 + hardening._is_within(快照 URL 白名单)。合并后两步: + ① hardening 改为 `from cloud.gates import resolve_inside`; + ② 删本模块 resolve_inside。语义必须逐字一致,改完 + 重跑快照白名单测试(逃逸/符号链接/绝对路径三例)。 +- R2 sandbox_policy.tmpfs_isolated:形状占位,当前无 + 生产调用方(只有测试)。合并后一步:删本模块 + tmpfs_isolated;将来有调用方时直接 import #215 版, + 不得再本地复刻。CHV 语义是“一律不隔离(fail-closed)”, + 若 #215 版对 cloud-hypervisor 返回 True,替换即改行为, + 必须先停下来核对,不得机械替换。 +- R3 timeouts / quota:永不替换。#215 的 timeouts 是 + 审批/队列超时(None/0 = 不过期),quota 是按月美元 + 配额;CHV 的 api/ready 超时是秒数(0 无效)、配额是 + VM 数/磁盘 GB/快照数,单位与语义都不同。清单里明写 + “不替换”,防止合并时有人按名字硬套。 """ import os -def resolve_inside(base, rel): +# 合并后替换清单:每项的 local/replacement/test 字段由测试 +# 逐项核对(local 函数真实存在、#215 模块当前不存在、 +# test 命名的测试类真实存在),清单本身不得漂移。 +REPLACEMENT_POINTS = ( + { + "id": "R1", + "upstream": "cloud.gates.resolve_inside", + "local": "cloud.chv.compat215.resolve_inside", + "caller": "cloud.chv.hardening._is_within", + "replacement": "hardening 改为 from cloud.gates import " + "resolve_inside,并删除本地 resolve_inside", + "test": "Compat215Test + 快照白名单测试", + }, + { + "id": "R2", + "upstream": "cloud.sandbox_policy.tmpfs_isolated", + "local": "cloud.chv.compat215.tmpfs_isolated", + "caller": None, + "replacement": "删除本地 tmpfs_isolated;有调用方时" + "直接 import #215 版(先核对 CHV 一律 False 语义)", + "test": "Compat215Test.test_tmpfs_isolated_chv_and_unknown_" + "fail_closed", + }, + { + "id": "R3", + "upstream": "cloud.timeouts / cloud.quota", + "local": None, + "caller": None, + "replacement": None, + "test": "Compat215Test.test_no_timeout_or_quota_map", + }, +) + + +def resolve_inside(base: str, rel: str) -> str | None: """目录门:base 内的相对路径安全解析,越界回 None。 空串回 base 本身;绝对路径、.. 逃逸、符号链接逃逸、 @@ -48,7 +103,7 @@ def resolve_inside(base, rel): return target -def tmpfs_isolated(executor_kind, path): +def tmpfs_isolated(executor_kind: str, path: str) -> tuple[bool, str]: """某执行器下某路径是否走 tmpfs 隔离:(isolated, reason)。 cloud-hypervisor:客机 /tmp 落在客机磁盘上,不走宿主 diff --git a/cloud/chv/config.py b/cloud/chv/config.py index 48ee740..0265d93 100644 --- a/cloud/chv/config.py +++ b/cloud/chv/config.py @@ -15,20 +15,19 @@ cloud.sandbox,避免循环导入)。 直接 ValueError。 与 #215 的关系(只列清单,不复制模块):#215 未合并, -本轨不假装复用它。#215 合并后应再复用的点:超时类 -(api_timeout/ready_timeout/ready_interval/task_timeout → -timeouts 模块)、门禁类(preflight fail-fast、容量门、磁盘 -配额门、egress fail-closed → gates 模块)、配额类 -(max_vms、disk_gb、任务成本走 worker quota → quota 模块)、 -隔离类(单任务单 VM、磁盘 copy 模式、egress 代理、SSH 钉死 -→ sandbox_policy 模块)。见 PR 描述的复用清单。 +本轨不假装复用它。上一轨 ADR-7 的泛化映射已被证伪 +(#215 的 timeouts 是审批/队列超时、quota 是按月美元 +配额,与 CHV 的秒数超时、VM 数/磁盘 GB 单位语义不同, +硬套会改行为),真实映射只有两处,见 +cloud/chv/compat215.py 的 REPLACEMENT_POINTS 清单 +(合并时按清单机械替换,不在本轨硬 import 未合并模块)。 """ import os import re -def _parse_memory_bytes(value): +def _parse_memory_bytes(value: str) -> int: """内存字符串转字节:2G/512M/1024K/2048(无单位=字节)。 非法值 ValueError,绝不静默按默认算。""" m = re.match(r"^\s*(\d+)\s*([KMGkmg]?)\s*[Bb]?\s*$", @@ -45,15 +44,44 @@ class ChvConfig: 执行器 __init__ 直接把字段抄到 self 上,旧测试读属性 的方式全部不变。""" - def __init__(self, **fields): + def __init__(self, **fields: object) -> None: for key, value in fields.items(): setattr(self, key, value) +def _base_fragment(params: dict, env: dict) -> dict: + """身份/资源基底片段:二进制、内核、根盘、CPU/内存、 + state_dir、KVM/cpuinfo 路径、ch-remote、模型。无校验, + 取值口径与旧 build_chv_config 内联 base 逐字一致。""" + return { + "binary": params.get("binary") or env.get("CHV_BINARY") + or "cloud-hypervisor", + "kernel": params.get("kernel") or env.get("CHV_KERNEL"), + "rootfs": params.get("rootfs") or params.get("image") + or env.get("CHV_ROOTFS") or env.get("CHV_IMAGE"), + "cpus": str(params.get("cpus") or env.get("CHV_CPU") or "2"), + "memory": params.get("memory") or env.get("CHV_MEM") or "2G", + "state_dir": params.get("state_dir") + or env.get("CHV_STATE_DIR") + or os.path.join(os.path.expanduser("~"), + ".local", "share", "codex-app-server", "chv-vms"), + "kvm_path": params.get("kvm_path") + if params.get("kvm_path") is not None else "/dev/kvm", + "cpuinfo_path": params.get("cpuinfo_path") + if params.get("cpuinfo_path") is not None else "/proc/cpuinfo", + "ch_remote": params.get("ch_remote") or env.get("CHV_REMOTE") + or "ch-remote", + "model": params.get("model"), + } + + def _ssh_fragment(params: dict, env: dict) -> dict: - """SSH/命令面配置片段:返回 dict,供 build_chv_config 组装。 - 错误文案与原内联代码逐字一致,校验时机不变(构造时)。""" + """SSH/命令面片段:密钥、用户、端口、host key 模式、 + 客内 host.py 路径、内核命令行。错误文案与原内联代码 + 逐字一致,校验时机不变(构造时)。网络字段(net、 + ssh_host、egress)已移到 _network_fragment,不在本 + 片段,避免“SSH”片段名不副实。""" ssh_key = params.get("ssh_key") or env.get("CHV_SSH_KEY") if params.get("ssh_user") is not None \ and params.get("ssh_user") == "": @@ -82,19 +110,45 @@ def _ssh_fragment(params: dict, env: dict) -> dict: "guest_host_py": params.get("guest_host_py") or "/app/host.py", "cmdline": params.get("cmdline") or env.get("CHV_CMDLINE") or "console=hvc0 root=/dev/vda1 rw", + } + + +def _network_fragment(params: dict, env: dict) -> dict: + """网络片段:tap/桥接描述、SSH 目标、端口转发开关、 + egress 代理与直连开关。无数值校验(校验在 preflight + 的 net/egress 门里,不在配置解析层)。egress 从旧 + quota 片段移到这里:它是网络出口策略,不是 VM 配额。""" + return { "net": params.get("net") or env.get("CHV_NET"), "net_json": params.get("net_json") or env.get("CHV_NET_JSON"), "ssh_host": params.get("ssh_host") or env.get("CHV_SSH_HOST"), "ssh_forward": env.get("CHV_SSH_FORWARD") == "1", + "egress_proxy": env.get("CHV_EGRESS_PROXY"), + "allow_direct_egress": env.get("CHV_ALLOW_DIRECT_EGRESS") + == "1", } def _quota_fragment(env: dict) -> dict: - """配额/磁盘/快照配置片段:返回 dict,错误文案逐字一致。""" + """配额片段:仅容量数——最大并发 VM 数与预热池大小。 + 磁盘/快照数上限不在这里,它们是磁盘行为,见 + _disk_fragment。校验文案与旧代码逐字一致。""" try: max_vms = int(env.get("CHV_MAX_VMS") or 4) except ValueError: raise ValueError("CHV_MAX_VMS 必须是整数") + try: + warm_pool_size = int(env.get("CHV_WARM_POOL_SIZE") or 0) + except ValueError: + raise ValueError("CHV_WARM_POOL_SIZE 必须是整数") + return {"max_vms": max_vms, "warm_pool_size": warm_pool_size} + + +def _disk_fragment(env: dict) -> dict: + """磁盘片段:磁盘模式/大小、单租户 reuse 实验开关、 + 快照数/总量/保留期/目录/远端开关与快照 URL。校验 + 文案与旧 quota 片段逐字一致(disk_mode 白名单、 + disk_gb > 0、快照数非负)。""" disk_mode = env.get("CHV_DISK_MODE") or "copy" if disk_mode not in ("copy", "reuse"): raise ValueError( @@ -119,8 +173,7 @@ def _quota_fragment(env: dict) -> dict: or snapshot_retention_days < 0: raise ValueError("CHV_SNAPSHOT_* 不能为负数") return { - "max_vms": max_vms, "disk_mode": disk_mode, - "disk_gb": disk_gb, + "disk_mode": disk_mode, "disk_gb": disk_gb, "reuse_experiment": env.get( "CHV_REUSE_SINGLE_TENANT_EXPERIMENT") == "1", "single_tenant_project": env.get("CHV_SINGLE_TENANT_PROJECT"), @@ -130,21 +183,17 @@ def _quota_fragment(env: dict) -> dict: "snapshot_dir": env.get("CHV_SNAPSHOT_DIR"), "allow_remote_snapshot": env.get("CHV_ALLOW_REMOTE_SNAPSHOT") == "1", - "egress_proxy": env.get("CHV_EGRESS_PROXY"), - "allow_direct_egress": env.get("CHV_ALLOW_DIRECT_EGRESS") - == "1", + "snapshot_url": env.get("CHV_SNAPSHOT_URL"), } def _timeout_fragment(params: dict, env: dict) -> dict: - """超时/池/快照 URL 配置片段:返回 dict,错误文案逐字一致。""" + """超时片段:任务超时(None = 不限,旧语义)、VMM API + 超时、客机就绪超时/间隔。ready_interval <= 0 时回落 + 5 秒(旧语义,非校验失败)。校验文案逐字一致。""" task_timeout_raw = env.get("CHV_TASK_TIMEOUT") task_timeout = float(task_timeout_raw) \ if task_timeout_raw else None - try: - warm_pool_size = int(env.get("CHV_WARM_POOL_SIZE") or 0) - except ValueError: - raise ValueError("CHV_WARM_POOL_SIZE 必须是整数") try: api_timeout = float( params.get("api_timeout") @@ -163,73 +212,42 @@ def _timeout_fragment(params: dict, env: dict) -> dict: if ready_interval <= 0: ready_interval = 5 return { - "task_timeout": task_timeout, "warm_pool_size": warm_pool_size, - "api_timeout": api_timeout, "ready_timeout": ready_timeout, + "task_timeout": task_timeout, "api_timeout": api_timeout, + "ready_timeout": ready_timeout, "ready_interval": ready_interval, - "snapshot_url": env.get("CHV_SNAPSHOT_URL"), } -def _assemble_chv_config(base: dict, ssh: dict, quota: dict, - timeouts: dict): - """把基础字段 + SSH/配额/超时三个片段组装成 ChvConfig。 +def _assemble_chv_config(fragments: list) -> "ChvConfig": + """把各片段 dict 合并成 ChvConfig(纯合并,不改值)。 - 纯移动:字段名与取值逐字不变,build_chv_config 只解析。""" - return ChvConfig( - binary=base["binary"], kernel=base["kernel"], - rootfs=base["rootfs"], cpus=base["cpus"], - memory=base["memory"], state_dir=base["state_dir"], - ssh_key=ssh["ssh_key"], ssh_user=ssh["ssh_user"], - ssh_port=ssh["ssh_port"], - guest_host_py=ssh["guest_host_py"], cmdline=ssh["cmdline"], - net=ssh["net"], net_json=ssh["net_json"], - kvm_path=base["kvm_path"], cpuinfo_path=base["cpuinfo_path"], - ch_remote=base["ch_remote"], model=base["model"], - max_vms=quota["max_vms"], disk_mode=quota["disk_mode"], - disk_gb=quota["disk_gb"], ssh_host=ssh["ssh_host"], - ssh_forward=ssh["ssh_forward"], - reuse_experiment=quota["reuse_experiment"], - egress_proxy=quota["egress_proxy"], - allow_direct_egress=quota["allow_direct_egress"], - host_key_mode=ssh["host_key_mode"], - task_timeout=timeouts["task_timeout"], - snapshot_url=timeouts["snapshot_url"], - warm_pool_size=timeouts["warm_pool_size"], - api_timeout=timeouts["api_timeout"], - ready_timeout=timeouts["ready_timeout"], - ready_interval=timeouts["ready_interval"], - single_tenant_project=quota["single_tenant_project"], - snapshot_max_count=quota["snapshot_max_count"], - snapshot_max_total_gb=quota["snapshot_max_total_gb"], - snapshot_retention_days=quota["snapshot_retention_days"], - snapshot_dir=quota["snapshot_dir"], - allow_remote_snapshot=quota["allow_remote_snapshot"]) + 片段键集必须互不相交——键冲突是编程错误,直接 + ValueError,绝不让后一片段静默覆盖前一片段的值。""" + fields: dict = {} + for fragment in fragments: + for key, value in fragment.items(): + if key in fields: + raise ValueError( + "CHV 配置片段键冲突:%s" % key) + fields[key] = value + return ChvConfig(**fields) -def build_chv_config(params: dict, env: dict): - """把显式构造参数与环境变量解析成 ChvConfig(见 - _assemble_chv_config 的组装段;片段化与校验顺序不变)。""" +def build_chv_config(params: dict, env: dict) -> "ChvConfig": + """把显式构造参数与环境变量解析成 ChvConfig。 + + 片段调用顺序即旧校验顺序(SSH → 配额 → 磁盘 → + 超时),只有一处可观测的顺序变化:warm_pool_size + 从旧超时片段移到配额片段,因此“CHV_WARM_POOL_SIZE + 非法 + 磁盘非法同时出现”时先报前者;单项非法时 + 报错与旧代码逐字一致。字段集合与取值与旧代码一致, + 由测试的字段集断言钉住。""" env = env if env is not None else {} - base = { - "binary": params.get("binary") or env.get("CHV_BINARY") - or "cloud-hypervisor", - "kernel": params.get("kernel") or env.get("CHV_KERNEL"), - "rootfs": params.get("rootfs") or params.get("image") - or env.get("CHV_ROOTFS") or env.get("CHV_IMAGE"), - "cpus": str(params.get("cpus") or env.get("CHV_CPU") or "2"), - "memory": params.get("memory") or env.get("CHV_MEM") or "2G", - "state_dir": params.get("state_dir") - or env.get("CHV_STATE_DIR") - or os.path.join(os.path.expanduser("~"), - ".local", "share", "codex-app-server", "chv-vms"), - "kvm_path": params.get("kvm_path") - if params.get("kvm_path") is not None else "/dev/kvm", - "cpuinfo_path": params.get("cpuinfo_path") - if params.get("cpuinfo_path") is not None else "/proc/cpuinfo", - "ch_remote": params.get("ch_remote") or env.get("CHV_REMOTE") - or "ch-remote", - "model": params.get("model"), - } - return _assemble_chv_config( - base, _ssh_fragment(params, env), _quota_fragment(env), - _timeout_fragment(params, env)) + return _assemble_chv_config([ + _base_fragment(params, env), + _ssh_fragment(params, env), + _quota_fragment(env), + _disk_fragment(env), + _network_fragment(params, env), + _timeout_fragment(params, env), + ]) diff --git a/cloud/chv/disk.py b/cloud/chv/disk.py index 47a8450..f0404d2 100644 --- a/cloud/chv/disk.py +++ b/cloud/chv/disk.py @@ -33,7 +33,7 @@ def _dir_total_bytes(path): return total -def check_disk_quota(rootfs: str, disk_gb: float, state_dir=None) -> None: +def check_disk_quota(rootfs: str, disk_gb: float, state_dir: str | None = None) -> None: """磁盘配额门(feat-chv-fix 口径): - disk_gb 未设时不检查; @@ -162,8 +162,10 @@ def _save_snapshots(state_dir, vm_name, entries): os.replace(tmp_path, path) -def prune_snapshots(state_dir, vm_name, retention_days, - now=None, delete_files=True): +def prune_snapshots(state_dir: str, vm_name: str, + retention_days: float | None, + now: float | None = None, + delete_files: bool = True) -> tuple[list, list]: """按保留期剪枝:删过期条目;delete_files 时同时删 对应的 file:// 快照文件(只删登记里记的 path,不猜 文件名)。返回(保留条目,剪掉条目)。""" @@ -189,7 +191,8 @@ def prune_snapshots(state_dir, vm_name, retention_days, def check_snapshot_limits(state_dir: str, vm_name: str, max_count: int, - max_total_gb, retention_days): + max_total_gb: float, + retention_days: float | None) -> list: """快照上限预检(snapshot_vm 调 ch-remote 之前调): 先按保留期剪枝,再查数量(已有数 + 本次 > 上限即 snapshot_quota_exceeded)与总量(已有总量已达上限即 @@ -218,7 +221,7 @@ def check_snapshot_limits(state_dir: str, vm_name: str, max_count: int, return kept -def record_snapshot(state_dir: str, vm_name: str, snapshot_url: str, max_total_gb: float) -> dict: +def record_snapshot(state_dir: str, vm_name: str, snapshot_url: str, max_total_gb: float) -> tuple[list, list]: """快照成功后登记;file:// 目标记实际大小。登记后若 总量超上限,按最旧优先删登记外的旧快照文件(总量硬 帽,删动作由调用方审计——本函数返回被删的旧条目)。 diff --git a/cloud/chv/errors.py b/cloud/chv/errors.py index 45dd40c..ccf1d02 100644 --- a/cloud/chv/errors.py +++ b/cloud/chv/errors.py @@ -7,6 +7,6 @@ class ChvFailure(RuntimeError): 审计与失败分类用,绝不笼统只写 failed。kind 取值见 CloudHypervisorExecutor.preflight / wake_vm 的注释。""" - def __init__(self, kind, message): + def __init__(self, kind: str, message: str) -> None: super().__init__("chv_failure:%s: %s" % (kind, message)) self.kind = kind diff --git a/cloud/chv/executor.py b/cloud/chv/executor.py index 5c06379..d19d6a9 100644 --- a/cloud/chv/executor.py +++ b/cloud/chv/executor.py @@ -7,7 +7,8 @@ - ssh_transport.py:SSH 命令构造、每 VM known_hosts 钉死、 ssh-keyscan 首次捕获(TOFU 边界见该模块) - disk.py:每任务磁盘供给与磁盘配额门 -- pool.py:WarmPool 预热池 + registry 持久化与孤儿清理 +- orphans.py:生产残留清理唯一入口(registry 文件直读清理) +- experimental_pool.py:WarmPool 预热池(纯实验,生产禁 import) - executor.py(本模块):执行器类本体——生命周期方法 (wake/pause/resume/snapshot/restore/hibernate/stop/ cleanup)与任务执行方法(dispatch/run/goal/execute)。 @@ -154,11 +155,11 @@ class CloudHypervisorExecutor(Executor): - 单任务单 VM:wake → 跑 → finally 关,同一任务不共享 VM; 每任务磁盘 copy 模式独立,租户无共享路径、无东西向互访; VM 内 root 只能影响本 VM。 - - 快速启动:WarmPool(cloud/chv/pool.py)预热干净快照 VM, + - 快速启动:WarmPool(cloud/chv/experimental_pool.py)预热干净快照 VM, acquire 消耗、release 销毁,池空回落冷启动,绝不降级; 池状态另落 state_dir/warm-pool.json registry,worker 重启后载入的条目一律当孤儿、只能 reconcile_orphans() - 清理,绝不重新发给任务(见 pool.py)。 + 清理,绝不重新发给任务(见 orphans.py)。 - 长任务中途死亡:_run_with_vm 在客内命令抛传输异常、 或非 goal 结果已 failed 且 VM 登记进程已死时,改判 interrupted(断点可续跑)而不是笼统 failed;VM 还活 @@ -245,17 +246,30 @@ class CloudHypervisorExecutor(Executor): 开启嵌套虚拟化的实例,那是用户侧开通决策,不在本轨内。 """ - def __init__(self, binary=None, kernel=None, rootfs=None, - image=None, cpus=None, memory=None, state_dir=None, - runner=None, launcher=None, ssh_key=None, - ssh_user=None, ssh_port=None, - guest_host_py="/app/host.py", cmdline=None, - net=None, net_json=None, ssh_host=None, - kvm_path="/dev/kvm", - cpuinfo_path="/proc/cpuinfo", env=None, - model=None, ch_remote=None, api_client=None, - sleeper=None, api_timeout=None, ready_timeout=None, - ready_interval=None, host_key_mode=None): + 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: object = None, launcher: object = 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: object = None, + sleeper: object = 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 = env if env is not None else os.environ # 配置解析已拆到 cloud/chv/config.py(参数优先于环境 # 变量,默认值与 #216 一致;显式空用户/越界端口的收紧 @@ -326,7 +340,7 @@ class CloudHypervisorExecutor(Executor): return re.sub(r"[^A-Za-z0-9_.-]", "-", str(task.get("id"))) - def vm_name(self, task_or_name): + def vm_name(self, task_or_name: dict | str) -> str: """VM 名:chv-<安全字符任务名>(与 Docker 的 codex- 同款 派生,同一任务每次算出同一名字,清理才能按名找)。 幂等:传入的已是 chv- 前缀名时不再二次加前缀(清理 @@ -339,7 +353,7 @@ class CloudHypervisorExecutor(Executor): return safe return "chv-" + safe - def api_socket(self, task_or_name): + 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") @@ -559,7 +573,7 @@ class CloudHypervisorExecutor(Executor): validate_net_entries(_entries, []) except ChvFailure as exc: missing.append((exc.kind, str(exc))) - def preflight(self): + def preflight(self) -> bool: """预检(缺一不可则 fail-fast):收集全部缺失项一次报出 具体缺哪一项,绝不笼统报错;失败为 ChvFailure(带 kind, 供审计与失败分类用),绝不回落 Docker/Local。 @@ -651,7 +665,7 @@ class CloudHypervisorExecutor(Executor): cmd += ["--net", self.net] return cmd - def known_hosts_path(self, task_or_name): + 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, @@ -671,7 +685,7 @@ class CloudHypervisorExecutor(Executor): return str(entry["ip"]) return None - def resolve_ssh_host(self): + def resolve_ssh_host(self) -> str: """SSH 目标解析(feat-chv-fix,P0-4 选定方案:客机 IP): 显式 CHV_SSH_HOST > net 静态客机 IP > 显式转发宣称下 @@ -690,7 +704,7 @@ class CloudHypervisorExecutor(Executor): "里给 net 条目配 ip(静态 tap 客机 IP)、或显式设 " "CHV_SSH_FORWARD=1 宣称宿主 NAT 已转发回环端口") - def provision_host_key(self, task, secret_env=None): + def provision_host_key(self, task: dict, secret_env: dict | None = None) -> dict: """按 VM 预置 host key(补缺口轨强方案,见 hostkeys.py): 启动前为该 VM 生成一次性 ed25519 host key 对,公钥 立即写进该 VM 的 known_hosts(钉死),私钥只写进 @@ -741,7 +755,7 @@ class CloudHypervisorExecutor(Executor): return {"paths": paths, "seed_dir": seed_dir, "known_hosts": paths["known_hosts"]} - def capture_host_key(self, task): + def capture_host_key(self, task: dict) -> bool: """首次捕获客机 host key(TOFU,弱回落,边界见 ssh_transport.py 与 hostkeys.py):ssh-keyscan 经本机 回环端口抓 key 行,写入该 VM 的 known_hosts 文件 @@ -775,7 +789,7 @@ class CloudHypervisorExecutor(Executor): "vm=%s host=%s" % (self.vm_name(task), ssh_host)) return True - def build_ssh_command(self, task, inner_cmd, extra_env=None): + 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 @@ -821,7 +835,7 @@ class CloudHypervisorExecutor(Executor): guest_env_file=guest_env_file) # -- API 模式:VmConfig / API 调用 / 就绪轮询 ----------------------- - def build_vm_config(self, task_disk=None, seed_disk=None): + def build_vm_config(self, task_disk: str | None = None, seed_disk: str | None = None) -> dict: """API 模式的 VmConfig(vm.create 请求体,纯函数): cpus{boot_vcpus,max_vcpus}、memory{size: 字节}、 kernel{path}、cmdline{args}、disks[{path: 每任务磁盘}]、 @@ -968,7 +982,7 @@ class CloudHypervisorExecutor(Executor): self.state_dir, self.vm_name(task), self.rootfs, self.disk_mode) - def wait_api_ready(self, task): + def wait_api_ready(self, task: dict) -> bool: """等 vmm.ping 通:按次数轮询(api_timeout/1 秒取整, 至少 1 次),不用墙钟死循环——fake sleeper 不推进墙钟 时墙钟循环会空转,次数循环在单测里是确定性的。 @@ -1014,7 +1028,7 @@ class CloudHypervisorExecutor(Executor): return True return False - def wait_guest_ready(self, task): + def wait_guest_ready(self, task: dict) -> bool: """等 guest SSH 就绪:轮询 ssh "true",returncode 0 即就绪; 每轮先尽力捕获 host key(keyscan 模式),超时先关 VM 再 raise guest_not_ready,绝不在未就绪时派任务。""" @@ -1298,7 +1312,7 @@ class CloudHypervisorExecutor(Executor): return task_disk, socket_path - def wake_vm(self, task, extra_env=None): + def wake_vm(self, task: dict, extra_env: dict | None = None) -> dict: """主唤醒路径(API 模式,dispatch/run/goal 的前置步骤): preflight → 容量门 → 磁盘配额门 → 每任务磁盘供给 → launcher 只起 VMM(--api-socket)→ 等 vmm.ping 通 → @@ -1335,14 +1349,14 @@ class CloudHypervisorExecutor(Executor): return {"vm": name, "api_socket": socket_path, "state": "running", "ready": True, "mode": "cold"} - def start_vm(self, task): + 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): + 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) \ @@ -1351,7 +1365,7 @@ class CloudHypervisorExecutor(Executor): "vm=%s" % self.vm_name(task_or_name)) return True - def resume_vm(self, task_or_name): + def resume_vm(self, task_or_name: dict | str) -> bool: """唤醒已暂停的 VM:ch-remote resume。成功审计 vm_resume。 注意:这是暂停唤醒的唯一路径,恢复后的 VM 也走它 (见 restore_vm),绝不能只 restore 就算醒。""" @@ -1362,7 +1376,7 @@ class CloudHypervisorExecutor(Executor): "vm=%s" % self.vm_name(task_or_name)) return True - def snapshot_vm(self, task_or_name, snapshot_url): + def snapshot_vm(self, task_or_name: dict | str, snapshot_url: str) -> str: """给(已暂停的)VM 做快照:ch-remote snapshot 。snapshot_url 为空即 ValueError;成功 审计 vm_snapshot。快照本身不改变 VM 状态(仍 paused, @@ -1454,7 +1468,7 @@ class CloudHypervisorExecutor(Executor): self.resume_vm(task) self.wait_guest_ready(task) - def restore_vm(self, task, snapshot_url): + def restore_vm(self, task: dict, snapshot_url: str) -> dict: """快照恢复唤醒:校验 → 只起 VMM → restore → 显式 resume → 等 SSH 就绪。恢复后默认 paused,不 resume 就不算醒。任一步失败都关 VM 再抛;成功返回 @@ -1474,7 +1488,7 @@ class CloudHypervisorExecutor(Executor): return {"vm": name, "api_socket": self.api_socket(task), "state": "running", "ready": True, "mode": "restored"} - def hibernate_vm(self, task_or_name, snapshot_url): + def hibernate_vm(self, task_or_name: dict | str, snapshot_url: str) -> dict: """休眠(CHV 无原生 hibernate 端点,等价实现): pause + snapshot + shutdown。休眠后文件与 worktree 保留在快照里(快照是暂停时刻的完整状态),临时文件 @@ -1489,7 +1503,7 @@ class CloudHypervisorExecutor(Executor): return {"vm": self.vm_name(task_or_name), "state": "hibernated", "snapshot": snapshot_url} - def wake_from_hibernate(self, task, snapshot_url): + def wake_from_hibernate(self, task: dict, snapshot_url: str) -> dict: """从休眠唤醒:即 restore_vm(restore + 显式 resume + SSH 就绪),语义与 restore_vm 完全一致,只是名字 说明来源是休眠快照。""" @@ -1560,7 +1574,7 @@ class CloudHypervisorExecutor(Executor): except Exception: pass - def stop_vm(self, task_or_name): + def stop_vm(self, task_or_name: dict | str) -> bool: """停止 VM(尽力而为,不抛异常):已死进程直接清登记, 已关 VM 仍尽力调一次 ch-remote(返回 False),socket 残留删 socket(返回 True)。ch-remote shutdown 失败 @@ -1597,7 +1611,7 @@ class CloudHypervisorExecutor(Executor): _LOGGER.info("chv_vm_stop vm=%s had=%s", name, had) return had - def gc_stale_disks(self): + def gc_stale_disks(self) -> dict: """残留盘 GC(H3):扫描 state_dir/*/disk.raw,VM 非 live(无 api-socket 文件且无登记活进程)即删盘 并审计;删失败审计 vm_disk_delete_failed 并计入 @@ -1675,7 +1689,7 @@ class CloudHypervisorExecutor(Executor): pass return removed_priv, removed_seed - def cleanup_stale_secrets(self): + def cleanup_stale_secrets(self) -> list: """崩溃残留扫描(H5):私钥一律删、非 live VM 的 seed 同理删并审计;返回已清理 VM 名。worker 启动与 cleanup_stale_vms 时调它,不依赖 socket 是否存在。""" @@ -1700,18 +1714,18 @@ class CloudHypervisorExecutor(Executor): % (entry, removed_priv, removed_seed)) return cleaned - def shutdown_vm(self, task_or_name): + 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): + 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 recycle_vm(self, task): + def recycle_vm(self, task: dict) -> dict: """回收 VM(任务结束后的销毁语义): 先 shutdown,再返回保留/丢失清单。保留的只有宿主侧 的 task_checkpoints / task_attempts(数据库里的断点与 @@ -1767,7 +1781,7 @@ class CloudHypervisorExecutor(Executor): if dead: self._vms.pop(name, None) - def cleanup_stale_vms(self): + def cleanup_stale_vms(self) -> list: """清理残留 VM:先跑残留盘 GC 与残留密钥扫描,再走 socket 扫描与登记清理。只清 socket 残留与已死进程 对应的 VM;state_dir 不存在返回 [],绝不抛异常。""" @@ -1907,7 +1921,7 @@ class CloudHypervisorExecutor(Executor): """每任务 VM 生命周期(冷启动正式路径,feat-chv-arch95 定案):wake_vm(API 模式 + SSH 就绪)→ 跑客内命令 → finally shutdown_vm。本方法绝不查 WarmPool:预热池是 - 实验性的(见 pool.py 头定案与 ADR-5),dispatch 永远 + 实验性的(见 experimental_pool.py 头定案与 ADR-5),dispatch 永远 冷启动,warm_pool_wired_to_dispatch 恒 False。 wake 失败时不跑 任务、直接抛分类失败。本方法不做内部重试:重试是 @@ -1951,7 +1965,7 @@ class CloudHypervisorExecutor(Executor): except Exception: pass - def execute(self, task) -> dict: + def execute(self, task: dict) -> dict: """只读执行:客内 host.py status,不直接跑模型回合 (与 Local/Docker 的 execute 同款语义)。 @@ -1987,7 +2001,7 @@ class CloudHypervisorExecutor(Executor): "SSH 失败归类为未运行:%s" % exc) raise - def dispatch_task(self, task, extra_env=None, model_provider=None) -> dict: + def dispatch_task(self, task: dict, extra_env: dict | None = None, model_provider: str | None = None) -> dict: """客内 host.py dispatch:建 host 任务并跑一轮 turn。""" if not isinstance(task, dict) or task.get("id") is None: raise ValueError("任务缺少 id") @@ -2004,7 +2018,7 @@ class CloudHypervisorExecutor(Executor): model_provider=model_provider, sandbox=("read-only" if task.get("plan") else None)) - def run_task(self, task, extra_env=None, model_provider=None) -> dict: + 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") @@ -2017,7 +2031,7 @@ class CloudHypervisorExecutor(Executor): effort=task.get("effort"), approval=task.get("approval"), model_provider=model_provider) - def goal_task(self, task, extra_env=None, model_provider=None) -> dict: + 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: @@ -2048,8 +2062,10 @@ class CloudHypervisorExecutor(Executor): effort=task.get("effort"), approval=task.get("approval"), model_provider=model_provider) - def goal_resume_task(self, task, goal_text=None, - max_rounds=None, extra_env=None, model_provider=None) -> dict: + 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)。""" @@ -2067,9 +2083,9 @@ class CloudHypervisorExecutor(Executor): effort=task.get("effort"), approval=task.get("approval"), model_provider=model_provider) - def goal_resume_breakpoint_task(self, task, - model_provider=None, - extra_env=None) -> dict: + 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 断点任务 diff --git a/cloud/chv/pool.py b/cloud/chv/experimental_pool.py similarity index 79% rename from cloud/chv/pool.py rename to cloud/chv/experimental_pool.py index 0c4963f..5abccbb 100644 --- a/cloud/chv/pool.py +++ b/cloud/chv/experimental_pool.py @@ -1,4 +1,13 @@ -"""快照预热池 WarmPool(实验性,非生产路径,feat-chv-arch95 定案)。 +"""快照预热池 WarmPool(纯实验模块,非生产路径)。 + +本轨(feat-chv-arch-maint-fe)破环性清理:WarmPool 从 +cloud/chv/pool.py 移到本文件,cloud/chv/__init__ 与 +cloud/sandbox.py 不再 re-export 它,生产代码 import 本 +模块即违规(见 docs/chv-architecture.md ADR-12 的禁 +import 清单与测试钉住)。实验用法只走 +`from cloud.chv.experimental_pool import WarmPool`。 +生产残留清理已彻底分文件:见 cloud/chv/orphans.py 的 +reconcile_orphans_for_executor(),它不 import 本模块。 定案(架构决策,见 docs/chv-architecture.md ADR-5):WarmPool 不再宣称为生产快速启动特性。原因:预热 VM 的磁盘是干净快照 @@ -7,10 +16,11 @@ 语义三者都未实现),把预热盘直接当任务盘用会击穿“任务盘 隔离”不变量。acquire 内 resume 只是实验性接口,worker dispatch 永远走冷启动(wake_vm),_run_with_vm 绝不查池。 -pool 保留的唯一生产价值是 reconcile_orphans()(上一世残留 -清理),worker 启动/关闭仍调它。warm_pool_wired_to_dispatch -恒为 False,任何把它改成 True 的改动都必须先实现任务盘 -重供给并补不变量测试,否则不得合入。 +本模块的 reconcile_orphans() 只留给实验/旧测试直接调用; +生产启动/关闭走 orphans facade,不再经本类。 +warm_pool_wired_to_dispatch 恒为 False,任何把它改成 +True 的改动都必须先实现任务盘重供给并补不变量测试, +否则不得合入。 隔离红线(与 #216 一致,绝不妥协):预热条目是一次性的 @@ -43,13 +53,11 @@ Registry(本轨新增): 仍可用,重启后孤儿改由 socket 扫描兜底)。 """ -import json -import logging import os -_LOGGER = logging.getLogger("cloud.cloud_hypervisor") - -_REGISTRY_FILENAME = "warm-pool.json" +from cloud.chv.orphans import _REGISTRY_FILENAME +from cloud.chv.orphans import _read_registry +from cloud.chv.orphans import _write_registry class WarmPool: @@ -73,8 +81,9 @@ class WarmPool: wired_to_dispatch = False - def __init__(self, executor, size=None, snapshot_url=None, - registry_path=None): + def __init__(self, executor: object, size: int | None = None, + snapshot_url: str | None = None, + registry_path: str | None = None) -> None: self.executor = executor self.size = int(size if size is not None else getattr(executor, "warm_pool_size", 0)) @@ -93,24 +102,19 @@ class WarmPool: self._load_registry() # -- registry ------------------------------------------------------ - def _load_registry(self): + def _load_registry(self) -> None: """载入 registry:读到的条目一律标 orphan(见模块 docstring,绝不直接当 warm 发给任务)。无文件时为空 - 池;损坏时 quarantine 并从空开始。""" + 池;损坏时 quarantine 并从空开始。文件 IO 与损坏 + 语义唯一来源是 cloud.chv.orphans(生产模块),本 + 方法只做“条目 → 内存 orphan 态”的映射。""" if not self.registry_path: return - try: - with open(self.registry_path, "r") as fh: - data = json.load(fh) - except FileNotFoundError: - return - except (OSError, ValueError): + data, corrupt = _read_registry(self.registry_path) + if corrupt: self.registry_corrupt = True - self._quarantine_registry() return - if not isinstance(data, dict): - self.registry_corrupt = True - self._quarantine_registry() + if data is None: return for name, entry in data.items(): if not isinstance(entry, dict) \ @@ -125,22 +129,10 @@ class WarmPool: "snapshot_url": entry.get("snapshot_url"), } - def _quarantine_registry(self): - """把损坏的 registry 文件挪到 .corrupt 旁(尽力 - 而为),原路径不再留损坏数据给下一次载入。""" - if not self.registry_path: - return - try: - os.replace(self.registry_path, - self.registry_path + ".corrupt") - except OSError: - _LOGGER.warning("chv_pool_registry_quarantine_failed " - "path=%s", self.registry_path) - - def _save_registry(self): + def _save_registry(self) -> None: """落盘当前条目(只写 warm/assigned 态;orphan 是 待清理的上一世条目,reconcile 后即删,不写回)。 - 写失败只记日志,不炸任务流。""" + 写失败只记日志,不炸任务流。落盘 IO 委托 orphans。""" if not self.registry_path: return data = {} @@ -156,23 +148,14 @@ class WarmPool: "task_id": task_id, "snapshot_url": entry.get("snapshot_url"), } - try: - directory = os.path.dirname(self.registry_path) - if directory: - os.makedirs(directory, exist_ok=True) - tmp_path = self.registry_path + ".tmp" - with open(tmp_path, "w") as fh: - json.dump(data, fh) - os.replace(tmp_path, self.registry_path) - except OSError: - _LOGGER.warning("chv_pool_registry_save_failed path=%s", - self.registry_path) + _write_registry(self.registry_path, data) def reconcile_orphans(self) -> list: - """清理上一世残留条目:对每个 orphan/assigned 条目 - 尽力 shutdown_vm(清 socket 与残留登记),条目清掉 - 并落盘,返回已清理的 VM 名列表。worker 启动/清理时 - 显式调它(见模块 docstring 的单活池假设)。""" + """清理本池内存里的 orphan/assigned 条目(实验入口)。 + + 对每个条目尽力 shutdown_vm,条目清掉并落盘,返回 + 已清理的 VM 名列表。生产启动/关闭不走本方法,走 + cloud.chv.orphans.reconcile_orphans_for_executor()。""" cleaned = [] for name, entry in list(self._entries.items()): if entry["state"] not in ("orphan", "assigned"): @@ -204,7 +187,7 @@ class WarmPool: return sum(1 for e in self._entries.values() if e["state"] in ("orphan", "assigned")) - def prewarm(self, count=None): + def prewarm(self, count: int | None = None) -> list: """预热到目标数:已有 warm 条目不重复建;每个新条目 用一个合成任务名 wake + pause(合成任务 id 为 warm-<序号>,绝不与真实任务 id 撞名)。返回新增的 @@ -237,7 +220,7 @@ class WarmPool: self._save_registry() return added - def acquire(self, task): + def acquire(self, task: dict) -> dict: """分配一个预热 VM 给任务(feat-chv-fix,P1-3 接线): 有 warm 条目时先调 executor.resume_vm() 把暂停的 VM @@ -285,7 +268,7 @@ class WarmPool: "pool_task": entry["task"]} return {"mode": "cold", "vm": None, "pool_task": None} - def release(self, task, pool_vm=None): + def release(self, task: dict, pool_vm: str | None = None) -> bool: """释放:无论 warm 还是 cold 路径,VM 一律销毁 (executor.shutdown_vm),池条目清掉;绝不把用过 的 VM 放回 warm 池。返回 True 表示确实销毁了一个 @@ -304,13 +287,3 @@ class WarmPool: "warm_pool_release", task, "vm=%s destroyed=%s" % (pool_vm, destroyed)) return destroyed - - -def reconcile_orphans_for_executor(executor) -> list: - """生产路径唯一的 WarmPool 入口:只做孤儿清理。 - - worker 启动/关闭调它,不构造 WarmPool 类、不调 - prewarm/acquire/release——实验接口与生产路径彻底分离: - 生产代码 import 本函数即可,acquire/prewarm 只存在于 - 实验用的 WarmPool 类与测试里。""" - return WarmPool(executor).reconcile_orphans() diff --git a/cloud/chv/hardening.py b/cloud/chv/hardening.py index 21a60a8..c37f9b9 100644 --- a/cloud/chv/hardening.py +++ b/cloud/chv/hardening.py @@ -183,8 +183,9 @@ def _is_within(directory, path): return resolve_inside(directory, rel) is not None -def validate_snapshot_url(snapshot_url: str, state_dir: str, snapshot_dir=None, - allow_remote=False): +def validate_snapshot_url(snapshot_url: str, state_dir: str, + snapshot_dir: str | None = None, + allow_remote: bool = False) -> str | None: """快照 URL 白名单(L2): - file://:目标须在 state_dir 或 CHV_SNAPSHOT_DIR 内, diff --git a/cloud/chv/hostkeys.py b/cloud/chv/hostkeys.py index e7eaac2..65ff0cd 100644 --- a/cloud/chv/hostkeys.py +++ b/cloud/chv/hostkeys.py @@ -40,7 +40,7 @@ import shlex GUEST_ENV_PATH = "/etc/chv/guest-env" -def host_key_paths(state_dir: str, vm_name: str) -> tuple: +def host_key_paths(state_dir: str, vm_name: str) -> dict: """该 VM 的 host key 文件路径(私钥/公钥/known_hosts)。""" base = os.path.join(state_dir, vm_name) return { @@ -59,7 +59,7 @@ def dirty_marker_path(state_dir: str, vm_name: str) -> str: return os.path.join(state_dir, vm_name, ".cleanup-dirty") -def delete_host_private_key(state_dir: str, vm_name: str) -> None: +def delete_host_private_key(state_dir: str, vm_name: str) -> str: """删宿主侧 host 私钥文件(feat-chv-secfix,H5):seed 盘建好后宿主私钥已无存在必要(客机已有私钥、宿主只需 公钥 + known_hosts 做校验)。删失败即 OSError,由调用 @@ -70,7 +70,7 @@ def delete_host_private_key(state_dir: str, vm_name: str) -> None: return path -def known_hosts_line(port, public_key_text, host="127.0.0.1"): +def known_hosts_line(port: int, public_key_text: str, host: str = "127.0.0.1") -> str: """公钥文件内容转 known_hosts 行:只取前两列(类型+数据)。 公钥文件形如 'ssh-ed25519 AAAA... [comment]',known_hosts @@ -88,7 +88,7 @@ def seed_disk_path(state_dir: str, vm_name: str) -> str: return os.path.join(state_dir, vm_name, "seed.iso") -def build_seed_disk(seed_dir: str, seed_disk: str, runner=None) -> None: +def build_seed_disk(seed_dir: str, seed_disk: str, runner: object = None) -> str: """把 seed 目录做成 CIDATA 卷标 ISO(feat-chv-fix,P0-1 的 注入载体):genisoimage -volid CIDATA -joliet -rock。 @@ -121,7 +121,7 @@ def build_seed_disk(seed_dir: str, seed_disk: str, runner=None) -> None: return seed_disk -def build_seed_user_data(private_key_text, public_key_text): +def build_seed_user_data(private_key_text: str, public_key_text: str) -> str: """cloud-init user-data:把 host key 对写进客机 /etc/ssh。 注意:user-data 本身含私钥,只写进 per-VM seed 目录 @@ -156,8 +156,9 @@ def build_seed_user_data(private_key_text, public_key_text): return "\n".join(lines) + "\n" -def write_seed(seed_dir, vm_name, private_key_text, public_key_text, - secret_env=None): +def write_seed(seed_dir: str, vm_name: str, private_key_text: str, + public_key_text: str, + secret_env: dict | None = None) -> str: """写 seed 目录:meta-data + user-data(均为 0600)。 返回 seed 目录路径。seed 目录本身不含已知主机之外的 @@ -204,8 +205,9 @@ def write_seed(seed_dir, vm_name, private_key_text, public_key_text, return seed_dir -def generate_host_keypair(state_dir, vm_name, runner=None, - keygen="ssh-keygen"): +def generate_host_keypair(state_dir: str, vm_name: str, + runner: object = None, + keygen: str = "ssh-keygen") -> dict: """为该 VM 生成(或复用本 VM 已有的)host key 对。 同一 VM 重启/恢复时复用已有对(否则钉死的 known_hosts diff --git a/cloud/chv/orphans.py b/cloud/chv/orphans.py new file mode 100644 index 0000000..7c7c22a --- /dev/null +++ b/cloud/chv/orphans.py @@ -0,0 +1,142 @@ +"""CHV 上一世残留 VM 清理(生产路径唯一入口)。 + +本模块是叶子模块,只依赖标准库,绝不 import +cloud.chv.experimental_pool(实验接口)与 cloud.sandbox。 +生产代码(worker 启动/关闭)只调 +reconcile_orphans_for_executor(),它直接读 registry 文件、 +对每个上一世条目调 executor.shutdown_vm(),不构造任何 +池对象、不调 prewarm/acquire/release。 + +Registry 语义与旧 WarmPool._load_registry 完全一致 +(feat-chv-arch95plus 的 fail-safe 口径,本轨只搬位置、 +不改语义): +- 文件:state_dir/warm-pool.json,内容为 + {<合成名>: {"state", "vm", "task_id", "snapshot_url"}}。 +- 读到的 warm/assigned 条目一律当 orphan:旧 VMM 进程 + 已随旧 worker 消失,无法验证它还是当初那个干净快照 + VM,绝不发给任务,只能清理。 +- 损坏 fail-safe:文件不是合法 JSON / 顶层不是 dict 时, + 整个文件 quarantine 成 .corrupt,清理从空开始; + 单条目结构非法(无 vm、或 state 不在 warm/assigned) + 时只丢该条,不连坐其它条目。 +- 清理后落盘为空 dict(原子 tmp + rename);写失败只记 + 日志警告,不炸 worker 启动/关闭。shutdown_vm 抛错时该 + 条目仍计入已清理(旧 reconcile 语义:尽力清理、不因 + 单条失败卡住整批),_emit 失败同样 best-effort。 +""" + +import json +import logging +import os + +_LOGGER = logging.getLogger("cloud.cloud_hypervisor") + +_REGISTRY_FILENAME = "warm-pool.json" + + +def registry_path_for_executor(executor: object) -> str | None: + """执行器的 registry 路径:state_dir 下的 warm-pool.json。 + + 无 state_dir 时返回 None(调用方按“无残留”处理)。""" + state_dir = getattr(executor, "state_dir", None) + if not state_dir: + return None + return os.path.join(state_dir, _REGISTRY_FILENAME) + + +def _quarantine_registry(path: str) -> None: + """把损坏的 registry 文件挪到 .corrupt 旁(尽力而为)。""" + try: + os.replace(path, path + ".corrupt") + except OSError: + _LOGGER.warning("chv_pool_registry_quarantine_failed " + "path=%s", path) + + +def _read_registry(path: str) -> tuple[dict | None, bool]: + """读 registry 原始 dict:(data, corrupt)。 + + 无文件 → (None, False);损坏 → quarantine 后 (None, True); + 顶层非 dict 同样算损坏。调用方按 corrupt 决定是否记 + registry_corrupt(实验池需要区分“无文件”与“损坏”)。""" + try: + with open(path, "r") as fh: + data = json.load(fh) + except FileNotFoundError: + return None, False + except (OSError, ValueError): + _quarantine_registry(path) + return None, True + if not isinstance(data, dict): + _quarantine_registry(path) + return None, True + return data, False + + +def _write_registry(path: str, data: dict) -> None: + """原子落盘 registry(tmp + rename);写失败只记日志。""" + try: + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + tmp_path = path + ".tmp" + with open(tmp_path, "w") as fh: + json.dump(data, fh) + os.replace(tmp_path, path) + except OSError: + _LOGGER.warning("chv_pool_registry_save_failed path=%s", path) + + +def _orphan_entries(data: dict) -> list[dict]: + """从原始 registry dict 里筛出可清理的上一世条目。 + + 只留 state 为 warm/assigned 且有 vm 的条目;非法条目 + 静默丢弃(不连坐)。返回条目为 {"vm", "task_id", + "snapshot_url"} 的新 dict,不改原数据。""" + entries: list[dict] = [] + for entry in data.values(): + if not isinstance(entry, dict): + continue + if not entry.get("vm"): + continue + if entry.get("state") not in ("warm", "assigned"): + continue + entries.append({ + "vm": entry["vm"], + "task_id": entry.get("task_id"), + "snapshot_url": entry.get("snapshot_url"), + }) + return entries + + +def reconcile_orphans_for_executor(executor: object) -> list[str]: + """生产路径唯一的残留清理入口:返回已清理的 VM 名列表。 + + 只做清理:读 registry → 每个上一世条目尽力 + shutdown_vm → 落盘为空 → best-effort 审计事件。 + 绝不构造 WarmPool、绝不调 prewarm/acquire/release, + 绝不把任何条目发给任务。无 registry 路径时返回 []。""" + path = registry_path_for_executor(executor) + if not path: + return [] + data, _corrupt = _read_registry(path) + if data is None: + return [] + entries = _orphan_entries(data) + cleaned: list[str] = [] + for entry in entries: + vm = entry["vm"] + try: + executor.shutdown_vm(vm) # type: ignore[attr-defined] + except Exception: + pass + cleaned.append(vm) + if cleaned: + _write_registry(path, {}) + try: + executor._emit( # type: ignore[attr-defined] + "warm_pool_reconcile", {"id": None}, + "cleaned=%s" % ",".join(cleaned)) + except Exception: + pass + return cleaned diff --git a/cloud/chv/ssh_transport.py b/cloud/chv/ssh_transport.py index 5d64008..995c461 100644 --- a/cloud/chv/ssh_transport.py +++ b/cloud/chv/ssh_transport.py @@ -63,8 +63,9 @@ def split_secret_env(extra_env: dict) -> tuple: return secrets, plain -def remote_command(inner_cmd, egress_proxy=None, extra_env=None, - guest_env_file=None): +def remote_command(inner_cmd: list, egress_proxy: str | None = None, + extra_env: dict | None = None, + guest_env_file: str | None = None) -> list: """构造远程 shell 命令的 token 列表(不含 ssh 前缀)。 每个 token 都经 shlex.quote:ssh 客户端把 argv 用空格拼 @@ -108,9 +109,12 @@ def known_hosts_path(state_dir: str, vm_name: str) -> str: return os.path.join(state_dir, vm_name, "known_hosts") -def build_ssh_command(ssh_port, ssh_key, ssh_user, egress_proxy, - known_hosts_file, inner_cmd, ssh_host="127.0.0.1", - extra_env=None, guest_env_file=None): +def build_ssh_command(ssh_port: int, ssh_key: str | None, + ssh_user: str, egress_proxy: str | None, + known_hosts_file: str, inner_cmd: list, + ssh_host: str = "127.0.0.1", + extra_env: dict | None = None, + guest_env_file: str | None = None) -> list: """构造 SSH 命令:ssh -p <端口> -i + 钉死选项 + user@ + 客内命令(远程 token 均经 shlex.quote, 见 remote_command)。 @@ -138,7 +142,7 @@ def build_ssh_command(ssh_port, ssh_key, ssh_user, egress_proxy, "%s@%s" % (ssh_user, ssh_host)] + remote -def parse_keyscan_output(text, host="127.0.0.1"): +def parse_keyscan_output(text: str, host: str = "127.0.0.1") -> list: """从 ssh-keyscan 的 stdout 里挑出目标主机的 host key 行:形如 "[]: ssh-ed25519 AAAA..."(-p 时 的标准写法)或 " ssh-rsa AAAA..."。其它主机行、 diff --git a/cloud/console-react/src/api/chv.test.ts b/cloud/console-react/src/api/chv.test.ts index fec6f70..3a73d9e 100644 --- a/cloud/console-react/src/api/chv.test.ts +++ b/cloud/console-react/src/api/chv.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { chvAvailability, chvQueueHint, countChvWorkers, isWorkerAlive } from "./chv"; +import { chvAttachmentBlocked, chvAvailability, chvQueueHint, chvVmBadge, chvVmStatusText, countChvWorkers, isChvExecutor, isWorkerAlive } from "./chv"; // 纯逻辑测试:只测 chv.ts,不渲染组件、不发请求、不读 localStorage。 // alive 规则与 WorkersSection 现有口径一致:alive 字段优先, @@ -50,6 +50,49 @@ describe("chvAvailability", () => { }); }); +describe("isChvExecutor", () => { + it("精确值才算 CHV,不做大小写/前缀宽松匹配", () => { + expect(isChvExecutor("cloud-hypervisor")).toBe(true); + expect(isChvExecutor("Cloud-Hypervisor")).toBe(false); + expect(isChvExecutor("cloud-hypervisor-x")).toBe(false); + expect(isChvExecutor("docker")).toBe(false); + expect(isChvExecutor(undefined)).toBe(false); + expect(isChvExecutor(null)).toBe(false); + }); +}); + +describe("chvAttachmentBlocked", () => { + it("仅 CHV + 带附件时拦(后端 400 的前端提示口径)", () => { + expect(chvAttachmentBlocked("cloud-hypervisor", true)).toBe(true); + expect(chvAttachmentBlocked("cloud-hypervisor", false)).toBe(false); + expect(chvAttachmentBlocked("docker", true)).toBe(false); + expect(chvAttachmentBlocked("cloud", true)).toBe(false); + }); +}); + +describe("chvVmStatusText", () => { + it("已报告死亡时给死亡文案", () => { + expect(chvVmStatusText({ executor: "cloud-hypervisor", chv_vm_dead: true })).toContain("VM 中途死亡"); + }); + it("CHV 无字段时给未报告文案,绝不写成活着", () => { + const text = chvVmStatusText({ executor: "cloud-hypervisor" }); + expect(text).toContain("未报告 VM 死亡"); + expect(text).toContain("不代表 VM 活着"); + }); + it("非 CHV 任务不显示 VM 行", () => { + expect(chvVmStatusText({ executor: "docker" })).toBe(""); + expect(chvVmStatusText({ executor: "docker", chv_vm_dead: true })).toBe(""); + }); +}); + +describe("chvVmBadge", () => { + it("只有 CHV + 已报告死亡时才出徽标", () => { + expect(chvVmBadge({ executor: "cloud-hypervisor", chv_vm_dead: true })).toBe("VM 已报告死亡"); + expect(chvVmBadge({ executor: "cloud-hypervisor" })).toBe(""); + expect(chvVmBadge({ executor: "docker", chv_vm_dead: true })).toBe(""); + }); +}); + describe("chvQueueHint", () => { it("仅 CHV + 排队 + 已知零在线时给提示", () => { const knownZero = { state: "known" as const, online: 0, total: 0 }; diff --git a/cloud/console-react/src/api/chv.ts b/cloud/console-react/src/api/chv.ts index 4decbe6..31e32f8 100644 --- a/cloud/console-react/src/api/chv.ts +++ b/cloud/console-react/src/api/chv.ts @@ -35,6 +35,60 @@ export function chvAvailability( return { state: "unknown", reason: "not-loaded" }; } +// 执行器选择谓词:派发下拉与列表筛选都以字符串相等为准, +// 不做大小写/前缀宽松匹配(后端白名单是精确值,见 server +// POST /tasks 的 executor 白名单;宽松匹配会把非法值放进 +// “CHV 任务”分支,造成前端提示与后端拒绝不一致)。 +export function isChvExecutor(executor: string | undefined | null): boolean { + return executor === "cloud-hypervisor"; +} + +// 附件守卫谓词:CHV + 带附件 → 后端建任务即拒绝(server +// POST /tasks 的附件绑定分支,错误文案“附件不支持 +// cloud-hypervisor 执行器”)。本函数只作前端提示口径, +// 绝不代替后端白名单:前端照常提交,后端拒绝为准。 +export function chvAttachmentBlocked( + executor: string | undefined | null, + hasAttachments: boolean, +): boolean { + return isChvExecutor(executor) && hasAttachments; +} + +// VM 只读状态文案:输入只有后端已返回的两个字段,输出 +// 三种情形之一,绝不编造第四种: +// - chv_vm_dead 为 true → 已报告死亡(来源:worker 终局 +// 写回的 interrupted_reason,见 server _chv_vm_dead_from_row); +// - CHV 任务但无该字段 → 未报告死亡,且明写“后端无 live +// 状态,不代表 VM 活着”(server 没有 VM 登记表); +// - 非 CHV 任务 → 空串(不显示 VM 行)。 +export function chvVmStatusText(task: { + executor?: string | null; + chv_vm_dead?: boolean; +}): string { + // 死亡文案同样先过执行器门:后端只对 CHV 任务派生该字段, + // 万一某天非 CHV 行误带该字段,前端也不把 docker 任务 + // 写成“VM 死亡”。 + if (task.chv_vm_dead && isChvExecutor(task.executor)) { + return "VM 中途死亡,任务未完成、断点可续跑(后端只读字段 chv_vm_dead)"; + } + if (isChvExecutor(task.executor)) { + return "未报告 VM 死亡(后端无 live 状态字段,不代表 VM 活着)"; + } + return ""; +} + +// 列表行徽标:只有已报告死亡时才出现短徽标;未报告时 +// 列表行不写 VM 状态(详情里才写“未报告”长文案,避免 +// 列表行被未知状态刷屏)。 +export function chvVmBadge(task: { + executor?: string | null; + chv_vm_dead?: boolean; +}): string { + return task.chv_vm_dead && isChvExecutor(task.executor) + ? "VM 已报告死亡" + : ""; +} + // 排队原因提示:只在“已知零 CHV worker 在线 + CHV 任务排队”时 // 给出,文案明确写“可能原因”,且不编造后端未返回的 // VM 状态/IP/快照。未知时返回空串(不提示、不猜测)。 diff --git a/cloud/console-react/src/sections/DispatchSection.tsx b/cloud/console-react/src/sections/DispatchSection.tsx index 04f7237..c23281b 100644 --- a/cloud/console-react/src/sections/DispatchSection.tsx +++ b/cloud/console-react/src/sections/DispatchSection.tsx @@ -2,7 +2,7 @@ import { useRef, useState } from "react"; import { useConsole } from "../context"; import { Card, Bar } from "../components/ui"; import { gatherAttachments } from "../api/client"; -import { chvAvailability } from "../api/chv"; +import { chvAttachmentBlocked, chvAvailability, isChvExecutor } from "../api/chv"; import { EFFORTS } from "../api/types"; export default function DispatchSection() { @@ -26,7 +26,7 @@ export default function DispatchSection() { const [hasAttachments, setHasAttachments] = useState(false); const [msg, setMsg] = useState(""); const chvAvail = chvAvailability(ctx.workers, ctx.workersSummary, ctx.workersError); - const isChv = executor === "cloud-hypervisor"; + const isChv = isChvExecutor(executor); function refreshAttachments() { setHasAttachments( !!(filesRef.current?.files?.length || dirRef.current?.files?.length || imgRef.current?.files?.length), @@ -69,7 +69,7 @@ export default function DispatchSection() { if (goalMode || goalInvincible || planMode) { setMsg("协调员任务不能与 goal/计划模式同用:协调员任务本身不跑模型回合,请取消 goal/计划勾选,或清空协调员 JSON"); return; } } const atts = gatherAttachments(filesRef.current?.files || null, dirRef.current?.files || null, imgRef.current?.files || null); - if (isChv && atts.length) { setMsg("cloud-hypervisor 任务不支持附件:请先清空附件再提交(后端对带附件的 CHV 任务会 400)"); return; } + if (chvAttachmentBlocked(executor, atts.length > 0)) { setMsg("cloud-hypervisor 任务不支持附件:请先清空附件再提交(后端对带附件的 CHV 任务会 400)"); return; } try { const result = await ctx.dispatch(body, atts); setMsg(result); diff --git a/cloud/console-react/src/sections/TaskDetailPanel.tsx b/cloud/console-react/src/sections/TaskDetailPanel.tsx index 5a054b3..b3a8af6 100644 --- a/cloud/console-react/src/sections/TaskDetailPanel.tsx +++ b/cloud/console-react/src/sections/TaskDetailPanel.tsx @@ -4,7 +4,7 @@ import { Card } from "../components/ui"; import { shortId, fmt } from "../api/client"; import { STATUS_EXPLAIN } from "../api/types"; import { firstChar, durationOf, tokenOf, fmtCostUsd } from "../api/taskMeta"; -import { chvAvailability, chvQueueHint } from "../api/chv"; +import { chvAvailability, chvQueueHint, chvVmStatusText } from "../api/chv"; import DetailActions from "./detail/DetailActions"; import BrowserPanel from "./detail/BrowserPanel"; import ReviewPanel from "./detail/ReviewPanel"; @@ -131,7 +131,7 @@ export default function TaskDetailPanel() {
turn
{shortId(t.turn_id)}
root turn
{shortId(t.root_turn_id)}
executor
{t.executor || ""}{t.executor === "cloud-hypervisor" ? "(实验执行器,默认关闭;仅展示,无 VM 控制按钮)" : ""}
- {t.chv_vm_dead ?
VM 状态
VM 中途死亡,任务未完成、断点可续跑(后端只读字段 chv_vm_dead;无该字段时不代表 VM 活着)
: null} + {t.executor === "cloud-hypervisor" ?
VM 状态
{chvVmStatusText(t)}
: null}
主机
{t.worktree_host || ""}
模型
{t.model || "默认"}
强度
{t.effort || "默认"}
diff --git a/cloud/console-react/src/sections/TaskListSection.tsx b/cloud/console-react/src/sections/TaskListSection.tsx index 957f7a4..ff45d02 100644 --- a/cloud/console-react/src/sections/TaskListSection.tsx +++ b/cloud/console-react/src/sections/TaskListSection.tsx @@ -1,7 +1,7 @@ import { useConsole } from "../context"; import { shortId } from "../api/client"; import { firstChar, durationOf, tokenOf } from "../api/taskMeta"; -import { chvAvailability, chvQueueHint } from "../api/chv"; +import { chvAvailability, chvQueueHint, chvVmBadge } from "../api/chv"; // 任务列表行 meta:首字 / 总耗时 / token,计算见 api/taskMeta.ts, // 与任务详情共用同一口径。 @@ -80,6 +80,9 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) { {chvQueueHint(t.executor, t.status, chvAvail) ? (
{chvQueueHint(t.executor, t.status, chvAvail)}
) : null} + {chvVmBadge(t) ? ( +
{chvVmBadge(t)}
+ ) : null} ); diff --git a/cloud/console.html b/cloud/console.html index a1fd577..d7b0671 100644 --- a/cloud/console.html +++ b/cloud/console.html @@ -4,7 +4,7 @@ Codex 命令台 - +`)}}j("流已断开")}catch(nt){if(nt.name==="AbortError")return;R(nt instanceof Error?nt.message:String(nt)),j("未开始")}finally{g(!1)}},[r]),Q=d.useCallback(()=>{var k;(k=U.current)==null||k.abort(),g(!1),j("已停止")},[]),vt=d.useCallback(async k=>{if(r)try{const V=await at(W.taskBrowserControl(r.id),"POST",{holder:k});N(V),R("")}catch(V){R(V instanceof Error?V.message:String(V))}},[r]),St=d.useCallback(async k=>{if(!r||(p==null?void 0:p.holder)!=="user"||!F.current)return;const V=F.current.getBoundingClientRect(),nt=Math.round((k.clientX-V.left)/V.width*1280),Dt=Math.round((k.clientY-V.top)/V.height*800);try{await at(W.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:nt,y:Dt})}catch(qt){R(qt instanceof Error?qt.message:String(qt))}},[r,p]),jt=d.useCallback(async k=>{if(!(!r||(p==null?void 0:p.holder)!=="user")){k.preventDefault();try{k.key.length===1?await at(W.taskBrowserInput(r.id),"POST",{type:"type",text:k.key}):await at(W.taskBrowserInput(r.id),"POST",{type:"key",key:k.key})}catch(V){R(V instanceof Error?V.message:String(V))}}},[r,p]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const K=(p==null?void 0:p.holder)||"—",Nt=p!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:C})]}),c.jsxs("span",{className:"muted",children:["会话 ",Nt?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",K]}),p!=null&&p.url?c.jsx("span",{className:"muted",title:p.url,children:p.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[q?c.jsx("button",{type:"button",onClick:Q,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void J(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void X(),children:"刷新状态"}),K!=="user"?c.jsx("button",{type:"button",onClick:()=>void vt("user"),disabled:!Nt,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void vt("agent"),children:"交还给 Agent"})]}),z?c.jsx("div",{className:"muted",style:{marginTop:8},children:z}):null,K==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:F,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:k=>void St(k),onKeyDown:k=>void jt(k),style:{width:"100%",maxWidth:1280,cursor:K==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:Nt?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function Og(){const i=Kt(),[r,h]=d.useState("uncommittedChanges"),[f,p]=d.useState(""),[N,C]=d.useState(""),[j,z]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:R=>h(R.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:R=>p(R.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:N,onChange:R=>C(R.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:j,onChange:R=>z(R.target.value)}),c.jsxs(Ht,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:N.trim()||void 0,instructions:j.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(cu,{children:i.reviews.map(R=>{let q=R.target_type||"";return R.branch&&(q+=":"+R.branch),R.sha&&(q+=":"+String(R.sha).slice(0,12)),R.instructions&&(q+=":"+String(R.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",Ae(R.id)," | ",q," | ",c.jsx("span",{className:"st-"+(R.status||""),children:R.status}),R.created_at?" | "+Kl(R.created_at):"",c.jsx("br",{}),R.findings?String(R.findings).slice(0,1e3):c.jsx("span",{className:"muted",children:"(暂无 findings:评审尚未执行或尚未写回)"})]}),c.jsx("button",{onClick:()=>i.submitFindings(R.id),children:"写 findings"})]},R.id)})})]})}function zg(){const i=Kt();return i.currentTask?i.currentTask.coordinator_parts?i.coordChildren.length===0?c.jsx("div",{className:"muted",children:"(子任务尚未创建)"}):c.jsx(cu,{children:i.coordChildren.map(r=>c.jsx("li",{children:c.jsxs("span",{className:"row-main",children:["分工 ",c.jsx("b",{children:r.coordinator_part||""})," | 状态 ",c.jsx("span",{className:"st-"+r.status,children:r.status})," | ",Ae(r.id)," | ",String(r.prompt||"").slice(0,60)]})},r.id))}):c.jsx("div",{className:"muted",children:"(非协调员任务)"}):c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示协调员的子任务"})}function Rg(){const i=Kt();if(!i.currentTask)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示子代理链路"});if(i.agents.length===0)return c.jsxs("div",{className:"muted",children:["(暂无子代理)",i.agentsNote?c.jsx("span",{className:"err",children:i.agentsNote}):null]});const r={main:"主任务",delegate:"委派子任务",model:"模型子代理"},h={running:"进行中",completed:"已完成",failed:"失败",interrupted:"已中断",unknown:"未知"};return c.jsx(cu,{children:i.agents.map((f,p)=>c.jsx("li",{children:c.jsxs("span",{className:"row-main",children:[c.jsxs("b",{children:[r[f.kind||""]||f.kind||"",":"]}),f.name||""," ",c.jsx("span",{className:"st-"+(f.status||""),children:h[f.status||""]||f.status||"?"})," | 过程调用 "+(f.calls?f.calls.length:0)+" 条",f.updated_ts?" | 最近活动 "+Kl(f.updated_ts/1e3):"",f.prompt?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsxs("span",{className:"muted",children:["任务:",f.prompt]})]}):null,f.calls&&f.calls.length?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),f.calls.slice(-50).map((N,C)=>c.jsx("div",{children:po(N)},C))]}):c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsx("span",{className:"muted",children:"(暂无过程调用)"})]})]})},p))})}function Dg(){const i=Kt();return c.jsxs(c.Fragment,{children:[c.jsxs(Ht,{children:[c.jsx("button",{onClick:i.refreshApprovals,children:"刷新审批队列"}),c.jsx("span",{className:"muted",children:i.approvalMsg})]}),c.jsx("div",{className:"muted",children:"待审批项需人工点记录允许或记录拒绝;超时默认拒绝。当前版本只留回执,不拦截实时行为(实时请求的事前拦截待 host 接入后生效,在此之前实时行为不变)。"}),c.jsx(bl,{empty:"(暂无审批项)",items:i.approvals.map(r=>c.jsxs("span",{className:"row-main",children:["[",r.kind,"|",r.status,"] ",r.summary,["approved","denied","expired"].includes(r.status||"")?"|回执:"+(r.decision||r.status)+" by "+String(r.decided_by||"").slice(0,8)+" "+Kl(r.decided_at):"",r.status==="pending"?c.jsxs(c.Fragment,{children:[" ",c.jsx("button",{onClick:()=>i.decideApproval(r.id,"approved"),children:"记录允许"})," ",c.jsx("button",{onClick:()=>i.decideApproval(r.id,"denied"),children:"记录拒绝"})]}):null]},r.id))})]})}function wg(){const i=Kt(),[r,h]=d.useState(""),[f,p]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("input",{placeholder:"目标任务 id(与本任务同项目;对方运行中排队执行、已终态则续跑复活,绝不打断当前轮)",style:{width:"100%"},value:r,onChange:N=>h(N.target.value)}),c.jsx("textarea",{placeholder:"消息正文(以本任务名义发出;对方 agent 会看到发送方任务 id,可照此回消息)",value:f,onChange:N=>p(N.target.value)}),c.jsxs(Ht,{children:[c.jsx("button",{onClick:async()=>{await i.postPeer("queued",r,f),p("")},children:"排队发给对方"}),c.jsx("button",{className:"primary",onClick:async()=>{await i.postPeer("direct",r,f),p("")},children:"直接发送"}),c.jsx("span",{className:"muted",children:i.peerMsg})]}),c.jsx(bl,{empty:"(本任务尚未发出任务间消息)",items:i.peerMessages.map((N,C)=>c.jsxs("span",{className:"row-main",children:["[发给 ",String(N.task_id).slice(0,8),"|",N.mode,"|",N.status,"] ",String(N.body||"").slice(0,120)]},C))})]})}function kg(){const i=Kt(),[r,h]=d.useState(""),f=i.currentTask?["completed","failed","interrupted"].includes(i.currentTask.status):!1;return c.jsxs(c.Fragment,{children:[c.jsx("div",{className:"muted",children:i.threadText||"点任务行查看详情后,这里显示线程元数据"}),c.jsxs(Ht,{children:[c.jsx("input",{type:"text",placeholder:"新线程名",value:r,onChange:p=>h(p.target.value)}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("rename",r),children:"重命名"}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("archive",""),children:"归档"}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("unarchive",""),children:"取消归档"}),c.jsx("button",{disabled:!f,onClick:()=>i.threadAction("compact",""),children:"压缩"}),c.jsx("span",{className:"muted",children:i.threadGateNote}),c.jsx("span",{className:"muted",children:i.threadMsg})]}),c.jsx(bl,{empty:"暂无其他线程",items:i.threadList.map((p,N)=>c.jsxs("span",{className:"row-main",children:[p.name||p.thread_name||p.id||""," [",p.id||"","]",p.archived?" [已归档]":"",p.cwd?" "+p.cwd:""]},N))}),c.jsx("div",{className:"muted",children:"限制:Docker/异机任务线程操作一律 409;merged/merge-failed 不可改;turn/steer 与 review/start 不在本批。"})]})}function Ug(i){return/503|Service Unavailable/.test(i)?"上游模型服务暂时不可用(503):模型一轮都没跑出来,不是任务逻辑失败;等上游恢复或换模型重试":/429|rate limit/i.test(i)?"上游限流(429):模型拒绝了本次调用,可稍后重试":/timeout|timed out/i.test(i)?"上游调用超时:模型响应未在限时内返回":/401|403|Invalid API key|unauthorized/i.test(i)?"上游鉴权失败:网关 key 或权限有问题":/ECONNREFUSED|ENOTFOUND|network|fetch failed/i.test(i)?"连不上上游模型服务:网络或网关地址有问题":"任务失败(原因见原始错误文本)"}function Hg(i){return i===null||i<0?"未知":i<60?Math.floor(i)+" 秒前":i<3600?Math.floor(i/60)+" 分钟前":(i/3600).toFixed(1)+" 小时前"}const Bg=[["overview","概览"],["collab","协作"],["review","评审与审批"],["threads","线程"],["browser","浏览器"]];function qg(){const i=Kt(),r=i.currentTask,[h,f]=d.useState("overview");if(!r)return c.jsx(Ft,{id:"nav-detail",title:"任务详情",children:c.jsx("div",{className:"muted",children:"点任务行查看详情"})});const[p,N]=ev(r),[C,j]=lv(r),z=si(i.workers,i.workersSummary,i.workersError),R=kc(r.executor,r.status,z),q=tv(r),g=i.costSummary,U=r.plan?"计划模式(只读)":r.goal_mode?r.no_time_limit?"goal 模式无敌版(不限时)":"goal 模式(不限轮次,模型宣告完成)":r.verify_command?"goal 模式(旧语义,验收命令)":null;return c.jsxs("div",{className:"detail-stack",id:"nav-detail",children:[c.jsx("nav",{className:"detail-tabs","aria-label":"任务详情页签",children:Bg.map(([F,X])=>c.jsx("button",{type:"button",className:h===F?"active":"",onClick:()=>f(F),children:X},F))}),h==="overview"?c.jsxs(c.Fragment,{children:[c.jsxs(Ft,{title:"任务详情",children:[c.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8,flexWrap:"wrap"},children:[c.jsx("span",{className:"issue-first",title:"任务首字:"+q,"aria-hidden":"true",children:q}),c.jsxs("span",{children:["状态 ",c.jsx("b",{className:"st-"+r.status,children:r.status}),"(",tg[r.status]||"未知状态",")"]}),c.jsxs("span",{className:"muted",children:["更新 ",Kl(r.updated_at)]})]}),c.jsxs("div",{className:"detail-cards",children:[c.jsxs("div",{className:"detail-card",title:N,children:[c.jsx("div",{className:"detail-card-label",children:"耗时"}),c.jsx("div",{className:"detail-card-value",children:p})]}),c.jsxs("div",{className:"detail-card",title:j,children:[c.jsx("div",{className:"detail-card-label",children:"token"}),c.jsx("div",{className:"detail-card-value",children:C})]}),c.jsxs("div",{className:"detail-card",title:"LLM 请求数 = GET /tasks/{id}/cost 的 llm_requests,即 cost_records 的 completions 之和:host.py 每收到一次 thread/tokenUsage/updated(一次上游补全)计 1,worker 每轮终局记账时累加;估算行(无真实用量)计 0;部署前已记账的老任务没有 completions 列,显示 0 而非回填数",children:[c.jsx("div",{className:"detail-card-label",children:"LLM 请求数"}),c.jsx("div",{className:"detail-card-value",children:g===void 0?"加载中…":g===null?"—":(g.llm_requests??0).toLocaleString()}),g===void 0?c.jsx("div",{className:"muted",children:"成本加载中…"}):g===null?c.jsx("div",{className:"muted",children:"成本明细加载失败,LLM 请求数不可用"}):c.jsxs("div",{className:"muted",children:["上游补全次数之和| 轮数 ",g.turns??0," | fallback ",g.fallback_count??0]})]}),c.jsxs("div",{className:"detail-card",title:"成本明细:GET /tasks/{id}/cost 聚合;美元只计有价行,无价 token 单列;缓存是输入 token 的子集,不另计",children:[c.jsx("div",{className:"detail-card-label",children:"成本"}),c.jsx("div",{className:"detail-card-value",children:g===void 0?"加载中…":g===null?"—":xg(g.usd_micros)}),g===void 0?c.jsx("div",{className:"muted",children:"成本加载中…"}):g===null?c.jsx("div",{className:"muted",children:"成本明细加载失败(“查成本”按钮仍可手动拉取原始 JSON)"}):c.jsxs("div",{className:"muted",children:["token ",(g.total_tokens??0).toLocaleString(),"(实际 ",(g.actual_tokens??0).toLocaleString()," / 估算 ",(g.estimated_tokens??0).toLocaleString(),",缓存 ",(g.cached_input_tokens??0).toLocaleString(),",无价 ",(g.unpriced_actual_tokens??0).toLocaleString(),")| fallback ",g.fallback_count??0]})]})]})]}),r.status==="running"||r.status==="queued"?(()=>{const F=Date.now()/1e3,X=r.last_heartbeat||r.updated_at||null,J=X?F-X:null;return c.jsxs(Ft,{title:"LLM 进度",children:[c.jsxs("div",{children:["状态 ",r.status==="running"?"进行中":"排队中"," · 已用 ",p,J!==null?" · 上次活动 "+Hg(J):""]}),R?c.jsx("div",{className:"muted",children:R}):null,J!==null&&J>180?c.jsx("div",{className:"muted",children:"超过 3 分钟无活动:可能在等上游模型响应(上游 503 时 worker 会重试后失败,失败后这里会显示失败原因;无活动不等于已断连,服务端没有“LLM 已连接”字段,不作此断言)"}):c.jsx("div",{className:"muted",children:"首轮无输出时属正常:模型第一轮回复到达前 trace 里没有消息;回复到达后会在会话区出现,成本里的 LLM 请求数随之增加"})]})})():null,r.error_text?c.jsxs(Ft,{title:"失败原因",children:[c.jsx("div",{children:Ug(r.error_text)}),c.jsx("pre",{style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:r.error_text.slice(0,1e3)}),c.jsxs("div",{className:"muted",children:["完整错误文本仍在下方“操作与结果”里;模型 ",r.model||"默认"," · executor ",r.executor||""]})]}):null,c.jsx(Ft,{title:"基本信息",children:c.jsxs("dl",{className:"detail-kv",children:[c.jsxs("div",{children:[c.jsx("dt",{children:"任务"}),c.jsx("dd",{children:r.id})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"turn"}),c.jsx("dd",{children:Ae(r.turn_id)})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"root turn"}),c.jsx("dd",{children:Ae(r.root_turn_id)})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"executor"}),c.jsxs("dd",{children:[r.executor||"",r.executor==="cloud-hypervisor"?"(实验执行器,默认关闭;仅展示,无 VM 控制按钮)":""]})]}),r.executor==="cloud-hypervisor"?c.jsxs("div",{children:[c.jsx("dt",{children:"VM 状态"}),c.jsx("dd",{children:pg(r)})]}):null,c.jsxs("div",{children:[c.jsx("dt",{children:"主机"}),c.jsx("dd",{children:r.worktree_host||""})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"模型"}),c.jsx("dd",{children:r.model||"默认"})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"强度"}),c.jsx("dd",{children:r.effort||"默认"})]}),r.child_model||r.child_effort?c.jsxs("div",{children:[c.jsx("dt",{children:"子任务默认"}),c.jsx("dd",{children:(r.child_model||"跟随本任务")+" / "+(r.child_effort||"跟随本任务")})]}):null,c.jsxs("div",{children:[c.jsx("dt",{children:"权限"}),c.jsx("dd",{children:r.approval||"默认"})]}),c.jsxs("div",{children:[c.jsx("dt",{children:"项目"}),c.jsx("dd",{children:Ae(r.project_id)})]}),U?c.jsxs("div",{children:[c.jsx("dt",{children:"模式"}),c.jsx("dd",{children:U})]}):null,r.forked_from?c.jsxs("div",{children:[c.jsx("dt",{children:"分支自"}),c.jsx("dd",{children:Ae(r.forked_from)})]}):null,r.coordinator_parts?c.jsxs("div",{children:[c.jsx("dt",{children:"协调员"}),c.jsxs("dd",{children:[r.coordinator_parts.length," 个子任务(",r.coordinator_parts.map(F=>F.name).join("、"),")"]})]}):null,r.parent_task_id?c.jsxs("div",{children:[c.jsx("dt",{children:"协调子任务"}),c.jsxs("dd",{children:["父任务 ",Ae(r.parent_task_id),r.coordinator_part?"("+r.coordinator_part+")":""]})]}):null,r.attachments&&r.attachments.length?c.jsxs("div",{children:[c.jsx("dt",{children:"附件"}),c.jsx("dd",{children:r.attachments.join("、")})]}):null,r.pr_url?c.jsxs("div",{children:[c.jsx("dt",{children:"PR"}),c.jsx("dd",{children:c.jsxs("a",{href:r.pr_url,target:"_blank",rel:"noopener noreferrer",children:["PR #",r.pr_number]})})]}):null]})}),c.jsx(Ft,{title:"prompt",children:c.jsx("div",{style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:r.prompt||"(无 prompt)"})}),c.jsxs(Ft,{title:"操作与结果",children:[c.jsx(Ag,{}),c.jsx("pre",{children:i.detailBody||"(暂无 result/error 输出)"}),i.costBox?c.jsx("pre",{children:i.costBox}):null]})]}):h==="collab"?c.jsxs(c.Fragment,{children:[c.jsx(Ft,{title:"协调子任务",children:c.jsx(zg,{})}),c.jsx(Ft,{title:"子代理链路",children:c.jsx(Rg,{})}),c.jsx(Ft,{title:"给其他任务发消息",children:c.jsx(wg,{})})]}):h==="review"?c.jsxs(c.Fragment,{children:[c.jsx(Ft,{title:"评审队列",children:c.jsx(Og,{})}),c.jsx(Ft,{title:"运行时审批队列",children:c.jsx(Dg,{})})]}):h==="browser"?c.jsx(c.Fragment,{children:c.jsx(Ft,{title:"浏览器远控",children:c.jsx(Mg,{})})}):c.jsx(c.Fragment,{children:c.jsx(Ft,{title:"线程",children:c.jsx(kg,{})})})]})}function Yg(){const i=Kt(),[r,h]=d.useState(""),[f,p]=d.useState(""),[N,C]=d.useState([]),[j,z]=d.useState(null),[R,q]=d.useState(!1),[g,U]=d.useState(""),[F,X]=d.useState(""),J=d.useCallback(async()=>{if(!i.keySaved){C([]);return}try{const K=await at(W.userKeys);C(K.keys||[]),X("")}catch(K){X(K instanceof Error?K.message:"加载 key 列表失败")}},[i.keySaved]);d.useEffect(()=>{J()},[J]);const Q=async()=>{U(""),X(""),q(!1);try{const K=await at(W.userKeys,"POST",{label:f.trim()||"api-key"});z(K),p(""),U("新 key 已生成,只显示一次:复制后请妥善保存。"),await J()}catch(K){X(K instanceof Error?K.message:"生成 key 失败")}},vt=async()=>{if(j)try{await navigator.clipboard.writeText(j.api_key),q(!0)}catch{X("复制失败:请手动选中上方 key 文本复制")}},St=()=>{j&&(i.saveKeyInput(j.api_key),z(null),U("已存为当前浏览器 key(原有 key 未被撤销,需要时可单独撤销)。"))},jt=async K=>{const Nt=(()=>{try{return sl().slice(-4)}catch{return""}})(),k=K.id==="legacy"?"(主 key:撤销后当前浏览器保存的 key 也可能随之失效,需重新登录拿新 key)":K.hint&&K.hint===Nt?"(这就是本浏览器当前在用的 key,撤销后本页会立刻未连接)":"";if(window.confirm(`撤销 key「${K.label}」(后 4 位 ${K.hint||"无"})吗?${k}撤销不可恢复。`)){U(""),X("");try{await at(W.userKey(K.id),"DELETE"),U(`已撤销 key「${K.label}」。`),await J()}catch(V){X(V instanceof Error?V.message:"撤销失败")}}};return c.jsxs(Ft,{id:"nav-key",title:"API Key",children:[c.jsxs(Ht,{children:[c.jsx("input",{type:"password",placeholder:"粘贴你的 X-API-Key(与调 API 用的同一个)",autoComplete:"off",value:r,onChange:K=>h(K.target.value)}),c.jsx("button",{className:"primary",onClick:()=>{i.saveKeyInput(r),h("")},children:"保存到本浏览器"}),c.jsx("button",{onClick:i.clearKeyInput,children:"清除"}),c.jsx("span",{className:"muted",children:i.keyState})]}),c.jsx("div",{children:c.jsx("span",{className:"muted",children:i.quotaText})}),!i.keySaved&&c.jsx("div",{children:c.jsx("span",{className:"muted",children:"先粘贴并保存已有 key,再生成或管理 key(生成接口本身也要用已有 key 鉴权)。"})}),i.keySaved&&c.jsxs(c.Fragment,{children:[c.jsxs(Ht,{children:[c.jsx("input",{placeholder:"新 key 备注(可选,默认 api-key,如 harness2-temp)",autoComplete:"off",value:f,onChange:K=>p(K.target.value)}),c.jsx("button",{className:"primary",onClick:()=>void Q(),children:"生成新 key"}),c.jsx("button",{onClick:()=>void J(),children:"刷新列表"})]}),j&&c.jsxs("div",{children:[c.jsx("div",{children:c.jsxs("span",{className:"muted",children:["新 key(",j.label,",后 4 位 ",j.hint,"):只显示一次,关闭后无法再查看。"]})}),c.jsxs(Ht,{children:[c.jsx("input",{type:"text",readOnly:!0,value:j.api_key,onFocus:K=>K.target.select()}),c.jsx("button",{className:"primary",onClick:()=>void vt(),children:R?"已复制":"复制"}),c.jsx("button",{onClick:St,children:"存为当前浏览器 key"}),c.jsx("button",{onClick:()=>z(null),children:"关闭(不再显示)"})]})]}),g&&c.jsx("div",{children:c.jsx("span",{className:"muted",children:g})}),F&&c.jsx("div",{children:c.jsx("span",{children:F})}),c.jsxs("div",{className:"api-key-list",children:[N.length===0&&c.jsx("span",{className:"muted",children:"暂无 key(或列表加载失败,见上方报错)。"}),N.map(K=>c.jsxs("div",{children:[c.jsx("span",{children:K.label})," ",c.jsxs("span",{className:"muted",children:["后 4 位 ",K.hint||"无"," · 创建 ",Kl(K.created_at)," · 最近使用 ",K.last_used_at?Kl(K.last_used_at):"未使用过"]})," ",K.revoked_at?c.jsxs("span",{className:"muted",children:["已撤销(",Kl(K.revoked_at),")"]}):c.jsx("button",{onClick:()=>void jt(K),children:"撤销"})]},K.id))]})]})]})}function Gg(){const i=Kt();return c.jsxs(Ft,{id:"adminCard",title:c.jsxs(c.Fragment,{children:["管理区 ",c.jsx("span",{className:"muted",children:"(仅全局管理员)"})]}),children:[c.jsx("div",{className:"muted",children:i.adminGate}),i.adminBody?c.jsxs(c.Fragment,{children:[c.jsxs(Ht,{children:[c.jsx("input",{type:"text",placeholder:"要查配额的用户 id",value:i.adminUserId,onChange:r=>i.setAdminUserId(r.target.value)}),c.jsx("button",{onClick:i.adminQuota,children:"查该用户配额"})]}),c.jsxs(Ht,{children:[c.jsx("button",{onClick:i.adminSweep,children:"手动触发 sweep(清理陈旧 running 任务)"}),c.jsx("button",{onClick:i.adminBackup,children:"手动触发备份"})]}),c.jsx("div",{className:"muted",children:i.adminResult})]}):null]})}function Vg(){const i=Kt(),[r,h]=d.useState(""),[f,p]=d.useState(""),[N,C]=d.useState("");return c.jsxs(Ft,{id:"nav-projects",title:"项目",children:[c.jsxs(Ht,{children:[c.jsxs("select",{"aria-label":"项目",value:i.projectId,onChange:j=>i.selectProject(j.target.value),children:[i.projects.length===0?c.jsx("option",{value:"",children:"(先加载项目)"}):null,i.projects.map(j=>c.jsxs("option",{value:j.id,children:[j.name," (",Ae(j.id),")"]},j.id))]}),c.jsx("button",{onClick:i.refreshProjects,children:"刷新"})]}),c.jsxs(Ht,{children:[c.jsx("input",{type:"text",placeholder:"新项目名",value:r,onChange:j=>h(j.target.value)}),c.jsxs("select",{"aria-label":"从 GitHub 仓库选",value:f,onChange:j=>{p(j.target.value),j.target.value&&C(j.target.value)},children:[c.jsx("option",{value:"",children:"(从 GitHub App 安装仓库里选,或手动输入)"}),i.githubRepos.map(j=>c.jsx("option",{value:j.clone_url,children:j.repo_full_name},j.clone_url))]}),c.jsx("input",{type:"text",placeholder:"repo_url(可选)",value:N,onChange:j=>C(j.target.value)}),c.jsx("button",{onClick:async()=>{await i.createProject(r,N),h(""),C(""),p("")},children:"新建项目"})]}),c.jsx("span",{className:"muted",children:i.projectMsg})]})}function Qg(){const i=Kt(),[r,h]=d.useState(""),[f,p]=d.useState("contributor"),N=i.projects.find(C=>C.id===i.projectId);return c.jsxs(Ft,{title:"项目成员",children:[N?c.jsxs("div",{className:"muted",children:[N.name," | ",N.id," | 仓库 ",N.repo_url||"未绑定"," | 我的角色 ",N.my_role||"未知"]}):c.jsx("div",{className:"muted",children:"先选项目"}),c.jsxs(Ht,{children:[c.jsx("input",{type:"text",placeholder:"成员 user_id 或用户名",value:r,onChange:C=>h(C.target.value)}),c.jsxs("select",{"aria-label":"邀请角色",value:f,onChange:C=>p(C.target.value),children:[c.jsx("option",{value:"viewer",children:"viewer(只读)"}),c.jsx("option",{value:"contributor",children:"contributor(可建任务)"}),c.jsx("option",{value:"maintainer",children:"maintainer(可批合并)"}),c.jsx("option",{value:"owner",children:"owner(可管项目)"})]}),c.jsx("button",{onClick:async()=>{await i.inviteMember(r,f),h("")},children:"邀请成员"}),c.jsx("button",{onClick:i.refreshMembers,children:"刷新成员"})]}),i.memberError?c.jsx("div",{className:"muted",children:i.memberError}):i.members.length===0?c.jsx("div",{className:"muted",children:"先选项目"}):c.jsx(cu,{children:i.members.map(C=>{const j=(C.username||"")+(C.username?"("+String(C.user_id).slice(0,8)+")":String(C.user_id).slice(0,8));return c.jsxs("li",{children:[c.jsx("span",{className:"row-main",children:j}),c.jsx("select",{"aria-label":"成员角色",value:C.role||"viewer",onChange:z=>void i.changeMemberRole(C.user_id,z.target.value),children:eg.map(z=>c.jsx("option",{value:z,children:z},z))})]},C.user_id)})}),c.jsx("span",{className:"muted",children:i.memberMsg})]})}function Xg(){const i=Kt(),[r,h]=d.useState(""),[f,p]=d.useState(""),[N,C]=d.useState(""),j=si(i.workers,i.workersSummary,i.workersError);return c.jsxs(Ft,{id:"nav-env",title:c.jsxs(c.Fragment,{children:["环境 ",c.jsx("span",{className:"muted",children:"(选、建、管:当前项目的基准检出)"})]}),children:[c.jsxs(Ht,{children:[c.jsx("button",{onClick:i.refreshEnvs,children:"刷新"}),c.jsx("span",{className:"muted",children:i.envCount})]}),i.envs.length===0?c.jsx("div",{className:"muted",children:"先选项目"}):c.jsx(cu,{children:i.envs.map(z=>c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[z.name,"(",Ae(z.id),") / 分支 ",z.branch||"默认分支"," / 执行器 ",z.executor||"不限"," / 状态 ",z.status,z.status==="failed"&&z.error_text?" / 失败原因:"+z.error_text:""]}),z.status==="archived"?null:c.jsx("button",{onClick:()=>void i.archiveEnv(z.id),children:"归档"})]},z.id))}),c.jsxs(Ht,{children:[c.jsx("input",{type:"text",placeholder:"新环境名",value:r,onChange:z=>h(z.target.value)}),c.jsx("input",{type:"text",placeholder:"分支(可选,空=默认分支)",value:f,onChange:z=>p(z.target.value)}),c.jsxs("select",{"aria-label":"新环境执行器",value:N,onChange:z=>C(z.target.value),children:[c.jsx("option",{value:"",children:"执行器:不限"}),c.jsx("option",{value:"local",children:"local"}),c.jsx("option",{value:"docker",children:"docker"}),c.jsx("option",{value:"cloud-hypervisor",children:"cloud-hypervisor(实验,默认关闭)"})]}),c.jsx("button",{onClick:async()=>{await i.createEnv(r,f,N),h(""),p("")},children:"新建环境"})]}),N==="cloud-hypervisor"?c.jsxs("div",{className:"muted",children:["实验执行器,默认关闭:环境创建白名单同样允许 cloud-hypervisor(后端环境接口 allowlist:local / docker / cloud-hypervisor,不含 cloud);仍需后端有 KVM 宿主上的 CHV worker 才会认领,否则任务排队。前端只传值,不绕过后端白名单。",j.state==="known"?c.jsxs(c.Fragment,{children:[" CHV worker 在线 ",j.online," / 共 ",j.total,"(来自 /workers)",j.online===0?":当前无 CHV worker 在线,任务将一直排队。":""]}):" CHV worker 在线数未知:完整 worker 清单仅全局管理员可见。"]}):null,c.jsx("span",{className:"muted",children:i.envMsg})]})}function Lg(){const i=Kt(),[r,h]=d.useState("");return c.jsxs(Ft,{title:"仓库与环境上下文",children:[c.jsx("div",{className:"muted",children:i.envRepoLine||"先选项目"}),c.jsx("div",{className:"muted",children:i.envCount}),i.envRepoBar?c.jsxs(Ht,{children:[c.jsxs("select",{"aria-label":"补仓库",value:r,onChange:f=>h(f.target.value),children:[c.jsx("option",{value:"",children:"(从 GitHub App 安装仓库里选)"}),i.githubRepos.map(f=>c.jsx("option",{value:f.clone_url,children:f.repo_full_name},f.clone_url))]}),c.jsx("button",{onClick:()=>void i.saveEnvRepo(r),children:"保存仓库"})]}):null,c.jsx("span",{className:"muted",children:i.envRepoMsg})]})}function Zg(){const i=Kt(),r=si(i.workers,i.workersSummary,i.workersError);return c.jsxs(Ft,{id:"nav-workers",title:"Workers",children:[c.jsx(Ht,{children:c.jsx("button",{onClick:i.refreshWorkers,children:"刷新"})}),c.jsx("div",{className:"muted",children:r.state==="known"?c.jsxs(c.Fragment,{children:["cloud-hypervisor worker 在线 ",r.online," / 共 ",r.total,"(实验,默认关闭)",r.online===0?":当前无 CHV worker 在线,CHV 任务将一直排队。":""]}):"cloud-hypervisor worker 在线数未知:完整 worker 清单仅全局管理员可见,非管理员只能看到聚合数,无法按执行器分项。"}),i.workersError?c.jsx("div",{className:"muted",children:i.workersError}):i.workers===null?c.jsx("div",{className:"muted",children:i.workersSummary||"未加载"}):i.workers.length===0?c.jsx("div",{className:"muted",children:"无 worker 注册"}):c.jsx(cu,{children:i.workers.map((h,f)=>{const p=h.running===void 0||h.running===null?"?":h.running,N=h.capacity===void 0||h.capacity===null?"?":h.capacity,C=bo(h),j=h.alive!==void 0&&h.alive!==null?!!h.alive:C.age!==null&&C.age<=120,z=j?" / 在线":" / 离线(占用为上次上报值)";return c.jsx("li",{className:j?"is-online":"is-offline",children:c.jsxs("span",{className:"row-main",children:[h.worker_id||h.id||"?"," / ",h.executor||""," / 占用 ",p,"/",N,z," / 最后心跳 ",C.text]})},f)})})]})}function Kg(){const i=Kt();return c.jsxs(Ft,{title:"健康与配额",children:[c.jsxs(Ht,{children:[c.jsx("span",{className:"muted",children:i.healthText}),c.jsx("button",{onClick:i.refreshHealth,children:"刷新"})]}),c.jsx("div",{children:c.jsx("span",{className:"muted",children:i.quotaText})}),c.jsx("div",{className:"muted",children:i.workersSummary})]})}function Jg(){const i=Kt();return c.jsxs(Ft,{id:"nav-skills",title:"技能与 MCP",children:[c.jsx("div",{className:"muted",children:"反映宿主租户 home,容器内 home 可能不同;重载只在 schema 枚举里,当前 pin 的二进制拒绝时原样 502;OAuth 只发起登录,不代表登录完成。"}),c.jsxs(Ht,{children:[c.jsx("button",{onClick:i.refreshSkills,children:"刷新"}),c.jsx("span",{className:"muted",children:i.skillMsg})]}),c.jsx("h3",{children:"技能"}),i.skillError?c.jsx("div",{className:"muted",children:i.skillError}):c.jsx(bl,{empty:"暂无技能",items:i.skills.map((r,h)=>c.jsxs("span",{className:"row-main",children:[r.name||r.id||"",r.description?" - "+r.description:"",r.detail?" - "+r.detail:""]},h))}),c.jsx("h3",{children:"应用"}),c.jsx(bl,{empty:"暂无应用",items:i.apps.map((r,h)=>c.jsxs("span",{className:"row-main",children:[r.name||r.id||"",r.description?" - "+r.description:""]},h))}),c.jsx("h3",{children:"MCP 服务"}),c.jsx(bl,{empty:"暂无 MCP 服务",items:i.mcpServers.map((r,h)=>{const f=r.detail||r.details||r.state||"";return c.jsxs("span",{className:"row-main",children:[r.name||r.id||""," | 状态 ",r.status||"",f?" | "+f:""]},h)})})]})}function Fg(){const i=Kt(),[r,h]=d.useState("");return c.jsxs(Ft,{title:"MCP 操作",children:[c.jsxs(Ht,{children:[c.jsx("button",{onClick:i.reloadMcp,children:"重载配置"}),c.jsx("span",{className:"muted",children:i.mcpMsg})]}),c.jsxs(Ht,{children:[c.jsx("input",{type:"text",placeholder:"MCP 服务名",value:r,onChange:f=>h(f.target.value)}),c.jsx("button",{onClick:()=>void i.mcpOauth(r),children:"发起 OAuth 登录"}),c.jsx("span",{className:"muted",children:i.mcpOauthMsg})]}),i.mcpOauthBox?c.jsx("pre",{children:i.mcpOauthBox}):null]})}const Fh=160;function Wg(i){return i?i%86400===0?"(每天)":i%3600===0?"(每小时)":i%60===0?"(每 "+i/60+" 分钟)":"":""}function $g(){const i=Kt(),[r,h]=d.useState(""),[f,p]=d.useState(""),[N,C]=d.useState(""),[j,z]=d.useState(""),[R,q]=d.useState(""),[g,U]=d.useState(""),[F,X]=d.useState(""),[J,Q]=d.useState(""),[vt,St]=d.useState(""),[jt,K]=d.useState({}),Nt=k=>{q(k.id),U(k.name||""),X(k.prompt||""),Q(k.interval_seconds?String(k.interval_seconds):""),St(k.repo_url||"")};return c.jsxs(c.Fragment,{children:[c.jsx("input",{placeholder:"自动化名称(同项目内启用中的名称唯一)",style:{width:"100%"},value:r,onChange:k=>h(k.target.value)}),c.jsx("textarea",{placeholder:"定时任务内容(创建只登记定义与到期状态;执行由后续执行器经 claim 抢单后建任务,不另起调度器)",value:f,onChange:k=>p(k.target.value)}),c.jsxs(Ht,{children:[c.jsx("input",{placeholder:"间隔秒数(默认 86400,最小 60)",style:{width:"40%"},value:N,onChange:k=>C(k.target.value)}),c.jsx("input",{placeholder:"仓库(可选)",style:{width:"40%"},value:j,onChange:k=>z(k.target.value)}),c.jsx("button",{className:"primary",onClick:async()=>{await i.createAutomation(r,f,N,j),h(""),p(""),C(""),z("")},children:"创建自动化"}),c.jsx("button",{onClick:i.refreshAutomations,children:"刷新自动化"}),c.jsx("span",{className:"muted",children:i.autoMsg})]}),c.jsx(bl,{empty:"(暂无自动化)",items:i.automations.map(k=>{const V=k.prompt||"",nt=!!jt[k.id],Dt=V.length>Fh,qt=V?nt||!Dt?V:V.slice(0,Fh)+"…":"(无内容)";return c.jsxs("div",{children:[c.jsxs("div",{className:"row-main",children:["[",k.status,k.due?"|到期":"",k.run_once?"|一次性":"","] ",k.name,"|每 ",k.interval_seconds," 秒",Wg(k.interval_seconds),"|上次 ",Kl(k.last_run_at)||"未运行","|下次 ",Kl(k.next_run_at),k.status==="active"?c.jsxs(c.Fragment,{children:[" ",c.jsx("button",{onClick:()=>Nt(k),children:"编辑"})," ",c.jsx("button",{onClick:()=>i.cancelAutomation(k.id),children:"取消"})]}):null]}),k.repo_url?c.jsxs("div",{className:"muted",children:["仓库:",k.repo_url]}):null,k.status==="cancelled"&&k.cancelled_at?c.jsxs("div",{className:"muted",children:["已取消于 ",Kl(k.cancelled_at)]}):null,c.jsxs("div",{className:"muted",style:{whiteSpace:"pre-wrap",overflowWrap:"anywhere"},children:[qt,Dt?c.jsx("button",{onClick:()=>K(dt=>({...dt,[k.id]:!dt[k.id]})),children:nt?"收起":"展开"}):null]}),R===k.id?c.jsxs("div",{children:[c.jsx("input",{placeholder:"自动化名称",style:{width:"100%"},value:g,onChange:dt=>U(dt.target.value)}),c.jsx("textarea",{placeholder:"定时任务内容",value:F,onChange:dt=>X(dt.target.value)}),c.jsxs(Ht,{children:[c.jsx("input",{placeholder:"间隔秒数(最小 60,留空保持原值)",style:{width:"40%"},value:J,onChange:dt=>Q(dt.target.value)}),c.jsx("input",{placeholder:"仓库(可选,清空则清除)",style:{width:"40%"},value:vt,onChange:dt=>St(dt.target.value)}),c.jsx("button",{className:"primary",onClick:async()=>{const dt=i.automations.find(H=>H.id===R)||k;await i.updateAutomation(dt,g,F,J,vt)&&q("")},children:"保存"}),c.jsx("button",{onClick:()=>q(""),children:"取消"})]})]}):null]},k.id)})})]})}function Ig(){var h,f,p,N;const i=Kt(),r=i.triage;return i.triageError?c.jsx("div",{className:"muted",children:i.triageError}):r?c.jsxs(c.Fragment,{children:[c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["到期自动化(",((h=r.counts)==null?void 0:h.automations_due)||0,")"]}),c.jsx(bl,{empty:"(无)",items:(r.automations_due||[]).map((C,j)=>c.jsxs("span",{className:"row-main",children:[C.name,"|每 ",C.interval_seconds," 秒"]},j))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["失败/中断任务(",((f=r.counts)==null?void 0:f.failed_tasks)||0,")"]}),c.jsx(bl,{empty:"(无)",items:(r.failed_tasks||[]).map((C,j)=>c.jsxs("span",{className:"row-main",children:[Ae(C.id),"|",C.status,"|",String(C.prompt||"").slice(0,80)]},j))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["待处理后续消息(",((p=r.counts)==null?void 0:p.pending_followups)||0,")"]}),c.jsx(bl,{empty:"(无)",items:(r.pending_followups||[]).map((C,j)=>c.jsxs("span",{className:"row-main",children:["任务 ",Ae(C.task_id),"|",String(C.body||"").slice(0,80)]},j))})]}),c.jsxs("div",{className:"data-group",children:[c.jsxs("b",{children:["最近自动化产出(",((N=r.counts)==null?void 0:N.recent_runs)||0,")"]}),c.jsx(bl,{empty:"(无)",items:(r.recent_runs||[]).map((C,j)=>c.jsxs("span",{className:"row-main",children:[Ae(C.automation_id),"|",C.status,C.task_id?"|任务 "+Ae(C.task_id):"",C.task_status?"|"+C.task_status:""]},j))})]})]}):c.jsx("div",{className:"muted",children:"(先选项目)"})}function Pg(){return c.jsx(Ft,{id:"nav-automations",title:"自动化",children:c.jsx($g,{})})}function tp(){const i=Kt();return c.jsxs(Ft,{id:"nav-triage",title:"分诊收件箱",children:[c.jsx(Ht,{children:c.jsx("button",{onClick:i.refreshTriage,children:"刷新分诊"})}),c.jsx(Ig,{})]})}const go=["pending","in_progress","blocked","completed","cancelled"],wc={pending:"待办",in_progress:"进行中",blocked:"受阻",completed:"已完成",cancelled:"已取消"},ep=["low","medium","high"],Wh={low:"低",medium:"中",high:"高"};function lp(i){return i?new Date(i*1e3).toLocaleString():""}function ap(){const i=Kt(),[r,h]=d.useState([]),[f,p]=d.useState({}),[N,C]=d.useState(0),[j,z]=d.useState(""),[R,q]=d.useState(""),[g,U]=d.useState(""),[F,X]=d.useState(""),[J,Q]=d.useState(""),[vt,St]=d.useState(""),[jt,K]=d.useState("medium"),[Nt,k]=d.useState(""),[V,nt]=d.useState(""),Dt=d.useCallback(async()=>{if(!i.projectId){h([]),p({}),C(0);return}try{let H=W.projectTodos(i.projectId);const ot=new URLSearchParams;g&&ot.set("status",g),F.trim()&&ot.set("q",F.trim());const yt=ot.toString();yt&&(H+="?"+yt);const G=await at(H);h(G.todos||[]),p(G.counts||{}),C(G.total||0),q("")}catch(H){q("待办加载失败:"+((H==null?void 0:H.message)||String(H)))}},[i.projectId,g,F]);d.useEffect(()=>{Dt()},[Dt]);const qt=async()=>{if(!i.projectId){z("先选项目");return}if(!J.trim()){z("标题必填");return}z("");try{const H={title:J.trim(),description:vt,priority:jt};Nt.trim()&&(H.task_id=Nt.trim()),V&&(H.due_at=Math.floor(new Date(V).getTime()/1e3)),await at(W.projectTodos(i.projectId),"POST",H),Q(""),St(""),k(""),nt(""),z("已创建"),await Dt()}catch(H){z("创建失败:"+((H==null?void 0:H.message)||String(H)))}},dt=async(H,ot)=>{if(i.projectId)try{await at(W.projectTodo(i.projectId,H.id),"PATCH",ot),await Dt()}catch(yt){q("待办更新失败:"+((yt==null?void 0:yt.message)||String(yt)))}},wt=async H=>{if(i.projectId&&window.confirm("删除待办“"+H.title+"”?删除后不可恢复(取消请改状态为已取消)。"))try{await at(W.projectTodo(i.projectId,H.id),"DELETE"),await Dt()}catch(ot){q("待办删除失败:"+((ot==null?void 0:ot.message)||String(ot)))}};return c.jsxs(Ft,{id:"nav-todos",title:"项目待办",children:[c.jsx("div",{className:"muted",children:i.projectId?"共 "+N+" 项 | "+go.map(H=>wc[H]+" "+(f[H]||0)).join(" | "):"先选项目"}),R?c.jsx("div",{className:"err",children:R}):null,c.jsxs(Ht,{children:[c.jsxs("select",{"aria-label":"状态筛选",value:g,onChange:H=>U(H.target.value),children:[c.jsx("option",{value:"",children:"状态:全部"}),go.map(H=>c.jsx("option",{value:H,children:wc[H]},H))]}),c.jsx("input",{type:"text",placeholder:"搜索标题/说明",value:F,onChange:H=>X(H.target.value)}),c.jsx("button",{onClick:()=>void Dt(),children:"刷新"})]}),c.jsx("input",{type:"text",placeholder:"待办标题(必填)",style:{width:"100%"},value:J,onChange:H=>Q(H.target.value)}),c.jsx("textarea",{placeholder:"说明(可选)",value:vt,onChange:H=>St(H.target.value)}),c.jsxs(Ht,{children:[c.jsx("select",{"aria-label":"优先级",value:jt,onChange:H=>K(H.target.value),children:ep.map(H=>c.jsxs("option",{value:H,children:["优先级:",Wh[H]]},H))}),c.jsx("input",{type:"text",placeholder:"关联任务 id(可选)",value:Nt,onChange:H=>k(H.target.value)}),c.jsx("input",{type:"datetime-local","aria-label":"截止时间",value:V,onChange:H=>nt(H.target.value)}),c.jsx("button",{className:"primary",onClick:()=>void qt(),children:"新建待办"}),c.jsx("span",{className:"muted",children:j})]}),c.jsx(bl,{empty:i.projectId?"(暂无待办)":"(先选项目)",items:r.map(H=>c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:H.title})," | ",c.jsx("span",{className:"st-"+H.status,children:wc[H.status]||H.status})," | 优先级 "+(Wh[H.priority]||H.priority),H.task_id?" | 任务 "+Ae(H.task_id):"",H.due_at?" | 截止 "+lp(H.due_at):"",H.completed_at?" | 完成于 "+new Date(H.completed_at*1e3).toLocaleString():"",H.description?c.jsxs(c.Fragment,{children:[c.jsx("br",{}),c.jsx("span",{className:"muted",children:H.description})]}):null,c.jsx("br",{}),c.jsx("select",{"aria-label":"改状态",value:H.status,onChange:ot=>void dt(H,{status:ot.target.value}),children:go.map(ot=>c.jsx("option",{value:ot,children:wc[ot]},ot))})," ",c.jsx("button",{onClick:()=>void dt(H,{status:"completed"}),children:"完成"})," ",c.jsx("button",{onClick:()=>void wt(H),children:"删除"})]},H.id))}),c.jsx("div",{className:"muted",children:"状态与 Muse 待办同款五态;完成时服务端记完成时间,改回其它状态时清掉;已取消是软关闭,删除才是硬删除。"})]})}const jo=[["tasks","任务工作台","任务"],["todos","项目待办","任务"],["projects","项目与成员","管理"],["resources","环境与 Workers","管理"],["automation","技能与自动化","管理"],["admin","系统与 Key","管理"]],np=Object.fromEntries(jo.map(([i,r])=>[i,r]));function $h(){const i=window.location.hash.replace("#view-","");return jo.some(([r])=>r===i)?i:"tasks"}function up({view:i,setView:r}){return c.jsxs("aside",{className:"shell-side",children:[c.jsx("div",{className:"shell-brand",children:"Codex 命令台"}),c.jsx("nav",{className:"shell-nav","aria-label":"导航",children:["任务","管理"].map(h=>c.jsxs("div",{className:"shell-nav-group",children:[c.jsx("div",{className:"shell-nav-group-label",children:h}),jo.filter(([,,f])=>f===h).map(([f,p])=>c.jsx("button",{type:"button",className:i===f?"active":"",onClick:()=>r(f),children:p},f))]},h))}),c.jsxs("div",{className:"shell-stats",children:[c.jsx("div",{className:"shell-stats-label",children:"概览"}),c.jsx(hg,{})]})]})}function ip(){const i=Kt();return c.jsx("span",{className:i.keySaved?"dash-status ok":"dash-status off",children:i.keySaved?"● 已连接":"○ 未连接"})}function cp(){const i=mg();og(j=>i.refreshAll(j),1e4),fg(()=>i.refreshSlow(),6e4);const[r,h]=d.useState(()=>$h()),[f,p]=d.useState(!0),N=j=>{h(j);try{window.history.replaceState(null,"","#view-"+j)}catch{}};d.useEffect(()=>{const j=()=>h($h());return window.addEventListener("hashchange",j),()=>window.removeEventListener("hashchange",j)},[]);const C=()=>{N("tasks"),p(!0),setTimeout(()=>{var j;return(j=document.getElementById("sec-dispatch"))==null?void 0:j.scrollIntoView({behavior:"smooth"})},0)};return c.jsx(Ph.Provider,{value:i,children:c.jsxs("div",{className:"shell",children:[c.jsx(up,{view:r,setView:N}),c.jsxs("div",{className:"shell-main",children:[c.jsxs("header",{className:"shell-header",children:[c.jsx("h1",{children:np[r]}),c.jsx(ip,{}),c.jsx("span",{className:"muted shell-hide-sm",children:i.healthText}),c.jsx("span",{className:"muted shell-hide-sm",children:i.quotaText}),c.jsxs("span",{className:"muted shell-stamp",children:["自动刷新 · 每 10 秒 · 已刷新 ",i.refreshStamp]})]}),r==="tasks"?c.jsxs("div",{className:"workspace",children:[c.jsxs("section",{className:"workspace-rail","aria-label":"任务列表",children:[c.jsx("h2",{children:"任务列表"}),c.jsx(bg,{onNew:C})]}),c.jsxs("section",{className:"workspace-center",children:[c.jsx("div",{className:"composer-toggle",children:c.jsx("button",{type:"button",onClick:()=>p(!f),children:f?"收起新建任务":"新建任务"})}),f?c.jsx("section",{id:"sec-dispatch",children:c.jsx(Sg,{})}):null,c.jsxs("section",{className:"card workspace-conv",children:[c.jsx("h2",{children:"对话"}),c.jsx(Cg,{})]})]}),c.jsx("section",{className:"workspace-inspector","aria-label":"任务详情",children:c.jsx(qg,{})})]}):c.jsx("main",{className:"page",children:r==="todos"?c.jsx(c.Fragment,{children:c.jsx(ap,{})}):r==="projects"?c.jsxs(c.Fragment,{children:[c.jsx(Vg,{}),c.jsx(Qg,{})]}):r==="resources"?c.jsxs(c.Fragment,{children:[c.jsx(Xg,{}),c.jsx(Lg,{}),c.jsx(Zg,{}),c.jsx(Kg,{})]}):r==="automation"?c.jsxs(c.Fragment,{children:[c.jsx(Jg,{}),c.jsx(Fg,{}),c.jsx(Pg,{}),c.jsx(tp,{})]}):c.jsxs(c.Fragment,{children:[c.jsx(Gg,{}),c.jsx(Yg,{})]})})]})]})})}Iy.createRoot(document.getElementById("root")).render(c.jsx(Xy.StrictMode,{children:c.jsx(cp,{})})); diff --git a/cloud/sandbox.py b/cloud/sandbox.py index cb3481f..c08a96b 100644 --- a/cloud/sandbox.py +++ b/cloud/sandbox.py @@ -2208,13 +2208,17 @@ class CloudExecutor(Executor): # -- Cloud Hypervisor 执行器(已拆到 cloud/chv/,这里只留兼容 re-export) -- # #216 的 CHV 类(原 2233-3342 行)与 WarmPool(原 3342-3437 行)、 # _parse_memory_bytes、_unix_socket_api 已分别移到 cloud/chv/ -# 的 executor.py / pool.py / config.py / api_client.py;本模块 -# 不再保留它们的副本,旧导入路径(from cloud.sandbox import -# CloudHypervisorExecutor / WarmPool / ChvFailure)仍可用, -# 新代码优先从 cloud.chv import。执行器方法内按需 import +# 的 executor.py / experimental_pool.py / config.py / +# api_client.py;本模块不再保留它们的副本。生产接口旧导入 +# 路径(from cloud.sandbox import CloudHypervisorExecutor / +# ChvFailure)仍可用,新代码优先从 cloud.chv import。 +# WarmPool 除外:本轨破环性清理(feat-chv-arch-maint-fe, +# 见 cloud/chv/__init__.py),`from cloud.sandbox import +# WarmPool` 一律 ImportError,实验用法只走 +# cloud.chv.experimental_pool。执行器方法内按需 import # 本模块共享 helper 的原因见 cloud/chv/executor.py 头注释。 from cloud.chv import ChvFailure # noqa: F401 from cloud.chv import CloudHypervisorExecutor # noqa: F401 -from cloud.chv import WarmPool # noqa: F401 +from cloud.chv import reconcile_orphans_for_executor # noqa: F401 from cloud.chv.api_client import _unix_socket_api # noqa: F401 from cloud.chv.config import _parse_memory_bytes # noqa: F401 diff --git a/cloud/server.py b/cloud/server.py index ab48819..88e62bd 100644 --- a/cloud/server.py +++ b/cloud/server.py @@ -1766,6 +1766,30 @@ def _row_get(row, name): return None +def _chv_vm_dead_from_row(row): + """CHV 只读字段 chv_vm_dead 的派生规则(本轨命名抽出, + 行为与 feat-chv-arch95plus 逐字一致,见 ADR-11/14)。 + + 来源链(缺一环字段就不出现,绝不编造): + 执行器 _vm_death_result 写 result["vm_dead"]=True → + worker 终局仅当 status='interrupted' 时把原因写进 + tasks.interrupted_reason(非 goal 形态是完整原因文本, + goal 形态无 interrupted_reason 字段时回落为纯文本 + “VM 中途死亡”)→ 本函数只在 executor 为 + cloud-hypervisor 且原因文本含“VM 中途死亡”时返回 + True,其它情形返回 None(调用方不写字段)。 + 本字段是“已报告死亡”,不是 live 状态:server 进程 + 没有 VM 登记表,VM 活着/死了的 live 状态编不出来; + 前端无该字段时必须显示“未报告死亡”,不得写成活着。 + """ + if _row_get(row, "executor") != "cloud-hypervisor": + return None + reason = _row_get(row, "interrupted_reason") + if reason and "VM 中途死亡" in str(reason): + return True + return None + + def _task_to_dict(row, attachments=None, total_tokens=None): depends_on = row["depends_on"] try: @@ -1826,16 +1850,14 @@ def _task_to_dict(row, attachments=None, total_tokens=None): # 列表接口由 _task_tokens_map 一次聚合传入;其它调用方 # 不传时为 None(前端显示占位),绝不把“未算”写成 0。 "total_tokens": total_tokens, - # feat-chv-arch95plus 只读字段:仅当 CHV 任务且 worker - # 已把 VM 死亡写进 interrupted_reason 时才出现,值为 - # True;其它情形一律不返回该字段(绝不把“未报告死亡” - # 写成“VM 活着”,live VM 状态不在 server 进程里, - # server 没有 VM 登记表,编不出来也不编)。 + # feat-chv-arch-maint-fe:只读字段经 + # _chv_vm_dead_from_row 派生(规则见该函数;本轨 + # 未新增第二个生命周期字段——server 持久化的 CHV + # 生命周期事实只有 executor/status/interrupted_reason + # 三项,chv_vm_dead 已是其完整派生,再编字段就是 + # 复制 interrupted_reason 或猜 live 状态,两者都不做)。 **({"chv_vm_dead": True} - if _row_get(row, "executor") == "cloud-hypervisor" - and _row_get(row, "interrupted_reason") - and "VM 中途死亡" in str(_row_get(row, "interrupted_reason")) - else {}), + if _chv_vm_dead_from_row(row) else {}), } diff --git a/cloud/tests/test_chv_arch95.py b/cloud/tests/test_chv_arch95.py index 50f8867..f33d078 100644 --- a/cloud/tests/test_chv_arch95.py +++ b/cloud/tests/test_chv_arch95.py @@ -14,7 +14,7 @@ import unittest from pathlib import Path try: - from cloud.chv.pool import WarmPool + from cloud.chv.experimental_pool import WarmPool from cloud.sandbox import ( ChvFailure, CloudHypervisorExecutor, @@ -23,7 +23,7 @@ try: ) except ImportError: sys.path.insert(0, str(Path(__file__).resolve().parents[2])) - from cloud.chv.pool import WarmPool + from cloud.chv.experimental_pool import WarmPool from cloud.sandbox import ( ChvFailure, CloudHypervisorExecutor, diff --git a/cloud/tests/test_chv_arch95plus.py b/cloud/tests/test_chv_arch95plus.py index f47b59c..9fff5e5 100644 --- a/cloud/tests/test_chv_arch95plus.py +++ b/cloud/tests/test_chv_arch95plus.py @@ -19,14 +19,14 @@ from pathlib import Path try: from cloud.chv.compat215 import resolve_inside, tmpfs_isolated from cloud.chv.hardening import validate_snapshot_url - from cloud.chv.pool import reconcile_orphans_for_executor + from cloud.chv.orphans import reconcile_orphans_for_executor from cloud.sandbox import CloudHypervisorExecutor from cloud.server import _task_to_dict except ImportError: sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from cloud.chv.compat215 import resolve_inside, tmpfs_isolated from cloud.chv.hardening import validate_snapshot_url - from cloud.chv.pool import reconcile_orphans_for_executor + from cloud.chv.orphans import reconcile_orphans_for_executor from cloud.sandbox import CloudHypervisorExecutor from cloud.server import _task_to_dict @@ -92,10 +92,11 @@ class Compat215Test(unittest.TestCase): class PoolFacadeTest(unittest.TestCase): def test_facade_only_reconciles_never_acquires(self): - # facade 内部构造 WarmPool:用假执行器 + 空 registry, - # 只要它不调 acquire/prewarm 就返回 [] 且不炸。 + # facade 直接读 registry 文件做清理(本轨不再构造 + # WarmPool):用假执行器 + 空 registry,返回 [] 不炸, + # 且绝不调 acquire/prewarm(假执行器根本没有这两个方法)。 ex = FakeExecutor() - # WarmPool 构造需要 state_dir 属性;没有则 registry 为空 + # facade 需要 state_dir 属性定 registry 路径;没有则返回 [] ex.state_dir = tempfile.mkdtemp() ex.warm_pool_size = 0 ex.snapshot_url = None diff --git a/cloud/tests/test_chv_arch_maint_fe.py b/cloud/tests/test_chv_arch_maint_fe.py new file mode 100644 index 0000000..56a8a78 --- /dev/null +++ b/cloud/tests/test_chv_arch_maint_fe.py @@ -0,0 +1,464 @@ +"""CHV 架构/可维护性/前端(只读侧)推进轨测试。 + +本轨 feat-chv-arch-maint-fe,堆叠于 #254(feat-chv- +arch95plus)。只用标准库与 mock,绝不启动真实 VM; +测试里只用 FAKE 占位值,绝无真实密钥。覆盖: +- WarmPool 破环性清理:旧 import 路径一律 ImportError, + 实验路径唯一,生产代码不 import 实验模块; +- orphans facade:registry 语义(有效/非法/损坏/缺失/ + shutdown 抛错/_emit 抛错)与旧 WarmPool 逐项一致; +- 配置片段:五片段键集互不相交、并集等于旧字段集、 + 默认值与单项非法报错逐字一致、组装键冲突即报错; +- ADR-12 模块依赖图:叶子模块只依赖标准库、禁 import + 清单、公开 API 全量类型标注且无 Any 逃逸; +- #215 替换清单:每项 local 真实存在、#215 模块当前 + 不存在、R3 永不替换; +- 服务端只读字段:goal 回落形态(纯文本“VM 中途死亡”) + 同样派生 chv_vm_dead,非 VM 中断原因一律不派生。 +""" + +import ast +import importlib.util +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + +try: + from cloud.chv import reconcile_orphans_for_executor + from cloud.chv.compat215 import REPLACEMENT_POINTS + from cloud.chv.compat215 import resolve_inside, tmpfs_isolated + from cloud.chv.config import _assemble_chv_config + from cloud.chv.config import _base_fragment + from cloud.chv.config import _disk_fragment + from cloud.chv.config import _network_fragment + from cloud.chv.config import _quota_fragment + from cloud.chv.config import _ssh_fragment + from cloud.chv.config import _timeout_fragment + from cloud.chv.config import build_chv_config + from cloud.chv.experimental_pool import WarmPool + from cloud.chv.orphans import _REGISTRY_FILENAME + from cloud.chv.orphans import reconcile_orphans_for_executor as _facade + from cloud.server import _chv_vm_dead_from_row, _task_to_dict +except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.chv import reconcile_orphans_for_executor + from cloud.chv.compat215 import REPLACEMENT_POINTS + from cloud.chv.compat215 import resolve_inside, tmpfs_isolated + from cloud.chv.config import _assemble_chv_config + from cloud.chv.config import _base_fragment + from cloud.chv.config import _disk_fragment + from cloud.chv.config import _network_fragment + from cloud.chv.config import _quota_fragment + from cloud.chv.config import _ssh_fragment + from cloud.chv.config import _timeout_fragment + from cloud.chv.config import build_chv_config + from cloud.chv.experimental_pool import WarmPool + from cloud.chv.orphans import _REGISTRY_FILENAME + from cloud.chv.orphans import reconcile_orphans_for_executor as _facade + from cloud.server import _chv_vm_dead_from_row, _task_to_dict + +ROOT = Path(__file__).resolve().parents[2] +CHV = ROOT / "cloud" / "chv" + +EXPECTED_FIELDS = { + "binary", "kernel", "rootfs", "cpus", "memory", "state_dir", + "kvm_path", "cpuinfo_path", "ch_remote", "model", + "ssh_key", "ssh_user", "ssh_port", "host_key_mode", + "guest_host_py", "cmdline", + "net", "net_json", "ssh_host", "ssh_forward", + "egress_proxy", "allow_direct_egress", + "max_vms", "warm_pool_size", + "disk_mode", "disk_gb", "reuse_experiment", + "single_tenant_project", "snapshot_max_count", + "snapshot_max_total_gb", "snapshot_retention_days", + "snapshot_dir", "allow_remote_snapshot", "snapshot_url", + "task_timeout", "api_timeout", "ready_timeout", "ready_interval", +} + + +class FakeProdExecutor: + """生产 facade 替身:只有 facade 允许碰的属性。 + + acquire/prewarm/release 一律不存在——facade 若敢碰, + __getattr__ 直接抛错,测试即 fail。""" + + def __init__(self, state_dir): + self.state_dir = state_dir + self.shutdowns = [] + self.emits = [] + + def __getattr__(self, name): + if name in ("acquire", "prewarm", "release"): + raise AssertionError("facade 不得碰实验接口:%s" % name) + raise AttributeError(name) + + def shutdown_vm(self, vm): + self.shutdowns.append(vm) + return True + + def _emit(self, event, task, detail): + self.emits.append(event) + + +def _write_registry(state_dir, data): + path = os.path.join(state_dir, _REGISTRY_FILENAME) + if isinstance(data, str): + with open(path, "w") as fh: + fh.write(data) + else: + with open(path, "w") as fh: + json.dump(data, fh) + return path + + +class BreakingCleanupTest(unittest.TestCase): + def test_old_paths_raise_import_error(self): + with self.assertRaises(ImportError): + from cloud.chv import WarmPool # noqa: F401 + with self.assertRaises(ImportError): + from cloud.sandbox import WarmPool # noqa: F401 + with self.assertRaises(ImportError): + import cloud.chv.pool # noqa: F401 + + def test_new_paths_and_identity(self): + self.assertEqual(WarmPool.__module__, + "cloud.chv.experimental_pool") + self.assertIs(reconcile_orphans_for_executor, _facade) + import cloud.sandbox as sb + self.assertIs(sb.reconcile_orphans_for_executor, _facade) + self.assertFalse(WarmPool.wired_to_dispatch) + + def test_production_sources_never_import_experimental(self): + # 钉的是 import 语句,不是文档提及:executor/__init__ + # 的 docstring 本来就要写清实验模块去向,文本匹配会 + # 误杀,故用 AST 只看 Import/ImportFrom 节点。 + for rel in ("cloud/worker.py", "cloud/chv/executor.py", + "cloud/chv/__init__.py", "cloud/sandbox.py", + "cloud/chv/orphans.py"): + tree = ast.parse((ROOT / rel).read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for a in node.names: + self.assertNotIn(a.name, ( + "cloud.chv.pool", + "cloud.chv.experimental_pool"), rel) + if isinstance(node, ast.ImportFrom): + self.assertNotIn(node.module, ( + "cloud.chv.pool", + "cloud.chv.experimental_pool"), rel) + if node.module in ("cloud.chv", "cloud.sandbox"): + names = [a.name for a in node.names] + self.assertNotIn("WarmPool", names, rel) + + +class OrphansTest(unittest.TestCase): + def test_valid_warm_and_assigned_cleaned_invalid_dropped(self): + with tempfile.TemporaryDirectory() as d: + path = _write_registry(d, { + "a": {"state": "warm", "vm": "chv-a", + "task_id": "a", "snapshot_url": None}, + "b": {"state": "assigned", "vm": "chv-b", + "task_id": "b", "snapshot_url": None}, + "c": {"state": "warm", "task_id": "c"}, + "e": {"state": "running", "vm": "chv-e"}, + "f": "not-a-dict", + }) + ex = FakeProdExecutor(d) + self.assertEqual( + reconcile_orphans_for_executor(ex), + ["chv-a", "chv-b"]) + self.assertEqual(ex.shutdowns, ["chv-a", "chv-b"]) + self.assertEqual(ex.emits, ["warm_pool_reconcile"]) + with open(path) as fh: + self.assertEqual(json.load(fh), {}) + + def test_invalid_only_returns_empty_and_leaves_file(self): + with tempfile.TemporaryDirectory() as d: + path = _write_registry(d, { + "c": {"state": "warm", "task_id": "c"}}) + ex = FakeProdExecutor(d) + self.assertEqual(reconcile_orphans_for_executor(ex), []) + self.assertEqual(ex.shutdowns, []) + with open(path) as fh: + self.assertIn("task_id", fh.read()) + + def test_corrupt_json_quarantined(self): + with tempfile.TemporaryDirectory() as d: + path = _write_registry(d, "{not json") + ex = FakeProdExecutor(d) + self.assertEqual(reconcile_orphans_for_executor(ex), []) + self.assertFalse(os.path.exists(path)) + self.assertTrue(os.path.exists(path + ".corrupt")) + + def test_nondict_top_level_quarantined(self): + with tempfile.TemporaryDirectory() as d: + path = _write_registry(d, "[1, 2]") + ex = FakeProdExecutor(d) + self.assertEqual(reconcile_orphans_for_executor(ex), []) + self.assertTrue(os.path.exists(path + ".corrupt")) + + def test_missing_file_and_no_state_dir(self): + with tempfile.TemporaryDirectory() as d: + ex = FakeProdExecutor(d) + self.assertEqual(reconcile_orphans_for_executor(ex), []) + + class NoDir: + def shutdown_vm(self, vm): + raise AssertionError("must not be called") + + self.assertEqual( + reconcile_orphans_for_executor(NoDir()), []) + + def test_shutdown_and_emit_failures_do_not_break_cleanup(self): + with tempfile.TemporaryDirectory() as d: + _write_registry(d, { + "a": {"state": "warm", "vm": "chv-a"}}) + ex = FakeProdExecutor(d) + + def boom(vm): + raise RuntimeError("shutdown down") + + ex.shutdown_vm = boom + ex._emit = lambda *a, **k: (_ for _ in ()).throw( + RuntimeError("audit down")) + self.assertEqual( + reconcile_orphans_for_executor(ex), ["chv-a"]) + + def test_experimental_pool_registry_roundtrip_unchanged(self): + # 实验池自身的 registry 语义(载入即 orphan、reconcile + # 清理)与上一轨一致,只是文件 IO 委托给 orphans。 + with tempfile.TemporaryDirectory() as d: + ex = FakeProdExecutor(d) + ex.warm_pool_size = 0 + ex.snapshot_url = None + pool1 = WarmPool(ex) + pool1._entries["chv-old"] = { + "state": "warm", "vm": "chv-old", + "task": {"id": "old"}, "snapshot_url": None} + pool1._save_registry() + pool2 = WarmPool(ex) + self.assertEqual(pool2.orphan_count(), 1) + self.assertEqual(pool2.warm_count(), 0) + self.assertEqual(pool2.reconcile_orphans(), ["chv-old"]) + + +class ConfigFragmentsTest(unittest.TestCase): + def test_fragment_keys_disjoint_and_union_is_field_set(self): + frags = [_base_fragment({}, {}), _ssh_fragment({}, {}), + _network_fragment({}, {}), _quota_fragment({}), + _disk_fragment({}), _timeout_fragment({}, {})] + seen = set() + for frag in frags: + self.assertFalse(seen & set(frag), "片段键重叠") + seen |= set(frag) + self.assertEqual(seen, EXPECTED_FIELDS) + self.assertEqual(len(EXPECTED_FIELDS), 38) + + def test_defaults_and_overrides(self): + cfg = build_chv_config({}, {}) + self.assertEqual(cfg.binary, "cloud-hypervisor") + self.assertEqual(cfg.ssh_port, 2222) + self.assertEqual(cfg.ssh_user, "root") + self.assertEqual(cfg.host_key_mode, "provisioned") + self.assertEqual(cfg.max_vms, 4) + self.assertEqual(cfg.disk_mode, "copy") + self.assertEqual(cfg.disk_gb, 50.0) + self.assertEqual(cfg.api_timeout, 30.0) + self.assertEqual(cfg.ready_timeout, 120.0) + self.assertEqual(cfg.ready_interval, 5.0) + self.assertIsNone(cfg.task_timeout) + self.assertIsNone(cfg.snapshot_url) + cfg2 = build_chv_config( + {"ssh_user": "u", "ssh_port": 2223}, + {"CHV_SSH_USER": "env-user", "CHV_MAX_VMS": "2", + "CHV_EGRESS_PROXY": "http://p:1", + "CHV_SNAPSHOT_URL": "file:///snap/base"}) + self.assertEqual(cfg2.ssh_user, "u") + self.assertEqual(cfg2.ssh_port, 2223) + self.assertEqual(cfg2.max_vms, 2) + self.assertEqual(cfg2.egress_proxy, "http://p:1") + self.assertEqual(cfg2.snapshot_url, "file:///snap/base") + + def test_single_error_messages_unchanged(self): + cases = [ + ({"ssh_user": ""}, {}, "CHV_SSH_USER 不能为空字符串"), + ({}, {"CHV_SSH_PORT": "0"}, "CHV_SSH_PORT 必须在 1-65535 之间"), + ({}, {"CHV_SSH_PORT": "99999"}, + "CHV_SSH_PORT 必须在 1-65535 之间"), + ({}, {"CHV_DISK_MODE": "bad"}, + "CHV_DISK_MODE 只能是 copy 或 reuse"), + ({}, {"CHV_DISK_GB": "0"}, "CHV_DISK_GB 必须大于 0"), + ({}, {"CHV_SNAPSHOT_MAX_COUNT": "-1"}, + "CHV_SNAPSHOT_* 不能为负数"), + ({}, {"CHV_MAX_VMS": "x"}, "CHV_MAX_VMS 必须是整数"), + ({}, {"CHV_WARM_POOL_SIZE": "x"}, + "CHV_WARM_POOL_SIZE 必须是整数"), + ({}, {"CHV_API_TIMEOUT": "x"}, + "CHV_*_TIMEOUT/INTERVAL 必须是数字"), + ] + for params, env, msg in cases: + with self.assertRaises(ValueError) as cm: + build_chv_config(params, env) + self.assertIn(msg, str(cm.exception)) + + def test_assemble_key_conflict_raises(self): + with self.assertRaises(ValueError): + _assemble_chv_config([{"a": 1}, {"a": 2}]) + + +def _top_level_imports(path): + tree = ast.parse(path.read_text()) + mods = set() + for node in tree.body: + if isinstance(node, ast.Import): + mods.update(a.name for a in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + mods.add(node.module) + return mods + + +class DependencyGraphTest(unittest.TestCase): + def test_leaf_modules_only_import_stdlib(self): + for name in ("orphans", "config", "compat215", "errors"): + mods = _top_level_imports(CHV / (name + ".py")) + for mod in mods: + top = mod.split(".")[0] + self.assertFalse(top.startswith("cloud"), + "%s 不得 import %s" % (name, mod)) + + def test_executor_never_imports_pool_or_orphans(self): + # 同上:只看 AST import 节点,模块头 docstring 的 + # 分工说明不算 import。 + tree = ast.parse((CHV / "executor.py").read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for a in node.names: + self.assertNotIn(a.name, ( + "cloud.chv.pool", + "cloud.chv.experimental_pool", + "cloud.chv.orphans")) + if isinstance(node, ast.ImportFrom): + self.assertNotIn(node.module, ( + "cloud.chv.pool", + "cloud.chv.experimental_pool", + "cloud.chv.orphans")) + + def test_no_chv_function_over_50_lines(self): + over = [] + for f in CHV.glob("*.py"): + tree = ast.parse(f.read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef): + n = node.end_lineno - node.lineno + 1 + if n > 50: + over.append("%s:%s:%d" % (f.name, node.name, n)) + self.assertEqual(over, []) + + +class PublicApiTypesTest(unittest.TestCase): + def test_public_api_fully_annotated_and_no_any(self): + import re + unannotated = [] + for f in CHV.glob("*.py"): + text = f.read_text() + for line in text.splitlines(): + self.assertIsNone(re.search(r"\bAny\b", line), + "%s 含 Any 逃逸:%s" % (f.name, line)) + tree = ast.parse(text) + for node in tree.body: + if isinstance(node, ast.FunctionDef) \ + and not node.name.startswith("_"): + bad = [a.arg for a in node.args.args + if a.annotation is None] + if bad or node.returns is None: + unannotated.append("%s:%s" % (f.name, node.name)) + if isinstance(node, ast.ClassDef): + for m in node.body: + if isinstance(m, ast.FunctionDef) and ( + not m.name.startswith("_") + or m.name == "__init__"): + bad = [a.arg for a in m.args.args + if a.arg != "self" + and a.annotation is None] + bad += [a.arg for a in m.args.kwonlyargs + if a.annotation is None] + if bad or m.returns is None: + unannotated.append( + "%s:%s.%s" % (f.name, node.name, + m.name)) + self.assertEqual(unannotated, []) + + def test_corrected_annotations_match_runtime(self): + from cloud.chv.disk import record_snapshot + from cloud.chv.hostkeys import host_key_paths + with tempfile.TemporaryDirectory() as d: + entries, removed = record_snapshot( + d, "vm1", "https://example.invalid/snap", 20.0) + self.assertIsInstance(entries, list) + self.assertIsInstance(removed, list) + paths = host_key_paths(d, "vm1") + self.assertIsInstance(paths, dict) + self.assertIn("private", paths) + + +class ReplacementChecklistTest(unittest.TestCase): + def test_points_resolve_and_215_modules_absent(self): + by_id = {p["id"]: p for p in REPLACEMENT_POINTS} + self.assertEqual(set(by_id), {"R1", "R2", "R3"}) + self.assertIsNotNone(resolve_inside) + self.assertIsNotNone(tmpfs_isolated) + hardening = (ROOT / "cloud" / "chv" / "hardening.py").read_text() + self.assertIn("resolve_inside", hardening) + for mod in ("cloud.gates", "cloud.sandbox_policy", + "cloud.timeouts", "cloud.quota"): + self.assertIsNone(importlib.util.find_spec(mod), mod) + import cloud.chv.compat215 as m + self.assertFalse(hasattr(m, "resolve_module_timeout")) + self.assertFalse(hasattr(m, "decide")) + self.assertIsNone(by_id["R3"]["replacement"]) + self.assertIsNone(by_id["R2"]["caller"]) + + def test_tmpfs_isolated_has_no_production_caller(self): + hits = [] + for f in (ROOT / "cloud").rglob("*.py"): + if "tests" in f.parts: + continue + if "tmpfs_isolated" in f.read_text(): + hits.append(str(f.relative_to(ROOT))) + self.assertEqual(hits, ["cloud/chv/compat215.py"]) + + +def _row(**kw): + base = { + "id": "t1", "project_id": "p1", "prompt": "x", + "status": "interrupted", "idempotency_key": None, + "depends_on": "[]", "priority": 0, "created_by": "u", + "created_at": 0, "updated_at": 0, "last_heartbeat": 0, + } + base.update(kw) + return base + + +class ServerLifecycleTest(unittest.TestCase): + def test_goal_fallback_form_also_derives_dead(self): + row = _row(executor="cloud-hypervisor", + interrupted_reason="VM 中途死亡") + self.assertTrue(_chv_vm_dead_from_row(row)) + self.assertTrue(_task_to_dict(row).get("chv_vm_dead")) + + def test_non_vm_reasons_never_derive(self): + for reason in ("quota", "worker_shutdown", None, ""): + row = _row(executor="cloud-hypervisor", + interrupted_reason=reason) + self.assertIsNone(_chv_vm_dead_from_row(row)) + self.assertNotIn("chv_vm_dead", _task_to_dict(row)) + row = _row(executor="docker", + interrupted_reason="VM 中途死亡,任务未完成") + self.assertIsNone(_chv_vm_dead_from_row(row)) + + +if __name__ == "__main__": + unittest.main() diff --git a/cloud/tests/test_chv_fix.py b/cloud/tests/test_chv_fix.py index b8c400a..39b212e 100644 --- a/cloud/tests/test_chv_fix.py +++ b/cloud/tests/test_chv_fix.py @@ -23,7 +23,7 @@ try: from cloud.chv.api_client import _unix_socket_api from cloud.chv.disk import check_disk_quota, provision_task_disk from cloud.chv.hostkeys import build_seed_disk, seed_disk_path - from cloud.chv.pool import WarmPool + from cloud.chv.experimental_pool import WarmPool from cloud.sandbox import ( ChvFailure, CloudHypervisorExecutor, @@ -34,7 +34,7 @@ except ImportError: from cloud.chv.api_client import _unix_socket_api from cloud.chv.disk import check_disk_quota, provision_task_disk from cloud.chv.hostkeys import build_seed_disk, seed_disk_path - from cloud.chv.pool import WarmPool + from cloud.chv.experimental_pool import WarmPool from cloud.sandbox import ( ChvFailure, CloudHypervisorExecutor, diff --git a/cloud/tests/test_chv_gaps.py b/cloud/tests/test_chv_gaps.py index cfd798b..7fb2abe 100644 --- a/cloud/tests/test_chv_gaps.py +++ b/cloud/tests/test_chv_gaps.py @@ -23,7 +23,7 @@ try: known_hosts_line, write_seed, ) - from cloud.chv.pool import WarmPool + from cloud.chv.experimental_pool import WarmPool from cloud.sandbox import ( ChvFailure, CloudHypervisorExecutor, @@ -39,7 +39,7 @@ except ImportError: known_hosts_line, write_seed, ) - from cloud.chv.pool import WarmPool + from cloud.chv.experimental_pool import WarmPool from cloud.sandbox import ( ChvFailure, CloudHypervisorExecutor, @@ -232,28 +232,38 @@ class ReconcileWiringTest(ChvGapCase): self.assertTrue( os.path.exists(os.path.join(self.state_dir, "warm-pool.json"))) # Worker 构造即 startup reconcile:用假 shutdown 记录。 + # 本轨 worker 不再持有 _warm_pool(生产走 orphans + # facade);实验池只用于本测试造 registry 种子。 shutdowns = [] orig_shutdown = ex.shutdown_vm ex.shutdown_vm = lambda name: shutdowns.append(name) or True worker = Worker(db_path, "/fake/host.py", executor=ex, env={"XCM_API_KEY": "test-placeholder"}) self.assertIn("chv-old", shutdowns) - self.assertEqual(worker._warm_pool.orphan_count(), 0) + self.assertFalse(hasattr(worker, "_warm_pool")) + with open(os.path.join(self.state_dir, "warm-pool.json")) as fh: + self.assertEqual(json.load(fh), {}) worker.close() # close 时再次 reconcile 不炸(池已空,返回 [] 即可)。 self.assertEqual(worker._reconcile_warm_pool("shutdown"), []) def test_reconcile_failure_does_not_break_close(self): from cloud.server import init_db + import cloud.chv.orphans as orphans db_path = os.path.join(self.dir, "control.db") init_db(db_path) ex = self.make_exec() worker = Worker(db_path, "/fake/host.py", executor=ex, env={"XCM_API_KEY": "test-placeholder"}) - # 池的 reconcile 抛错时 close 仍不炸。 - worker._warm_pool.reconcile_orphans = lambda: (_ for _ in ()).throw( - RuntimeError("boom")) - worker.close() # 不抛即通过 + # facade 抛错时 close 仍不炸(worker 方法内每次现取 + # facade,猴子补丁模块属性即生效)。 + orig = orphans.reconcile_orphans_for_executor + orphans.reconcile_orphans_for_executor = lambda ex: ( + (_ for _ in ()).throw(RuntimeError("boom"))) + try: + worker.close() # 不抛即通过 + finally: + orphans.reconcile_orphans_for_executor = orig # -- (3) SSH host key 预置 --------------------------------------------- diff --git a/cloud/tests/test_chv_optimize.py b/cloud/tests/test_chv_optimize.py index ffa778c..e4ccda3 100644 --- a/cloud/tests/test_chv_optimize.py +++ b/cloud/tests/test_chv_optimize.py @@ -23,14 +23,15 @@ from pathlib import Path try: from cloud.chv import ChvFailure from cloud.chv import CloudHypervisorExecutor - from cloud.chv import WarmPool + from cloud.chv import reconcile_orphans_for_executor + from cloud.chv.experimental_pool import WarmPool from cloud.chv.api_client import _unix_socket_api as _chv_api from cloud.chv.config import _parse_memory_bytes as _chv_mem from cloud.sandbox import ChvFailure as _sb_failure from cloud.sandbox import CloudHypervisorExecutor as _sb_exec from cloud.sandbox import DockerExecutor + from cloud.sandbox import reconcile_orphans_for_executor as _sb_reconcile from cloud.sandbox import LocalExecutor - from cloud.sandbox import WarmPool as _sb_pool from cloud.sandbox import _call_runner_with_timeout from cloud.sandbox import _parse_memory_bytes as _sb_mem from cloud.sandbox import _unix_socket_api as _sb_api @@ -38,14 +39,15 @@ except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.p sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from cloud.chv import ChvFailure from cloud.chv import CloudHypervisorExecutor - from cloud.chv import WarmPool + from cloud.chv import reconcile_orphans_for_executor + from cloud.chv.experimental_pool import WarmPool from cloud.chv.api_client import _unix_socket_api as _chv_api from cloud.chv.config import _parse_memory_bytes as _chv_mem from cloud.sandbox import ChvFailure as _sb_failure from cloud.sandbox import CloudHypervisorExecutor as _sb_exec from cloud.sandbox import DockerExecutor + from cloud.sandbox import reconcile_orphans_for_executor as _sb_reconcile from cloud.sandbox import LocalExecutor - from cloud.sandbox import WarmPool as _sb_pool from cloud.sandbox import _call_runner_with_timeout from cloud.sandbox import _parse_memory_bytes as _sb_mem from cloud.sandbox import _unix_socket_api as _sb_api @@ -168,17 +170,27 @@ class ChvOptCase(unittest.TestCase): class SplitTest(unittest.TestCase): def test_new_and_old_import_paths_are_same_objects(self): self.assertIs(CloudHypervisorExecutor, _sb_exec) - self.assertIs(WarmPool, _sb_pool) self.assertIs(ChvFailure, _sb_failure) self.assertIs(_chv_mem, _sb_mem) self.assertIs(_chv_api, _sb_api) + self.assertIs(reconcile_orphans_for_executor, _sb_reconcile) def test_classes_live_in_chv_modules(self): self.assertEqual(CloudHypervisorExecutor.__module__, "cloud.chv.executor") - self.assertEqual(WarmPool.__module__, "cloud.chv.pool") + self.assertEqual(WarmPool.__module__, + "cloud.chv.experimental_pool") self.assertEqual(ChvFailure.__module__, "cloud.chv.errors") + def test_old_warm_pool_import_paths_are_gone(self): + # 本轨破环性清理:旧路径一律 ImportError,实验路径可用。 + with self.assertRaises(ImportError): + from cloud.chv import WarmPool # noqa: F401 + with self.assertRaises(ImportError): + from cloud.sandbox import WarmPool # noqa: F401 + with self.assertRaises(ImportError): + import cloud.chv.pool # noqa: F401 + # -- 去重复:goal 解析与 host 命令 ------------------------------------------ class SharedGoalTest(unittest.TestCase): diff --git a/cloud/tests/test_chv_secfix.py b/cloud/tests/test_chv_secfix.py index ea1ba66..7a34ef3 100644 --- a/cloud/tests/test_chv_secfix.py +++ b/cloud/tests/test_chv_secfix.py @@ -38,7 +38,7 @@ try: dirty_marker_path, write_seed, ) - from cloud.chv.pool import WarmPool + from cloud.chv.experimental_pool import WarmPool from cloud.chv.ssh_transport import ( build_ssh_command, split_secret_env, @@ -67,7 +67,7 @@ except ImportError: dirty_marker_path, write_seed, ) - from cloud.chv.pool import WarmPool + from cloud.chv.experimental_pool import WarmPool from cloud.chv.ssh_transport import ( build_ssh_command, split_secret_env, diff --git a/cloud/tests/test_cloud_hypervisor.py b/cloud/tests/test_cloud_hypervisor.py index c9e62eb..fd3d062 100644 --- a/cloud/tests/test_cloud_hypervisor.py +++ b/cloud/tests/test_cloud_hypervisor.py @@ -20,18 +20,18 @@ from pathlib import Path from unittest import mock try: + from cloud.chv.experimental_pool import WarmPool from cloud.sandbox import ( ChvFailure, CloudHypervisorExecutor, - WarmPool, _parse_memory_bytes, ) except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.chv.experimental_pool import WarmPool from cloud.sandbox import ( ChvFailure, CloudHypervisorExecutor, - WarmPool, _parse_memory_bytes, ) diff --git a/cloud/worker.py b/cloud/worker.py index 8a8ef37..77a99bc 100644 --- a/cloud/worker.py +++ b/cloud/worker.py @@ -551,17 +551,14 @@ class Worker: self.executor.audit_callback = _chv_audit except Exception: pass - # CHV WarmPool:仅孤儿清理接线(feat-chv-arch95 定案)。 - # 池构造本身不自动 - # 清理(单活池假设,见 pool.py),worker 启动是显式 - # 入口,在这里建池并调 reconcile_orphans();关闭时 - # close() 再调一次。调用失败不炸 worker 构造/关闭, - # 但要进审计(_audit 本身 best-effort)。 - self._warm_pool = None + # CHV 残留清理:仅孤儿清理接线(feat-chv-arch95 定案, + # 本轨分文件:生产入口 cloud.chv.orphans,绝不构造 + # WarmPool、不 import 实验模块)。worker 启动是显式 + # 入口,在这里调 facade;关闭时 close() 再调一次。 + # 调用失败不炸 worker 构造/关闭,但要进审计(_audit + # 本身 best-effort)。本 worker 不再持有 _warm_pool。 if isinstance(self.executor, CloudHypervisorExecutor): try: - from cloud.chv.pool import WarmPool as _WarmPool - self._warm_pool = _WarmPool(self.executor) self._reconcile_warm_pool("startup") except Exception: pass @@ -800,25 +797,22 @@ class Worker: return self._cost_store def _reconcile_warm_pool(self, when): - """显式调 WarmPool.reconcile_orphans(),返回已清理 VM 名。 + """显式调生产残留清理 facade,返回已清理 VM 名。 - 仅 CHV worker 且池已建时有效;池未建时若执行器是 CHV - 则现建现调(供测试与手动清理用)。调用失败不炸主流程, - 但进审计(失败本身也审计,不静默丢弃)。orphan 条目 - 绝不发给任务,reconcile 只做清理。""" - pool = getattr(self, "_warm_pool", None) - if pool is None: - executor = getattr(self, "executor", None) - if not isinstance(executor, CloudHypervisorExecutor): - return [] - try: - from cloud.chv.pool import WarmPool as _WarmPool - pool = _WarmPool(executor) - self._warm_pool = pool - except Exception: - return [] + 仅 CHV worker 有效(其它执行器返回 [])。实现是 + cloud.chv.orphans.reconcile_orphans_for_executor(): + 直接读 registry 文件做清理,不构造 WarmPool、不碰 + 实验接口(方法内 import,失败时按下面分支审计后 + 返回 [])。调用失败不炸主流程,但进审计(失败本身 + 也审计,不静默丢弃)。orphan 条目绝不发给任务, + reconcile 只做清理。""" + executor = getattr(self, "executor", None) + if not isinstance(executor, CloudHypervisorExecutor): + return [] try: - cleaned = pool.reconcile_orphans() or [] + from cloud.chv.orphans import \ + reconcile_orphans_for_executor as _reconcile + cleaned = _reconcile(executor) or [] except Exception as exc: try: self._audit(None, "chv_warm_pool_reconcile_failed", diff --git a/docs/chv-architecture.md b/docs/chv-architecture.md index 90930f2..28e81c3 100644 --- a/docs/chv-architecture.md +++ b/docs/chv-architecture.md @@ -1,6 +1,6 @@ # Cloud Hypervisor 执行器架构决策记录(ADR) -范围:cloud/chv/ 执行器链(#216 → #221 → #230 → #232 → #234 → #239 → #246 → 本轨)。 +范围:cloud/chv/ 执行器链(#216 → #221 → #230 → #232 → #234 → #239 → #246 → #252 → #254 → 本轨 feat-chv-arch-maint-fe)。 本文件只记录已落代码的决策,不记录未验证为已验证;真机、压测、动态安全 仍见 docs/chv-production-readiness.md §11。 @@ -97,6 +97,15 @@ import 路径),新代码不得经类入口进实验接口。 - 测试钉住:facade 测试断言 acquire/prewarm 绝不被调; executor 不 import pool 由模块依赖检查覆盖。 +- 本轨更新(feat-chv-arch-maint-fe,取代上文“保留兼容” + 部分):CHV 尚未合并、尚无生产用户,“怕断 import” + 不是保留理由,兼容保留已删除。WarmPool 从 pool.py + 移到 experimental_pool.py,cloud/chv/__init__ 与 + cloud/sandbox.py 不再导出它,生产残留清理彻底分文件 + 到 cloud/chv/orphans.py(facade 不再构造 WarmPool, + 直接读 registry 文件做清理)。旧 import 路径一律 + ImportError,见 ADR-12;合并前即生效的破环性清理, + 合并时须按 PR 描述的清单检查调用方。 ## ADR-9 审计失败取舍:记日志 + 计数,不中断任务 @@ -125,6 +134,93 @@ 三个标记);_unix_socket_api 的读满语义仍由四个 helper 共同保证,改任一 helper 须重跑截断/超时测试。 +## ADR-12 模块职责图与禁 import 清单(本轨) + +依赖方向(箭头 = 可以 import,叶子在下): +- worker.py → orphans.py(生产残留清理,方法内 import) + → 标准库;worker 不 import experimental_pool。 +- executor.py → config/disk/hardening/hostkeys/ + ssh_transport/api_client/errors(方法内按需 import + sandbox 共享 helper 除外,顶层绝不 import sandbox)。 +- experimental_pool.py → orphans.py(只借 registry 文件 + IO 与损坏语义,方向是实验 → 生产,绝不反向)。 +- orphans/config/compat215/errors → 只依赖标准库 + (叶子模块,测试用 AST 钉住)。 +- __init__.py / sandbox.py → 只 re-export 生产接口 + (CloudHypervisorExecutor、ChvFailure、 + reconcile_orphans_for_executor),不导出 WarmPool。 +- config.py → executor.py(executor 只经 build_chv_config + 取配置,不直接读 env 做二次解析)。 + +禁 import 清单(测试用 AST 逐项钉住,文档提及不算): +- orphans/executor/worker/__init__/sandbox 一律不得 + import experimental_pool 或已删除的 cloud.chv.pool; +- executor 一律不得 import orphans(执行器本体不做 + 残留清理,那是 worker 启动/关闭的职责); +- 任何生产模块不得 import cloud.gates / + cloud.sandbox_policy / cloud.timeouts / cloud.quota + (#215 未合并,见 compat215 REPLACEMENT_POINTS)。 + +## ADR-13 配置组装片段化(本轨,行为不变) + +- build_chv_config 拆成六个片段:base(身份/资源基底)、 + ssh(密钥/用户/端口/host key 模式/客内路径/命令行)、 + network(net/net_json/ssh_host/ssh_forward/egress)、 + quota(max_vms/warm_pool_size)、disk(磁盘模式/大小/ + reuse 实验/快照上限与 URL)、timeout(任务/API/就绪 + 超时)。组装只做 dict 合并,键冲突即 ValueError, + 绝不静默覆盖。 +- 移动说明(字段集合与取值不变,测试以 38 字段集钉住): + egress 从旧 quota 片段移到 network(它是出口策略, + 不是 VM 配额);snapshot_url 从旧超时片段移到 disk; + warm_pool_size 从旧超时片段移到 quota。唯一可观测 + 的校验顺序变化:warm_pool_size 非法与磁盘非法同时 + 出现时先报前者;单项非法时报错文案逐字一致。 +- 类型:cloud/chv/ 公开 API(非下划线函数 + 公开类 + __init__)全量标注,无 Any 逃逸;本轨顺手修正三处 + 旧标注错误(record_snapshot 应为 tuple、host_key_paths + 应为 dict、build_seed_disk 应为 str)。私有 helper + 不在“公开 API”口径内,不硬标。 + +## ADR-14 前端只读侧扩展与验证方式(本轨) + +- 本轨前端只做只读侧,不做控制按钮:后端无 VM 控制 + 端点且安全审计 No-Go,绝不发明控制接口。 +- 列表行:此前 chv_vm_dead 只在详情展示,本轨列表行 + 增加“VM 已报告死亡”徽标(字段已由同一 serializer + 返回,列表此前只是没渲染);无徽标时不代表 VM 活着。 +- 详情:此前只有 chv_vm_dead 时才出现 VM 行,本轨对 + 所有 CHV 任务都显示 VM 行——已报告死亡给死亡文案, + 否则给“未报告 VM 死亡(后端无 live 状态字段,不代表 + VM 活着)”。非 CHV 任务不显示 VM 行;死亡文案同样 + 先过执行器门,防止非 CHV 行误带字段时误显示。 +- 池管理与成本页:不新开页面。已有数据继续在已有 + 位置展示:WorkersSection 的 CHV worker 在线数/共计、 + 详情成本卡(token/LLM 请求数/美元明细,来自 + GET /tasks/{id}/cost)。没有数据的页面绝不新开。 +- 后端字段:本轨未新增第二个生命周期字段,理由写清—— + server 持久化的 CHV 生命周期事实只有 executor / + status / interrupted_reason 三项,chv_vm_dead 已是其 + 完整派生;再编一个字段要么是 interrupted_reason 的 + 复制,要么是猜 live 状态,两者都不做。本轨后端改动 + 是把派生抽成 _chv_vm_dead_from_row(行为逐字一致) + 并补 goal 回落形态(纯文本“VM 中途死亡”)的测试。 +- 前端测试:纯逻辑全覆盖——执行器选择谓词(精确值, + 不宽松匹配)、可用性(上一轨)、附件守卫谓词(CHV + + 带附件 → 后端 400,前端提示口径,后端是最终权威; + 派发页提交拦截已接该谓词)、只读状态文案与列表徽标。 + 组件渲染不在覆盖内(无 testing-library 依赖,不临时 + 加依赖冒充覆盖)。 +- 验证方式(仓库浏览器任务拒绝回环地址是已知限制, + 本轨绝不声称已做真机点击):① tsc + vite 构建通过; + ② npm run build:console 产物 console.html 经 grep 含 + 新文案;③ Vitest 全过;④ 用户侧 live browser 手动 + 检查清单(见 PR 描述):本地起 vite preview 或直接 + 打开部署后的控制台,建一个 CHV 任务(无附件)进详情 + 看 VM 行文案、带附件提交看拦截文案、列表行对已报告 + 死亡任务看徽标;真机点击结果由用户侧回填,不在本轨 + 回执里预填。 + ## ADR-11 只读 VM 字段边界:chv_vm_dead 不是 live 状态 - 决策:server 任务序列化只在 CHV 任务且 worker 已把