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
@@ -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()
|
||||
Reference in new issue
Block a user