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)
This commit is contained in:
1 parent
06c3e172fe
commit
8d4771bce3
7 files changed
+1975
-24
No files matched your search
@@ -0,0 +1,211 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user