- test_chv_muse_round12.py: drop unused socket/time imports, drop unnecessary sys.path.insert (P3 pattern needs none), sort method-local imports (activity/capabilities, approval_card/channel/sentinel) - audit_trail.py: single-line method-local imports (I001) - swarm.py / presentation.py / browser_tasks.py: store-path fail-safe except narrowed from bare Exception to (sqlite3.Error, OSError) (DB/file failures only); transport spawn/ping/close handlers in browser_tasks.py deliberately stay bare Exception (arbitrary transport errors must map to failed/False/close_error) - NOT fixed (pre-existing style, left as-is): UP031 % formatting (used throughout the codebase incl. the copied lines), BLE001/ TRY004 in untouched code, F401 atomic_write + RUF059 swarm (P7/P8 pre-existing)
222 lines
8.5 KiB
Python
222 lines
8.5 KiB
Python
"""P8 presentation:后台任务完成后“是否值得呈现”的评估,与
|
||
通知去重节流账本。
|
||
|
||
对齐 Muse B2(官方设计文):完成后台工作后评估是否值得呈现,
|
||
只在真正有新内容或需要输入时通知用户。
|
||
|
||
判定是确定性规则(不是模型判断):
|
||
- needs_input:需要用户输入 -> 通知。
|
||
- failed/interrupted:失败、需要决策 -> 通知。
|
||
- completed 且有实质新内容(新产物、改动文件、或显式 notify
|
||
标志)-> 通知;否则常规完成只记日志。
|
||
- cancelled / 未知终态 -> 只记日志(未知终态 fail-closed,
|
||
不通知)。
|
||
|
||
账本:宿主级 AppStateStore(round-12 已迁移,见
|
||
docs/chv-muse-p8-gap.md §6):state_dir/app-state.sqlite 的
|
||
``p8-ledger`` 命名空间,一行存整份账本;旧
|
||
notification-ledger.json 首次加载时自动导入并改名
|
||
``.migrated``;行损坏/库打不开时回空账本(去重信息丢了只会
|
||
导致一次重复通知,不影响任务本身),下次成功保存即覆盖自愈。
|
||
同一任务同一原因在节流窗口(3600 秒,本项目自定)内只通知一次。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import sqlite3
|
||
import logging
|
||
import os
|
||
import time
|
||
|
||
from cloud.chv.app_state import AppStateStore, host_state_db_path
|
||
|
||
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
|
||
|
||
# 同一任务同一原因的通知节流窗口(秒,本项目自定)。
|
||
NOTIFY_THROTTLE_SECONDS = 3600
|
||
_LEDGER_FILENAME = "notification-ledger.json"
|
||
_KV_NAMESPACE = "p8-ledger"
|
||
_KV_KEY = "registry"
|
||
_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 _open_store(state_dir: str) -> AppStateStore:
|
||
"""打开宿主级状态库(每次调用新建连接,用完即关)。"""
|
||
store = AppStateStore(host_state_db_path(state_dir))
|
||
store.open()
|
||
return store
|
||
|
||
|
||
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:
|
||
"""读账本:KV 行缺失时走一次性旧文件导入;行损坏/库打不
|
||
开时回空结构(去重信息丢了只会导致一次重复通知,不影响
|
||
任务本身;下次成功保存即覆盖自愈)。"""
|
||
try:
|
||
with _open_store(state_dir) as store:
|
||
raw = store.kv_get(_KV_NAMESPACE, _KV_KEY)
|
||
except (sqlite3.Error, OSError):
|
||
_LOGGER.warning("chv_ledger_store_unreadable state_dir=%s",
|
||
state_dir)
|
||
return {"notified": {}}
|
||
if raw is None:
|
||
return _import_legacy(state_dir)
|
||
try:
|
||
data = json.loads(raw)
|
||
except ValueError:
|
||
_LOGGER.warning("chv_ledger_corrupt state_dir=%s", state_dir)
|
||
return {"notified": {}}
|
||
if not isinstance(data, dict) \
|
||
or not isinstance(data.get("notified"), dict):
|
||
return {"notified": {}}
|
||
return data
|
||
|
||
|
||
def _import_legacy(state_dir: str) -> dict:
|
||
"""一次性导入旧 notification-ledger.json:合法即进 KV 并
|
||
把文件改名 ``.migrated``;无文件回空;损坏则 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": {}}
|
||
try:
|
||
with _open_store(state_dir) as store:
|
||
store.kv_put(_KV_NAMESPACE, _KV_KEY, json.dumps(data))
|
||
except (sqlite3.Error, OSError):
|
||
_LOGGER.warning("chv_ledger_import_failed state_dir=%s",
|
||
state_dir)
|
||
return data
|
||
try:
|
||
os.replace(path, path + ".migrated")
|
||
except OSError:
|
||
pass
|
||
return data
|
||
|
||
|
||
def _save_ledger(state_dir: str, data: dict) -> None:
|
||
"""落盘账本到 KV 行(截断到上限);写失败只记日志。"""
|
||
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:
|
||
with _open_store(state_dir) as store:
|
||
store.kv_put(_KV_NAMESPACE, _KV_KEY, json.dumps(data))
|
||
except (sqlite3.Error, OSError):
|
||
_LOGGER.warning("chv_ledger_save_failed state_dir=%s", state_dir)
|
||
|
||
|
||
class NotificationLedger:
|
||
"""通知去重节流账本(每任务每原因节流)。
|
||
|
||
should_notify(task_id, reason) 在窗口内未通知过时回 True
|
||
并记下这次通知;窗口内已通知过回 False。账本落在宿主级
|
||
AppStateStore(round-12 已迁移,见模块 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}
|