feat: 成本预算与失败通知第一批(G1/G2/G5/G6/无价上报)

- G1:修正 _check_quota docstring(Local popen 30 秒轮询掐断、
  Docker/同步路径只有边界检查),README 配额节同步;followup
  创建不查配额是已声明设计,保持不动。
- G2:notify_failure 对插值 shlex.quote,模板错误记日志不静默
  吞,命令返回值/超时/异常记 host-run.log;dispatch 前置预算门
  与 goal 预算中断补 budget-exceeded 通知(没配命令时只写日志)。
- G5/G6:重试与续跑的配额检查按任务创建者查(费用记创建者名下),
  维护者替他人操作不再用错人的额度;续跑 API 侧补查,超限回
  402 且不改状态,不再先 200 排队再悄悄 interrupted。
- 无价上报:配额自查/管理端点返回增加 unpriced_actual_tokens
  (actual 且 usd_micros 为 NULL,与 CostStore 口径一致),只读
  上报、不计价、不拦任务;不给 gpt-5.6-terra 编造价格。
测试:host 68、cloud 760(4 跳过)、deploy 19 全过。
This commit is contained in:
host committed 2026-10-27 10:13:51 +00:00
1 parent c2f82da5e0
commit e4b5545a24
5 files changed
+304 -38

No files matched your search

+71 -20
View File
@@ -3661,6 +3661,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
在旧线程上开新目标循环;goal_mode 不传时 goal 任务沿用
原语义续跑。状态回 queued 时上一轮 result/error 清空
(与重试一致,否则排队中的任务还挂着旧结果)。
配额:全部校验通过后、写库之前按任务创建者查一次
(与重试同口径,费用记创建者名下);超限回 402,不
改状态——以前是先回 200 排队、随后 worker 认领门把
任务悄悄标 interrupted 且不推送。后续消息创建不查
配额(排队不花钱),本接口不是 followup,不要混淆。
并发下用 CAS(WHERE status IN 终端四态),没改到行
说明状态已变,返回 409 而不是假装成功。写审计
task_continued。
@@ -3768,6 +3773,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
sets.append("verify_command = NULL")
sets.append("goal_max_rounds = 0")
vals.append(task_id)
# 续跑也要花模型钱:校验全过后、写库之前按创建者查
# 配额,超限回 402 且不改状态(见本函数 docstring)。
if self._reject_if_over_quota(
user_id, quota_user_id=task.get("created_by") or user_id):
return
conn = self._db()
try:
cur = conn.execute(
@@ -3802,6 +3812,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
interrupted / merge-failed(其它状态一律 409:running
本来就在跑,queued 本来就在等,completed/merged 已有
结果,重跑应是新任务而不是覆盖结果)。
配额按任务创建者查(费用记在创建者名下):维护者替
他人重试时,操作人超限不应拦、创建者超限才应拦。
成功后状态回 queued,上一轮的 result_text/error_text/
actual_model 清空(否则新一轮的结果会和旧结果混在一起
无法分辨);prompt、模型、优先级、依赖原样保留,依赖
@@ -3820,7 +3832,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
user_id, task["project_id"], "approve_merge"):
self._send_json(403, {"error": "forbidden"})
return
if self._reject_if_over_quota(user_id):
if self._reject_if_over_quota(
user_id, quota_user_id=task.get("created_by") or user_id):
return
if task["status"] not in ("failed", "interrupted", "merge-failed"):
self._send_json(409, {
@@ -3899,20 +3912,23 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
finally:
conn.close()
def _quota_payload(self, user_id, quota, used, month):
return {"user_id": user_id, "month": month,
"quota_usd_micros": quota, "used_usd_micros": used,
"remaining_usd_micros": (quota - used)
if quota is not None else None}
def _quota_unpriced_tokens(self, user_id, month):
def _quota_unpriced_tokens(self, user_id, month=None):
"""当月无价模型 token 数(usd_micros IS NULL 的成本行,
input + output 之和;cached 已含在 input 里,不重复计)。
与 _quota_status 同款 CostStore 懒建表做法,全新库上
cost_records 不存在时先建表再查。无配额行时调用方不
应以此挡人(见 _check_unpriced_token_cap)。
合并说明(#86 × #90):两边曾各自定义过一次(#90 的
只要 user_id 且只计 source='actual',#86 的要 month 且
计全部 usd IS NULL 行),自动合并后后一个覆盖前一个,
上报调用点会 TypeError。这里统一成一个方法,以 main
(#86)的口径为准:计全部 usd IS NULL 行(含 estimated
行),上报与上限共用同一口径,不再留两套数字;month
缺省为当月,兼容两边的调用点。与 _quota_status 同款
CostStore 懒建表做法,全新库上 cost_records 不存在时
先建表再查。无配额行时调用方不应以此挡人(见
_check_unpriced_token_cap)。
"""
if month is None:
month = self._quota_month()
conn = self._db()
try:
_cost_store = CostStore(self.server.db_path)
@@ -3930,6 +3946,16 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
finally:
conn.close()
def _quota_payload(self, user_id, quota, used, month,
unpriced_actual_tokens=None):
payload = {"user_id": user_id, "month": month,
"quota_usd_micros": quota, "used_usd_micros": used,
"remaining_usd_micros": (quota - used)
if quota is not None else None}
if unpriced_actual_tokens is not None:
payload["unpriced_actual_tokens"] = unpriced_actual_tokens
return payload
def _check_unpriced_token_cap(self, user_id):
"""无价 token 上限准入:超限返回 (cap, used, month),否则 None。
@@ -3958,16 +3984,34 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
def _check_quota(self, user_id):
"""配额准入:放行返回 None;超限返回 (quota, used,
month),调用方一律回 402——这是钱用完了,不是请求
太快,所以不用 429。注意:只做建任务/分支/重跑时的
准入校验;跑起来的回合中途超限不会被掐断(worker
侧没有按金额中途停转的钩子,那是下一步的事)。"""
太快,所以不用 429。
准入点:建任务 / 分支 / 重试 / 续跑,均为权限校验
通过后、写库之前。后续消息(followup)创建不查:
排队本身不花钱,执行时轮次边界的配额门照常生效,
不要顺手把 followup 也改成准入校验。
跑中掐断(与旧 docstring 不同,旧说法已过时):
Local popen 路径的 worker 轮询每 30 秒查一次配额,
超限杀进程组;Docker / 同步路径没有轮询钩子,只有
认领与轮次边界检查,单轮内的超支要到轮次结束才停;
单轮本身的钱无法按 token 流式截断(用量只在回合
结束才回传),掐断粒度是轮次边界 + 长轮询中的
30 秒检查,不是 token 级。"""
quota, used, month = self._quota_status(user_id)
if quota is not None and used >= quota:
return quota, used, month
return None
def _reject_if_over_quota(self, user_id):
blocked = self._check_quota(user_id)
def _reject_if_over_quota(self, user_id, quota_user_id=None):
"""配额超限时回 402 并返回 True,否则 False。
quota_user_id:实际查谁的配额。建任务 / 分支时调用
方就是创建者,缺省即调用方;重试 / 续跑时费用记在
原任务创建者名下,必须传 task 的 created_by,否则
维护者替他人重试会用错人的额度(旧 bug:重试按
操作人查、费用记创建者名下)。"""
blocked = self._check_quota(quota_user_id or user_id)
if blocked is not None:
quota, used, month = blocked
self._send_json(402, {"error": "quota_exceeded",
@@ -3975,7 +4019,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"quota_usd_micros": quota,
"used_usd_micros": used})
return True
capped = self._check_unpriced_token_cap(user_id)
capped = self._check_unpriced_token_cap(
quota_user_id or user_id)
if capped is not None:
cap, used, month = capped
self._send_json(402, {"error": "quota_exceeded",
@@ -4011,7 +4056,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
def _user_quota_self(self, user_id):
quota, used, month = self._quota_status(user_id)
self._send_json(200,
self._quota_payload(user_id, quota, used, month))
self._quota_payload(
user_id, quota, used, month,
self._quota_unpriced_tokens(user_id)))
def _admin_quota_get(self, user_id, query):
if not self._is_global_maintainer(user_id):
@@ -4026,7 +4073,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return
quota, used, month = self._quota_status(target)
self._send_json(200,
self._quota_payload(target, quota, used, month))
self._quota_payload(
target, quota, used, month,
self._quota_unpriced_tokens(target)))
def _admin_quota_set(self, user_id):
if not self._is_global_maintainer(user_id):
@@ -4075,7 +4124,9 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"%s:%s" % (target, quota))
quota, used, month = self._quota_status(target)
self._send_json(200,
self._quota_payload(target, quota, used, month))
self._quota_payload(
target, quota, used, month,
self._quota_unpriced_tokens(target)))
# -- worker 池 -----------------------------------------------------
def _workers_list(self, user_id):