merge(chv-muse-round12): 合入 P8 后台工作 (8d4771b)
This commit is contained in:
commit
240949e444
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)
|
||||
@@ -0,0 +1,281 @@
|
||||
"""P8 crons:定时任务的启用/停用、失败重试、tick 驱动。
|
||||
|
||||
建在 cloud.automations 之上(不改 host.py run-due:它是 host
|
||||
侧唯一的执行器,见 automations 模块 docstring)。本模块只依赖
|
||||
标准库 + cloud.automations,不 import 其它 cloud 模块。
|
||||
|
||||
语义(数值全部是本项目自定,见 docs/chv-muse-p8-gap.md §4;
|
||||
Meta 未公开 cron 的调度粒度、数量上限与失败重试策略):
|
||||
- 停用(paused):不到期、不抢单、可恢复;恢复时若同项目已有
|
||||
同名 active 定义则恢复失败(调用方据 ok=False 区分)。
|
||||
- 失败重试:执行终态为 failed/interrupted 时记一次失败;
|
||||
失败次数 <= max_retries 时按下次重试时刻 next_retry_at =
|
||||
now + min(backoff × 2^(n-1), 3600) 安排重试;超限后不再
|
||||
重试,间隔时钟照常走(下个间隔仍会正常触发)。
|
||||
skipped_quota 不是失败,不进重试。
|
||||
- 抢单:间隔抢单与重试抢单各守各的门(claim_due_automation
|
||||
改 last_run_at;claim_retry_automation 只清 next_retry_at、
|
||||
不动间隔时钟),同一执行同一时刻只一个执行器拿到。
|
||||
- tick:只列出本轮到期的定义(间隔到期 + 重试到期,单项目
|
||||
单轮上限 5 个),不抢单;执行器对每条分别调
|
||||
run_due_automation / run_retry_automation(抢单在入口内)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from cloud.automations import ACTIVE
|
||||
from cloud.automations import AUTOMATION_COLUMNS
|
||||
from cloud.automations import DEFAULT_MAX_RETRIES
|
||||
from cloud.automations import PAUSED
|
||||
from cloud.automations import _audit_row
|
||||
from cloud.automations import _row_dict
|
||||
from cloud.automations import automation_to_dict
|
||||
from cloud.automations import due_automations
|
||||
from cloud.automations import ensure_automations_tables
|
||||
from cloud.automations import get_automation
|
||||
from cloud.automations import row_get
|
||||
from cloud.automations import run_due_automation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cloud.db import PgConnection
|
||||
|
||||
# 单项目单轮 tick 最多认领的执行数(本项目自定)。
|
||||
MAX_CONCURRENT_CRON_RUNS_PER_PROJECT = 5
|
||||
# 重试退避上限(秒,本项目自定)。
|
||||
RETRY_BACKOFF_CAP_SECONDS = 3600
|
||||
# 计入失败重试的执行终态(skipped_quota 不是失败,不进重试)。
|
||||
RETRYABLE_STATUSES = ("failed", "interrupted")
|
||||
|
||||
|
||||
def retry_delay_seconds(failures: int, backoff_seconds: int) -> int:
|
||||
"""第 failures 次失败后的重试等待秒数:基数 × 2^(n-1),
|
||||
上限 3600 秒。failures < 1 时按 1 算。"""
|
||||
n = failures if failures >= 1 else 1
|
||||
delay = backoff_seconds * (2 ** (n - 1))
|
||||
return min(delay, RETRY_BACKOFF_CAP_SECONDS)
|
||||
|
||||
|
||||
def _fetch(conn: sqlite3.Connection | PgConnection,
|
||||
automation_id: str) -> dict | None:
|
||||
"""取单条定义的原始行 dict;不存在回 None。"""
|
||||
ensure_automations_tables(conn)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM automations WHERE id = ?",
|
||||
(automation_id,)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return _row_dict(row, AUTOMATION_COLUMNS)
|
||||
|
||||
|
||||
def pause_automation(conn: sqlite3.Connection | PgConnection,
|
||||
automation_id: str,
|
||||
now: float | None = None
|
||||
) -> tuple[bool, dict | None]:
|
||||
"""停用自动化:仅 active 的行能停用。
|
||||
|
||||
返回 (ok, auto):行不存在时 (False, None);已停用/已取消
|
||||
时 (False, 旧 dict),调用方据此区分 404 与 409。成功时
|
||||
commit 并记审计。停用后不到期、不抢单,last_run_at 不动
|
||||
(恢复后按原间隔时钟继续,不立即补跑一轮)。"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
row = _fetch(conn, automation_id)
|
||||
if row is None:
|
||||
return False, None
|
||||
if row_get(row, "status") != ACTIVE:
|
||||
return False, get_automation(conn, automation_id)
|
||||
cur = conn.execute(
|
||||
"UPDATE automations SET status = ?"
|
||||
" WHERE id = ? AND status = ?",
|
||||
(PAUSED, automation_id, ACTIVE))
|
||||
conn.commit()
|
||||
if cur.rowcount != 1:
|
||||
return False, get_automation(conn, automation_id)
|
||||
_audit_row(conn, row_get(row, "project_id"),
|
||||
"automation_paused", automation_id, now)
|
||||
return True, get_automation(conn, automation_id)
|
||||
|
||||
|
||||
def resume_automation(conn: sqlite3.Connection | PgConnection,
|
||||
automation_id: str,
|
||||
now: float | None = None
|
||||
) -> tuple[bool, dict | None]:
|
||||
"""恢复已停用的自动化:仅 paused 的行能恢复。
|
||||
|
||||
同项目已有同名 active 定义时恢复失败(False, 旧 dict):
|
||||
名字唯一规则只认 active,停用期间建了同名新定义是允许
|
||||
的,恢复撞名时必须先改名或取消那一个。成功时 commit 并
|
||||
记审计;last_run_at 不动(恢复后若间隔已过,下一轮 tick
|
||||
即到期,不立即触发)。"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
row = _fetch(conn, automation_id)
|
||||
if row is None:
|
||||
return False, None
|
||||
if row_get(row, "status") != PAUSED:
|
||||
return False, get_automation(conn, automation_id)
|
||||
clash = conn.execute(
|
||||
"SELECT id FROM automations WHERE project_id = ?"
|
||||
" AND name = ? AND status = ? AND id != ?",
|
||||
(row_get(row, "project_id"), row_get(row, "name"),
|
||||
ACTIVE, automation_id)).fetchone()
|
||||
if clash is not None:
|
||||
return False, get_automation(conn, automation_id)
|
||||
cur = conn.execute(
|
||||
"UPDATE automations SET status = ?"
|
||||
" WHERE id = ? AND status = ?",
|
||||
(ACTIVE, automation_id, PAUSED))
|
||||
conn.commit()
|
||||
if cur.rowcount != 1:
|
||||
return False, get_automation(conn, automation_id)
|
||||
_audit_row(conn, row_get(row, "project_id"),
|
||||
"automation_resumed", automation_id, now)
|
||||
return True, get_automation(conn, automation_id)
|
||||
|
||||
|
||||
def retry_due_automations(conn: sqlite3.Connection | PgConnection,
|
||||
project_id: str,
|
||||
now: float | None = None) -> list[dict]:
|
||||
"""列出某项目当前重试到期的定义(纯读,不抢单):
|
||||
active、next_retry_at 已到、失败次数 > 0 且未超 max_retries
|
||||
(老库 NULL 列按默认值 3 算)。按创建时间排序。"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
ensure_automations_tables(conn)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM automations WHERE project_id = ?"
|
||||
" AND status = ? AND next_retry_at IS NOT NULL"
|
||||
" AND next_retry_at <= ?"
|
||||
" AND COALESCE(consecutive_failures, 0) > 0"
|
||||
" AND COALESCE(consecutive_failures, 0)"
|
||||
" <= COALESCE(max_retries, ?)"
|
||||
" ORDER BY created_at, id",
|
||||
(project_id, ACTIVE, now, DEFAULT_MAX_RETRIES)).fetchall()
|
||||
return [automation_to_dict(r, now) for r in rows]
|
||||
|
||||
|
||||
def claim_retry_automation(conn: sqlite3.Connection | PgConnection,
|
||||
automation_id: str,
|
||||
now: float | None = None) -> bool:
|
||||
"""重试抢单:重试执行的唯一入口(与间隔抢单分开)。
|
||||
|
||||
条件 UPDATE 只有一行能成功;抢到即清 next_retry_at 并写
|
||||
last_claimed_at,**不动 last_run_at**(间隔时钟不受重试
|
||||
影响)。返回 True 表示抢到,False 表示不存在、非 active、
|
||||
未到重试时刻、失败次数已超限或已被别的执行器抢走。"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
ensure_automations_tables(conn)
|
||||
cur = conn.execute(
|
||||
"UPDATE automations SET last_claimed_at = ?,"
|
||||
" next_retry_at = NULL"
|
||||
" WHERE id = ? AND status = ?"
|
||||
" AND next_retry_at IS NOT NULL AND next_retry_at <= ?"
|
||||
" AND COALESCE(consecutive_failures, 0) > 0"
|
||||
" AND COALESCE(consecutive_failures, 0)"
|
||||
" <= COALESCE(max_retries, ?)",
|
||||
(now, automation_id, ACTIVE, now, DEFAULT_MAX_RETRIES))
|
||||
conn.commit()
|
||||
return cur.rowcount == 1
|
||||
|
||||
|
||||
def record_automation_outcome(conn: sqlite3.Connection | PgConnection,
|
||||
automation_id: str, status: str,
|
||||
now: float | None = None
|
||||
) -> dict | None:
|
||||
"""记一次自动化执行的终态,更新失败计数与下次重试时刻。
|
||||
|
||||
- failed/interrupted:失败次数 +1;未超 max_retries 时
|
||||
next_retry_at = now + 退避,超限后清 next_retry_at
|
||||
(不再重试,间隔时钟照常走);记审计。
|
||||
- completed:失败次数清零、next_retry_at 清空。
|
||||
- 其它终态(created/skipped_quota 等):不改计数。
|
||||
定义不存在时回 None。成功时 commit 并返回最新 dict。"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
auto = get_automation(conn, automation_id)
|
||||
if auto is None:
|
||||
return None
|
||||
if status in RETRYABLE_STATUSES:
|
||||
return _record_failure(conn, auto, now)
|
||||
if status == "completed":
|
||||
return _record_success(conn, auto, now)
|
||||
return auto
|
||||
|
||||
|
||||
def _record_failure(conn: sqlite3.Connection | PgConnection,
|
||||
auto: dict, now: float) -> dict | None:
|
||||
"""失败分支:失败次数 +1,未超限则安排下次重试时刻。"""
|
||||
failures = int(auto["consecutive_failures"]) + 1
|
||||
max_retries = int(auto["max_retries"])
|
||||
if failures <= max_retries:
|
||||
delay = retry_delay_seconds(
|
||||
failures, int(auto["retry_backoff_seconds"]))
|
||||
next_retry = now + delay
|
||||
else:
|
||||
next_retry = None
|
||||
conn.execute(
|
||||
"UPDATE automations SET consecutive_failures = ?,"
|
||||
" next_retry_at = ? WHERE id = ?",
|
||||
(failures, next_retry, auto["id"]))
|
||||
conn.commit()
|
||||
_audit_row(conn, auto["project_id"], "automation_run_failed",
|
||||
"%s: failures=%d next_retry_at=%s"
|
||||
% (auto["id"], failures, next_retry), now)
|
||||
return get_automation(conn, auto["id"])
|
||||
|
||||
|
||||
def _record_success(conn: sqlite3.Connection | PgConnection,
|
||||
auto: dict, now: float) -> dict | None:
|
||||
"""成功分支:失败次数清零、下次重试时刻清空。"""
|
||||
conn.execute(
|
||||
"UPDATE automations SET consecutive_failures = 0,"
|
||||
" next_retry_at = NULL WHERE id = ?",
|
||||
(auto["id"],))
|
||||
conn.commit()
|
||||
return get_automation(conn, auto["id"])
|
||||
|
||||
|
||||
def run_retry_automation(conn: sqlite3.Connection | PgConnection,
|
||||
automation_id: str,
|
||||
now: float | None = None) -> dict | None:
|
||||
"""重试执行的唯一入口:重试抢单 -> 配额门 -> 建任务 -> 记产出。
|
||||
|
||||
与 run_due_automation 共用配额门与建任务逻辑,只把抢单换
|
||||
成重试抢单;抢不到时回 None。调用方(定时执行器)只调本
|
||||
函数与 run_due_automation,绝不自己拼 claim + INSERT。"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
return run_due_automation(conn, automation_id, now,
|
||||
claim_fn=claim_retry_automation)
|
||||
|
||||
|
||||
def run_cron_tick(conn: sqlite3.Connection | PgConnection,
|
||||
project_id: str,
|
||||
now: float | None = None) -> dict:
|
||||
"""一轮 tick 列出本项目当前到期的定义(纯读,不抢单):
|
||||
间隔到期在前、重试到期在后,单轮总数上限
|
||||
MAX_CONCURRENT_CRON_RUNS_PER_PROJECT(本项目自定)。
|
||||
|
||||
返回 {"project_id", "due", "retry_due", "capped"};执行器
|
||||
对 due 的每条调 run_due_automation、对 retry_due 的每条调
|
||||
run_retry_automation(抢单在入口内,两个执行器同时 tick
|
||||
也只一个能抢到)。"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
due = due_automations(conn, project_id, now)
|
||||
retry = retry_due_automations(conn, project_id, now)
|
||||
cap = MAX_CONCURRENT_CRON_RUNS_PER_PROJECT
|
||||
kept_due = due[:cap]
|
||||
kept_retry = retry[:max(0, cap - len(kept_due))]
|
||||
capped = len(due) + len(retry) > cap
|
||||
return {
|
||||
"project_id": project_id,
|
||||
"due": kept_due,
|
||||
"retry_due": kept_retry,
|
||||
"capped": capped,
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"""P8 presentation:后台任务完成后“是否值得呈现”的评估,与
|
||||
通知去重节流账本。
|
||||
|
||||
对齐 Muse B2(官方设计文):完成后台工作后评估是否值得呈现,
|
||||
只在真正有新内容或需要输入时通知用户。
|
||||
|
||||
判定是确定性规则(不是模型判断):
|
||||
- needs_input:需要用户输入 -> 通知。
|
||||
- failed/interrupted:失败、需要决策 -> 通知。
|
||||
- completed 且有实质新内容(新产物、改动文件、或显式 notify
|
||||
标志)-> 通知;否则常规完成只记日志。
|
||||
- cancelled / 未知终态 -> 只记日志(未知终态 fail-closed,
|
||||
不通知)。
|
||||
|
||||
账本:state_dir/notification-ledger.json(原子写 + 损坏
|
||||
quarantine,orphans 同款口径);同一任务同一原因在节流窗口
|
||||
(3600 秒,本项目自定)内只通知一次。P7 状态存储落地后迁移,
|
||||
本模块 docstring 与差距表已标注;账本丢了只影响去重(可能
|
||||
重复通知一次),不影响任务执行本身。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
from cloud.chv.atomic_file import atomic_write_text
|
||||
|
||||
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
|
||||
|
||||
# 同一任务同一原因的通知节流窗口(秒,本项目自定)。
|
||||
NOTIFY_THROTTLE_SECONDS = 3600
|
||||
_LEDGER_FILENAME = "notification-ledger.json"
|
||||
_MAX_LEDGER_ENTRIES = 500
|
||||
|
||||
|
||||
def evaluate_presentation(result: dict) -> dict:
|
||||
"""评估一次后台任务终态是否值得呈现。
|
||||
|
||||
result 键:status(completed/failed/interrupted/cancelled/
|
||||
needs_input)、new_artifacts(list)、changed_files(list)、
|
||||
notify(bool,任务显式要求通知)。返回 {"present", "reason"};
|
||||
present 为 False 时调用方只记日志、不通知。"""
|
||||
if not isinstance(result, dict):
|
||||
return {"present": False, "reason": "unknown_status"}
|
||||
status = result.get("status")
|
||||
if status == "needs_input":
|
||||
return {"present": True, "reason": "needs_input"}
|
||||
if status in ("failed", "interrupted"):
|
||||
return {"present": True, "reason": "failed_needs_decision"}
|
||||
if status == "completed":
|
||||
return _evaluate_completed(result)
|
||||
if status == "cancelled":
|
||||
return {"present": False, "reason": "cancelled"}
|
||||
return {"present": False, "reason": "unknown_status"}
|
||||
|
||||
|
||||
def _evaluate_completed(result: dict) -> dict:
|
||||
"""完成态分支:有实质新内容才通知。"""
|
||||
artifacts = result.get("new_artifacts")
|
||||
changed = result.get("changed_files")
|
||||
has_new = bool(result.get("notify"))
|
||||
if isinstance(artifacts, list) and artifacts:
|
||||
has_new = True
|
||||
if isinstance(changed, list) and changed:
|
||||
has_new = True
|
||||
if has_new:
|
||||
return {"present": True, "reason": "substantive_new_content"}
|
||||
return {"present": False, "reason": "routine_completed"}
|
||||
|
||||
|
||||
def _ledger_path(state_dir: str) -> str:
|
||||
"""通知账本文件路径。"""
|
||||
return os.path.join(state_dir, _LEDGER_FILENAME)
|
||||
|
||||
|
||||
def _quarantine(path: str) -> None:
|
||||
"""把损坏的账本挪到 <path>.corrupt 旁(尽力而为)。"""
|
||||
try:
|
||||
os.replace(path, path + ".corrupt")
|
||||
except OSError:
|
||||
_LOGGER.warning("chv_ledger_quarantine_failed path=%s", path)
|
||||
|
||||
|
||||
def _load_ledger(state_dir: str) -> dict:
|
||||
"""读账本:无文件回空结构;损坏则 quarantine 后回空结构
|
||||
(去重信息丢了只会导致一次重复通知,不影响任务本身)。"""
|
||||
path = _ledger_path(state_dir)
|
||||
try:
|
||||
with open(path, "r") as fh:
|
||||
data = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
return {"notified": {}}
|
||||
except (OSError, ValueError):
|
||||
_quarantine(path)
|
||||
return {"notified": {}}
|
||||
if not isinstance(data, dict) \
|
||||
or not isinstance(data.get("notified"), dict):
|
||||
_quarantine(path)
|
||||
return {"notified": {}}
|
||||
return data
|
||||
|
||||
|
||||
def _save_ledger(state_dir: str, data: dict) -> None:
|
||||
"""原子落盘账本(截断到上限);写失败只记日志。"""
|
||||
notified = data.get("notified", {})
|
||||
if len(notified) > _MAX_LEDGER_ENTRIES:
|
||||
items = sorted(notified.items(), key=lambda kv: kv[1])
|
||||
data["notified"] = dict(items[-_MAX_LEDGER_ENTRIES:])
|
||||
try:
|
||||
os.makedirs(state_dir, exist_ok=True)
|
||||
atomic_write_text(_ledger_path(state_dir), json.dumps(data))
|
||||
except OSError:
|
||||
_LOGGER.warning("chv_ledger_save_failed state_dir=%s", state_dir)
|
||||
|
||||
|
||||
class NotificationLedger:
|
||||
"""通知去重节流账本(每任务每原因节流)。
|
||||
|
||||
should_notify(task_id, reason) 在窗口内未通知过时回 True
|
||||
并记下这次通知;窗口内已通知过回 False。账本先走原子
|
||||
JSON 文件,待迁移到 P7 状态存储(见模块 docstring)。
|
||||
"""
|
||||
|
||||
def __init__(self, state_dir: str) -> None:
|
||||
"""建账本:state_dir 为空即 ValueError。"""
|
||||
if not state_dir or not isinstance(state_dir, str):
|
||||
raise ValueError("state_dir required")
|
||||
self.state_dir = state_dir
|
||||
|
||||
def should_notify(self, task_id: str, reason: str,
|
||||
now: float | None = None) -> bool:
|
||||
"""该任务该原因现在能不能通知:窗口内没通知过就记下
|
||||
并回 True,否则回 False。task_id/reason 为空即
|
||||
ValueError。"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
if not task_id or not isinstance(task_id, str):
|
||||
raise ValueError("task_id required")
|
||||
if not reason or not isinstance(reason, str):
|
||||
raise ValueError("reason required")
|
||||
key = "%s:%s" % (task_id, reason)
|
||||
data = _load_ledger(self.state_dir)
|
||||
notified = data.get("notified", {})
|
||||
last = notified.get(key)
|
||||
if isinstance(last, (int, float)) \
|
||||
and now - last < NOTIFY_THROTTLE_SECONDS:
|
||||
return False
|
||||
notified[key] = now
|
||||
_save_ledger(self.state_dir, data)
|
||||
return True
|
||||
|
||||
|
||||
def present_or_log(result: dict, ledger: NotificationLedger,
|
||||
task_id: str,
|
||||
now: float | None = None) -> dict:
|
||||
"""评估 + 去重的一站式入口(worker/push 终态派发处调它)。
|
||||
|
||||
返回 {"present", "reason", "notified"}:present 是评估结论,
|
||||
notified 是本次是否真的发出通知(评估通过但窗口内已通知
|
||||
过时 notified 为 False、只记日志)。"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
decision = evaluate_presentation(result)
|
||||
if not decision["present"]:
|
||||
return {"present": False, "reason": decision["reason"],
|
||||
"notified": False}
|
||||
notified = ledger.should_notify(task_id, decision["reason"], now)
|
||||
return {"present": True, "reason": decision["reason"],
|
||||
"notified": notified}
|
||||
@@ -0,0 +1,383 @@
|
||||
"""P8 swarm:子代理群——建群、派代理、隔离、工具注册与审计。
|
||||
|
||||
对齐 Muse B3(官方安全文):启动成群的子代理、构建自己的工具;
|
||||
VM 需要足够资源处理并发子代理与 crons。资源上限是本项目自定
|
||||
(见 docs/chv-muse-p8-gap.md §4),不是 Meta 官方值。
|
||||
|
||||
隔离模型:每代理一个独立任务 id + 独立工作区(父任务工作区下
|
||||
按代理 id 分目录,`resolve_inside` 门守着);代理之间互不可见
|
||||
对方工作区(`check_agent_path` 否定测试钉住)。真正执行在代
|
||||
理自己的 VM/单元里(P1 常驻池 + P2 单元是底座),本层只做登
|
||||
记、派发与审计,不执行工具代码。
|
||||
|
||||
存储:state_dir/swarms.json(原子写 + 损坏 quarantine,orphans
|
||||
同款口径);P7 状态存储落地后迁移,本模块 docstring 与差距表
|
||||
已标注。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
|
||||
from cloud.chv.atomic_file import atomic_write_text
|
||||
from cloud.chv.compat215 import resolve_inside
|
||||
|
||||
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
|
||||
|
||||
MAX_AGENTS_PER_SWARM = 8
|
||||
MAX_AGENTS_LIMIT = 32
|
||||
MAX_SWARMS_PER_PROJECT = 10
|
||||
MAX_TOOL_NAME_LEN = 100
|
||||
MAX_AGENT_PROMPT_LEN = 20000
|
||||
MAX_AUDIT_ENTRIES = 200
|
||||
MAX_INVOCATION_ENTRIES = 200
|
||||
AGENT_STATUSES = ("pending", "running", "done", "failed")
|
||||
_REGISTRY_FILENAME = "swarms.json"
|
||||
|
||||
|
||||
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_swarm_registry_quarantine_failed "
|
||||
"path=%s", path)
|
||||
|
||||
|
||||
def _empty_registry() -> dict:
|
||||
"""空登记结构。"""
|
||||
return {"swarms": {}, "tools": {}, "invocations": [],
|
||||
"audit": []}
|
||||
|
||||
|
||||
def _load_registry(state_dir: str) -> dict:
|
||||
"""读登记:无文件回空结构;损坏则 quarantine 后回空结构
|
||||
(损坏丢状态是 orphans 同款 fail-safe 口径,见差距表)。"""
|
||||
path = _registry_path(state_dir)
|
||||
try:
|
||||
with open(path, "r") as fh:
|
||||
data = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
return _empty_registry()
|
||||
except (OSError, ValueError):
|
||||
_quarantine(path)
|
||||
return _empty_registry()
|
||||
if not isinstance(data, dict):
|
||||
_quarantine(path)
|
||||
return _empty_registry()
|
||||
data.setdefault("swarms", {})
|
||||
data.setdefault("tools", {})
|
||||
data.setdefault("invocations", [])
|
||||
data.setdefault("audit", [])
|
||||
return data
|
||||
|
||||
|
||||
def _save_registry(state_dir: str, data: dict) -> None:
|
||||
"""原子落盘登记(审计与调用记录各截断到上限);写失败
|
||||
只记日志,不炸调用方。"""
|
||||
data["audit"] = data.get("audit", [])[-MAX_AUDIT_ENTRIES:]
|
||||
data["invocations"] = data.get("invocations", [])[
|
||||
-MAX_INVOCATION_ENTRIES:]
|
||||
try:
|
||||
os.makedirs(state_dir, exist_ok=True)
|
||||
atomic_write_text(_registry_path(state_dir),
|
||||
json.dumps(data))
|
||||
except OSError:
|
||||
_LOGGER.warning("chv_swarm_registry_save_failed "
|
||||
"state_dir=%s", state_dir)
|
||||
|
||||
|
||||
def _audit(data: dict, action: str, detail: str, now: float) -> None:
|
||||
"""记一条审计(调用方负责落盘)。"""
|
||||
data.setdefault("audit", []).append(
|
||||
{"action": action, "detail": detail, "created_at": now})
|
||||
|
||||
|
||||
def _get_swarm_or_raise(data: dict, swarm_id: str) -> dict:
|
||||
"""取群,不存在即 ValueError(调用方转 404)。"""
|
||||
swarm = data.get("swarms", {}).get(swarm_id)
|
||||
if not isinstance(swarm, dict):
|
||||
raise ValueError("swarm not found")
|
||||
return swarm
|
||||
|
||||
|
||||
def create_swarm(state_dir: str, project_id: str,
|
||||
parent_task_id: str, max_agents: int = 8,
|
||||
now: float | None = None) -> dict:
|
||||
"""建群:每项目群上限 10(本项目自定),每群代理上限
|
||||
1–32(默认 8,本项目自定)。返回群 dict。"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
if isinstance(max_agents, bool) \
|
||||
or not isinstance(max_agents, int):
|
||||
raise ValueError("max_agents must be an integer")
|
||||
if not 1 <= max_agents <= MAX_AGENTS_LIMIT:
|
||||
raise ValueError("max_agents must be between 1 and %d"
|
||||
% MAX_AGENTS_LIMIT)
|
||||
if not project_id or not parent_task_id:
|
||||
raise ValueError("project_id and parent_task_id required")
|
||||
data = _load_registry(state_dir)
|
||||
count = sum(1 for s in data["swarms"].values()
|
||||
if isinstance(s, dict)
|
||||
and s.get("project_id") == project_id)
|
||||
if count >= MAX_SWARMS_PER_PROJECT:
|
||||
raise ValueError("too many swarms in this project")
|
||||
swarm_id = uuid.uuid4().hex
|
||||
swarm = {
|
||||
"id": swarm_id,
|
||||
"project_id": project_id,
|
||||
"parent_task_id": parent_task_id,
|
||||
"max_agents": max_agents,
|
||||
"status": "active",
|
||||
"agents": {},
|
||||
"created_at": now,
|
||||
}
|
||||
data["swarms"][swarm_id] = swarm
|
||||
_audit(data, "swarm_created",
|
||||
"%s parent=%s" % (swarm_id, parent_task_id), now)
|
||||
_save_registry(state_dir, data)
|
||||
return dict(swarm)
|
||||
|
||||
|
||||
def get_swarm(state_dir: str, swarm_id: str) -> dict | None:
|
||||
"""取群;不存在回 None。"""
|
||||
data = _load_registry(state_dir)
|
||||
swarm = data.get("swarms", {}).get(swarm_id)
|
||||
if not isinstance(swarm, dict):
|
||||
return None
|
||||
return dict(swarm)
|
||||
|
||||
|
||||
def spawn_agent(state_dir: str, swarm_id: str, prompt: str,
|
||||
workspace_root: str,
|
||||
now: float | None = None) -> dict:
|
||||
"""派一个子代理:群须 active、人数未满上限;代理得独立
|
||||
任务 id + 独立工作区(父工作区下按代理 id 分目录,
|
||||
resolve_inside 门守着,越界即 ValueError)。返回代理 dict。"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
if not prompt or not isinstance(prompt, str) \
|
||||
or not prompt.strip():
|
||||
raise ValueError("prompt required")
|
||||
if len(prompt) > MAX_AGENT_PROMPT_LEN:
|
||||
raise ValueError("prompt too long")
|
||||
if not workspace_root or not isinstance(workspace_root, str):
|
||||
raise ValueError("workspace_root required")
|
||||
data = _load_registry(state_dir)
|
||||
swarm = _get_swarm_or_raise(data, swarm_id)
|
||||
if swarm.get("status") != "active":
|
||||
raise ValueError("swarm is not active")
|
||||
agents = swarm.setdefault("agents", {})
|
||||
if len(agents) >= int(swarm["max_agents"]):
|
||||
raise ValueError("swarm agent limit reached")
|
||||
agent_id = uuid.uuid4().hex
|
||||
workspace = resolve_inside(workspace_root, agent_id)
|
||||
if workspace is None:
|
||||
raise ValueError("workspace_root is not usable")
|
||||
agent = {
|
||||
"id": agent_id,
|
||||
"task_id": uuid.uuid4().hex,
|
||||
"prompt": prompt.strip(),
|
||||
"workspace": workspace,
|
||||
"status": "pending",
|
||||
"created_at": now,
|
||||
}
|
||||
agents[agent_id] = agent
|
||||
_audit(data, "swarm_agent_spawned",
|
||||
"%s agent=%s" % (swarm_id, agent_id), now)
|
||||
_save_registry(state_dir, data)
|
||||
return dict(agent)
|
||||
|
||||
|
||||
def list_agents(state_dir: str, swarm_id: str) -> list[dict]:
|
||||
"""列出某群的全部代理(按创建时间排序)。群不存在即
|
||||
ValueError。"""
|
||||
data = _load_registry(state_dir)
|
||||
swarm = _get_swarm_or_raise(data, swarm_id)
|
||||
agents = [dict(a) for a in
|
||||
swarm.get("agents", {}).values()
|
||||
if isinstance(a, dict)]
|
||||
agents.sort(key=lambda a: (a.get("created_at") or 0,
|
||||
a.get("id") or ""))
|
||||
return agents
|
||||
|
||||
|
||||
def agent_workspace(state_dir: str, swarm_id: str,
|
||||
agent_id: str) -> str | None:
|
||||
"""取某代理的工作区路径;群或代理不存在回 None。"""
|
||||
data = _load_registry(state_dir)
|
||||
swarm = data.get("swarms", {}).get(swarm_id)
|
||||
if not isinstance(swarm, dict):
|
||||
return None
|
||||
agent = swarm.get("agents", {}).get(agent_id)
|
||||
if not isinstance(agent, dict):
|
||||
return None
|
||||
workspace = agent.get("workspace")
|
||||
return workspace if isinstance(workspace, str) else None
|
||||
|
||||
|
||||
def check_agent_path(state_dir: str, swarm_id: str, agent_id: str,
|
||||
path: str) -> bool:
|
||||
"""隔离门:path 是否在该代理自己的工作区内(realpath +
|
||||
commonpath 校验,符号链接逃逸、.. 逃逸、跨盘符一律 False)。
|
||||
群或代理不存在时同样 False(fail-closed)。"""
|
||||
workspace = agent_workspace(state_dir, swarm_id, agent_id)
|
||||
if not workspace or not isinstance(path, str) or not path:
|
||||
return False
|
||||
try:
|
||||
rel = os.path.relpath(path, workspace)
|
||||
except ValueError:
|
||||
return False
|
||||
return resolve_inside(workspace, rel) is not None
|
||||
|
||||
|
||||
def _get_agent_or_raise(data: dict, swarm_id: str,
|
||||
agent_id: str) -> tuple[dict, dict]:
|
||||
"""取(群,代理),任一不存在即 ValueError。"""
|
||||
swarm = _get_swarm_or_raise(data, swarm_id)
|
||||
agent = swarm.get("agents", {}).get(agent_id)
|
||||
if not isinstance(agent, dict):
|
||||
raise ValueError("agent not found")
|
||||
return swarm, agent
|
||||
|
||||
|
||||
def _refresh_swarm_status(swarm: dict) -> None:
|
||||
"""群终态推导:代理全终态后,全部 done 即 done、有 failed
|
||||
即 failed;否则保持 active。"""
|
||||
agents = [a for a in swarm.get("agents", {}).values()
|
||||
if isinstance(a, dict)]
|
||||
if not agents:
|
||||
return
|
||||
if any(a.get("status") not in ("done", "failed")
|
||||
for a in agents):
|
||||
return
|
||||
if any(a.get("status") == "failed" for a in agents):
|
||||
swarm["status"] = "failed"
|
||||
else:
|
||||
swarm["status"] = "done"
|
||||
|
||||
|
||||
def set_agent_status(state_dir: str, swarm_id: str, agent_id: str,
|
||||
status: str,
|
||||
now: float | None = None) -> dict:
|
||||
"""改代理状态(pending/running/done/failed);非法状态或
|
||||
代理不存在即 ValueError。改完推导群终态,返回代理 dict。"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
if status not in AGENT_STATUSES:
|
||||
raise ValueError("invalid agent status")
|
||||
data = _load_registry(state_dir)
|
||||
swarm, agent = _get_agent_or_raise(data, swarm_id, agent_id)
|
||||
agent["status"] = status
|
||||
_refresh_swarm_status(swarm)
|
||||
_audit(data, "swarm_agent_status",
|
||||
"%s agent=%s status=%s" % (swarm_id, agent_id, status),
|
||||
now)
|
||||
_save_registry(state_dir, data)
|
||||
return dict(agent)
|
||||
|
||||
|
||||
def register_tool(state_dir: str, swarm_id: str, agent_id: str,
|
||||
name: str, description: str,
|
||||
now: float | None = None) -> dict:
|
||||
"""子代理构建自己的工具:登记进群内工具表(名字在该代理
|
||||
内唯一,1–100 字符),带审计。登记只登记,不执行。"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
if not name or not isinstance(name, str) or not name.strip():
|
||||
raise ValueError("tool name required")
|
||||
name = name.strip()
|
||||
if len(name) > MAX_TOOL_NAME_LEN:
|
||||
raise ValueError("tool name too long")
|
||||
if not isinstance(description, str):
|
||||
raise ValueError("description must be a string")
|
||||
data = _load_registry(state_dir)
|
||||
_get_agent_or_raise(data, swarm_id, agent_id)
|
||||
tools = data.setdefault("tools", {})
|
||||
by_agent = tools.setdefault(swarm_id, {}).setdefault(agent_id, {})
|
||||
if name in by_agent:
|
||||
raise ValueError("tool already registered for this agent")
|
||||
tool = {"name": name, "description": description,
|
||||
"agent_id": agent_id, "swarm_id": swarm_id,
|
||||
"created_at": now}
|
||||
by_agent[name] = tool
|
||||
_audit(data, "swarm_tool_registered",
|
||||
"%s agent=%s tool=%s" % (swarm_id, agent_id, name), now)
|
||||
_save_registry(state_dir, data)
|
||||
return dict(tool)
|
||||
|
||||
|
||||
def record_tool_invocation(state_dir: str, swarm_id: str,
|
||||
agent_id: str, name: str,
|
||||
args_summary: str,
|
||||
now: float | None = None) -> dict:
|
||||
"""记一次工具调用意图(登记 + 审计):工具须是该代理自己
|
||||
注册的,别代理的工具名在这里叫不动。
|
||||
|
||||
注意:本函数**不执行工具代码**——执行在子代理自己的 VM/
|
||||
单元里(执行器派发代理任务时发生)。这里只留下“谁、在
|
||||
哪群、调了哪个工具、参数摘要”的审计记录。"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
if not isinstance(args_summary, str):
|
||||
raise ValueError("args_summary must be a string")
|
||||
data = _load_registry(state_dir)
|
||||
_get_agent_or_raise(data, swarm_id, agent_id)
|
||||
by_agent = data.get("tools", {}).get(swarm_id, {}).get(agent_id,
|
||||
{})
|
||||
if name not in by_agent:
|
||||
raise ValueError("tool not registered for this agent")
|
||||
record = {
|
||||
"id": uuid.uuid4().hex,
|
||||
"swarm_id": swarm_id,
|
||||
"agent_id": agent_id,
|
||||
"name": name,
|
||||
"args_summary": args_summary[:500],
|
||||
"created_at": now,
|
||||
}
|
||||
data.setdefault("invocations", []).append(record)
|
||||
_audit(data, "swarm_tool_invoked",
|
||||
"%s agent=%s tool=%s" % (swarm_id, agent_id, name), now)
|
||||
_save_registry(state_dir, data)
|
||||
return dict(record)
|
||||
|
||||
|
||||
def dispatch_agent(state_dir: str, swarm_id: str, agent_id: str,
|
||||
runner: Callable[[dict], dict],
|
||||
now: float | None = None) -> dict:
|
||||
"""派发代理去执行:代理须是 pending;runner 是调用方注入
|
||||
的执行函数(生产上接执行器 dispatch,测试里传 fake),
|
||||
收到任务 dict {"id", "prompt", "swarm_id", "agent_id",
|
||||
"workspace"},返回其执行结果。
|
||||
|
||||
派发前把代理置为 running;runner 抛错时代理状态不动
|
||||
(调用方按失败处理,不在这里把异常吞成 done)。"""
|
||||
if now is None:
|
||||
now = time.time()
|
||||
data = _load_registry(state_dir)
|
||||
swarm, agent = _get_agent_or_raise(data, swarm_id, agent_id)
|
||||
if agent.get("status") != "pending":
|
||||
raise ValueError("agent is not pending")
|
||||
task = {
|
||||
"id": agent["task_id"],
|
||||
"prompt": agent["prompt"],
|
||||
"swarm_id": swarm_id,
|
||||
"agent_id": agent_id,
|
||||
"workspace": agent["workspace"],
|
||||
}
|
||||
agent["status"] = "running"
|
||||
_audit(data, "swarm_agent_dispatched",
|
||||
"%s agent=%s task=%s"
|
||||
% (swarm_id, agent_id, agent["task_id"]), now)
|
||||
_save_registry(state_dir, data)
|
||||
return runner(task)
|
||||
Reference in new issue
Block a user