Files
codex-app-server/cloud/chv/presentation.py
T
host 414e245752 round-12: ruff hygiene on migration code (import sort, narrow store except)
- 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)
2026-11-07 00:57:05 +00:00

222 lines
8.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 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}