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

+14
View File
@@ -0,0 +1,14 @@
name: host-verify
on:
pull_request:
push:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python3 -m unittest discover -s tests -v
+227
View File
@@ -0,0 +1,227 @@
# CHANGELOG
语义化版本见 `VERSION`(当前 0.1.0)。以下按日期倒序,只列已交付
的大项,每项附实测证据;未建成的东西在“诚实边界”里明说,不夸大。
## 2026-10-26:缺口补齐(云端控制面第二批)
- 记忆 / KB / 成本 / 模型接成 HTTP 端点:记忆写/查/ supersede、
KB 索引/搜索(按项目隔离)、按任务与按项目/按日的成本(实际与
估算分开)、模型总览;全部走 `X-API-Key` + 项目 RBAC,
401 / 403 / 404 语义与跨项目隔离均有测试。
- SQLite 并发:共享 db 助手(WAL + busy_timeout + 有界重试),
并注明单机上限,Postgres 是下一阶段,不是本版已做。
- OAuth 脚手架:`cloud/oauth.py`,state 10 分钟过期、生成授权 URL、
回调验 state 后用可注入的 HTTP 客户端换 code;secret 只走环境变量,
没有假登录页,真实 OAuth 应用注册仍缺。
- 推送订阅与分发:`cloud/push.py`,webhook / slack / webpush /
apns / fcm 通道,即时/摘要/静默偏好,只发状态变更事件;缺发送方/
凭证时报 error,绝不静默报成功;真实推送凭证未配、真实推送未发。
- 实测证据:云端测试从 102 个涨到 157 个,全部通过
(`python3 -m unittest discover -s cloud/tests`);
host 测试同期 27 个,全部通过
(`python3 -m unittest discover -s tests`);
另有一次 live smoke:记忆写 201/读通、KB 索引/搜索命中、
OAuth 登录 URL 只返回不跳转、推送分发报“无发送方”错误、
无 key 查项目返回 401。
## 2026-10-26:云端控制面(第一批)
- 新 `cloud/` 包,零第三方依赖、只用 Python 标准库:
`server.py`(ThreadingHTTPServer + SQLite 状态,任务状态机与
host.py 对齐,含心跳与 stale 回收)、`auth.py`(API key 只存
SHA-256 哈希、按项目 RBAC owner / maintainer / contributor /
viewer、追加式审计日志)、`queue.py`(依赖拓扑 + 优先级 +
预算门)、`sandbox.py`(Local / Docker / Cloud 三种执行器,
缺 Docker 或缺云账号时明确报错、不静默降级)、`memory.py`
(线程摘要,可删/可 supersede,只取 top-k)、`kb.py`
(SQLite FTS,LIKE 兜底,按项目隔离)、`models.py`
(404 / 429 / 5xx / 超时才 fallback,不对 401 / 400 fallback,
实际用到的模型如实记录)、`cost.py`(读不到账单时返回
available=false,估算值明确标 estimated,实际与估算分开)、
`webhooks.py`(GitHub HMAC + Slack 签名与重放窗口,只解析,
只在状态变更时推送)。
- 实测证据:云端测试 102 个、host 测试 27 个,全部通过;
smoke:health 正常、无 key 查项目返回 401;代码里无密钥。
- 诚实边界:这是本地可跑的控制面演示,不是已建成的真实云服务;
仍缺常驻宿主、GitHub 远端 + PAT、Slack App、云容器账号、
定时/worker 跑模型时的模型 key 供给方式、真实 OAuth 应用注册、
真实推送凭证;server 本身不跑模型回合。
## 2026-10-26:GitHub PR / CI 一条线
- `host.py github-workflow`:生成 `.github/workflows/host-verify.yml`
(checkout + python + unittest),幂等。
- `host.py pr`:校验任务已完成且有分支,push 分支 + `gh pr create`,
成功后把 pr_url / pr_number 写回任务记录。
- `host.py pr-checks`:解析 `gh pr checks`,all_pass 仅在 checks
非空且全部 pass 时为真,空 checks 不算过。
- `host.py pr-merge`:先查 checks,未全过拒绝且不调 merge,
全过才 `gh pr merge --merge` 并标 merged,不动本地主分支
(与本地 `merge` 是两条线)。
- 实测证据:全部 gh 调用走可 mock 的 run_gh;测试从 19 个涨到
27 个,全部通过,含本地 bare 远端 push + 假 gh PR(写回编号 42)
与 workflow 幂等。
- 诚实边界:真实 PR 本轮没跑——本仓没有 git remote,gh 未登录;
真实 PR 仍缺远端与经 Secure Vault 的 PAT,那两样齐了才能跑。
## 2026-10-26:第二阶段(daemon / health / 自动恢复)
- `host.py daemon`:双 fork 到 PID 1,pidfile + host-daemon.log,
每轮跑 recover + run-due(各为独立 host.py 子进程);
`daemon-status` / `daemon-stop` 配套。
- `host.py health`:不跑模型,只查二进制、配置、state 有效性、
备份、daemon pid、server initialize + list、缺失 worktree;
失败时 exit 1。
- `host.py restore-state`:state 损坏/丢失时从 .bak / .bak.1 /
.bak.2 恢复,损坏文件改名为 .corrupt.<时间戳> 留证。
- `host.py recover`:超过 `--older-than`(默认 3600 秒)未更新的
running 任务标 interrupted 并记日志,可用 `run` 续跑;
故意不自动重跑模型回合,避免未经批准花 relay 额度。
- 附带修掉一个静默丢数据洞:旧 state 读取把 JSON 解析错误
当空 state 吞掉。
- 实测证据:daemon pid 的 PPID 为 1,测试中每 5 秒 tick、
stop 后 pidfile 删除;health 先 ok=true(7 个 server 线程),
损坏 state 后 ok=false / exit 1,restore 后 ok=true(备份版本 9);
recover 把 2 小时未动的 running 标 interrupted、1 分钟的保留;
测试从 15 个涨到 19 个,全部通过。
- 诚实边界:daemon 本身活不过平台 VM 回收(与微信桥 serve 同理);
VM 上更稳的定时是 Muse cron 调 run-due / health,不是只靠 daemon
或 crontab;本轮没有自动装 Muse cron 或 crontab,因为 run-due
到期会花模型额度,装定时需用户明确指定间隔与是否允许自动跑模型。
## 2026-10-26:第二类缺口(全部补齐)
- 合并自动验测 + 回滚:`merge` 默认仓库有 tests/ 就跑 unittest,
验测失败 reset 回合并前 HEAD,分支/worktree 保留;
`--no-verify` 跳过,`--verify` 可指定命令。
- 任务依赖 + 优先级 + 自动重试:jobs 里 depends_on / priority,
拓扑排序 + 按波次跑,依赖失败时依赖任务标 skipped 不跑;
依赖任务从依赖分支建 worktree(有血缘关系),不再文件拷贝;
dispatch / parallel 里 `--max-retries`,失败后同 prompt 再跑,
重试前查预算,默认 0、行为不变。
- 评审评论回流:`review-comment --file --line --comment` 把评论
存进任务,`review` 列出 open 评论,`fix-review` 按 open 评论
逐条修、修完标 resolved。
- server 崩溃重连:Server.ensure_alive,死了重拉并 resume thread
(thread 在 CODEX_HOME 里不丢);request 遇 BrokenPipe 重试一次;
单测 kill 进程后重拉 + list 能通。
- 成本预算 + 失败通知:配置 max_daily_turns,按 turn 数算
(协议不暴露真实 token 数,这是诚实边界),超预算不派发/
不重试;usage 记在 state 里,`doctor` 可看;失败时写
host-run.log + 跑配置的 notify_command(本地 shell,
模板 {task} {status});`failures` 列失败任务,`doctor` 列
失败/缺失 worktree/open 评论。
- 多机器 state 版本:state 加 version / last_writer / updated_at,
保存时磁盘版本比加载时新就记 STATE-CONFLICT 日志并合并保留
双方任务,不静默丢;真多机中心同步仍是检测、不是云同步。
- 合并时不再提交缓存文件:merge 前清 worktree 的
__pycache__ / *.pyc,主仓的 .pyc 改动先还原;单测覆盖 pyc 不入库。
- 实测证据:测试从 3 个涨到 15 个,全部通过;模型验证在
gap2 / gap3 仓上跑通(依赖并行、评审评论修复、带自动验测的合并),
并在实测中拦下两处真问题:文件拷贝依赖分支导致重复加同一段的
合并冲突(-X ours 兜底的新增行核对拦下一次 mul2 丢失并回滚,
后改从依赖分支建 worktree 修复)、merge 的 git add -A 提交
__pycache__ / *.pyc(已修)。
- 诚实边界:独立任务在同一文件位置追加仍会合并冲突,这是 git
自身行为,host abort / 回滚、不猜合并结果;多机共享 state
现在能检测、不静默丢,但 ChatGPT 云端式中心同步不在本地 host
范围内。
## 2026-10-26:生产加固
- state 文件锁(fcntl,只锁短读/写;保存时按任务/自动化名合并
磁盘状态,多进程并发写不丢对方任务;双进程 0.5 秒窗口竞写实测
双方任务都保留)。
- 项目配置 host-config.json(`init` / `config` 命令,CLI > 配置 >
默认;API key 绝不进配置文件,只走环境变量)。
- 定时自动化:run-due 在 host-run.log 记派发/跳过,
`automations --cron-line` 只打印 crontab 行不装机,
`--install-cron` 幂等装机;本轮测试里没有实际装 crontab,
避免未经批准的模型花费,且 VM crontab 可能活不过平台回收,
Muse cron 调 run-due 是更稳的 VM 选项。
- 诚实边界:锁只防同一台机器,不防两台机器共享同一 state 文件;
host.py 本身不常驻,仍需外部调度器触发 run-due。
## 2026-10-26:真实项目跑一轮(在打包前)
- 在 codex-app-server 本仓(非敏感的 wechat-bridge)上 dogfood:
先 git init(初始提交,.gitignore 排除 367M bin/、work/、
host-state*.json、host-run.log、本地 host 配置),真实任务用
单独的 host-state-real.json。
- 并行真实任务:real-tests 加 tests/test_host.py(3 个 unittest,
全部通过,host.py 本身未改);real-backup 加 save_state 备份轮转
(.bak / .bak.1 / .bak.2,在锁内)。
- 只读委派评审 real-backup 后发现多步轮转非原子、有截断拷贝风险、
无 fsync、fcntl-less 平台问题;便宜的修复已在 fix 回合里落地
(with open 读 state / 写临时文件、修掉 real-tests 发现的
ResourceWarning、备份经临时文件 + os.replace 拷贝)。
- 两任务都合并进 main,main 测试全过且无警告,手动双保存确认
.bak 里是上一版。
- 真实项目才有的教训:非 git 项目要先 git init 才能用
worktree / 合并门;缺 .gitignore 会把 bin/ 提交进去;
真实任务与玩具任务必须用不同的 state 文件。
- 记录在 TEST-REPORT.md;本轮打包前仍欠的下一步是:安装文档、
host 快速开始、版本/变更日志、合并后主仓自动验测(那一步当时
仍是手动)——其中合并后自动验测已在“第二类缺口”里补上,
安装文档与版本/变更日志由本 CHANGELOG 与 install.sh / README
快速开始补上。
## 2026-10-25:host 编排(多线程并行、worktree 隔离、评审/合并门)
- 单个 app-server 进程托管多 thread,后台读线程按 threadId 分发
turn / completed,多 thread 的 turn 真正并发,不是轮流排队。
- `dispatch` 自动 `git worktree add -b host/<task>`,一任务一
worktree;非 git 仓库退化为独立目录拷贝。
- `review`:status + diff + untracked;`merge`:先在 worktree
提交,再 `--no-ff` 合并进主仓,冲突自动 abort;
`discard`:删 worktree 和分支。
- `run`(resume + 新回合)、`fork`、`archive`、`list`;
`delegate`:子 thread(默认 read-only)跑完,结果可
`--report-to-parent` 汇总回主 thread。
- `goal`:同一 thread 多回合,直到 `--verify` 验收命令通过
或到 `--max-rounds`。
- `automations --add` + `run-due`:到期任务自动派发,结果进
host-state.json 评审队列;未到期不重复派发(幂等实测)。
- 边界实测:read-only thread 的写文件请求被拦下、文件确实没建;
untrusted + deny 时 agent 收到审批拒绝、没拿到命令输出;
模型内 3 子代理并行检查视角确实不同;turn/interrupt 后状态
为 interrupted,`compact` 需先 resume(host.py 的 compact 命令
已包好这一步);合并冲突时主仓干净、冲突 worktree 留着人工解;
隔离 CODEX_HOME 下的 MCP 工具能被发现并调用(MCP 审批是
elicitation 协议、不是通用 decision 字段,host.py 已分流处理,
修过一轮才通);同一 thread 内换模型续跑、turn 的 model 字段
随之改变;turn/steer 能把进行中的长任务改短;3 并行 worktree
互不污染;review 时 diff 为空也要看 untracked;不存在的模型
导致 task failed 并带上游 404 错误、host 不崩。
- 工具执行修复(VM 专属):系统裸 codex 二进制缺两个同伴
(codex-code-mode-host 与 codex-resources/bwrap),工具会
fail closed;配对目录 `bin/`(codex + codex-code-mode-host +
codex-resources/bwrap,约 367M,官方 0.164.0-alpha.9)经
start.sh 优先使用;relay 支持的模型回合实测 10.3 秒内在
work/xcm-test/test-result.txt 写出“codex-ok”。Mac 上正常
npm 安装自带这三件套,不需要 bin/。
- 协议层:start.sh 启动官方 codex app-server(0.164.0-alpha.9),
零依赖 Python stdio JSON-RPC 客户端(initialize / thread/start /
turn/start、审批、resume / fork),宿主级并行多 thread 编排,
配置示例与官方协议 schema(generate-json-schema 导出)。
隔离 CODEX_HOME 下验证到模型边界为止:initialize / thread/start /
turn/start 与事件流都通,turn 随后因缺模型登录返回 401 failed,
这是登录边界、不是协议 bug。
- 诚实边界:云端派发、推送通知、Slack / GitHub 触发、GUI inline
diff 评论、Computer Use 都不在开源里、没有复刻;本版是本地
host + 本地可跑的控制面演示,不是真实云服务。
## 诚实边界(贯穿所有版本)
- 没有建成真实云服务:cloud/ 是本地可跑的控制面演示,
生产仍缺常驻机器、GitHub 远端 + PAT、Slack App、云容器账号、
模型 key 供给方式、真实 OAuth 应用注册、真实推送凭证。
- 没有真实 PR、没有真实推送:PR 只做到代码 + 本地可测部分
(假 gh、本地 bare 远端);推送只做到订阅与分发逻辑,
缺凭证时明确报错。
- 没有真实 token 成本:协议不暴露真实 token 数,预算按 turn
数算;成本端点里实际与估算严格分开,估算值标 estimated。
- 没有在任何文件、state、日志或汇报里写密钥:API key、PAT、
Slack token、OAuth secret 一律只走环境变量或 Secure Vault,
配置文件里只放 env_key 这类名字,不放值。
+37
View File
@@ -198,6 +198,43 @@ turn 可以真正并发(不是轮流排队)。
或 crontab;daemon 适合常驻机器,VM 上是“会话内常驻 + 或 crontab;daemon 适合常驻机器,VM 上是“会话内常驻 +
回收后重拉”的定位。 回收后重拉”的定位。
## 宿主快速开始(最小闭环)
下面全部是 `host.py --help` 核对过的真实子命令和参数,
不要凭印象编命令。先装检查,再走闭环;**不要在示例里
直接派发真实模型任务来“试一下”**——dispatch 一步就会跑模型。
```bash
cd ~/workspace/codex-app-server
./install.sh # 只检查 python3、git、codex,不写密钥、不跑模型
python3 host.py health # 不跑模型:查二进制、配置、state、server、daemon
python3 host.py init --repo /path/to/your/repo
# 上一步写 host-config.json(CLI > 配置 > 默认;key 绝不进配置文件,只走环境变量)
python3 host.py dispatch --name example-task --task "你要做的事" --repo /path/to/your/repo
# ^ 这一步跑模型:需要模型 key 已走环境变量提供,且会建 git worktree host/example-task
python3 host.py review --task example-task # 看 status + diff + untracked + open 评审评论
python3 host.py merge --task example-task # 默认仓库有 tests/ 就跑 unittest,验测失败自动回滚
# 合并不了就别硬合:python3 host.py discard --task example-task # 删 worktree 和分支
python3 host.py daemon --interval 300 # 双 fork 常驻:每轮 recover + run-due
python3 host.py daemon-status
python3 host.py daemon-stop
```
三条边界,闭环前先知道:
- `health` 不跑模型、不花额度,随时可跑;`daemon-status` /
`daemon-stop` 同样不跑模型。
- `daemon` 在这台 VM 上活不过平台回收(和微信桥 serve 同理):
回收后要重拉;VM 上更稳的定时是 Muse cron 调 `health` /
`run-due`,而不是只靠 daemon 或 crontab。
- `run-due` 到期会跑模型、会花额度:先用
`python3 host.py automations --add --name <名> --task "<任务>" --repo <仓> --interval 86400`
登记自动化,再确认模型 key 已走环境变量提供,才让定时去调
`python3 host.py run-due`;未到期的自动化 run-due 时不重复派发。
- 云端控制面演示另起一端(不跑模型回合):
`python3 -m cloud.server`(默认 127.0.0.1:8765),
除 `GET /health`、`/webhooks/*`、`/oauth/*` 外都要 `X-API-Key` 请求头。
## GitHub PR/CI 的外部条件(真实 PR 仍缺这两样) ## GitHub PR/CI 的外部条件(真实 PR 仍缺这两样)
PR/CI 这条线本轮只做了代码和本地可测部分,真实 PR 没有跑, PR/CI 这条线本轮只做了代码和本地可测部分,真实 PR 没有跑,
+1
View File
@@ -0,0 +1 @@
0.1.0
+50 -2
View File
@@ -20,6 +20,8 @@ cloud/server.py(控制面,ThreadingHTTPServer)
├─ OAuth:cloud/oauth.py(state + 授权 URL + 回调,脚手架) ├─ OAuth:cloud/oauth.py(state + 授权 URL + 回调,脚手架)
├─ 推送订阅与分发:cloud/push.py(订阅表 + 偏好过滤) ├─ 推送订阅与分发:cloud/push.py(订阅表 + 偏好过滤)
├─ 认证 / 权限 / 审计:cloud/auth.py(AuthStore) ├─ 认证 / 权限 / 审计:cloud/auth.py(AuthStore)
├─ 数据库接口:cloud/db.py(Database 接口 + SQLite 实现,
│ WAL + busy_timeout + 有限重试;Postgres 只是骨架,见下)
├─ 并发:cloud/db.py(WAL + busy_timeout + 有限重试) ├─ 并发:cloud/db.py(WAL + busy_timeout + 有限重试)
└─ webhook 入站:cloud/webhooks.py(验签 + 解析,只解析不执行) └─ webhook 入站:cloud/webhooks.py(验签 + 解析,只解析不执行)
``` ```
@@ -110,6 +112,51 @@ server 的连接统一走 `cloud/db.py`:WAL 模式、busy_timeout 5000ms、
写仍是单写者串行,写密集或跨机器时必须换 Postgres,这是下一阶段, 写仍是单写者串行,写密集或跨机器时必须换 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"}` | | `GET /health` | 无需认证 | `200 {"ok": true, "service": "cloud-control-plane"}` |
@@ -225,5 +272,6 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
端点(state + 授权 URL + 回调),但真实应用注册、回调公网地址、 端点(state + 授权 URL + 回调),但真实应用注册、回调公网地址、
已有账号绑定仍缺;推送已接成订阅 + 分发端点,但 Web Push / 已有账号绑定仍缺;推送已接成订阅 + 分发端点,但 Web Push /
APNs / FCM 的真实凭证与发送端仍缺;单机 SQLite 在高并发写下 APNs / FCM 的真实凭证与发送端仍缺;单机 SQLite 在高并发写下
仍有上限,生产量上来后需换 Postgres;worker 执行、云端执行、 仍有上限,生产量上来后需换 Postgres(接口层与骨架已就绪,
真实通知与真实模型回合均未接通。 但真实部署、驱动安装与迁移脚本仍缺,见上文“数据库后端选择”);
worker 执行、云端执行、真实通知与真实模型回合均未接通。
+1 -1
View File
@@ -1,5 +1,5 @@
"""云端控制面模块包。 """云端控制面模块包。
只含 docstring,无副作用。子模块(auth、queue、sandbox、memory、kb、 只含 docstring,无副作用。子模块(auth、queue、sandbox、memory、kb、
models、cost、webhooks、server)均零第三方依赖,只用 Python 标准库。 models、cost、webhooks、server、worker)均零第三方依赖,只用 Python 标准库。
""" """
+255 -1
View File
@@ -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 文件时的写冲突。 只解决一件事:ThreadingHTTPServer 多线程 + 单个 SQLite 文件时的写冲突。
不碰任何业务逻辑、不定义新 schema、不改 server.py 的接线: 不碰任何业务逻辑、不定义新 schema、不改 server.py 的接线:
@@ -18,10 +28,25 @@ memory.py 的 summaries 完全一致,因为本模块根本不建表)。
- WAL + busy_timeout + 有限重试只能缓解单机多线程写冲突, - WAL + busy_timeout + 有限重试只能缓解单机多线程写冲突,
不能变成多机/高并发数据库:写仍是单写者串行,读多写少尚可, 不能变成多机/高并发数据库:写仍是单写者串行,读多写少尚可,
写密集或跨机器时必须换 Postgres(下一阶段,不在本轮假装解决)。 写密集或跨机器时必须换 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 sqlite3
import time import time
from abc import ABC, abstractmethod
from contextlib import contextmanager from contextlib import contextmanager
# 与 server.py 旧 _connect 的 timeout=30s 保持一致的连接级等待; # 与 server.py 旧 _connect 的 timeout=30s 保持一致的连接级等待;
@@ -34,6 +59,18 @@ DEFAULT_MAX_RETRIES = 3
DEFAULT_BACKOFF_SECONDS = (0.05, 0.1, 0.2) DEFAULT_BACKOFF_SECONDS = (0.05, 0.1, 0.2)
_LOCKED_MARKER = "database is locked" _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, def connect(db_path, busy_timeout_ms=DEFAULT_BUSY_TIMEOUT_MS,
timeout=DEFAULT_CONNECT_TIMEOUT_S): timeout=DEFAULT_CONNECT_TIMEOUT_S):
@@ -133,3 +170,220 @@ def transaction(conn):
except Exception: except Exception:
pass pass
raise 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
View File
@@ -9,6 +9,8 @@
- CloudExecutor:占位,未接通云端执行。 - CloudExecutor:占位,未接通云端执行。
""" """
import json
import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
@@ -50,20 +52,121 @@ class LocalExecutor(Executor):
runner 默认是 subprocess.run 的薄封装;单测可注入假 runner,假 runner 默认是 subprocess.run 的薄封装;单测可注入假 runner,假
runner 返回 CompletedProcess 风格的 result 对象(含 returncode、 runner 返回 CompletedProcess 风格的 result 对象(含 returncode、
stdout),本类只读这两个字段,不依赖其它字段。 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.host_py_path = host_py_path
self.config_path = config_path self.config_path = config_path
self.runner = runner if runner is not None else _default_runner 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] cmd = [sys.executable, self.host_py_path]
if self.config_path is not None: if self.config_path is not None:
cmd += ["--config", self.config_path] cmd += ["--config", self.config_path]
return cmd
def _command(self, task_id):
cmd = self._base_command()
cmd += ["status", "--task", str(task_id)] cmd += ["status", "--task", str(task_id)]
return cmd 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: def execute(self, task) -> dict:
task_id = task.get("id") if isinstance(task, dict) else None task_id = task.get("id") if isinstance(task, dict) else None
if task_id is None: if task_id is None:
+20 -3
View File
@@ -29,6 +29,8 @@ audit_log 三张表;本 server 用 projects/tasks 两张表,表名不冲突
启动: 启动:
python3 -m cloud.server [--host 127.0.0.1] [--port 8765] [--db <路径>] 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 测试用 create_server(db_path, host, port) 只返回 ThreadingHTTPServer
实例,不自动 serve。 实例,不自动 serve。
@@ -47,6 +49,7 @@ from urllib.parse import parse_qs, urlparse
from cloud.auth import AuthStore from cloud.auth import AuthStore
from cloud.cost import CostStore from cloud.cost import CostStore
from cloud.db import connect as _db_connect from cloud.db import connect as _db_connect
from cloud.db import create_database as _create_database
from cloud.kb import KBStore from cloud.kb import KBStore
from cloud.memory import MemoryStore from cloud.memory import MemoryStore
from cloud.oauth import ( from cloud.oauth import (
@@ -1157,9 +1160,19 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(200, parse_slack_command(text)) self._send_json(200, parse_slack_command(text))
def create_server(db_path, host="127.0.0.1", port=8765): def create_server(db_path, host="127.0.0.1", port=8765, backend=None):
"""建 server 实例(不自动 serve,供测试与 __main__ 共用)。""" """建 server 实例(不自动 serve,供测试与 __main__ 共用)。
backend 为 None 时读环境变量 CLOUD_DB_BACKEND,未设则默认
sqlite(旧行为不变)。选 postgres 时在建表之前就调用工厂:
驱动缺失则初始化即抛明确错误(文案含 psycopg),启动即失败,
绝不在运行时中途才炸、也绝不静默回落到 SQLite;Postgres
目前只是骨架,即使驱动已装也尚未接通,见 cloud/db.py 与 README。
"""
db_path = str(db_path) db_path = str(db_path)
# 后端校验必须在 init_db 之前:postgres 骨架不能先建 SQLite 表
# 再失败,那会假装 sqlite 已可用。
_create_database(backend=backend, db_path=db_path)
init_db(db_path) init_db(db_path)
# 确保 AuthStore 的三张表也存在,后续请求才能直接查 project_members。 # 确保 AuthStore 的三张表也存在,后续请求才能直接查 project_members。
with _auth_store(db_path): 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("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765) parser.add_argument("--port", type=int, default=8765)
parser.add_argument("--db", default="cloud-control-plane.db") 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) 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( print(
"cloud-control-plane listening on %s:%d db=%s" "cloud-control-plane listening on %s:%d db=%s"
% (args.host, server.server_address[1], args.db), % (args.host, server.server_address[1], args.db),
+349
View File
@@ -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()
+295
View File
@@ -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
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()
Executable
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
# codex-app-server 安装检查(不装包、不写密钥、不跑模型)。
#
# 用法:
# ./install.sh
#
# 通过后怎么启动(见 README“宿主快速开始”):
# python3 host.py health # 不跑模型,只做健康检查
# python3 host.py init --repo <你的仓库路径>
# python3 -m cloud.server # 云端控制面演示,默认 127.0.0.1:8765
#
# 三条硬规则(脚本本身也遵守):
# 1. bin/codex 约 367M 不进仓库:.gitignore 已排除 bin/,安装时需
# 自行配对官方二进制(本机 VM 做法见下;Mac 上 npm 正常安装
# 自带 codex-code-mode-host 与 codex-resources/bwrap,不需要 bin/)。
# 2. 模型 key 只走环境变量:配置文件里只放 env_key 这类名字
# (例如 XCM_API_KEY 这类名字),绝不在脚本、配置、日志里写 key 的值。
# 3. 本脚本不登录任何账号、不向任何外部服务发请求、不跑模型回合。
set -euo pipefail
cd "$(dirname "$0")"
fail=0
# 1. python3 是否存在
if command -v python3 >/dev/null 2>&1; then
echo "ok: python3 $(python3 --version 2>&1)"
else
echo "missing: python3(请先安装 Python 3)" >&2
fail=1
fi
# 2. git 是否存在(dispatch / review / merge 走 git worktree)
if command -v git >/dev/null 2>&1; then
echo "ok: git $(git --version 2>&1)"
else
echo "missing: git(host.py 的 worktree 隔离与合并门需要 git)" >&2
fail=1
fi
# 3. 官方 codex 二进制是否可用(只检查存在性,不启动、不登录)
if [ -n "${CODEX_BIN:-}" ] && [ -x "$CODEX_BIN" ]; then
echo "ok: codex via CODEX_BIN=$CODEX_BIN"
elif [ -x "bin/codex" ]; then
echo "ok: codex via bin/codex(本地配对三件套,约 367M,不进仓库)"
elif command -v codex >/dev/null 2>&1; then
echo "ok: codex via PATH: $(command -v codex)"
else
echo "warn: 未找到 codex(二进制需自行配对官方安装;"
echo " 本机 VM 曾用 bin/ 配对 codex + codex-code-mode-host +"
echo " codex-resources/bwrap 三件套,Mac 上 npm 安装自带三件套)"
echo " 没有 codex 时 host.py health 会报失败,dispatch 会跑不起来,"
echo " 但本脚本不替你下载或登录。"
fi
# 4. 提醒:模型 key 只走环境变量,这里只检查名字是否被设置过方向,
# 绝不打印、绝不写入任何 key 的值。
if [ -n "${XCM_API_KEY:-}" ]; then
echo "ok: 模型 key 已通过环境变量提供(名字:XCM_API_KEY;值不在此打印)"
else
echo "note: 未检测到 XCM_API_KEY 这类环境变量名——跑模型回合"
echo " (dispatch / run-due 到期派发)前需先在你自己的 shell 里"
echo " export 相应变量;health 不跑模型,不需要 key。"
fi
if [ "$fail" -ne 0 ]; then
echo "install check FAILED:补齐上面 missing 的项后再跑。" >&2
exit 1
fi
echo "install check OK:接下来跑 python3 host.py health,再按 README 快速开始走 init → dispatch → review → merge。"