feat(chv): 对齐 Muse P11 用量计量与配额执行——四项计量 + 三档档位门 + 哈希链账本

- cloud/usage.py(新):TIER_LIMITS 三档提案(Free $0/Paid $19/Heavy $99,
  提案非定价,payload 带 proposal 标注)、TierStore(无档位行不受档位门
  约束)、UsageLedger(追加写哈希链,篡改可发现)、UsageMeter(token 读
  cost_records、消息读账本、并发/存储注入实测)、QuotaGate(纯准入判定,
  402,不碰在跑任务)、80% 提醒每维度每窗口一次
- cloud/server.py:_reject_if_over_quota 内加档位门(同一漏斗,双人门
  自动覆盖;查询异常 fail-closed 503)、GET /users/me/usage、
  GET/POST /admin/tiers、存储实测复用 _task_worktree_for_files
- docs/chv-muse-p11-gap.md:差距表(先写表再动手)+ 诚实边界(USD 路径
  30 秒轮询掐断与“不掐断”语义不一致是已知待决;并发即拒与设计文档
  排队不同;cloud 任务存储量不到;Heavy 消息/天提案未给,本轨定不限)
- 测试 22/22,全量 1889 OK(skipped=8);mypy 在 usage.py 上干净,
  server.py 5 错均为基线旧错(marker_tools/sub2api/_row_get,与本轨无关)
- 真机验证排队在 round-11 与 P4~P9 之后;不接真实计费
This commit is contained in:
host committed 2026-11-06 22:42:30 +00:00
1 parent 06c3e172fe
commit 60c371d9d5
5 files changed
+1474

No files matched your search

+183
View File
@@ -62,6 +62,7 @@ from cloud.auth import AuthStore
from cloud.coordinator import parse_parts as _coord_parse_parts
from cloud.coordinator import validate_parts as _coord_validate_parts
from cloud.cost import CostStore, unpriced_token_cap
from cloud.usage import QuotaGate, UsageMeter, disk_usage_bytes
from cloud.db import connect as _db_connect
from cloud.db import create_database as _create_database
from cloud.db import init_postgres_schema as _init_postgres_schema
@@ -515,6 +516,52 @@ def _task_worktree_for_files(server, task):
return None, (404, "no local worktree for this task")
def _measure_user_storage_bytes(server, user_id):
"""Per-user storage measurement (P11): bytes, measured, total.
Sums each of the user's tasks' local worktrees (via
``_task_worktree_for_files``) plus the user's task attachment
sizes from the DB. Cloud / cloud-hypervisor tasks have no local
worktree and are not measurable on this host: they count in
``total`` but not in ``measured``, so callers can say so
honestly instead of pretending the number is complete.
"""
conn = _connect(server.db_path)
conn.row_factory = sqlite3.Row
tasks = []
try:
rows = conn.execute(
"SELECT id, project_id, executor, worktree_host"
" FROM tasks WHERE created_by = ?",
(user_id,)).fetchall()
tasks = [dict(row) for row in rows]
finally:
conn.close()
total = len(tasks)
measured = 0
size = 0
for task in tasks:
worktree, _err = _task_worktree_for_files(server, task)
if worktree is None:
continue
measured += 1
size += disk_usage_bytes(worktree)
try:
conn = _connect(server.db_path)
try:
row = conn.execute(
"SELECT COALESCE(SUM(a.size), 0) FROM task_attachments a"
" JOIN tasks t ON a.task_id = t.id"
" WHERE t.created_by = ?",
(user_id,)).fetchone()
size += int(row[0] or 0)
finally:
conn.close()
except Exception:
pass
return size, measured, total
def _safe_worktree_path(worktree, rel):
"""worktree 内相对路径的安全解析:空串返回 worktree 本身;
绝对路径、.. 逃逸、符号链接逃逸一律返回 None。realpath 后
@@ -2294,6 +2341,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(404, {"error": "not found"})
elif path == "/users/me/quota" and method == "GET":
self._user_quota_self(user_id)
elif path == "/users/me/usage" and method == "GET":
self._user_usage_self(user_id)
elif path == "/users/me/profile" and method == "GET":
self._identity_profile_get(user_id)
elif path == "/users/me/profile" and method in ("PUT", "POST"):
@@ -2336,6 +2385,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._admin_quota_get(user_id, query)
elif path == "/admin/quotas" and method == "POST":
self._admin_quota_set(user_id)
elif path == "/admin/tiers" and method == "GET":
self._admin_tiers_get(user_id, query)
elif path == "/admin/tiers" and method == "POST":
self._admin_tiers_set(user_id)
elif path == "/admin/push/dispatch" and method == "POST":
self._push_dispatch_endpoint(user_id)
elif path.startswith("/tasks/"):
@@ -8326,6 +8379,16 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"unpriced_token_cap": cap,
"unpriced_tokens_used": used})
return True
try:
denied = self._tier_gate(quota_user_id or user_id)
except Exception:
self._send_json(503, {"error": "quota_check_unavailable",
"detail": "tier quota lookup failed; "
"no task was created or queued"})
return True
if denied is not None:
self._send_json(402, denied)
return True
return False
def _reject_if_over_quota_for_task(self, caller_id, task):
@@ -8417,6 +8480,126 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
payload["unpriced_token_cap"] = unpriced_token_cap()
self._send_json(200, payload)
def _tier_running_tasks(self, user_id):
"""该用户正在跑的任务数(queued / running),档位门用。"""
conn = self._db()
try:
row = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE created_by = ?"
" AND status IN ('queued', 'running')",
(user_id,)).fetchone()
return int(row[0] or 0)
finally:
conn.close()
def _tier_meter(self, user_id):
"""接好两个实测器的 UsageMeter;存储数只测一次。
存储实测(磁盘 walk)比较贵:这里先测一次,把字节数与
任务计数都交给 meter 的注入函数与 payload,避免一次
请求走两遍磁盘。
"""
measured = {}
def _storage(uid):
if uid not in measured:
size, n_measured, n_total = (
_measure_user_storage_bytes(self.server, uid))
measured[uid] = (size, n_measured, n_total)
return measured[uid][0]
meter = UsageMeter(
self.server.db_path,
running_task_count=self._tier_running_tasks,
storage_bytes=_storage)
return meter, measured
def _tier_gate(self, user_id):
"""档位准入:无档位行放行;有则查 token/并发/存储三门。
返回 None 放行,否则否决 dict(由调用方回 402)。
"""
meter, _measured = self._tier_meter(user_id)
return QuotaGate(meter).check_task_admission(user_id)
def _user_usage_self(self, user_id):
"""GET /users/me/usage:当期用量、剩余额度、提醒、账本校验。
USD 配额沿用既有 _quota_status 口径并排返回;四项档位
用量走 UsageMeter。未定档用户 limits 为 None,只看到
用量,看不到档位剩余额度——定档是管理员显式操作。
"""
meter, measured = self._tier_meter(user_id)
payload = meter.summary(user_id)
quota, used, month = self._quota_status(user_id)
payload["quota_usd_micros"] = quota
payload["used_usd_micros"] = used
payload["unpriced_tokens_used"] = self._quota_unpriced_tokens(
user_id, month)
payload["unpriced_token_cap"] = unpriced_token_cap()
_size, n_measured, n_total = measured.get(
user_id, (payload["used"]["storage_bytes"], 0, 0))
payload["storage_measured_tasks"] = n_measured
payload["storage_total_tasks"] = n_total
self._send_json(200, payload)
def _admin_tiers_get(self, user_id, query):
if not self._is_global_admin(user_id):
self._audit_admin(user_id, "admin_tiers_denied",
"get tiers")
self._send_json(403, {"error": "forbidden"})
return
target = (query.get("user_id") or [None])[0]
if not target or not isinstance(target, str):
self._send_json(400, {"error": "user_id required"})
return
if not self._user_exists(target):
self._send_json(404, {"error": "user not found"})
return
meter = UsageMeter(self.server.db_path)
self._send_json(200, {
"proposal": True,
"user_id": target,
"tier": meter.tiers.get_tier(target),
"limits": meter.tiers.limits_for(target)})
def _admin_tiers_set(self, user_id):
if not self._is_global_admin(user_id):
self._audit_admin(user_id, "admin_tiers_denied",
"set tiers")
self._send_json(403, {"error": "forbidden"})
return
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
target = body.get("user_id")
if not target or not isinstance(target, str):
self._send_json(400, {"error": "user_id required"})
return
if not self._user_exists(target):
self._send_json(404, {"error": "user not found"})
return
tier = body.get("tier")
meter = UsageMeter(self.server.db_path)
if tier is None:
meter.tiers.clear_tier(target)
elif not isinstance(tier, str):
self._send_json(400, {"error": "tier must be a string"})
return
else:
try:
meter.tiers.set_tier(target, tier)
except ValueError:
self._send_json(400, {
"error": "unknown tier (want free, paid, heavy)"})
return
self._send_json(200, {
"proposal": True,
"user_id": target,
"tier": meter.tiers.get_tier(target),
"limits": meter.tiers.limits_for(target)})
def _admin_quota_get(self, user_id, query):
if not self._is_global_admin(user_id):
self._audit_admin(user_id, "admin_quota_denied",