feat: cloud 门禁/超时/成本/沙箱收敛为单一判定源
- 新增标准库叶子层 timeouts/gates/quota/sandbox_policy: 审批 3600 与单轮 600 各有名常量与环境覆盖层,已建项 expires_at 永不重算;目录/控制/合并状态门禁统一判定, 目录违规无任何覆盖参数;配额取数与比较唯一来源 (USD 先于无价、命中均为 >=、无价只计 actual 行); /tmp 隔离矩阵与失联清理判定纯函数化。 - 调用点委托:server/worker/automations/auto_memory 的 配额 SQL 与比较、server/tree/marker 的目录解析、 marker 的控制门、sandbox 的任务容器名正则、两处 300 秒 陈旧阈值默认值均收敛,别名与错误文案不变。 - 统一两个 P0 口径分歧:服务端无价计数由全部 usd IS NULL 行改为 actual-only;显式不限额用户在服务端准入亦不受 无价上限约束(与 worker 运行时一致)。 - 不新增门禁种类与 record 模式(主线无此实现);不改 skill_seed/skills/SKILL.md;不改 v1.1 契约与分支保护。 - 新增 test_arch_converge.py 18 项。
This commit is contained in:
1 parent
17fa493da7
commit
facc2a249a
16 files changed
+1070
-301
No files matched your search
+92
-91
@@ -75,6 +75,17 @@ from cloud.cost import CostStore, unpriced_token_cap
|
||||
from cloud.models import evaluate_capability as _evaluate_capability
|
||||
from cloud.models import load_routing_policy as _load_routing_policy
|
||||
from cloud.routing_shadow import evaluate_shadow as _evaluate_shadow
|
||||
from cloud.gates import merge_state_decision as _merge_state_decision
|
||||
from cloud.gates import resolve_inside as _gate_resolve_inside
|
||||
from cloud.quota import decide as _quota_decide
|
||||
from cloud.quota import default_quota_usd_micros as _quota_default
|
||||
from cloud.quota import monthly_usage as _quota_monthly_usage
|
||||
from cloud.quota import quota_for_owner as _quota_for_owner
|
||||
from cloud.timeouts import STALE_SWEEP_SECONDS as _STALE_SWEEP
|
||||
from cloud.timeouts import approval_default_seconds as _approval_default
|
||||
from cloud.timeouts import validate_server_timeout as _validate_timeout
|
||||
# 演练注:上为 main 侧 models/routing_shadow 导入与本提交
|
||||
# gates/quota/timeouts 导入的并集,两边调用点各在正文存在。
|
||||
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
|
||||
@@ -558,19 +569,11 @@ def _task_worktree_for_files(server, task):
|
||||
|
||||
|
||||
def _safe_worktree_path(worktree, rel):
|
||||
"""worktree 内相对路径的安全解析:空串返回 worktree 本身;
|
||||
绝对路径、.. 逃逸、符号链接逃逸一律返回 None。realpath 后
|
||||
再校验 commonpath,目录内的符号链接指回内部时放行(读到
|
||||
的仍是 worktree 内的内容),指出去时拦住。"""
|
||||
if not rel:
|
||||
return worktree
|
||||
if os.path.isabs(rel):
|
||||
return None
|
||||
base = os.path.realpath(worktree)
|
||||
target = os.path.realpath(os.path.join(base, rel))
|
||||
if os.path.commonpath([base, target]) != base:
|
||||
return None
|
||||
return target
|
||||
"""worktree 内相对路径的安全解析:判定收敛到 cloud/gates.py
|
||||
(空串返回 worktree 本身;绝对路径、.. 逃逸、符号链接
|
||||
逃逸一律返回 None;目录内的符号链接指回内部时放行,
|
||||
指出去时拦住;目录违规无任何覆盖通道)。"""
|
||||
return _gate_resolve_inside(worktree, rel)
|
||||
|
||||
|
||||
def _extract_thread_list(result):
|
||||
@@ -847,7 +850,8 @@ def _build_agent_chain(task, trace_dirs):
|
||||
[s.get("ts"), done.get("ts") if done else None]),
|
||||
})
|
||||
return agents
|
||||
DEFAULT_SWEEP_THRESHOLD_SECONDS = 300
|
||||
# 陈旧回收阈值默认值收敛到 cloud/timeouts.py(别名保留)。
|
||||
DEFAULT_SWEEP_THRESHOLD_SECONDS = _STALE_SWEEP
|
||||
# 限流默认值:每 IP / 每 API key 每分钟 120 次;环境变量
|
||||
# CLOUD_RATE_LIMIT_PER_MINUTE / CLOUD_RATE_LIMIT_WINDOW_SECONDS 可调。
|
||||
# 限流默认内存滑动窗口;Postgres 下改走 DB 共享计数(多 server
|
||||
@@ -1625,12 +1629,9 @@ def _default_quota_usd_micros():
|
||||
新号现在建号即写一行默认配额(老用户无配额行不受影响,
|
||||
行为不变)。环境变量 CLOUD_DEFAULT_QUOTA_USD_MICROS 可
|
||||
覆盖,默认 10_000_000($10/月);非法值按默认计。
|
||||
解析收敛到 cloud/quota.py(别名保留)。
|
||||
"""
|
||||
try:
|
||||
return max(0, int(os.environ.get(
|
||||
"CLOUD_DEFAULT_QUOTA_USD_MICROS", "10000000")))
|
||||
except (TypeError, ValueError):
|
||||
return 10000000
|
||||
return _quota_default()
|
||||
|
||||
|
||||
def _apply_default_quota(conn, user_id):
|
||||
@@ -2142,7 +2143,7 @@ def request_kill(db_path, task_id, requested_by):
|
||||
conn.close()
|
||||
|
||||
|
||||
def sweep_stale_tasks(db_path, threshold_seconds=300):
|
||||
def sweep_stale_tasks(db_path, threshold_seconds=_STALE_SWEEP):
|
||||
"""把 running 且心跳过期的任务改为 interrupted,返回被扫任务 id 列表。
|
||||
|
||||
心跳取 last_heartbeat,无心跳则用 updated_at;早于
|
||||
@@ -8583,9 +8584,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
user_id, task["project_id"], "approve_merge"):
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
if task["status"] != "completed":
|
||||
_merge_ok, _merge_reason = _merge_state_decision(
|
||||
task["status"])
|
||||
if not _merge_ok:
|
||||
self._send_json(409, {
|
||||
"error": "only completed tasks can be marked merged",
|
||||
"error": _merge_reason,
|
||||
"status": task["status"],
|
||||
})
|
||||
return
|
||||
@@ -8698,7 +8701,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
权限:项目 create_task(建审批项是写动作,与后续消息
|
||||
同级)。kind 缺省 elicitation;summary 必填;
|
||||
timeout_seconds 只接受 60 到 86400 秒整数,缺省 3600,
|
||||
超出范围回 400。成功记审计 approval_created。"""
|
||||
超出范围回 400(校验收敛到 cloud/timeouts.py,文案
|
||||
不变)。成功记审计 approval_created。"""
|
||||
row = self._fetch_task(task_id)
|
||||
if row is None:
|
||||
self._send_json(404, {"error": "task not found"})
|
||||
@@ -8720,9 +8724,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
detail, (dict, list, str)):
|
||||
self._send_json(400, {"error": "detail must be an object"})
|
||||
return
|
||||
timeout = body.get("timeout_seconds", 3600)
|
||||
if not isinstance(timeout, int) or isinstance(timeout, bool) \
|
||||
or timeout < 60 or timeout > 86400:
|
||||
timeout = body.get("timeout_seconds", _approval_default())
|
||||
try:
|
||||
timeout = _validate_timeout(timeout)
|
||||
except ValueError:
|
||||
self._send_json(
|
||||
400, {"error": "timeout_seconds must be 60..86400"})
|
||||
return
|
||||
@@ -9121,77 +9126,61 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
from datetime import datetime, timezone
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m")
|
||||
|
||||
def _quota_status(self, user_id):
|
||||
"""返回 (quota_usd_micros|None, used_usd_micros, month)。
|
||||
P0 安全批后:无配额行不再等于不限额——缺行时按默认
|
||||
配额处理(与 _default_quota_usd_micros() 同值,只在
|
||||
返回值里合成,不在这里写行;写行由 _apply_default_quota
|
||||
/ _backfill_default_quotas 负责)。只有管理员显式把
|
||||
行写成 NULL 时,quota 才为 None(不限额)。"""
|
||||
month = self._quota_month()
|
||||
conn = self._db()
|
||||
try:
|
||||
_ensure_quotas_table(conn)
|
||||
conn.commit()
|
||||
# cost_records 由 CostStore 懒建:全新库上它还不存在,
|
||||
# 直接 JOIN 会报 no such table——先实例化一次建表
|
||||
# (与 _project_cost 同款做法),再跑配额查询。
|
||||
_cost_store = CostStore(self.server.db_path)
|
||||
try:
|
||||
_cost_store._conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
row = conn.execute(
|
||||
"SELECT quota_usd_micros FROM user_quotas"
|
||||
" WHERE user_id = ?",
|
||||
(user_id,)).fetchone()
|
||||
if row is None:
|
||||
quota = _default_quota_usd_micros()
|
||||
else:
|
||||
quota = row[0]
|
||||
used = conn.execute(
|
||||
"SELECT COALESCE(SUM(c.usd_micros), 0)"
|
||||
" FROM cost_records c JOIN tasks t ON c.task_id = t.id"
|
||||
" WHERE t.created_by = ? AND c.day LIKE ?",
|
||||
(user_id, month + "%")).fetchone()[0]
|
||||
return quota, int(used or 0), month
|
||||
finally:
|
||||
conn.close()
|
||||
def _quota_snapshot(self, user_id, month=None):
|
||||
"""配额快照:(quota, used_usd, unpriced_actual, month)。
|
||||
|
||||
def _quota_unpriced_tokens(self, user_id, month=None):
|
||||
"""当月无价模型 token 数(usd_micros IS NULL 的成本行,
|
||||
input + output 之和;cached 已含在 input 里,不重复计)。
|
||||
|
||||
合并说明(#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)。
|
||||
取数收敛到 cloud/quota.py:配额行(无行按默认值、
|
||||
显式 NULL 行为不限额)与当月用量(USD 全量 +
|
||||
无价 actual-only)各只有一处 SQL。CostStore 懒建表
|
||||
步骤保留(全新库上 cost_records 不存在时先建表再查)。
|
||||
本函数只读:无行时不在此写行(写行仍由
|
||||
_apply_default_quota / _backfill_default_quota 负责)。
|
||||
"""
|
||||
if month is None:
|
||||
month = self._quota_month()
|
||||
conn = self._db()
|
||||
try:
|
||||
_ensure_quotas_table(conn)
|
||||
conn.commit()
|
||||
_cost_store = CostStore(self.server.db_path)
|
||||
try:
|
||||
_cost_store._conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(SUM(c.input_tokens + c.output_tokens), 0)"
|
||||
" FROM cost_records c JOIN tasks t ON c.task_id = t.id"
|
||||
" WHERE t.created_by = ? AND c.day LIKE ?"
|
||||
" AND c.usd_micros IS NULL",
|
||||
(user_id, month + "%")).fetchone()
|
||||
return int(row[0] if row is not None else 0)
|
||||
quota = _quota_for_owner(conn, user_id)
|
||||
used, unpriced = _quota_monthly_usage(conn, user_id, month)
|
||||
return quota, used, unpriced, month
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _quota_status(self, user_id):
|
||||
"""返回 (quota_usd_micros|None, used_usd_micros, month)。
|
||||
P0 安全批后:无配额行不再等于不限额——缺行时按默认
|
||||
配额处理(只在返回值里合成,不在这里写行;写行由
|
||||
_apply_default_quota / _backfill_default_quota 负责)。
|
||||
只有管理员显式把行写成 NULL 时,quota 才为 None
|
||||
(不限额)。取数见 _quota_snapshot。"""
|
||||
quota, used, _unpriced, month = self._quota_snapshot(user_id)
|
||||
return quota, used, month
|
||||
|
||||
def _quota_unpriced_tokens(self, user_id, month=None):
|
||||
"""当月无价模型 token 数(actual-only,见下)。
|
||||
|
||||
收敛说明(改动点,见 cloud/quota.py 模块头):
|
||||
旧实现计全部 usd IS NULL 的行(含 estimated 估算行),
|
||||
与 cost.py 的 P1 决议、CostStore 报表、worker、
|
||||
自动化三处 actual-only 口径不一致;现统一为
|
||||
actual-only:估算行不是真实花费,绝不触发上限。
|
||||
input + output 之和;cached 已含在 input 里,不重复计。
|
||||
month 缺省为当月。与 _quota_status 同款 CostStore
|
||||
懒建表做法。无配额行时调用方不应以此挡人(见
|
||||
_check_unpriced_token_cap 与 quota.decide 的
|
||||
不限额语义)。
|
||||
"""
|
||||
_quota, _used, unpriced, _month = self._quota_snapshot(
|
||||
user_id, month=month)
|
||||
return unpriced
|
||||
|
||||
def _quota_payload(self, user_id, quota, used, month,
|
||||
unpriced_actual_tokens=None):
|
||||
payload = {"user_id": user_id, "month": month,
|
||||
@@ -9205,14 +9194,23 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
def _check_unpriced_token_cap(self, user_id):
|
||||
"""无价 token 上限准入:超限返回 (cap, used, month),否则 None。
|
||||
|
||||
P0 安全批后:无配额行用户同样受限(无价 token 上限与
|
||||
配额行存在与否无关);USD 配额本身不计量无价模型,所
|
||||
判定收敛到 cloud/quota.py:无配额行用户按默认配额受限;
|
||||
显式 NULL(不限额)行用户不受限——与 worker 运行时门
|
||||
一致(旧实现不论配额行一律拦,是三处不一致之一,
|
||||
本轨随收敛统一)。USD 配额本身不计量无价模型,所
|
||||
以这道是补 USD 口径的洞,不是第二套计费。"""
|
||||
month = self._quota_month()
|
||||
used = self._quota_unpriced_tokens(user_id, month)
|
||||
cap = unpriced_token_cap()
|
||||
if used >= cap:
|
||||
return cap, used, month
|
||||
# 取数经 _quota_status / _quota_unpriced_tokens(两者
|
||||
# 内部共用 _quota_snapshot),比较经 quota.decide。
|
||||
# _quota_status 在此保留为可 mock 的接缝(503 测试依赖
|
||||
# 它抛异常时准入点 fail-closed),故 _check_* 不直连
|
||||
# _quota_snapshot。
|
||||
quota, _used, month = self._quota_status(user_id)
|
||||
unpriced = self._quota_unpriced_tokens(user_id, month)
|
||||
decision = _quota_decide(quota, _used, unpriced,
|
||||
unpriced_token_cap(), month)
|
||||
if decision is not None and decision["kind"] == "unpriced":
|
||||
return (decision["unpriced_token_cap"],
|
||||
decision["unpriced_tokens_used"], month)
|
||||
return None
|
||||
|
||||
def _check_quota(self, user_id):
|
||||
@@ -9248,7 +9246,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
回合结束才回传),掐断粒度是轮次边界 + 长轮询中的
|
||||
30 秒检查,不是 token 级。"""
|
||||
quota, used, month = self._quota_status(user_id)
|
||||
if quota is not None and used >= quota:
|
||||
unpriced = self._quota_unpriced_tokens(user_id, month)
|
||||
decision = _quota_decide(quota, used, unpriced,
|
||||
unpriced_token_cap(), month)
|
||||
if decision is not None and decision["kind"] == "usd":
|
||||
return quota, used, month
|
||||
return None
|
||||
|
||||
|
||||
Reference in new issue
Block a user