Files
codex-app-server/cloud/worker.py
T

493 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""云端 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()