cloud: worker dispatch, DB abstraction, packaging 0.1.0, repo CI workflow
This commit is contained in:
1 parent
076ba01c74
commit
07ef869267
13 files changed
+1916
-9
No files matched your search
+50
-2
@@ -20,6 +20,8 @@ cloud/server.py(控制面,ThreadingHTTPServer)
|
||||
├─ OAuth:cloud/oauth.py(state + 授权 URL + 回调,脚手架)
|
||||
├─ 推送订阅与分发:cloud/push.py(订阅表 + 偏好过滤)
|
||||
├─ 认证 / 权限 / 审计:cloud/auth.py(AuthStore)
|
||||
├─ 数据库接口:cloud/db.py(Database 接口 + SQLite 实现,
|
||||
│ WAL + busy_timeout + 有限重试;Postgres 只是骨架,见下)
|
||||
├─ 并发:cloud/db.py(WAL + busy_timeout + 有限重试)
|
||||
└─ webhook 入站:cloud/webhooks.py(验签 + 解析,只解析不执行)
|
||||
```
|
||||
@@ -110,6 +112,51 @@ server 的连接统一走 `cloud/db.py`:WAL 模式、busy_timeout 5000ms、
|
||||
写仍是单写者串行,写密集或跨机器时必须换 Postgres,这是下一阶段,
|
||||
不是本轮假装解决。
|
||||
|
||||
### 数据库后端选择(接口层)
|
||||
|
||||
`cloud/db.py` 已抽成接口层,不改旧调用:
|
||||
|
||||
- `Database` 接口的方法就是原有 SQLite 实现对外提供的全部方法,
|
||||
按实际代码为准:`connect`(连接 + WAL 设置)、
|
||||
`execute_with_retry`(执行)、`read_all`(查询)、
|
||||
`transaction`(事务)、`is_locked_error`(锁冲突判定)。
|
||||
- `SQLiteDatabase`:现有实现改名后的类封装,原样保留
|
||||
WAL / busy_timeout / 有界重试;模块级函数 `connect` /
|
||||
`execute_with_retry` / `read_all` / `transaction` /
|
||||
`is_locked_error` 仍可用,行为不变。
|
||||
- `PostgresDatabase`:骨架,不是可用后端。即使驱动已装,其
|
||||
`connect` / 执行 / 查询 / 事务方法仍抛 `NotImplementedError`。
|
||||
- 工厂 `create_database(backend, db_path)` / `database_from_env()`:
|
||||
两个后端提供同一组接口方法与签名,见
|
||||
`cloud/tests/test_db_backend.py` 的接口契约测试。
|
||||
|
||||
后端选择环境变量(server 启动时生效,默认仍是 SQLite):
|
||||
|
||||
| 环境变量 | 说明 | 默认 |
|
||||
| --- | --- | --- |
|
||||
| `CLOUD_DB_BACKEND` | `sqlite` 或 `postgres`(`postgresql` 同义);其它值启动即报 `ValueError`,不静默当 sqlite | `sqlite` |
|
||||
| `CLOUD_DB_HOST` | Postgres 主机,全部走环境变量,不硬编码地址 | 无(未设即 None) |
|
||||
| `CLOUD_DB_PORT` | Postgres 端口,须为整数 | 无(未设即 None,走驱动默认) |
|
||||
| `CLOUD_DB_NAME` | Postgres 库名 | 无 |
|
||||
| `CLOUD_DB_USER` | Postgres 用户名 | 无 |
|
||||
| `CLOUD_DB_PASSWORD` | Postgres 密码,只走环境变量,绝不写文件、绝不进日志与 `repr` | 无 |
|
||||
|
||||
选 `postgres` 且驱动缺失时启动即明确报错:`PostgresDatabase`
|
||||
初始化时探测 `psycopg`(优先)/ `psycopg2`,两者都缺就抛
|
||||
`PostgresDriverMissingError`(`RuntimeError` 子类,文案含
|
||||
psycopg,写明“需安装 psycopg”),绝不静默回落到 SQLite,
|
||||
也绝不假装已接通 Postgres。`create_server` 在建表之前就做这
|
||||
一步校验,所以失败时不会先建出一个 SQLite 库假装可用。
|
||||
|
||||
> 高并发换 Postgres 是下一阶段的运维项,不是本轮代码一改就
|
||||
> 自动变生产。接口层 + 骨架只解决了“以后能换”,真正上生产还
|
||||
> 需要:真实 Postgres 部署(含备份、连接数与权限)、安装
|
||||
> psycopg 驱动、写迁移脚本并把现有 SQLite 表结构与数据迁过去、
|
||||
> 把 server / AuthStore / MemoryStore / KBStore / CostStore 等
|
||||
> 目前仍直连 SQLite 的调用点逐一接到新后端并重测。在这些完成
|
||||
> 之前,即使把 `CLOUD_DB_BACKEND` 设成 `postgres`,server 也只会
|
||||
> 在启动时明确报错,不会跑起来——这是有意为之的诚实边界。
|
||||
|
||||
| 方法与路径 | 权限 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET /health` | 无需认证 | `200 {"ok": true, "service": "cloud-control-plane"}` |
|
||||
@@ -225,5 +272,6 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
|
||||
端点(state + 授权 URL + 回调),但真实应用注册、回调公网地址、
|
||||
已有账号绑定仍缺;推送已接成订阅 + 分发端点,但 Web Push /
|
||||
APNs / FCM 的真实凭证与发送端仍缺;单机 SQLite 在高并发写下
|
||||
仍有上限,生产量上来后需换 Postgres;worker 执行、云端执行、
|
||||
真实通知与真实模型回合均未接通。
|
||||
仍有上限,生产量上来后需换 Postgres(接口层与骨架已就绪,
|
||||
但真实部署、驱动安装与迁移脚本仍缺,见上文“数据库后端选择”);
|
||||
worker 执行、云端执行、真实通知与真实模型回合均未接通。
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
"""云端控制面模块包。
|
||||
|
||||
只含 docstring,无副作用。子模块(auth、queue、sandbox、memory、kb、
|
||||
models、cost、webhooks、server)均零第三方依赖,只用 Python 标准库。
|
||||
models、cost、webhooks、server、worker)均零第三方依赖,只用 Python 标准库。
|
||||
"""
|
||||
+255
-1
@@ -1,4 +1,14 @@
|
||||
"""SQLite 并发 helper(云端控制面单机阶段)。
|
||||
"""数据库接口层(云端控制面单机阶段)。
|
||||
|
||||
本模块分两层,调用方按需选用:
|
||||
|
||||
1. 模块级函数(历史接口,原样保留,见下文):``connect`` /
|
||||
``is_locked_error`` / ``execute_with_retry`` / ``read_all`` /
|
||||
``transaction``。server.py 与旧测试仍走这条路,行为不变。
|
||||
2. ``Database`` 接口 + 两个实现:``SQLiteDatabase``(现有 SQLite
|
||||
行为的类封装)与 ``PostgresDatabase``(骨架,见下文)。
|
||||
新代码应走接口 + ``create_database`` 工厂,不要再直接拼
|
||||
sqlite3 连接。
|
||||
|
||||
只解决一件事:ThreadingHTTPServer 多线程 + 单个 SQLite 文件时的写冲突。
|
||||
不碰任何业务逻辑、不定义新 schema、不改 server.py 的接线:
|
||||
@@ -18,10 +28,25 @@ memory.py 的 summaries 完全一致,因为本模块根本不建表)。
|
||||
- WAL + busy_timeout + 有限重试只能缓解单机多线程写冲突,
|
||||
不能变成多机/高并发数据库:写仍是单写者串行,读多写少尚可,
|
||||
写密集或跨机器时必须换 Postgres(下一阶段,不在本轮假装解决)。
|
||||
|
||||
后端选择(环境变量,见 cloud/README.md 数据库一节):
|
||||
- ``CLOUD_DB_BACKEND``:``sqlite``(默认)或 ``postgres``。
|
||||
- Postgres 连接参数全部走环境变量,不硬编码地址或密码:
|
||||
``CLOUD_DB_HOST``、``CLOUD_DB_PORT``、``CLOUD_DB_NAME``、
|
||||
``CLOUD_DB_USER``、``CLOUD_DB_PASSWORD``。
|
||||
- Postgres 骨架的诚实边界:标准库没有 Postgres 驱动,
|
||||
``PostgresDatabase`` 初始化时找不到 psycopg/psycopg2 会立即
|
||||
明确报错(``PostgresDriverMissingError``,文案含 psycopg),
|
||||
绝不静默回落到 SQLite;即使驱动已安装,其连接/执行/查询/
|
||||
事务方法目前仍是 ``NotImplementedError`` 骨架,尚未接通真实
|
||||
Postgres——换生产数据库还需要真实部署、驱动安装与迁移脚本,
|
||||
不是改一个环境变量就自动完成。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
|
||||
# 与 server.py 旧 _connect 的 timeout=30s 保持一致的连接级等待;
|
||||
@@ -34,6 +59,18 @@ DEFAULT_MAX_RETRIES = 3
|
||||
DEFAULT_BACKOFF_SECONDS = (0.05, 0.1, 0.2)
|
||||
_LOCKED_MARKER = "database is locked"
|
||||
|
||||
# Postgres 连接参数的环境变量名(名字即文档,见 cloud/README.md)。
|
||||
POSTGRES_ENV_VARS = {
|
||||
"host": "CLOUD_DB_HOST",
|
||||
"port": "CLOUD_DB_PORT",
|
||||
"dbname": "CLOUD_DB_NAME",
|
||||
"user": "CLOUD_DB_USER",
|
||||
"password": "CLOUD_DB_PASSWORD",
|
||||
}
|
||||
BACKEND_ENV_VAR = "CLOUD_DB_BACKEND"
|
||||
DEFAULT_BACKEND = "sqlite"
|
||||
DEFAULT_SQLITE_PATH = "cloud-control-plane.db"
|
||||
|
||||
|
||||
def connect(db_path, busy_timeout_ms=DEFAULT_BUSY_TIMEOUT_MS,
|
||||
timeout=DEFAULT_CONNECT_TIMEOUT_S):
|
||||
@@ -133,3 +170,220 @@ def transaction(conn):
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Database 接口层
|
||||
# ----------------------------------------------------------------------
|
||||
# 方法名按实际代码为准,只覆盖模块级函数已提供的能力,不 invent
|
||||
# 不存在的方法名:connect(连接 + WAL 设置)、execute_with_retry
|
||||
# (执行)、read_all(查询)、transaction(事务)、is_locked_error
|
||||
# (锁冲突判定)。两个后端的方法签名必须一致,见 test_db_backend.py
|
||||
# 的接口契约测试(按类检查方法名与签名,不依赖驱动是否安装)。
|
||||
|
||||
|
||||
class Database(ABC):
|
||||
"""数据库后端接口。实现者不得静默回落到另一个后端。"""
|
||||
|
||||
@abstractmethod
|
||||
def connect(self):
|
||||
"""返回一个新连接(SQLite:已设 WAL + busy_timeout + Row)。"""
|
||||
|
||||
@abstractmethod
|
||||
def execute_with_retry(self, conn, sql, params=(),
|
||||
max_retries=DEFAULT_MAX_RETRIES,
|
||||
backoff_seconds=DEFAULT_BACKOFF_SECONDS):
|
||||
"""执行单条写语句,遇写锁冲突时有限重试,返回 cursor。"""
|
||||
|
||||
@abstractmethod
|
||||
def read_all(self, conn, sql, params=()):
|
||||
"""执行查询并返回全部行。"""
|
||||
|
||||
@abstractmethod
|
||||
def transaction(self, conn):
|
||||
"""返回单连接事务上下文:成功 commit,异常 rollback 后原样抛出。"""
|
||||
|
||||
@abstractmethod
|
||||
def is_locked_error(self, exc):
|
||||
"""判定异常是否为可重试的写锁冲突;非锁错误必须返回 False。"""
|
||||
|
||||
|
||||
class SQLiteDatabase(Database):
|
||||
"""Database 的 SQLite 实现:原样保留 WAL/busy_timeout/有界重试。
|
||||
|
||||
模块级函数仍是同一份实现,本类只是类封装,逐方法委托,
|
||||
因此旧调用与新接口看到的行为完全一致。
|
||||
"""
|
||||
|
||||
def __init__(self, db_path, busy_timeout_ms=DEFAULT_BUSY_TIMEOUT_MS,
|
||||
timeout=DEFAULT_CONNECT_TIMEOUT_S):
|
||||
self.db_path = str(db_path)
|
||||
self.busy_timeout_ms = int(busy_timeout_ms)
|
||||
self.timeout = timeout
|
||||
|
||||
def __repr__(self):
|
||||
return ("SQLiteDatabase(db_path=%r, busy_timeout_ms=%r, "
|
||||
"timeout=%r)" % (self.db_path, self.busy_timeout_ms,
|
||||
self.timeout))
|
||||
|
||||
def connect(self):
|
||||
return connect(self.db_path,
|
||||
busy_timeout_ms=self.busy_timeout_ms,
|
||||
timeout=self.timeout)
|
||||
|
||||
def execute_with_retry(self, conn, sql, params=(),
|
||||
max_retries=DEFAULT_MAX_RETRIES,
|
||||
backoff_seconds=DEFAULT_BACKOFF_SECONDS):
|
||||
return execute_with_retry(conn, sql, params,
|
||||
max_retries=max_retries,
|
||||
backoff_seconds=backoff_seconds)
|
||||
|
||||
def read_all(self, conn, sql, params=()):
|
||||
return read_all(conn, sql, params)
|
||||
|
||||
@contextmanager
|
||||
def transaction(self, conn):
|
||||
with transaction(conn) as cur:
|
||||
yield cur
|
||||
|
||||
def is_locked_error(self, exc):
|
||||
return is_locked_error(exc)
|
||||
|
||||
|
||||
class PostgresDriverMissingError(RuntimeError):
|
||||
"""Postgres 驱动缺失:明确报错,绝不静默回落到 SQLite。"""
|
||||
|
||||
|
||||
def _load_postgres_driver():
|
||||
"""返回 (驱动名, 模块);两者都缺时抛 PostgresDriverMissingError。
|
||||
|
||||
优先 psycopg(v3),其次 psycopg2;只做 import 探测,不连接、
|
||||
不读任何真实数据库、不碰任何密码。
|
||||
"""
|
||||
for name in ("psycopg", "psycopg2"):
|
||||
try:
|
||||
module = __import__(name)
|
||||
except ImportError:
|
||||
continue
|
||||
return name, module
|
||||
raise PostgresDriverMissingError(
|
||||
"Postgres 后端需安装 psycopg 驱动(例如 pip install psycopg),"
|
||||
"当前环境未找到 psycopg/psycopg2;已拒绝静默回落到 SQLite,"
|
||||
"请安装驱动后再选 CLOUD_DB_BACKEND=postgres。"
|
||||
)
|
||||
|
||||
|
||||
_POSTGRES_NOT_IMPLEMENTED = (
|
||||
"PostgresDatabase 是骨架,尚未实现:需要真实 Postgres 部署、"
|
||||
"psycopg 驱动安装与迁移脚本后才能接通,当前不假装已接通 Postgres。"
|
||||
)
|
||||
|
||||
|
||||
class PostgresDatabase(Database):
|
||||
"""Database 的 Postgres 骨架(未接通真实 Postgres)。
|
||||
|
||||
连接参数全部走环境变量(见 POSTGRES_ENV_VARS),显式参数优先
|
||||
于环境变量;未给出的参数保持 None,不硬编码任何地址或密码。
|
||||
初始化时即探测驱动:缺驱动立即抛 PostgresDriverMissingError,
|
||||
因此 server 选 postgres 后端时在启动阶段就失败,不会在运行时
|
||||
中途才炸。即使驱动已安装,connect/execute/read/transaction
|
||||
目前仍抛 NotImplementedError——骨架不是可用后端。
|
||||
本类的 __repr__ 绝不输出 password。
|
||||
"""
|
||||
|
||||
def __init__(self, host=None, port=None, dbname=None, user=None,
|
||||
password=None):
|
||||
env = POSTGRES_ENV_VARS
|
||||
self.host = host if host is not None else os.environ.get(env["host"])
|
||||
raw_port = port if port is not None else os.environ.get(env["port"])
|
||||
if raw_port is None or raw_port == "":
|
||||
self.port = None
|
||||
else:
|
||||
try:
|
||||
self.port = int(raw_port)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(
|
||||
"环境变量 %s 须为整数端口,当前值无法解析" % env["port"])
|
||||
self.dbname = (dbname if dbname is not None
|
||||
else os.environ.get(env["dbname"]))
|
||||
self.user = (user if user is not None
|
||||
else os.environ.get(env["user"]))
|
||||
# 只保存、不输出、不记录日志;调用方负责传入方式的安全。
|
||||
self.password = (password if password is not None
|
||||
else os.environ.get(env["password"]))
|
||||
# 驱动探测在初始化时完成:缺驱动这里就报错,不等到首次查询。
|
||||
self.driver_name, self._driver = _load_postgres_driver()
|
||||
|
||||
def __repr__(self):
|
||||
return ("PostgresDatabase(host=%r, port=%r, dbname=%r, user=%r)"
|
||||
% (self.host, self.port, self.dbname, self.user))
|
||||
|
||||
def connect(self):
|
||||
raise NotImplementedError(_POSTGRES_NOT_IMPLEMENTED)
|
||||
|
||||
def execute_with_retry(self, conn, sql, params=(),
|
||||
max_retries=DEFAULT_MAX_RETRIES,
|
||||
backoff_seconds=DEFAULT_BACKOFF_SECONDS):
|
||||
raise NotImplementedError(_POSTGRES_NOT_IMPLEMENTED)
|
||||
|
||||
def read_all(self, conn, sql, params=()):
|
||||
raise NotImplementedError(_POSTGRES_NOT_IMPLEMENTED)
|
||||
|
||||
def transaction(self, conn):
|
||||
raise NotImplementedError(_POSTGRES_NOT_IMPLEMENTED)
|
||||
|
||||
def is_locked_error(self, exc):
|
||||
raise NotImplementedError(_POSTGRES_NOT_IMPLEMENTED)
|
||||
|
||||
|
||||
def create_database(backend=None, db_path=None, **kwargs):
|
||||
"""后端工厂:按 backend(或环境变量)返回 Database 实现。
|
||||
|
||||
- backend 为 None 时读 ``CLOUD_DB_BACKEND``,未设则默认 sqlite。
|
||||
- ``sqlite``:返回 SQLiteDatabase(db_path 未给则用默认文件名,
|
||||
与 server 旧默认值一致);kwargs 可传 busy_timeout_ms/timeout。
|
||||
- ``postgres``/``postgresql``:返回 PostgresDatabase;驱动缺失
|
||||
时初始化即抛 PostgresDriverMissingError(文案含 psycopg),
|
||||
绝不回落到 SQLite;kwargs 可传 host/port/dbname/user/password
|
||||
覆盖环境变量(测试只能用 fake/test 占位)。
|
||||
- 其它值抛 ValueError,不静默当成 sqlite。
|
||||
"""
|
||||
resolved = backend
|
||||
if resolved is None:
|
||||
resolved = os.environ.get(BACKEND_ENV_VAR) or DEFAULT_BACKEND
|
||||
resolved = str(resolved).strip().lower()
|
||||
if resolved == "sqlite":
|
||||
path = db_path if db_path is not None else DEFAULT_SQLITE_PATH
|
||||
return SQLiteDatabase(path, **kwargs)
|
||||
if resolved in ("postgres", "postgresql"):
|
||||
return PostgresDatabase(**kwargs)
|
||||
raise ValueError(
|
||||
"未知数据库后端 %r(%s),仅支持 sqlite/postgres"
|
||||
% (resolved, BACKEND_ENV_VAR))
|
||||
|
||||
|
||||
def database_from_env(db_path=None, **kwargs):
|
||||
"""create_database 的环境变量版:backend 一律读 CLOUD_DB_BACKEND。"""
|
||||
return create_database(backend=None, db_path=db_path, **kwargs)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_BUSY_TIMEOUT_MS",
|
||||
"DEFAULT_CONNECT_TIMEOUT_S",
|
||||
"DEFAULT_MAX_RETRIES",
|
||||
"DEFAULT_BACKOFF_SECONDS",
|
||||
"POSTGRES_ENV_VARS",
|
||||
"BACKEND_ENV_VAR",
|
||||
"DEFAULT_BACKEND",
|
||||
"connect",
|
||||
"is_locked_error",
|
||||
"execute_with_retry",
|
||||
"read_all",
|
||||
"transaction",
|
||||
"Database",
|
||||
"SQLiteDatabase",
|
||||
"PostgresDatabase",
|
||||
"PostgresDriverMissingError",
|
||||
"create_database",
|
||||
"database_from_env",
|
||||
]
|
||||
+105
-2
@@ -9,6 +9,8 @@
|
||||
- CloudExecutor:占位,未接通云端执行。
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -50,20 +52,121 @@ class LocalExecutor(Executor):
|
||||
runner 默认是 subprocess.run 的薄封装;单测可注入假 runner,假
|
||||
runner 返回 CompletedProcess 风格的 result 对象(含 returncode、
|
||||
stdout),本类只读这两个字段,不依赖其它字段。
|
||||
|
||||
execute() 之外另有 dispatch_task()/run_task():它们是 worker
|
||||
派发时调 host.py 真实子命令的入口——dispatch 对应 host.py 的
|
||||
dispatch(建任务并跑一轮 turn),run 对应 host.py 的 run
|
||||
(在已有 host 任务上再跑一轮 turn)。两者都只发真实存在的
|
||||
host.py 子命令,不编造命令名;execute() 本身仍只发只读 status,
|
||||
绝不在 execute 里直接跑模型回合(见 test_queue_sandbox 的断言)。
|
||||
"""
|
||||
|
||||
def __init__(self, host_py_path, config_path=None, runner=None):
|
||||
def __init__(self, host_py_path, config_path=None, runner=None,
|
||||
repo=None):
|
||||
self.host_py_path = host_py_path
|
||||
self.config_path = config_path
|
||||
self.runner = runner if runner is not None else _default_runner
|
||||
self.repo = repo
|
||||
|
||||
def _command(self, task_id):
|
||||
def _base_command(self):
|
||||
cmd = [sys.executable, self.host_py_path]
|
||||
if self.config_path is not None:
|
||||
cmd += ["--config", self.config_path]
|
||||
return cmd
|
||||
|
||||
def _command(self, task_id):
|
||||
cmd = self._base_command()
|
||||
cmd += ["status", "--task", str(task_id)]
|
||||
return cmd
|
||||
|
||||
def _result_from_runner(self, task_id, result):
|
||||
"""把 runner 返回值统一成 execute() 同款结果 dict。
|
||||
|
||||
host.py dispatch/run 成功时打印 JSON(含 status/text),
|
||||
失败时以 SystemExit(1) 退出并打印错误文本。因此:returncode
|
||||
非 0 一律 failed;returncode 为 0 时优先取 JSON 里的 status
|
||||
(completed 外的状态,如 timeout,均按失败计,worker 侧再
|
||||
判断是否可 fallback),取不到 JSON 时按 completed 计。
|
||||
"""
|
||||
returncode = getattr(result, "returncode", 1)
|
||||
stdout = _truncate_stdout(getattr(result, "stdout", ""))
|
||||
status = "failed"
|
||||
if returncode == 0:
|
||||
status = "completed"
|
||||
try:
|
||||
parsed = json.loads(stdout)
|
||||
except (TypeError, ValueError):
|
||||
parsed = None
|
||||
if isinstance(parsed, dict) and parsed.get("status"):
|
||||
status = ("completed" if parsed["status"] == "completed"
|
||||
else "failed")
|
||||
host_status = parsed["status"]
|
||||
else:
|
||||
host_status = None
|
||||
else:
|
||||
host_status = None
|
||||
return {
|
||||
"executor": "local",
|
||||
"task_id": task_id,
|
||||
"status": status,
|
||||
"stdout": stdout,
|
||||
"returncode": returncode,
|
||||
"host_status": host_status,
|
||||
# host.py 的 RuntimeError(如“任务已存在”)走 traceback 到
|
||||
# stderr,worker 判断 dispatch 续跑时需要它;execute()
|
||||
# 的旧结果 dict 不含此字段,保持不变。
|
||||
"stderr": _truncate_stdout(getattr(result, "stderr", "")),
|
||||
}
|
||||
|
||||
def _host_name(self, task):
|
||||
"""cloud 任务 id -> host 任务名:只保留安全字符。
|
||||
|
||||
server 生成的 id 本就是 uuid hex,走这一步只是防御自定义 id;
|
||||
同一 id 每次算出同一名字,dispatch 失败后的 run 续跑才能
|
||||
找到同一个 host 任务。
|
||||
"""
|
||||
raw = str(task.get("id"))
|
||||
return re.sub(r"[^A-Za-z0-9_.-]", "-", raw)
|
||||
|
||||
def dispatch_task(self, task) -> dict:
|
||||
"""调 host.py dispatch:建 host 任务并跑一轮 turn。
|
||||
|
||||
命令:host.py [--config CFG] dispatch --name <host 名>
|
||||
--task <prompt> [--repo <repo>]。repo 取任务的 repo 字段,
|
||||
没有则取构造时传的 repo,都没有则不传(此时 host.py 用其
|
||||
配置文件里的 repo,配置里也没有时 host.py 自己报错,
|
||||
worker 把该报错原样写回 failed,不编造成功)。
|
||||
"""
|
||||
if not isinstance(task, dict) or task.get("id") is None:
|
||||
raise ValueError("任务缺少 id")
|
||||
if not task.get("prompt"):
|
||||
raise ValueError("任务缺少 prompt")
|
||||
cmd = self._base_command()
|
||||
cmd += ["dispatch", "--name", self._host_name(task),
|
||||
"--task", task["prompt"]]
|
||||
repo = task.get("repo") or self.repo
|
||||
if repo:
|
||||
cmd += ["--repo", repo]
|
||||
return self._result_from_runner(
|
||||
task["id"], self.runner(cmd))
|
||||
|
||||
def run_task(self, task) -> dict:
|
||||
"""调 host.py run:在已有 host 任务上再跑一轮 turn。
|
||||
|
||||
命令:host.py [--config CFG] run --task <host 名>
|
||||
--prompt <prompt>。worker 只在 dispatch 报“任务已存在”时
|
||||
用它续跑,不用它做首次派发。
|
||||
"""
|
||||
if not isinstance(task, dict) or task.get("id") is None:
|
||||
raise ValueError("任务缺少 id")
|
||||
if not task.get("prompt"):
|
||||
raise ValueError("任务缺少 prompt")
|
||||
cmd = self._base_command()
|
||||
cmd += ["run", "--task", self._host_name(task),
|
||||
"--prompt", task["prompt"]]
|
||||
return self._result_from_runner(
|
||||
task["id"], self.runner(cmd))
|
||||
|
||||
def execute(self, task) -> dict:
|
||||
task_id = task.get("id") if isinstance(task, dict) else None
|
||||
if task_id is None:
|
||||
|
||||
+20
-3
@@ -29,6 +29,8 @@ audit_log 三张表;本 server 用 projects/tasks 两张表,表名不冲突
|
||||
|
||||
启动:
|
||||
python3 -m cloud.server [--host 127.0.0.1] [--port 8765] [--db <路径>]
|
||||
后端由 CLOUD_DB_BACKEND 选(sqlite 默认;postgres 目前只是骨架,
|
||||
驱动缺失时启动即明确报错,见 cloud/db.py)。
|
||||
|
||||
测试用 create_server(db_path, host, port) 只返回 ThreadingHTTPServer
|
||||
实例,不自动 serve。
|
||||
@@ -47,6 +49,7 @@ from urllib.parse import parse_qs, urlparse
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.cost import CostStore
|
||||
from cloud.db import connect as _db_connect
|
||||
from cloud.db import create_database as _create_database
|
||||
from cloud.kb import KBStore
|
||||
from cloud.memory import MemoryStore
|
||||
from cloud.oauth import (
|
||||
@@ -1157,9 +1160,19 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(200, parse_slack_command(text))
|
||||
|
||||
|
||||
def create_server(db_path, host="127.0.0.1", port=8765):
|
||||
"""建 server 实例(不自动 serve,供测试与 __main__ 共用)。"""
|
||||
def create_server(db_path, host="127.0.0.1", port=8765, backend=None):
|
||||
"""建 server 实例(不自动 serve,供测试与 __main__ 共用)。
|
||||
|
||||
backend 为 None 时读环境变量 CLOUD_DB_BACKEND,未设则默认
|
||||
sqlite(旧行为不变)。选 postgres 时在建表之前就调用工厂:
|
||||
驱动缺失则初始化即抛明确错误(文案含 psycopg),启动即失败,
|
||||
绝不在运行时中途才炸、也绝不静默回落到 SQLite;Postgres
|
||||
目前只是骨架,即使驱动已装也尚未接通,见 cloud/db.py 与 README。
|
||||
"""
|
||||
db_path = str(db_path)
|
||||
# 后端校验必须在 init_db 之前:postgres 骨架不能先建 SQLite 表
|
||||
# 再失败,那会假装 sqlite 已可用。
|
||||
_create_database(backend=backend, db_path=db_path)
|
||||
init_db(db_path)
|
||||
# 确保 AuthStore 的三张表也存在,后续请求才能直接查 project_members。
|
||||
with _auth_store(db_path):
|
||||
@@ -1175,8 +1188,12 @@ def main(argv=None):
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
parser.add_argument("--db", default="cloud-control-plane.db")
|
||||
parser.add_argument("--db-backend", default=None,
|
||||
help="sqlite(默认)或 postgres;未给则读 "
|
||||
"CLOUD_DB_BACKEND 环境变量")
|
||||
args = parser.parse_args(argv)
|
||||
server = create_server(args.db, args.host, args.port)
|
||||
server = create_server(args.db, args.host, args.port,
|
||||
backend=args.db_backend)
|
||||
print(
|
||||
"cloud-control-plane listening on %s:%d db=%s"
|
||||
% (args.host, server.server_address[1], args.db),
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
"""cloud/db.py 接口层单测:Database + 双后端 + 工厂 + server 启动校验。
|
||||
|
||||
不删、不改 cloud/tests/test_db.py 的原有测试;本文件只覆盖新增的
|
||||
接口层。测试里出现的 Postgres 密码一律是 fake/test 占位
|
||||
("test-placeholder"),不是真实格式的密钥,绝不写真实凭证。
|
||||
|
||||
覆盖点(与任务一一对应):
|
||||
- SQLite 后端原有行为不变:WAL / busy_timeout / Row / 有界重试 /
|
||||
事务提交回滚,全部经 SQLiteDatabase 实例走一遍。
|
||||
- Postgres 后端无驱动时明确报错:实例化即抛 RuntimeError 且文案含
|
||||
psycopg,且不是静默回落(返回值绝不是 SQLiteDatabase)。
|
||||
- 后端切换不丢接口契约:Database 的抽象方法集合 == 两个后端类
|
||||
都提供的方法集合,且签名(除 self 外)一致;Postgres 方法体在
|
||||
骨架阶段抛 NotImplementedError/驱动缺失错误。
|
||||
- server 启动校验:CLOUD_DB_BACKEND=postgres 时 create_server 在
|
||||
建表前就报错;默认/sqlite 时仍能正常建 server。
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
try:
|
||||
from cloud.db import (
|
||||
Database,
|
||||
PostgresDatabase,
|
||||
PostgresDriverMissingError,
|
||||
SQLiteDatabase,
|
||||
create_database,
|
||||
database_from_env,
|
||||
)
|
||||
from cloud.server import create_server
|
||||
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.db import (
|
||||
Database,
|
||||
PostgresDatabase,
|
||||
PostgresDriverMissingError,
|
||||
SQLiteDatabase,
|
||||
create_database,
|
||||
database_from_env,
|
||||
)
|
||||
from cloud.server import create_server
|
||||
|
||||
INTERFACE_METHODS = (
|
||||
"connect",
|
||||
"execute_with_retry",
|
||||
"read_all",
|
||||
"transaction",
|
||||
"is_locked_error",
|
||||
)
|
||||
|
||||
TASKS_DDL = (
|
||||
"CREATE TABLE IF NOT EXISTS tasks("
|
||||
"id TEXT PRIMARY KEY, project_id TEXT, prompt TEXT, status TEXT)"
|
||||
)
|
||||
|
||||
|
||||
class _EnvCleanMixin:
|
||||
def setUp(self):
|
||||
self._env_backup = {
|
||||
k: os.environ.get(k) for k in
|
||||
("CLOUD_DB_BACKEND", "CLOUD_DB_HOST", "CLOUD_DB_PORT",
|
||||
"CLOUD_DB_NAME", "CLOUD_DB_USER", "CLOUD_DB_PASSWORD")
|
||||
}
|
||||
for key in self._env_backup:
|
||||
os.environ.pop(key, None)
|
||||
|
||||
def tearDown(self):
|
||||
for key, value in self._env_backup.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
class SQLiteBackendPreservedTestCase(_EnvCleanMixin, unittest.TestCase):
|
||||
def test_sqlite_database_keeps_wal_busy_timeout_row_retry(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "backend.db")
|
||||
db = SQLiteDatabase(path)
|
||||
conn = db.connect()
|
||||
try:
|
||||
self.assertEqual(conn.row_factory, sqlite3.Row)
|
||||
self.assertEqual(
|
||||
conn.execute("PRAGMA journal_mode").fetchone()[0], "wal")
|
||||
self.assertEqual(
|
||||
conn.execute("PRAGMA busy_timeout").fetchone()[0], 5000)
|
||||
conn.execute(TASKS_DDL)
|
||||
conn.commit()
|
||||
with db.transaction(conn):
|
||||
db.execute_with_retry(
|
||||
conn,
|
||||
"INSERT INTO tasks(id, project_id, prompt, status)"
|
||||
" VALUES(?, ?, ?, 'queued')",
|
||||
("t1", "p1", "hello"))
|
||||
rows = db.read_all(
|
||||
conn, "SELECT * FROM tasks WHERE id = ?", ("t1",))
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["prompt"], "hello")
|
||||
# 事务回滚:重复主键失败后仍只有 1 条。
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
with db.transaction(conn):
|
||||
db.execute_with_retry(
|
||||
conn,
|
||||
"INSERT INTO tasks(id, project_id, prompt,"
|
||||
" status) VALUES(?, ?, ?, 'queued')",
|
||||
("t1", "p1", "dup"))
|
||||
self.assertEqual(
|
||||
len(db.read_all(conn, "SELECT * FROM tasks")), 1)
|
||||
finally:
|
||||
conn.close()
|
||||
# 锁判定仍走原规则:锁错误 True,其它 False。
|
||||
self.assertTrue(db.is_locked_error(
|
||||
sqlite3.OperationalError("database is locked")))
|
||||
self.assertFalse(db.is_locked_error(
|
||||
sqlite3.OperationalError("no such table: tasks")))
|
||||
|
||||
def test_sqlite_database_custom_busy_timeout(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "backend.db")
|
||||
db = SQLiteDatabase(path, busy_timeout_ms=1234)
|
||||
conn = db.connect()
|
||||
try:
|
||||
self.assertEqual(
|
||||
conn.execute("PRAGMA busy_timeout").fetchone()[0], 1234)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_sqlite_retry_still_bounded_via_instance(self):
|
||||
# 用 fake 连接走实例方法:重试耗尽抛原始错误,不无限循环。
|
||||
class FakeConn:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def execute(self, sql, params=()):
|
||||
self.calls += 1
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db = SQLiteDatabase(os.path.join(tmp, "x.db"))
|
||||
fake = FakeConn()
|
||||
with mock.patch("cloud.db.time.sleep"):
|
||||
with self.assertRaises(sqlite3.OperationalError):
|
||||
db.execute_with_retry(fake, "INSERT ...", ())
|
||||
self.assertEqual(fake.calls, 4) # 1 次初始 + 3 次重试
|
||||
|
||||
|
||||
class PostgresSkeletonTestCase(_EnvCleanMixin, unittest.TestCase):
|
||||
def test_postgres_init_without_driver_raises_psycopg_error(self):
|
||||
# 本环境无 psycopg/psycopg2(已实测);若将来装了驱动,
|
||||
# 本用例应改为跳过——当前断言的是“无驱动时明确报错”。
|
||||
try:
|
||||
import psycopg # noqa: F401
|
||||
has_driver = True
|
||||
except ImportError:
|
||||
try:
|
||||
import psycopg2 # noqa: F401
|
||||
has_driver = True
|
||||
except ImportError:
|
||||
has_driver = False
|
||||
if has_driver:
|
||||
self.skipTest("环境已装 psycopg 驱动,无驱动报错路径不适用")
|
||||
with self.assertRaises(PostgresDriverMissingError) as ctx:
|
||||
PostgresDatabase(host="test-host", dbname="test-db",
|
||||
user="test-user",
|
||||
password="test-placeholder")
|
||||
self.assertIn("psycopg", str(ctx.exception))
|
||||
# 绝不静默回落:异常不是 SQLite 实例,工厂同样抛错而非返回。
|
||||
with self.assertRaises(PostgresDriverMissingError) as ctx2:
|
||||
create_database(backend="postgres")
|
||||
self.assertIn("psycopg", str(ctx2.exception))
|
||||
self.assertIsInstance(ctx2.exception, RuntimeError)
|
||||
|
||||
def test_postgres_params_come_from_env_not_hardcoded(self):
|
||||
# 驱动探测 mock 成已装(不连真实 Postgres),验证参数来源:
|
||||
# 环境变量进属性、显式参数优先于环境变量、repr 不泄密码。
|
||||
from cloud.db import POSTGRES_ENV_VARS
|
||||
self.assertEqual(POSTGRES_ENV_VARS["host"], "CLOUD_DB_HOST")
|
||||
self.assertEqual(POSTGRES_ENV_VARS["port"], "CLOUD_DB_PORT")
|
||||
self.assertEqual(POSTGRES_ENV_VARS["dbname"], "CLOUD_DB_NAME")
|
||||
self.assertEqual(POSTGRES_ENV_VARS["user"], "CLOUD_DB_USER")
|
||||
self.assertEqual(POSTGRES_ENV_VARS["password"], "CLOUD_DB_PASSWORD")
|
||||
env = {
|
||||
"CLOUD_DB_HOST": "env-host",
|
||||
"CLOUD_DB_PORT": "5433",
|
||||
"CLOUD_DB_NAME": "env-db",
|
||||
"CLOUD_DB_USER": "env-user",
|
||||
"CLOUD_DB_PASSWORD": "test-placeholder",
|
||||
}
|
||||
with mock.patch.dict(os.environ, env):
|
||||
with mock.patch("cloud.db._load_postgres_driver",
|
||||
return_value=("psycopg", object())):
|
||||
db = PostgresDatabase()
|
||||
self.assertEqual(db.host, "env-host")
|
||||
self.assertEqual(db.port, 5433)
|
||||
self.assertEqual(db.dbname, "env-db")
|
||||
self.assertEqual(db.user, "env-user")
|
||||
self.assertNotIn("test-placeholder", repr(db))
|
||||
# 显式参数优先于环境变量;未给出的参数不硬编码成默认值。
|
||||
db2 = PostgresDatabase(host="explicit-host")
|
||||
self.assertEqual(db2.host, "explicit-host")
|
||||
self.assertEqual(db2.dbname, "env-db")
|
||||
|
||||
def test_postgres_methods_are_skeleton_not_fake_success(self):
|
||||
# 绕过 __init__(驱动探测)构造骨架实例,验证方法体本身
|
||||
# 就是明确的 NotImplementedError,而不是假装成功。
|
||||
skeleton = object.__new__(PostgresDatabase)
|
||||
for name in ("connect", "read_all", "transaction",
|
||||
"is_locked_error"):
|
||||
method = getattr(skeleton, name)
|
||||
with self.assertRaises(NotImplementedError):
|
||||
if name in ("connect", "transaction"):
|
||||
method(None) if name == "transaction" else method()
|
||||
elif name == "is_locked_error":
|
||||
method(ValueError("x"))
|
||||
else:
|
||||
method(None, "SELECT 1")
|
||||
with self.assertRaises(NotImplementedError):
|
||||
skeleton.execute_with_retry(None, "SELECT 1", ())
|
||||
|
||||
def test_postgres_repr_never_contains_password(self):
|
||||
skeleton = object.__new__(PostgresDatabase)
|
||||
skeleton.host = "test-host"
|
||||
skeleton.port = None
|
||||
skeleton.dbname = "test-db"
|
||||
skeleton.user = "test-user"
|
||||
skeleton.password = "test-placeholder"
|
||||
self.assertNotIn("test-placeholder", repr(skeleton))
|
||||
|
||||
|
||||
class InterfaceContractTestCase(_EnvCleanMixin, unittest.TestCase):
|
||||
def test_database_is_abstract(self):
|
||||
with self.assertRaises(TypeError):
|
||||
Database()
|
||||
|
||||
def test_both_backends_expose_same_interface_methods(self):
|
||||
abstract = set(Database.__abstractmethods__)
|
||||
self.assertEqual(set(INTERFACE_METHODS), abstract)
|
||||
for cls in (SQLiteDatabase, PostgresDatabase):
|
||||
for name in INTERFACE_METHODS:
|
||||
self.assertTrue(
|
||||
callable(getattr(cls, name, None)),
|
||||
"%s 缺少接口方法 %s" % (cls.__name__, name))
|
||||
|
||||
def test_both_backends_signatures_match_interface(self):
|
||||
for cls in (SQLiteDatabase, PostgresDatabase):
|
||||
for name in INTERFACE_METHODS:
|
||||
expected = inspect.signature(getattr(Database, name))
|
||||
actual = inspect.signature(getattr(cls, name))
|
||||
expected_params = [p for p in expected.parameters
|
||||
if p != "self"]
|
||||
actual_params = [p for p in actual.parameters
|
||||
if p != "self"]
|
||||
self.assertEqual(
|
||||
expected_params, actual_params,
|
||||
"%s.%s 签名与 Database 不一致" % (cls.__name__, name))
|
||||
|
||||
def test_factory_backend_switching(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "factory.db")
|
||||
# 默认(环境变量未设)仍是 SQLite。
|
||||
db = create_database(db_path=path)
|
||||
self.assertIsInstance(db, SQLiteDatabase)
|
||||
self.assertIsInstance(db, Database)
|
||||
# 显式 sqlite。
|
||||
self.assertIsInstance(
|
||||
create_database(backend="sqlite", db_path=path),
|
||||
SQLiteDatabase)
|
||||
# 环境变量 sqlite。
|
||||
with mock.patch.dict(os.environ,
|
||||
{"CLOUD_DB_BACKEND": "sqlite"}):
|
||||
self.assertIsInstance(
|
||||
database_from_env(db_path=path), SQLiteDatabase)
|
||||
# 未知后端:明确 ValueError,不静默当 sqlite。
|
||||
with self.assertRaises(ValueError):
|
||||
create_database(backend="mysql", db_path=path)
|
||||
with mock.patch.dict(os.environ,
|
||||
{"CLOUD_DB_BACKEND": "mysql"}):
|
||||
with self.assertRaises(ValueError):
|
||||
database_from_env(db_path=path)
|
||||
# 环境变量 postgres:无驱动时抛含 psycopg 的错误,
|
||||
# 绝不返回一个 SQLite 实例假装切换成功。
|
||||
with mock.patch.dict(os.environ,
|
||||
{"CLOUD_DB_BACKEND": "postgres"}):
|
||||
try:
|
||||
import psycopg # noqa: F401
|
||||
has_driver = True
|
||||
except ImportError:
|
||||
try:
|
||||
import psycopg2 # noqa: F401
|
||||
has_driver = True
|
||||
except ImportError:
|
||||
has_driver = False
|
||||
if has_driver:
|
||||
self.skipTest("环境已装 psycopg 驱动")
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
database_from_env(db_path=path)
|
||||
self.assertIn("psycopg", str(ctx.exception))
|
||||
|
||||
|
||||
class ServerBackendSelectionTestCase(_EnvCleanMixin, unittest.TestCase):
|
||||
def test_server_postgres_backend_fails_at_startup(self):
|
||||
try:
|
||||
import psycopg # noqa: F401
|
||||
has_driver = True
|
||||
except ImportError:
|
||||
try:
|
||||
import psycopg2 # noqa: F401
|
||||
has_driver = True
|
||||
except ImportError:
|
||||
has_driver = False
|
||||
if has_driver:
|
||||
self.skipTest("环境已装 psycopg 驱动")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "should-not-be-created.db")
|
||||
with mock.patch.dict(os.environ,
|
||||
{"CLOUD_DB_BACKEND": "postgres"}):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
create_server(path, "127.0.0.1", 0)
|
||||
self.assertIn("psycopg", str(ctx.exception))
|
||||
# 启动前失败:不应已建出 SQLite 库文件假装可用。
|
||||
self.assertFalse(os.path.exists(path))
|
||||
|
||||
def test_server_default_and_sqlite_still_work(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "ok.db")
|
||||
server = create_server(path, "127.0.0.1", 0)
|
||||
try:
|
||||
self.assertTrue(os.path.exists(path))
|
||||
finally:
|
||||
server.server_close()
|
||||
with mock.patch.dict(os.environ,
|
||||
{"CLOUD_DB_BACKEND": "sqlite"}):
|
||||
path2 = os.path.join(tmp, "ok2.db")
|
||||
server2 = create_server(path2, "127.0.0.1", 0)
|
||||
try:
|
||||
self.assertTrue(os.path.exists(path2))
|
||||
finally:
|
||||
server2.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,295 @@
|
||||
"""cloud worker 单测:认领顺序、取消、缺 key、失败写回、预算门。
|
||||
|
||||
运行(在 ~/workspace/codex-app-server 下):
|
||||
python3 -m unittest discover -s cloud/tests -p "test_worker.py" -v
|
||||
|
||||
说明:只用注入的假 runner(假 CLI),不跑真实模型回合、不联网、
|
||||
不登录任何服务;env 映射也是注入的普通 dict,里面的值只是
|
||||
占位用的假名,绝不是真实密钥。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
try:
|
||||
from cloud.cost import CostStore
|
||||
from cloud.models import ModelRouter
|
||||
from cloud.server import init_db
|
||||
from cloud.worker import Worker, resolve_env_key
|
||||
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.cost import CostStore
|
||||
from cloud.models import ModelRouter
|
||||
from cloud.server import init_db
|
||||
from cloud.worker import Worker, resolve_env_key
|
||||
|
||||
|
||||
def _insert_task(db_path, task_id, prompt="do work", status="queued",
|
||||
depends_on=None, priority=0, project_id="p1"):
|
||||
now = time.time()
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO tasks(id, project_id, prompt, status,"
|
||||
" idempotency_key, depends_on, priority, created_by,"
|
||||
" created_at, updated_at, last_heartbeat)"
|
||||
" VALUES(?, ?, ?, ?, NULL, ?, ?, 'tester', ?, ?, NULL)",
|
||||
(task_id, project_id, prompt, status,
|
||||
json.dumps(depends_on or []), priority, now, now),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _status(db_path, task_id):
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT status FROM tasks WHERE id = ?", (task_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def _completed_runner(cmd):
|
||||
"""假 CLI:dispatch/run 一律成功,打印 host.py 风格的 JSON。"""
|
||||
if "dispatch" in cmd:
|
||||
name = cmd[cmd.index("--name") + 1]
|
||||
else:
|
||||
name = cmd[cmd.index("--task") + 1]
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout=json.dumps({"task": name, "status": "completed",
|
||||
"text": "done"}),
|
||||
stderr="")
|
||||
|
||||
|
||||
class WorkerTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
init_db(self.db_path)
|
||||
self.env = {"XCM_API_KEY": "test-placeholder"}
|
||||
self.calls = []
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _worker(self, runner=None, **kwargs):
|
||||
kwargs.setdefault("env", self.env)
|
||||
return Worker(self.db_path, "/fake/host.py",
|
||||
runner=runner or _completed_runner, **kwargs)
|
||||
|
||||
def _recording_runner(self, inner):
|
||||
def fake(cmd):
|
||||
self.calls.append(list(cmd))
|
||||
return inner(cmd)
|
||||
return fake
|
||||
|
||||
# -- 认领顺序:依赖拓扑 + 优先级 ------------------------------------
|
||||
def test_claim_order_dependency_and_priority(self):
|
||||
_insert_task(self.db_path, "A", priority=0)
|
||||
_insert_task(self.db_path, "B", depends_on=["A"], priority=0)
|
||||
_insert_task(self.db_path, "C", priority=9)
|
||||
worker = self._worker(runner=self._recording_runner(_completed_runner))
|
||||
results = worker.run_all()
|
||||
self.assertEqual([r["status"] for r in results],
|
||||
["completed"] * 3)
|
||||
# 首波 {C, A} 按优先级降序:C 先于 A;B 依赖 A,只能最后。
|
||||
dispatch_names = [c[c.index("--name") + 1] for c in self.calls
|
||||
if "dispatch" in c]
|
||||
self.assertEqual(dispatch_names, ["C", "A", "B"])
|
||||
for task_id in ("A", "B", "C"):
|
||||
self.assertEqual(_status(self.db_path, task_id), "completed")
|
||||
|
||||
def test_dependency_on_failed_task_not_dispatched(self):
|
||||
def fake(cmd):
|
||||
if "dispatch" in cmd and cmd[cmd.index("--name") + 1] == "A":
|
||||
return SimpleNamespace(returncode=1, stdout="boom",
|
||||
stderr="")
|
||||
return _completed_runner(cmd)
|
||||
_insert_task(self.db_path, "A")
|
||||
_insert_task(self.db_path, "B", depends_on=["A"])
|
||||
worker = self._worker(runner=fake)
|
||||
results = worker.run_all()
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0]["status"], "failed")
|
||||
self.assertEqual(_status(self.db_path, "A"), "failed")
|
||||
# B 的依赖未完成,始终不可派,保持 queued。
|
||||
self.assertEqual(_status(self.db_path, "B"), "queued")
|
||||
|
||||
# -- 取消 ------------------------------------------------------------
|
||||
def test_cancel_queued_task_marks_interrupted(self):
|
||||
_insert_task(self.db_path, "A")
|
||||
worker = self._worker(runner=self._recording_runner(
|
||||
_completed_runner))
|
||||
self.assertTrue(worker.cancel("A"))
|
||||
self.assertEqual(_status(self.db_path, "A"), "interrupted")
|
||||
# 已终态:再次取消返回 False;不存在的任务也是 False。
|
||||
self.assertFalse(worker.cancel("A"))
|
||||
self.assertFalse(worker.cancel("NOPE"))
|
||||
# 已取消的任务不再被派发。
|
||||
self.assertEqual(worker.run_all(), [])
|
||||
self.assertEqual(self.calls, [])
|
||||
|
||||
def test_cancel_during_dispatch_keeps_interrupted(self):
|
||||
_insert_task(self.db_path, "A")
|
||||
worker = None
|
||||
|
||||
def fake(cmd):
|
||||
# 执行中途从外部取消:worker 返回后不得用 completed 覆盖。
|
||||
worker.cancel("A")
|
||||
return _completed_runner(cmd)
|
||||
|
||||
worker = self._worker(runner=fake)
|
||||
result = worker.run_one()
|
||||
self.assertEqual(result["status"], "interrupted")
|
||||
self.assertEqual(_status(self.db_path, "A"), "interrupted")
|
||||
|
||||
# -- 缺 key 门 --------------------------------------------------------
|
||||
def test_missing_key_fails_clearly_and_does_not_run(self):
|
||||
_insert_task(self.db_path, "A")
|
||||
worker = self._worker(
|
||||
runner=self._recording_runner(_completed_runner), env={})
|
||||
result = worker.run_one()
|
||||
self.assertTrue(result["dispatched"])
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertEqual(result["error"],
|
||||
"Missing environment variable: XCM_API_KEY")
|
||||
self.assertEqual(_status(self.db_path, "A"), "failed")
|
||||
self.assertEqual(self.calls, []) # 缺 key 时绝不调 host.py
|
||||
|
||||
def test_missing_key_empty_value_counts_as_missing(self):
|
||||
_insert_task(self.db_path, "A")
|
||||
worker = self._worker(env={"XCM_API_KEY": ""})
|
||||
result = worker.run_one()
|
||||
self.assertEqual(result["error"],
|
||||
"Missing environment variable: XCM_API_KEY")
|
||||
|
||||
def test_custom_env_key_name_from_arg_and_config(self):
|
||||
# 参数指定的名字出现在报错里(只含名字,不含值)。
|
||||
_insert_task(self.db_path, "A")
|
||||
worker = self._worker(env={}, env_key="MY_RELAY_KEY")
|
||||
self.assertEqual(worker.run_one()["error"],
|
||||
"Missing environment variable: MY_RELAY_KEY")
|
||||
# codex 配置文件里的 env_key 行只用来取名字。
|
||||
cfg = os.path.join(self.tmp.name, "config.toml")
|
||||
with open(cfg, "w", encoding="utf-8") as f:
|
||||
f.write('env_key = "CUSTOM_RELAY_KEY"\n')
|
||||
self.assertEqual(resolve_env_key(codex_config_path=cfg),
|
||||
"CUSTOM_RELAY_KEY")
|
||||
_insert_task(self.db_path, "B")
|
||||
worker2 = self._worker(env={}, codex_config_path=cfg)
|
||||
self.assertEqual(worker2.env_key, "CUSTOM_RELAY_KEY")
|
||||
|
||||
def test_key_present_proceeds(self):
|
||||
_insert_task(self.db_path, "A")
|
||||
worker = self._worker()
|
||||
result = worker.run_one()
|
||||
self.assertEqual(result["status"], "completed")
|
||||
self.assertIsNone(result["error"])
|
||||
|
||||
# -- 执行失败写回 + 成本 ----------------------------------------------
|
||||
def test_execution_failure_writes_back_failed_and_cost(self):
|
||||
def fake(cmd):
|
||||
return SimpleNamespace(returncode=1, stdout="boom", stderr="")
|
||||
_insert_task(self.db_path, "A", prompt="do work")
|
||||
worker = self._worker(runner=fake)
|
||||
result = worker.run_one()
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertIn("boom", result["error"])
|
||||
self.assertEqual(_status(self.db_path, "A"), "failed")
|
||||
store = CostStore(self.db_path)
|
||||
try:
|
||||
report = store.report_by_task("A")
|
||||
row = store._conn.execute(
|
||||
"SELECT model, source, fallback_count FROM cost_records"
|
||||
" WHERE task_id = ?", ("A",)).fetchone()
|
||||
finally:
|
||||
store._conn.close()
|
||||
self.assertEqual(report["turns"], 1)
|
||||
self.assertGreater(report["estimated_tokens"], 0)
|
||||
self.assertEqual(report["actual_tokens"], 0)
|
||||
# 复用 cost.py 的记录结构:估算标 estimated,不冒充 actual。
|
||||
self.assertEqual(row[0], "gpt-6.1-sol")
|
||||
self.assertEqual(row[1], "estimated")
|
||||
self.assertEqual(row[2], 0)
|
||||
|
||||
# -- 预算门 ------------------------------------------------------------
|
||||
def test_budget_exceeded_no_dispatch(self):
|
||||
_insert_task(self.db_path, "A")
|
||||
worker = self._worker(
|
||||
runner=self._recording_runner(_completed_runner),
|
||||
max_turns=0)
|
||||
result = worker.run_one()
|
||||
self.assertFalse(result["dispatched"])
|
||||
self.assertEqual(result["reason"], "budget_exceeded")
|
||||
self.assertEqual(_status(self.db_path, "A"), "queued")
|
||||
self.assertEqual(self.calls, [])
|
||||
|
||||
def test_cost_budget_exceeded_no_dispatch(self):
|
||||
_insert_task(self.db_path, "A")
|
||||
worker = self._worker(max_cost_usd=0.0)
|
||||
result = worker.run_one()
|
||||
self.assertFalse(result["dispatched"])
|
||||
self.assertEqual(_status(self.db_path, "A"), "queued")
|
||||
|
||||
# -- fallback 事件:只记、不自动重试 -----------------------------------
|
||||
def test_timeout_fallback_event_recorded_not_retried(self):
|
||||
def fake(cmd):
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout=json.dumps({"task": "A", "status": "timeout",
|
||||
"text": ""}),
|
||||
stderr="")
|
||||
_insert_task(self.db_path, "A")
|
||||
router = ModelRouter(["gpt-6.1-sol", "gpt-6-sol"])
|
||||
worker = self._worker(runner=self._recording_runner(fake),
|
||||
router=router)
|
||||
result = worker.run_one()
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertEqual(len(result["events"]), 1)
|
||||
event = result["events"][0]
|
||||
self.assertEqual(event["type"], "model_fallback")
|
||||
self.assertEqual(event["from_model"], "gpt-6.1-sol")
|
||||
self.assertEqual(event["to_model"], "gpt-6-sol")
|
||||
self.assertEqual(event["reason"], "timeout")
|
||||
# 只记事件不重试:假 CLI 只被调用一次。
|
||||
self.assertEqual(len(self.calls), 1)
|
||||
store = CostStore(self.db_path)
|
||||
try:
|
||||
report = store.report_by_task("A")
|
||||
finally:
|
||||
store._conn.close()
|
||||
self.assertEqual(report["fallback_count"], 1)
|
||||
|
||||
# -- dispatch 已存在时用 run 续跑 --------------------------------------
|
||||
def test_dispatch_existing_falls_back_to_run(self):
|
||||
def fake(cmd):
|
||||
if "dispatch" in cmd:
|
||||
return SimpleNamespace(
|
||||
returncode=1, stdout="",
|
||||
stderr="Traceback: task 已存在:A,先换名或 discard 它")
|
||||
return _completed_runner(cmd)
|
||||
_insert_task(self.db_path, "A")
|
||||
worker = self._worker(runner=self._recording_runner(fake))
|
||||
result = worker.run_one()
|
||||
self.assertEqual(result["status"], "completed")
|
||||
self.assertEqual(_status(self.db_path, "A"), "completed")
|
||||
kinds = ["dispatch" if "dispatch" in c else "run"
|
||||
for c in self.calls]
|
||||
self.assertEqual(kinds, ["dispatch", "run"])
|
||||
run_cmd = [c for c in self.calls if "run" in c][0]
|
||||
self.assertEqual(run_cmd[run_cmd.index("--task") + 1], "A")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
"""云端 worker:把控制面的 queued 任务接通到 host.py 执行。
|
||||
|
||||
零第三方依赖,只用 Python 标准库;不改 host.py 行为、不动
|
||||
~/.codex 配置、不碰 wechat-bridge。
|
||||
|
||||
流程(与 server.py 的任务状态机对齐):
|
||||
queued -> running -> completed / failed / interrupted
|
||||
|
||||
1. 认领:pick_next() 用 queue.ready_tasks() 按依赖拓扑 + 优先级
|
||||
排序,跳过依赖未完成(未 completed/merged)的任务;再过
|
||||
queue.budget_exceeded() 预算门,超限不派发、任务保持 queued。
|
||||
claim() 用 `WHERE status='queued'` 的 CAS 更新把任务标 running
|
||||
并记 last_heartbeat / updated_at,避免两个 worker 重复认领。
|
||||
2. 缺 key 门(执行前必查):只检查配置的 env_key 名在进程环境
|
||||
里是否存在且非空,绝不读出、打印、写进文件或日志 key 的值。
|
||||
缺 key 时任务直接标 failed,错误固定为
|
||||
`Missing environment variable: <env_key 名>`,与之前
|
||||
real-nightly-tests automation 的失败模式一致,绝不静默假装跑了。
|
||||
env_key 名的来源:显式参数 > codex 配置文件里的 env_key 行
|
||||
(只读名字)> 默认 XCM_API_KEY(中转站 relay 的约定名)。
|
||||
3. 执行:经 LocalExecutor 调 host.py 真实子命令。dispatch
|
||||
(建 host 任务并跑一轮 turn)是首次派发;dispatch 报
|
||||
“任务已存在”时改用 run(在已有 host 任务上再跑一轮)续跑。
|
||||
host 任务名由 cloud 任务 id 派生(见 sandbox._host_name),
|
||||
同一任务每次算出同一名字,续跑才能找到同一个 host 任务。
|
||||
4. 写回:server 的 tasks 表没有 result/error 列,所以执行结果
|
||||
的持久化分三处,各复用已有结构、不另起字段名:
|
||||
- tasks 表:最终状态 + updated_at(取消时为 interrupted);
|
||||
- cost.py CostStore:每轮实际执行记一条 source="estimated"
|
||||
的成本记录(host 协议不暴露真实 token 数,估算绝不冒充
|
||||
actual;total_tokens 由 CostStore.record 按 input+output 算);
|
||||
- models.py:可 fallback 的失败(如 host 状态 timeout)且
|
||||
router 给出下一个模型时,用 record_fallback() 在任务记录上
|
||||
记 model_fallback 事件,并把 fallback_count 写进成本记录。
|
||||
注意:worker 只记录 fallback 事件,不自动用下一个模型重试——
|
||||
自动重试等于未经批准烧 relay 额度,重试与否由调用方决定。
|
||||
审计:认领/完成/失败/取消/缺 key 各记一条 AuthStore 审计
|
||||
(user_id="system",与 server.sweep_stale_tasks 的做法一致),
|
||||
审计失败不影响任务状态(best-effort)。
|
||||
|
||||
取消:host.py 的 argparse 子命令表里没有 interrupt(turn/interrupt
|
||||
只存在于 host.py Server 类内部的 app-server 协议调用,未暴露为
|
||||
CLI),所以 worker 的 cancel() 走 server 的 cancel 语义:把
|
||||
queued/running 任务直接标 interrupted(已终态的任务不动)。
|
||||
进行中的 host.py 子进程不会因此被杀(中途杀进程可能把 host
|
||||
state 写一半);run_one() 在执行返回后会先查任务是否已被取消,
|
||||
已被取消的一律保持 interrupted,不用执行结果覆盖它。
|
||||
|
||||
可注入:runner(假 CLI,见 sandbox.LocalExecutor)与 env 映射
|
||||
均可注入,单测不跑真实模型回合、不联网、不登录任何服务。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.cost import CostStore, estimate_tokens
|
||||
from cloud.db import connect as _db_connect
|
||||
from cloud.models import ModelRouter, is_fallbackable, record_fallback
|
||||
from cloud.queue import budget_exceeded, ready_tasks
|
||||
from cloud.sandbox import LocalExecutor
|
||||
|
||||
# 与 server.TERMINAL_STATUSES 对齐:这些状态的任务不再被认领或取消。
|
||||
TERMINAL_STATUSES = frozenset(
|
||||
{"completed", "failed", "interrupted", "merged", "merge-failed"})
|
||||
COMPLETED_STATUSES = frozenset({"completed", "merged"})
|
||||
DEFAULT_ENV_KEY = "XCM_API_KEY"
|
||||
DEFAULT_MODEL = "gpt-6.1-sol"
|
||||
# host.py dispatch 的原话是 "task 已存在: <name>,先换名或 discard 它"
|
||||
#(英文 task + 已存在),取共有子串做判断,不依赖标点。
|
||||
_ALREADY_EXISTS_MARKER = "已存在"
|
||||
|
||||
|
||||
def _missing_key_error(env_key):
|
||||
"""缺 key 的标准错误:只含 env_key 名,与 relay 侧的报错一致。"""
|
||||
return "Missing environment variable: %s" % env_key
|
||||
|
||||
|
||||
def resolve_env_key(env_key=None, codex_config_path=None):
|
||||
"""确定模型 key 对应的环境变量名(只返回名字,从不返回值)。
|
||||
|
||||
显式参数优先;其次读 codex 配置文件(如 relay 的 config.toml)
|
||||
里的 `env_key = "..."` 行,只取名字;文件不存在或无此行时
|
||||
用默认名。绝不读 ~/.codex/config.toml 之外的默认位置——
|
||||
调用方要读哪个配置文件,必须显式传 codex_config_path。
|
||||
"""
|
||||
if env_key:
|
||||
return env_key
|
||||
if codex_config_path:
|
||||
try:
|
||||
with open(codex_config_path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
text = ""
|
||||
match = re.search(
|
||||
r'^\s*env_key\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return DEFAULT_ENV_KEY
|
||||
|
||||
|
||||
def _resolve_model(model=None, config_path=None):
|
||||
"""模型名:显式参数 > host 配置文件里的 model > host 默认值。"""
|
||||
if model:
|
||||
return model
|
||||
if config_path:
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
cfg = {}
|
||||
if isinstance(cfg, dict) and cfg.get("model"):
|
||||
return cfg["model"]
|
||||
return DEFAULT_MODEL
|
||||
|
||||
|
||||
def _task_from_row(row):
|
||||
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"],
|
||||
}
|
||||
|
||||
|
||||
class Worker:
|
||||
"""单机 worker:一轮认领一个 queued 任务并执行到底。
|
||||
|
||||
前置条件:db_path 须已由 cloud.server.init_db() 建好 tasks 表;
|
||||
本模块不自建 server 的表(成本表与审计表由 CostStore/AuthStore
|
||||
在首次使用时自建,与 server 的做法一致)。
|
||||
"""
|
||||
|
||||
def __init__(self, db_path, host_py_path, config_path=None, repo=None,
|
||||
runner=None, env=None, env_key=None,
|
||||
codex_config_path=None, model=None, router=None,
|
||||
max_turns=None, max_cost_usd=None, executor=None,
|
||||
cost_store=None):
|
||||
self.db_path = str(db_path)
|
||||
self.host_py_path = host_py_path
|
||||
self.config_path = config_path
|
||||
self.repo = repo
|
||||
# env 只用于 key 存在性检查:默认接进程环境,单测可注入
|
||||
# 一个普通 dict;worker 永远不把 env 里的值写出。
|
||||
self._env = env if env is not None else os.environ
|
||||
self.env_key = resolve_env_key(env_key, codex_config_path)
|
||||
self.model = _resolve_model(model, config_path)
|
||||
if router is not None and not isinstance(router, ModelRouter):
|
||||
raise ValueError("router 必须是 cloud.models.ModelRouter")
|
||||
self.router = router
|
||||
self.max_turns = max_turns
|
||||
self.max_cost_usd = max_cost_usd
|
||||
self.usage_turns = 0
|
||||
self.usage_cost_usd = 0.0
|
||||
if executor is not None:
|
||||
self.executor = executor
|
||||
else:
|
||||
self.executor = LocalExecutor(
|
||||
host_py_path, config_path=config_path, runner=runner,
|
||||
repo=repo)
|
||||
self._cost_store = cost_store
|
||||
self._cancel_requested = set()
|
||||
|
||||
# -- 成本存储(惰性建表,避免只认领不执行时也建表)--------------
|
||||
@property
|
||||
def costs(self):
|
||||
if self._cost_store is None:
|
||||
self._cost_store = CostStore(self.db_path)
|
||||
return self._cost_store
|
||||
|
||||
def close(self):
|
||||
"""关闭惰性建的成本存储连接(注入的 cost_store 由调用方管)。"""
|
||||
if self._cost_store is not None:
|
||||
try:
|
||||
self._cost_store._conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -- DB 读写 ------------------------------------------------------
|
||||
def load_tasks(self):
|
||||
conn = _db_connect(self.db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM tasks ORDER BY created_at, id").fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [_task_from_row(r) for r in rows]
|
||||
|
||||
def get_task(self, task_id):
|
||||
conn = _db_connect(self.db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
return _task_from_row(row) if row is not None else None
|
||||
|
||||
def _claim(self, task_id):
|
||||
"""CAS 认领:仅当任务仍为 queued 时标 running 并记心跳。"""
|
||||
now = time.time()
|
||||
conn = _db_connect(self.db_path)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'running',"
|
||||
" last_heartbeat = ?, updated_at = ?"
|
||||
" WHERE id = ? AND status = 'queued'",
|
||||
(now, now, task_id),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount == 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _set_status(self, task_id, status):
|
||||
now = time.time()
|
||||
conn = _db_connect(self.db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status = ?, last_heartbeat = ?,"
|
||||
" updated_at = ? WHERE id = ?",
|
||||
(status, now, now, task_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def heartbeat(self, task_id):
|
||||
"""刷新 running 任务的心跳;任务不在 running 时返回 False。"""
|
||||
now = time.time()
|
||||
conn = _db_connect(self.db_path)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET last_heartbeat = ?, updated_at = ?"
|
||||
" WHERE id = ? AND status = 'running'",
|
||||
(now, now, task_id),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount == 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _audit(self, project_id, action, detail=""):
|
||||
"""best-effort 审计:失败不影响任务状态。"""
|
||||
try:
|
||||
store = AuthStore(self.db_path)
|
||||
try:
|
||||
store.audit("system", project_id, action, detail)
|
||||
finally:
|
||||
try:
|
||||
store.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -- 认领 ----------------------------------------------------------
|
||||
def _budget_exceeded(self):
|
||||
return budget_exceeded(self.usage_turns, self.max_turns,
|
||||
self.usage_cost_usd, self.max_cost_usd)
|
||||
|
||||
def pick_next(self):
|
||||
"""下一个可派任务,或 None(无就绪任务 / 预算超限 / 依赖成环)。
|
||||
|
||||
依赖满足的定义:依赖任务状态为 completed 或 merged。
|
||||
依赖成环时 ready_tasks 抛 ValueError,worker 视为无就绪任务
|
||||
(成环任务本就永远派不出去,run_all 不应因此崩溃)。
|
||||
"""
|
||||
if self._budget_exceeded():
|
||||
return None
|
||||
tasks = self.load_tasks()
|
||||
completed_ids = {t["id"] for t in tasks
|
||||
if t["status"] in COMPLETED_STATUSES}
|
||||
try:
|
||||
ready = ready_tasks(tasks, completed_ids)
|
||||
except ValueError:
|
||||
return None
|
||||
return ready[0] if ready else None
|
||||
|
||||
# -- 取消(server cancel 语义,见模块 docstring)-------------------
|
||||
def cancel(self, task_id):
|
||||
"""把 queued/running 任务标 interrupted;已终态或不存在返回 False。"""
|
||||
task = self.get_task(task_id)
|
||||
if task is None or task["status"] in TERMINAL_STATUSES:
|
||||
return False
|
||||
self._cancel_requested.add(task_id)
|
||||
conn = _db_connect(self.db_path)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'interrupted', updated_at = ?"
|
||||
" WHERE id = ? AND status NOT IN"
|
||||
" ('completed', 'failed', 'interrupted', 'merged',"
|
||||
" 'merge-failed')",
|
||||
(time.time(), task_id),
|
||||
)
|
||||
conn.commit()
|
||||
claimed = cur.rowcount == 1
|
||||
finally:
|
||||
conn.close()
|
||||
if claimed:
|
||||
self._audit(task["project_id"], "worker_cancelled", task_id)
|
||||
return claimed
|
||||
|
||||
def _was_cancelled(self, task_id):
|
||||
if task_id in self._cancel_requested:
|
||||
return True
|
||||
task = self.get_task(task_id)
|
||||
return task is not None and task["status"] == "interrupted"
|
||||
|
||||
# -- 缺 key 门 ------------------------------------------------------
|
||||
def key_available(self):
|
||||
"""只查存在性:名在环境里且值非空即 True;值本身绝不返回。"""
|
||||
try:
|
||||
value = self._env.get(self.env_key)
|
||||
except Exception:
|
||||
return False
|
||||
return value is not None and value != ""
|
||||
|
||||
# -- 执行 ----------------------------------------------------------
|
||||
def run_one(self, task=None):
|
||||
"""认领并执行一个任务,返回结果 dict(见模块 docstring)。
|
||||
|
||||
未派发时返回 {"dispatched": False, "reason": ...} 且不动任务;
|
||||
派发后(即使缺 key 失败)返回 dispatched=True 与最终状态。
|
||||
"""
|
||||
if self._budget_exceeded():
|
||||
return {"dispatched": False, "reason": "budget_exceeded"}
|
||||
if task is None:
|
||||
task = self.pick_next()
|
||||
if task is None:
|
||||
return {"dispatched": False, "reason": "no_ready_task"}
|
||||
task_id = task["id"]
|
||||
if not self._claim(task_id):
|
||||
return {"dispatched": False, "reason": "already_claimed",
|
||||
"task_id": task_id}
|
||||
task = self.get_task(task_id) or task
|
||||
self._audit(task["project_id"], "worker_claim", task_id)
|
||||
|
||||
# 缺 key 门:认领之后、调 host.py 之前;缺 key 直接 failed,
|
||||
# 不调 runner(假 CLI 单测可断言 runner 未被调用)。
|
||||
if not self.key_available():
|
||||
error = _missing_key_error(self.env_key)
|
||||
self._set_status(task_id, "failed")
|
||||
self._audit(task["project_id"], "worker_missing_key", error)
|
||||
return {"dispatched": True, "task_id": task_id,
|
||||
"host_name": self.executor._host_name(task)
|
||||
if hasattr(self.executor, "_host_name") else task_id,
|
||||
"status": "failed", "executor": "local",
|
||||
"stdout": "", "error": error, "events": [],
|
||||
"cost_record_id": None}
|
||||
|
||||
events = []
|
||||
fallback_count = 0
|
||||
result = None
|
||||
error = None
|
||||
try:
|
||||
result = self.executor.dispatch_task(task)
|
||||
combined = (result.get("stdout", "") or "") + \
|
||||
(result.get("stderr", "") or "")
|
||||
if result["status"] != "completed" and \
|
||||
_ALREADY_EXISTS_MARKER in combined:
|
||||
# host 任务已存在(上轮 worker 在 dispatch 后、写回前
|
||||
# 崩溃):改用 run 在已有任务上续跑,不重复建 worktree。
|
||||
result = self.executor.run_task(task)
|
||||
except Exception as exc: # runner 异常也如实写回 failed
|
||||
result = {"executor": "local", "task_id": task_id,
|
||||
"status": "failed", "stdout": "", "returncode": None,
|
||||
"host_status": None, "stderr": ""}
|
||||
error = str(exc)
|
||||
|
||||
# 取消优先:执行期间被 cancel 的任务保持 interrupted,
|
||||
# 绝不用执行结果覆盖它(见模块 docstring 的取消说明)。
|
||||
if self._was_cancelled(task_id):
|
||||
self._set_status(task_id, "interrupted")
|
||||
return {"dispatched": True, "task_id": task_id,
|
||||
"host_name": self.executor._host_name(task)
|
||||
if hasattr(self.executor, "_host_name") else task_id,
|
||||
"status": "interrupted", "executor": "local",
|
||||
"stdout": (result or {}).get("stdout", ""),
|
||||
"error": "cancelled", "events": [],
|
||||
"cost_record_id": None}
|
||||
|
||||
stdout = result.get("stdout", "") or ""
|
||||
host_status = result.get("host_status")
|
||||
final = result["status"]
|
||||
if final != "completed":
|
||||
if error is None:
|
||||
error = stdout.strip() or "host execution failed"
|
||||
# fallback 事件:只记、不自动重试(见模块 docstring)。
|
||||
if self.router is not None and host_status is not None \
|
||||
and is_fallbackable(host_status):
|
||||
nxt = self.router.next_after(self.model, host_status)
|
||||
if nxt is not None:
|
||||
record = dict(task)
|
||||
record["events"] = events
|
||||
# record_fallback 本身会 append 到 events,
|
||||
# 这里不要再 append 一次,否则事件会重复。
|
||||
record_fallback(
|
||||
record, self.model, nxt, str(host_status))
|
||||
fallback_count = 1
|
||||
self._audit(task["project_id"], "worker_fallback",
|
||||
"%s -> %s" % (self.model, nxt))
|
||||
self._set_status(task_id, final)
|
||||
self.usage_turns += 1
|
||||
# 成本:host 协议不暴露真实 token 数,用 cost.py 的估算结构
|
||||
# 记 estimated,绝不标 actual;fallback_count 复用同一记录。
|
||||
total = estimate_tokens(task.get("prompt") or "", stdout)
|
||||
cost_id = self.costs.record(
|
||||
task_id, task["project_id"], self.model,
|
||||
input_tokens=total, output_tokens=0, usd_micros=None,
|
||||
source="estimated", fallback_count=fallback_count)
|
||||
self._audit(task["project_id"],
|
||||
"worker_completed" if final == "completed"
|
||||
else "worker_failed", task_id)
|
||||
return {"dispatched": True, "task_id": task_id,
|
||||
"host_name": self.executor._host_name(task)
|
||||
if hasattr(self.executor, "_host_name") else task_id,
|
||||
"status": final, "executor": "local",
|
||||
"stdout": stdout, "error": error, "events": events,
|
||||
"cost_record_id": cost_id}
|
||||
|
||||
def run_all(self, max_tasks=None):
|
||||
"""循环认领执行,直到无就绪任务、预算超限或达到 max_tasks。
|
||||
|
||||
每轮都重新读库,所以上一轮刚 completed 的任务会解锁它的
|
||||
依赖任务;每轮至少推进一个任务到终态,不会空转死循环。
|
||||
"""
|
||||
results = []
|
||||
while max_tasks is None or len(results) < max_tasks:
|
||||
result = self.run_one()
|
||||
if not result["dispatched"]:
|
||||
break
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
"""单次派发循环:python3 -m cloud.worker --db <库> --host-py <路径>。
|
||||
|
||||
只打印任务结果摘要(状态、host 名、错误文本),绝不打印
|
||||
任何环境变量的值。
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="cloud worker (one pass)")
|
||||
parser.add_argument("--db", default="cloud-control-plane.db")
|
||||
parser.add_argument("--host-py", default=None,
|
||||
help="host.py 路径,缺省为 cloud/ 上一级的 host.py")
|
||||
parser.add_argument("--config", default=None)
|
||||
parser.add_argument("--repo", default=None)
|
||||
parser.add_argument("--env-key", default=None)
|
||||
parser.add_argument("--codex-config", default=None,
|
||||
help="含 env_key 行的 codex 配置文件(只读名字)")
|
||||
parser.add_argument("--model", default=None)
|
||||
parser.add_argument("--max-turns", type=int, default=None)
|
||||
parser.add_argument("--max-tasks", type=int, default=None)
|
||||
args = parser.parse_args(argv)
|
||||
host_py = args.host_py
|
||||
if host_py is None:
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
host_py = os.path.join(os.path.dirname(here), "host.py")
|
||||
worker = Worker(args.db, host_py, config_path=args.config,
|
||||
repo=args.repo, env_key=args.env_key,
|
||||
codex_config_path=args.codex_config, model=args.model,
|
||||
max_turns=args.max_turns)
|
||||
try:
|
||||
results = worker.run_all(max_tasks=args.max_tasks)
|
||||
finally:
|
||||
worker.close()
|
||||
print(json.dumps(
|
||||
[{"task_id": r.get("task_id"), "status": r.get("status"),
|
||||
"reason": r.get("reason"), "error": r.get("error")}
|
||||
for r in results],
|
||||
indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user