"""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: """把损坏的账本挪到 .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}