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