Files
codex-app-server/cloud/chv/browser_tasks.py
T
host 8d4771bce3 feat(chv): align Muse P8 background work (crons/swarm/presentation/per-task browser)
- crons: pause/resume, exponential-backoff retry (cap 1h), retry claim
  single-winner reusing quota+task build, run_cron_tick cap 5/project
- swarm: per-agent task id + isolated workspace (resolve_inside gate),
  tool register + invocation audit (no execution in swarm layer)
- presentation: worth-presenting rules (needs_input/failed/new
  content only) + notification ledger dedup 1h per (task, reason)
- browser_tasks: one BrowserTransport instance per browser task,
  single-use (reuse after destroy refused), shots dir per instance
- automations: paused status + retry columns (max_retries,
  retry_backoff_seconds, consecutive_failures, next_retry_at)
- gap table docs/chv-muse-p8-gap.md; self-defined values listed there
  (not Meta official): tick cap 5, swarm max 32/default 8, retry
  base 60s cap 3600s, ledger window 3600s, snapshot trunc 200/64KB
- tests: 49 new in cloud/tests/test_chv_muse_p8.py (incl. negatives:
  cron unauthorized trigger, cross-workspace access, no-content
  silence, dedup); full cloud suite 1916 OK (skipped=8)
2026-11-06 22:42:31 +00:00

212 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""P8 browser_tasks:每浏览器任务一个实例——建/绑/毁生命周期。
对齐 Muse B4(官方安全文):每个浏览器任务由专门浏览器子代理
完成。P2 已把浏览器子代理独立成进程(chv-browser.py,常驻、
close 只关页不杀进程);本轨改为每任务一个实例:建实例、绑
定一个浏览器任务、用完即毁,状态绝不跨任务复用。
transport 接口:真实 transport 走 P2 的 broker 协议
(open/snapshot/screenshot/close/ping,经 /run/chv/browser.sock);
客机侧“每任务起一个全新 chv-browser.py、close 后杀进程”
是差距表 §3/§6 的跟进项(P2 的 chv-broker.py 里改,本轨不
复制 P2 的文件)。本层只管生命周期、单实例单任务与状态隔离,
transport 注入(测试用 fake)。
存储:state_dir/browser-tasks.json(原子写 + 损坏 quarantine,
orphans 同款口径);P7 状态存储落地后迁移。
"""
from __future__ import annotations
import json
import logging
import os
import time
import uuid
from cloud.chv.atomic_file import atomic_write_text
from cloud.chv.compat215 import resolve_inside
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
_REGISTRY_FILENAME = "browser-tasks.json"
class BrowserTransport:
"""浏览器实例 transport 接口:真实实现走 P2 的 broker 协议。
spawn 建实例、close 毁实例、ping 探活。子类须实现三者;
基类方法一律 NotImplementedError(绝不静默假装成功)。"""
def spawn(self, instance: dict) -> None:
"""建一个浏览器实例(instance 含 instance_id/task_id)。"""
raise NotImplementedError
def close(self, instance: dict) -> None:
"""毁一个浏览器实例(进程退出、状态清理)。"""
raise NotImplementedError
def ping(self, instance: dict) -> bool:
"""实例是否还活着。"""
raise NotImplementedError
def _registry_path(state_dir: str) -> str:
"""实例登记文件路径。"""
return os.path.join(state_dir, _REGISTRY_FILENAME)
def _quarantine(path: str) -> None:
"""把损坏的登记文件挪到 <path>.corrupt 旁(尽力而为)。"""
try:
os.replace(path, path + ".corrupt")
except OSError:
_LOGGER.warning("chv_browser_tasks_quarantine_failed "
"path=%s", path)
def _load_registry(state_dir: str) -> dict:
"""读登记:无文件回空结构;损坏则 quarantine 后回空结构。"""
path = _registry_path(state_dir)
try:
with open(path, "r") as fh:
data = json.load(fh)
except FileNotFoundError:
return {"instances": {}}
except (OSError, ValueError):
_quarantine(path)
return {"instances": {}}
if not isinstance(data, dict) \
or not isinstance(data.get("instances"), dict):
_quarantine(path)
return {"instances": {}}
return data
def _save_registry(state_dir: str, data: dict) -> None:
"""原子落盘登记;写失败只记日志。"""
try:
os.makedirs(state_dir, exist_ok=True)
atomic_write_text(_registry_path(state_dir), json.dumps(data))
except OSError:
_LOGGER.warning("chv_browser_tasks_save_failed "
"state_dir=%s", state_dir)
def _get_or_raise(data: dict, task_id: str) -> dict:
"""取某任务的实例;不存在即 ValueError。"""
instance = data.get("instances", {}).get(task_id)
if not isinstance(instance, dict):
raise ValueError("browser instance not found")
return instance
def create_instance(state_dir: str, task_id: str,
transport: BrowserTransport,
now: float | None = None) -> dict:
"""为一个浏览器任务建一个实例:单任务单实例(该任务已有
任何实例记录——active、destroyed 乃至 failed——即
ValueError;一个任务一生只有一个实例,毁后不复建,审计
轨迹留在登记里);先落盘 creating、再调 transport.spawn、
成功后置 active;spawn 抛错时记 failed 并原样抛出
(不留半个实例)。返回实例 dict。"""
if now is None:
now = time.time()
if not task_id or not isinstance(task_id, str):
raise ValueError("task_id required")
data = _load_registry(state_dir)
existing = data["instances"].get(task_id)
if isinstance(existing, dict):
raise ValueError("browser instance already exists")
instance_id = uuid.uuid4().hex
instance = {
"instance_id": instance_id,
"task_id": task_id,
"status": "creating",
"shots_dir": "browser-tasks/%s/shots" % instance_id,
"created_at": now,
}
data["instances"][task_id] = instance
_save_registry(state_dir, data)
try:
transport.spawn(instance)
except Exception:
instance["status"] = "failed"
_save_registry(state_dir, data)
raise
instance["status"] = "active"
_save_registry(state_dir, data)
return dict(instance)
def get_instance(state_dir: str, task_id: str) -> dict | None:
"""取某任务的实例;不存在回 None。"""
data = _load_registry(state_dir)
instance = data.get("instances", {}).get(task_id)
if not isinstance(instance, dict):
return None
return dict(instance)
def list_instances(state_dir: str) -> list[dict]:
"""列出全部实例(按创建时间排序)。"""
data = _load_registry(state_dir)
out = [dict(v) for v in data["instances"].values()
if isinstance(v, dict)]
out.sort(key=lambda i: (i.get("created_at") or 0,
i.get("task_id") or ""))
return out
def destroy_instance(state_dir: str, task_id: str,
transport: BrowserTransport,
now: float | None = None) -> dict:
"""毁一个实例:须是 active(不存在/已毁即 ValueError);
调 transport.close,close 抛错也记下并照常置 destroyed
(清理不能因一次 close 失败卡住),返回实例 dict。"""
if now is None:
now = time.time()
data = _load_registry(state_dir)
instance = _get_or_raise(data, task_id)
if instance.get("status") != "active":
raise ValueError("browser instance is not active")
close_error = ""
try:
transport.close(instance)
except Exception as exc:
close_error = str(exc)[:300]
instance["status"] = "destroyed"
instance["destroyed_at"] = now
instance["close_error"] = close_error
_save_registry(state_dir, data)
return dict(instance)
def ping_instance(state_dir: str, task_id: str,
transport: BrowserTransport) -> bool:
"""实例是否还活着:非 active 一律 False;active 时问
transport。"""
data = _load_registry(state_dir)
instance = data.get("instances", {}).get(task_id)
if not isinstance(instance, dict):
return False
if instance.get("status") != "active":
return False
try:
return bool(transport.ping(instance))
except Exception:
return False
def instance_shots_dir(state_dir: str, task_id: str) -> str | None:
"""取某任务实例的截图目录(state_dir 内、resolve_inside
校验,越界回 None);实例不存在回 None。"""
data = _load_registry(state_dir)
instance = data.get("instances", {}).get(task_id)
if not isinstance(instance, dict):
return None
rel = instance.get("shots_dir")
if not isinstance(rel, str) or not rel:
return None
return resolve_inside(state_dir, rel)