cloud: worker dispatch, DB abstraction, packaging 0.1.0, repo CI workflow

This commit is contained in:
host committed 2026-10-26 01:44:15 +08:00
1 parent 076ba01c74
commit 07ef869267
13 files changed
+1916 -9

No files matched your search

+492
View File
@@ -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()