Files
codex-app-server/cloud/handler_support.py
T
host 4fa94edd9b feat(chv): 架构/可维护性/前端只读侧推进——生产实验分离、配置片段化、VM 只读展示
架构:WarmPool 从 cloud/chv/pool.py 移到 experimental_pool.py,
生产残留清理彻底分文件到 cloud/chv/orphans.py(facade 直接读
registry 文件清理,不构造池、不碰 prewarm/acquire/release);
cloud/chv 与 cloud/sandbox 不再导出 WarmPool,旧路径一律
ImportError(CHV 未合并、无生产用户,兼容保留已无保留理由);
worker 改走 orphans facade,不再持有 _warm_pool;#215 对齐从
接口形状推进到 REPLACEMENT_POINTS 替换清单(R1/R2 可机械替换、
R3 永不替换,#215 模块当前不存在由测试钉住)。

可维护性:build_chv_config 拆成 base/ssh/network/quota/disk/
timeout 六片段(组装只做 dict 合并,键冲突即报错;38 字段集
与取值不变,单项非法报错文案逐字一致);ADR-12 模块职责图
与禁 import 清单(AST 钉住);cloud/chv/ 公开 API 全量类型
标注、无 Any 逃逸,并修正三处旧标注错误;死代码扫一遍
(删实验池已无调用的 _quarantine 委托与未用 _LOGGER)。

前端(只读侧,不做控制按钮):chv.ts 新增纯逻辑谓词与文案
(执行器精确匹配、附件守卫、VM 状态文案、列表徽标);列表
行新增 VM 已报告死亡徽标;详情对全部 CHV 任务显示 VM 行
(已报告死亡/未报告死亡,不猜 live);WorkersSection 与成本
卡沿用已有数据,不新开池管理/成本页;服务端未新增第二个
生命周期字段(持久化事实只有三项,chv_vm_dead 已是完整派生),
只把派生抽成 _chv_vm_dead_from_row 并补 goal 回落形态测试。
2026-11-08 09:44:34 +00:00

267 lines
12 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.
"""Handler 共享 helper:被多个 handler 分组与留守代码共用。
由 cloud/server.py 拆出,行为与原来逐字一致;server.py 以
重导出保留旧导入路径(见 server.py 的 handler_support 导入块)。
本模块不 import cloud.server(避免循环),不直连 sqlite3,
不 import psycopg;存储一律走 cloud.db / 各 Store。
"""
from cloud.agent_def import parse_agent_tool_approvals_column as _parse_tool_approvals_col
from cloud.agent_def import parse_agent_tools_column as _parse_tools_col
from cloud.auth import AuthStore
from cloud.coordinator import parse_parts as _coord_parse_parts
from cloud.peer_messages import ensure_followups_table as _peer_ensure_followups
from cloud.quota import default_quota_usd_micros as _quota_default
from cloud.peer_messages import row_get as _row_get
from contextlib import contextmanager
import json
import os
import time
def _env_int(name, default):
try:
value = int(os.environ.get(name, "") or default)
except (TypeError, ValueError):
return default
return value if value > 0 else default
def _ensure_followups_table(conn):
"""幂等建后续消息表:委托给 peer_messages(两边同一份 DDL,
server init_db、各 followup 接口与 worker 投递路径共用)。
老库第一次跑到这里时自动补表补列,不丢数据。DOUBLE
PRECISION 在 SQLite 与 Postgres 下都可用(SQLite 按 NUMERIC
亲和处理)。"""
_peer_ensure_followups(conn)
def _ensure_quotas_table(conn):
"""幂等建配额表:server init_db 与配额查询都可调它,老库
第一次跑到这里时自动补表,不丢数据。quota_usd_micros 为
NULL 表示不限额(与 0 区分:0 是一分钱也不能花);金额
单位与 cost_records 一致,都是 USD 微单位(1 美元 =
1_000_000),避免浮点。"""
conn.execute(
"CREATE TABLE IF NOT EXISTS user_quotas("
"user_id TEXT PRIMARY KEY, "
"quota_usd_micros BIGINT, "
"updated_at DOUBLE PRECISION)")
def _default_quota_usd_micros():
"""OAuth 新用户建号时写入的默认配额(微美元)。
无配额行 = 不限额,所以 OAuth 自动建号过去等于无限额入驻。
新号现在建号即写一行默认配额(老用户无配额行不受影响,
行为不变)。环境变量 CLOUD_DEFAULT_QUOTA_USD_MICROS 可
覆盖,默认 10_000_000($10/月);非法值按默认计。
解析收敛到 cloud/quota.py(别名保留)。
"""
return _quota_default()
def _apply_default_quota(conn, user_id):
"""给用户写默认配额行(已存在则不动,幂等)。
INSERT OR IGNORE 保证重复回调不覆盖管理员后来改过的额度。
返回 True 表示本次写了新行(调用方据此记 quota_default
审计)。P0 安全批后:OAuth 新建、绑定、已存在用户登录
三条分支都调它补行,不再只给新建用户补。
"""
_ensure_quotas_table(conn)
cur = conn.execute(
"INSERT OR IGNORE INTO user_quotas(user_id, quota_usd_micros,"
" updated_at) VALUES(?, ?, ?)",
(user_id, _default_quota_usd_micros(), time.time()))
try:
conn.commit()
except Exception:
pass
return cur.rowcount == 1
def _global_admin_ids():
"""真全局管理员清单:环境变量 CODEX_GLOBAL_ADMINS。
逗号分隔的用户 id 列表,空白项忽略。未配置或为空时返回
空集——此时 admin 端点一律 fail-closed,绝不回落到项目
角色推导(P0 安全批前“任一项目 maintainer 即全局管理”
的推导已被废掉,见 _is_global_admin)。
"""
raw = os.environ.get("CODEX_GLOBAL_ADMINS") or ""
return {part.strip() for part in raw.split(",") if part.strip()}
def _environment_to_dict(row):
return {
"id": row["id"],
"project_id": row["project_id"],
"name": row["name"],
"branch": _row_get(row, "branch"),
"executor": _row_get(row, "executor"),
"status": row["status"],
"error_text": _row_get(row, "error_text"),
"repo_url": _row_get(row, "repo_url"),
"created_by": row["created_by"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
@contextmanager
def _auth_store(db_path):
"""每请求一个 AuthStore 实例(自带新连接),用完即关闭。"""
store = AuthStore(db_path)
try:
yield store
finally:
try:
store.conn.close()
except Exception:
pass
def _agent_tools_from_row(row):
"""agent_tools 列的容错解析:唯一实现在
agent_def.parse_agent_tools_column(9.5-code 统一);本
函数只取列值并转发,缺列/脏数据为 None(= 未声明),
序列化口径与统一前逐字一致。"""
return _parse_tools_col(_row_get(row, "agent_tools"))
def _agent_tool_approvals_from_row(row):
"""agent_tool_approvals 列的容错解析:唯一实现在
agent_def.parse_agent_tool_approvals_column;缺列/脏
数据为 None(NULL = 未声明,与 v3 tools 同款语义)。"""
return _parse_tool_approvals_col(
_row_get(row, "agent_tool_approvals"))
# 演练注:两 helper 由 main 侧 server.py 原样移入(split 后
# _task_to_dict 的唯一定义在此,两 helper 须与它同模块,
# 否则 handler_support 无法引用;server.py 重导出旧路径)。
def _chv_vm_dead_from_row(row):
"""CHV 只读字段 chv_vm_dead 的派生规则(本轨命名抽出,
行为与 feat-chv-arch95plus 逐字一致,见 ADR-11/14)。
来源链(缺一环字段就不出现,绝不编造):
执行器 _vm_death_result 写 result["vm_dead"]=True →
worker 终局仅当 status='interrupted' 时把原因写进
tasks.interrupted_reason(非 goal 形态是完整原因文本,
goal 形态无 interrupted_reason 字段时回落为纯文本
“VM 中途死亡”)→ 本函数只在 executor 为
cloud-hypervisor 且原因文本含“VM 中途死亡”时返回
True,其它情形返回 None(调用方不写字段)。
本字段是“已报告死亡”,不是 live 状态:server 进程
没有 VM 登记表,VM 活着/死了的 live 状态编不出来;
前端无该字段时必须显示“未报告死亡”,不得写成活着。
"""
if _row_get(row, "executor") != "cloud-hypervisor":
return None
reason = _row_get(row, "interrupted_reason")
if reason and "VM 中途死亡" in str(reason):
return True
return None
def _task_to_dict(row, attachments=None, total_tokens=None):
depends_on = row["depends_on"]
try:
depends_on = json.loads(depends_on) if depends_on else []
except (TypeError, ValueError):
depends_on = []
if not isinstance(depends_on, list):
depends_on = [depends_on]
return {
"id": row["id"],
"project_id": row["project_id"],
"prompt": row["prompt"],
"status": row["status"],
"idempotency_key": row["idempotency_key"],
"depends_on": depends_on,
"priority": row["priority"],
"created_by": row["created_by"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"last_heartbeat": row["last_heartbeat"],
"result_text": _row_get(row, "result_text"),
"result_full_available": bool(_row_get(row, "result_text_full")),
"error_text": _row_get(row, "error_text"),
"actual_model": _row_get(row, "actual_model"),
"started_at": _row_get(row, "started_at"),
"finished_at": _row_get(row, "finished_at"),
"child_pid": _row_get(row, "child_pid"),
"executor": _row_get(row, "executor"),
"pr_url": _row_get(row, "pr_url"),
"pr_number": _row_get(row, "pr_number"),
"goal": _row_get(row, "goal"),
"verify_command": _row_get(row, "verify_command"),
"goal_max_rounds": _row_get(row, "goal_max_rounds"),
"goal_mode": _row_get(row, "goal_mode") or 0,
# 运行中改 goal:goal_version 是已提交版本(服务端写库),
# applied_goal_version 是 host 循环已应用版本(镜像回写),
# 两者不等时说明信号在途或未到轮次边界,绝不能冒充已应用。
"goal_version": _row_get(row, "goal_version") or 0,
"applied_goal_version": _row_get(row, "applied_goal_version") or 0,
"attachments": list(attachments or []),
"resume_message": _row_get(row, "resume_message"),
"worktree_host": _row_get(row, "worktree_host"),
"claimed_by_worker": _row_get(row, "claimed_by_worker"),
"model": _row_get(row, "model"),
"effort": _row_get(row, "effort"),
"child_model": _row_get(row, "child_model"),
"child_effort": _row_get(row, "child_effort"),
"approval": _row_get(row, "approval"),
"plan": _row_get(row, "plan") or 0,
"forked_from": _row_get(row, "forked_from"),
"environment_id": _row_get(row, "environment_id"),
"turn_id": _row_get(row, "turn_id"),
"root_turn_id": _row_get(row, "root_turn_id"),
"coordinator_parts": (
_coord_parse_parts(_row_get(row, "coordinator_parts"))
if _row_get(row, "coordinator_parts") else None),
"parent_task_id": _row_get(row, "parent_task_id"),
"coordinator_part": _row_get(row, "coordinator_part"),
# 旁聊(Muse 角色对齐 P2):NULL = 老行 = 主聊。
"kind": _row_get(row, "kind") or "main",
"goal_resume_pending": _row_get(row, "goal_resume_pending") or 0,
# agent 定义 v2:身份与指令落库后直接回读(老行缺列
# 时为 None;指令是定义文本、非密钥,任务详情里可见)。
"agent_name": _row_get(row, "agent_name"),
"agent_version": _row_get(row, "agent_version"),
"agent_instructions": _row_get(row, "agent_instructions"),
# v3:agent_tools 存 JSON 数组;脏数据/老行缺列时
# 按 None 计,不炸列表接口。
"agent_tools": _agent_tools_from_row(row),
# v9:agent_tool_approvals 存 JSON 对象;脏数据/老行
# 缺列时按 None 计(= 未声明)。
"agent_tool_approvals": _agent_tool_approvals_from_row(row),
# 审计循环接线:开关回读(老行缺列按 0 计);推回包
# JSON 是 worker 内部状态,不进任务详情回读(控制台
# 不应渲染机器包,运营方看推回审计行即可)。
# 演练注:以上 6 字段由 main 侧 _task_to_dict 原样并入
# (split 基底无此 6 字段,不并入则合入丢序列化字段)。
"audit_loop": _row_get(row, "audit_loop") or 0,
"paused_by_user": _row_get(row, "paused_by_user") or 0,
"interrupted_reason": _row_get(row, "interrupted_reason"),
"auto_resume_count": _row_get(row, "auto_resume_count") or 0,
"no_time_limit": _row_get(row, "no_time_limit") or 0,
# 列表接口由 _task_tokens_map 一次聚合传入;其它调用方
# 不传时为 None(前端显示占位),绝不把“未算”写成 0。
"total_tokens": total_tokens,
# feat-chv-arch-maint-fe:只读字段经
# _chv_vm_dead_from_row 派生(规则见该函数;本轨
# 未新增第二个生命周期字段——server 持久化的 CHV
# 生命周期事实只有 executor/status/interrupted_reason
# 三项,chv_vm_dead 已是其完整派生,再编字段就是
# 复制 interrupted_reason 或猜 live 状态,两者都不做)。
**({"chv_vm_dead": True}
if _chv_vm_dead_from_row(row) else {}),
}
MAX_PROJECTS_PER_USER = _env_int("CODEX_MAX_PROJECTS_PER_USER", 50)
MAX_PROJECT_ID_LEN = _env_int("CODEX_MAX_PROJECT_ID_LEN", 64)
MAX_PROJECT_NAME_LEN = _env_int("CODEX_MAX_PROJECT_NAME_LEN", 100)