- 拆模块:CHV 从 cloud/sandbox.py 巨类拆到 cloud/chv/(executor/ pool/config/api_client/ssh_transport/disk/errors),sandbox 只留 兼容 re-export,旧导入路径与新路径是同一对象 - 去重复:goal 解析、host 顶层参数、runner 超时抽成共享 helper, Local 与 CHV 在矩阵输入下结论全等(Docker goal-resume 变体 有意保留内联版,见 PR 描述) - 安全:SSH host key 按 VM 钉死(state_dir/<vm>/known_hosts, ssh-keyscan 首次捕获 TOFU),废除 no + /dev/null;CHV_SSH_USER 空串与 CHV_SSH_PORT 越界直接拒绝,不静默 fallback - execute 语义:只读已运行 VM,未运行报 vm_not_running,不为 status 起 VM - 预热池:registry 落盘、重启载入只当孤儿、reconcile 清理、 损坏 quarantine;条目仍一次性,绝不复用脏 VM - 长任务:VM 中途死亡判 interrupted(断点可续跑);中途心跳 经评估不做(无客内进度信号可采) - #215 未合并:只列复用清单,不复制模块 测试:原 42 项 + 新 24 项全过;相关旧套件与 cloud 全量 1496 项 全过(skipped=4)。验证仍是 mock + 干跑 + 单元测试,未做真机、 压测、安全审计;堆叠于 #216 之上,base 为 feat-cloud-hypervisor。
1409 lines
98 KiB
Markdown
1409 lines
98 KiB
Markdown
# 云端控制面(cloud/)
|
||
|
||
本目录是 host.py 本地编排之上的云端架构实施:控制面 server +
|
||
已就绪的模块(认证、队列、沙箱、记忆、知识库、模型路由、成本、
|
||
webhook)。零第三方依赖,只用 Python 标准库。
|
||
|
||
> 诚实边界:这是一个本地可跑的控制面演示,不是已建成的真实云服务。
|
||
> 本 server 不跑模型回合、不连真实 GitHub/Slack/云服务。生产前仍缺
|
||
> 的外部条件见文末“生产前仍缺什么”一节,不要绕过去。
|
||
|
||
## 架构:谁负责什么
|
||
|
||
```
|
||
客户端 / GitHub / Slack
|
||
│ HTTP + X-API-Key(webhook 靠签名,OAuth 靠 state,见下)
|
||
▼
|
||
cloud/server.py(控制面,ThreadingHTTPServer)
|
||
├─ 项目 / 任务:建、查、取消、心跳、stale 回收、任务成本
|
||
├─ 记忆 / 知识库 / 成本 / 模型:已接成 HTTP 端点(见 API 表)
|
||
├─ 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(验签 + 解析,只解析不执行)
|
||
```
|
||
|
||
状态库:单一 SQLite 文件。AuthStore 用 `users` / `project_members` /
|
||
`audit_log` 三张表;server 用 `projects` / `tasks` 两张表,表名不冲突。
|
||
同一个 db_path 传给两边即可。不要再用共享 JSON 文件当多用户状态库。
|
||
|
||
并发:ThreadingHTTPServer 多线程处理请求,每个请求内新建 SQLite
|
||
连接(server 表用新连接,AuthStore 每次请求新建一个实例),绝不在
|
||
多线程 handler 间共用一个连接。注意:AuthStore 自身持有一个连接,
|
||
server 因此不在 handler 间共享 AuthStore 实例。
|
||
|
||
任务状态机(与 host.py 对齐):
|
||
|
||
```
|
||
queued → running → completed / failed / interrupted / merged / merge-failed
|
||
```
|
||
|
||
本版 server 只流转 queued → running(心跳)→ interrupted(取消 /
|
||
sweep 回收),以及 completed → merged(见下表 `POST /tasks/{id}/merge`)。
|
||
`completed` 由 worker 在跑完模型回合后回写;`merged` 只由合并
|
||
状态接口同步,不执行合并本身——合并仍由 host.py merge(本地
|
||
或 Docker 容器内)执行,成功后再调本接口,云端不该在任务
|
||
completed 时自动改主分支。
|
||
|
||
## 启动方式
|
||
|
||
```bash
|
||
cd ~/workspace/codex-app-server
|
||
python3 -m cloud.server [--host 127.0.0.1] [--port 8765] [--db <路径>]
|
||
# 默认:host 127.0.0.1,port 8765,db 为当前目录下的 cloud-control-plane.db
|
||
```
|
||
|
||
测试用(不自动 serve):
|
||
|
||
```python
|
||
from cloud.server import create_server
|
||
server = create_server(db_path, host="127.0.0.1", port=8765)
|
||
server.serve_forever()
|
||
```
|
||
|
||
用户与 API key 由调用方先用 AuthStore 建(明文 key 只返回一次,
|
||
库里只存 sha256 哈希,绝不把 key 写进文件或聊天记录):
|
||
|
||
```python
|
||
from cloud.auth import AuthStore
|
||
store = AuthStore(db_path)
|
||
user_id, api_key_plain = store.create_user("alice")
|
||
```
|
||
|
||
## API 列表
|
||
|
||
全部返回 JSON,`Content-Type: application/json`。除 `GET /health`、
|
||
`/webhooks/*`、`/oauth/*` 外,所有端点要求请求头 `X-API-Key`,无 key
|
||
或 key 错返回 `401 {"error": "unauthorized"}`。`/webhooks/*` 不走
|
||
X-API-Key,因为外部服务无法持本站 API key,改靠各自的签名认证。
|
||
`/oauth/*` 不走 X-API-Key,因为 OAuth 登录的起点就是用户还没有
|
||
本站 API key——它的认证靠随机 state(10 分钟过期、一次性)加
|
||
client_secret(只走环境变量),不是无认证后门。
|
||
|
||
本轮新增端点(同样要求 X-API-Key):`GET /workers`(注册 worker
|
||
列表,含 alive)、`POST /workers/heartbeat`(body 含 worker_id,
|
||
心跳即注册)、`POST /tasks/{id}/pr`(已 merged 任务的自动 PR,
|
||
默认关闭,缺条件时返回 pr_skipped 而不是失败;见下文“云端派发与
|
||
自动 PR”一节)。`POST /tasks` 新增可选 `executor`
|
||
(local/docker/cloud;未传且无环境继承时默认 docker——空
|
||
executor(任意 worker 可认领)会让任务落到隔离较弱的 local
|
||
worker,是多用户下的隔离缺口;`CLOUD_DEFAULT_EXECUTOR` 可
|
||
覆盖,空字符串恢复旧行为);
|
||
`POST /projects` 新增可选 `auto_pr`(0/1,默认 0)。
|
||
|
||
### OAuth 真实浏览器登录(已实现,部署前置仍需用户动手)
|
||
|
||
流程(标准 OAuth2 授权码流程):
|
||
1. `GET /oauth/{provider}/login?client_id=...&redirect_uri=...`
|
||
返回 `{provider, state, authorization_url}`;state 已入库
|
||
(10 分钟过期、一次性,provider / redirect_uri / client_id
|
||
都随 state 一起存下),把用户送到 provider 授权页。
|
||
2. 用户授权后 provider 重定向到本站
|
||
`GET /oauth/{provider}/callback?code=...&state=...`(真实浏览器
|
||
流程只带 code + state;client_id / redirect_uri 由 server 从
|
||
state 记录取回,不需要 provider 额外带回)。
|
||
3. server 验 state → 用 code 换 token → 调真实 userinfo 取身份
|
||
(GitHub:`https://api.github.com/user`,Bearer + User-Agent,
|
||
取 login/id;Google:`https://openidconnect.googleapis.com/v1/userinfo`,
|
||
取 sub/email/name)→ 映射到本站用户(确定性用户名
|
||
`oauth:<provider>:<login>`,新建或复用)→ 签发本站 API key
|
||
并记审计 `oauth_login`(轮换时另记 `api_key_rotated`)。
|
||
4. 成功页(GET 回调)只显示 login、user_id、是否新用户和新 API
|
||
key 一次,并写清“请立即保存,刷新后不再显示,旧 key 已失效
|
||
(如系轮换)”;成功页绝不显示 access_token、client_secret、
|
||
旧 API key。`POST /oauth/{provider}/callback`(JSON 版,body 带
|
||
code/state,client_id/redirect_uri 可省、从 state 取回)同样在
|
||
成功响应里返回一次新 API key;之后任何接口都不再返回它。
|
||
失败一律 400:state 无效/过期/不匹配、换 token 失败、userinfo
|
||
失败、client_secret 缺失都有明确 reason,且不建用户、不发 key。
|
||
state 仍是一次性、10 分钟过期、provider / redirect_uri 必须匹配。
|
||
|
||
真实部署前置(以下任何一项没做,都不算真登录,绝不是“改个
|
||
环境变量就自动有公网登录”):
|
||
- 在 GitHub 上注册 OAuth App(或 Google 上注册 OAuth 客户端):
|
||
应用名自定;Homepage URL 填本站公网地址;Authorization
|
||
callback URL 必须与本站回调地址逐字一致,即
|
||
`https://<你的域名>/oauth/github/callback`(Google 同理);
|
||
非 localhost 时 GitHub 要求回调地址是 https,http 会被拒绝。
|
||
- client_id 由登录请求方传入(login 接口的 query 参数不变);
|
||
client_secret 只从环境变量读(GitHub
|
||
`GITHUB_OAUTH_CLIENT_SECRET`、Google
|
||
`GOOGLE_OAUTH_CLIENT_SECRET`),绝不写文件、绝不硬编码、
|
||
绝不出现在授权 URL 或成功页里;secret 缺失时回调明确报
|
||
`client_secret_missing`,不会以空 secret 去撞真实端点。
|
||
- 本站控制面必须有公网可达的 https 回调地址:目前 server 默认
|
||
只绑 `127.0.0.1:8765`,没有公网入口、没有 TLS,这一步是部署
|
||
前置——需要在部署侧配反向代理 + TLS 证书并把公网域名指到
|
||
server,再把该域名下的回调地址填进 OAuth 应用的注册信息里。
|
||
- 登录后 API key 的保存与多 key 规则:新用户拿到创建时生成的
|
||
primary key;已存在用户每次 OAuth 登录签发一个附加 key
|
||
(label 为 `oauth:<provider>`),旧 key(primary 与之前的附加
|
||
key)全部继续有效,不再轮换——登录不会打断已存得到处都是的
|
||
旧 key。响应里的 `api_key_rotated` 字段为向后兼容保留,恒为
|
||
False;签发附加 key 时记审计 `api_key_issued`(不再记
|
||
`api_key_rotated`)。key 明文只在回调成功页/响应里出现一次,
|
||
丢了只能重新走一次 OAuth 登录拿新 key(旧 key 不受影响)。
|
||
- API keys 管理(全部只操作认证用户自己的 key,须持本站 key):
|
||
`GET /users/me/keys`(列出 primary(id 为 "legacy")+ 附加 key,
|
||
含 label、后 4 位提示、创建/最近使用/撤销时间,绝不返回明文
|
||
与哈希)、`POST /users/me/keys`(body 可含 label,签发附加 key,
|
||
明文只在本次响应里出现一次)、`DELETE /users/me/keys/{id}`
|
||
(撤销一个 key,primary 用 id "legacy",无此 key 或已撤销返回
|
||
404)、`POST /users/me/keys/revoke-others`(撤销除本次请求 key
|
||
之外的全部自己的 key,返回撤销数)。附加 key 认证成功时更新
|
||
last_used_at,但每小时最多写一次(认证是热路径)。
|
||
- 已有本站账号的绑定流程(已实现):持本站 key 调
|
||
`POST /oauth/{provider}/bind/initiate`(body 含 client_id +
|
||
redirect_uri),返回 `{provider, state, authorization_url}`,
|
||
state 里带 bind_user_id(10 分钟过期、一次性);用户授权后
|
||
回调走绑定分支(GET 回调成功页只显示 provider + 外部 login,
|
||
绝不显示任何 key、也不显示本站 user_id;POST 回调返回
|
||
`{linked: true, provider, login, external_id, user_id,
|
||
already_bound}`,同样不含 key)。绑定表
|
||
`external_identities` 有两个唯一约束:一个外部身份只能绑一个
|
||
本站账号、一个本站账号每个 provider 只能绑一个外部身份,
|
||
冲突返回 409(`identity_bound_to_other` /
|
||
`user_already_bound`),同一用户 + 同一身份重复绑是幂等的。
|
||
登录解析顺序:先查绑定表——绑过的身份落到被绑账号并按 OAuth
|
||
登录规矩轮换 key;没绑过才走确定性 oauth 账号映射。以前
|
||
GitHub 登录建的 oauth 账号不会被绑定删掉:绑完后该身份登录
|
||
改落到被绑账号,旧 oauth 账号成孤儿(key 仍可用);解绑
|
||
(`DELETE /oauth/{provider}/bindings`,须持本站 key,本来就
|
||
没有返回 404)后恢复原来的 oauth 账号登录。查看自己的绑定:
|
||
`GET /oauth/{provider}/bindings`(须持本站 key,只看得到自己的)。
|
||
|
||
### GitHub App 一键安装与 App-token(安装 + token 均已实现)
|
||
|
||
目标:把手动建 webhook(填 URL、secret、选事件)变成“点一下
|
||
安装”。流程用 GitHub manifest 流程(GitHub 文档标准流程):
|
||
|
||
1. 操作方持本站 key 调 `POST /github-app/setup/state`(body 可含
|
||
org),返回 `{state, setup_url}`;要求环境变量
|
||
`GITHUB_APP_PUBLIC_BASE`(公网域名,如 `https://api.diante.me`,
|
||
manifest 里的 hook / 回调地址都由它拼出)。
|
||
2. 用户打开 setup_url:表单页含 manifest JSON(App 名默认
|
||
`codex-app-server`,可由 `GITHUB_APP_NAME` / `GITHUB_APP_URL`
|
||
改;权限 contents:write、pull_requests:write、issues:read,
|
||
事件 issue_comment、pull_request;默认私有 App,只有所有者
|
||
能安装,`GITHUB_APP_PUBLIC=1` 可翻成公开)与 state,点提交
|
||
到 GitHub 建 App 页(个人账号 `/settings/apps/new`,组织账号
|
||
`/organizations/{org}/settings/apps/new`),用户确认 Create。
|
||
3. GitHub 跳回 `GET /github-app/callback?code=...&state=...`:
|
||
server 验 setup state(一次性、1 小时过期,manifest code 同样
|
||
1 小时内有效)→ POST
|
||
`api.github.com/app-manifests/{code}/conversions` 换回 id、slug、
|
||
webhook_secret、pem → 存库里 `github_app_config` 表(单行)。
|
||
成功页只显示 App 身份与安装链接
|
||
`https://github.com/apps/<slug>/installations/new`,绝不显示
|
||
webhook_secret 与 pem。
|
||
4. 用户点安装:installation 事件到 `/webhooks/github`,记
|
||
`github_app_installations` 表(created/added 增行、removed 减
|
||
行、deleted 清安装),`GET /github-app/status`(须持本站 key)
|
||
可查配置与安装列表,同样不含 secret/pem。
|
||
|
||
验签与去重:webhook 验签同时接受 App secret(库里)与 env 里
|
||
`GITHUB_WEBHOOK_SECRET`(手动 webhook,过渡期共存);App 装好
|
||
后应删掉手动 webhook,避免同一事件送两遍;即使共存,
|
||
/fix-review 也有内容去重(repo + PR + 评论 id,
|
||
`github_fix_review_dedup` 表),只建一次任务。
|
||
|
||
诚实边界(已缩小):pem 现在用于签 JWT、换 installation
|
||
token:`cloud/github_app.py` 的 `mint_app_jwt` 是纯标准库
|
||
RS256(解析 PKCS#1 / PKCS#8 私钥,不依赖 openssl、不落临时
|
||
文件),`exchange_installation_token` 换 1 小时有效的 token
|
||
(现换现用、不缓存、不存库),`resolve_app_token` 按仓库名
|
||
查 `github_app_installations` 找 installation id,任一步缺失
|
||
或失败返回 None(调用方回落 PAT,不报错)。推分支、建 PR
|
||
走 App 还是 PAT 见“云端派发与自动 PR”;host.py 本地操作
|
||
(pr / checks / merge)仍是操作员自己的 gh + PAT,不走
|
||
App-token。App 安装事件的现场记录路径仍待下一次真实
|
||
安装/增减仓库动作实弹验证(此前一次是数据修复,不是
|
||
webhook 实测)。
|
||
|
||
### 推送凭证清单
|
||
|
||
- webhook / slack:已有内置真实发送端(标准库 urllib POST,
|
||
单次 10 秒超时,失败记入分发结果的 errors):
|
||
webhook 投 JSON(event_type、project_id、task_id、payload),
|
||
slack 投 Slack incoming webhook 格式的一行文本(任务 id +
|
||
事件 + 项目,失败附 error 摘要)。任务终态(completed /
|
||
failed / goal-unmet)由 worker 在 run_one 的终态写回后自动
|
||
通知订阅者;PR 端点在建 PR 成功时发 review-requested、
|
||
落盘失败时发 merge-failed;手动分发走
|
||
`POST /admin/push/dispatch`,同样经内置发送端。
|
||
显式注入的 sender 优先于内置端。
|
||
- 摘要(digest,G11):非失败事件在 digest 偏好下不再只在
|
||
返回值里列入 skipped_for_digest 就完事——事件持久化进
|
||
`push_digest_events` 表,worker 循环每小时按(项目、订阅)
|
||
分组汇总发一条(payload 为 `{"digest": True, "events": [...],
|
||
"count": n}`),成功后整组标记已发送,失败整组保留待下
|
||
一次重发。失败类事件(failed/goal-unmet/merge-failed)不
|
||
进摘要,仍即时发。摘要节奏是“worker 循环每小时一批”,
|
||
不是“每条事件满 1 小时才发”——一批刚发完、紧接着到的
|
||
事件要等下一批(最长约 1 小时)。
|
||
- 推送失败重试(outbox,G12):webhook/slack 发送端抛异常
|
||
时,事件写进 `push_outbox` 表,worker 循环后台重试(最多
|
||
3 次、默认 60 秒退避);3 次仍失败则删行并记
|
||
worker_push_outbox_dropped 日志,不丢任务状态、不炸循环。
|
||
缺凭证(webpush/apns/fcm)与无 sender 不是瞬时故障,不
|
||
进 outbox,仍明确报错。at-least-once:webhook/slack 的重
|
||
试可能重复送达同一事件,接收方必须按 event/task 去重,
|
||
本模块不保证 exactly-once。
|
||
- webpush:需要 VAPID 密钥对(私钥绝不进仓库),且需要
|
||
浏览器端订阅(endpoint + p256dh + auth,现有订阅模型只有
|
||
一个 target 文本字段,装不下)与标准库没有的 P-256 ECDH +
|
||
AES-GCM——只有接口 + 缺凭证报错,没有真实发送端。
|
||
- apns:需要 APNs 证书与标准库没有的 HTTP/2——只有接口 +
|
||
缺凭证报错。
|
||
- fcm:需要 FCM key 与用户另建的 Firebase 服务账号——只有
|
||
接口 + 缺凭证报错。
|
||
以上三者目前只有接口 + 缺凭证报错,没有真实发送端,也
|
||
不编造推送。
|
||
|
||
### SQLite 并发上限
|
||
|
||
server 的连接统一走 `cloud/db.py`:WAL 模式、busy_timeout 5000ms、
|
||
写锁冲突有限重试 3 次(退避 0.05s/0.1s/0.2s,重试耗尽抛原始错误,
|
||
绝不无限循环)。多线程并发写测试已覆盖。但这只是单机缓解:
|
||
写仍是单写者串行,写密集或跨机器时必须换 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`:真实 Postgres 实现。调用方仍写 sqlite3
|
||
风格 SQL(`?` 占位符、`row["col"]`、`conn.execute` /
|
||
`fetchall` / `commit` / `rollback` / `close`),由
|
||
`PgConnection` / `PgCursor` / `PgRow` 适配层翻译成 Postgres
|
||
方言(`?` -> `%s`,字符串字面量里的 `?` 不动;
|
||
`INSERT OR REPLACE` -> `ON CONFLICT DO UPDATE`;
|
||
`PRAGMA table_info` -> information_schema;
|
||
`AUTOINCREMENT` -> `GENERATED BY DEFAULT AS IDENTITY`;
|
||
FTS5 虚拟表在 PG 上明确报错、KBStore 走 LIKE 分支)。
|
||
驱动异常映射成 `PostgresIntegrityError` /
|
||
`PostgresOperationalError` / `PostgresConnectionError`,
|
||
绝不把驱动异常原样漏成 sqlite3 异常。模块级 `connect()`
|
||
按 `CLOUD_DB_BACKEND` 分发,因此 server / AuthStore /
|
||
MemoryStore / KBStore / CostStore 等沿用 `connect()` 的
|
||
调用点无需重构即可走 Postgres;但 `backup.py` 的 SQLite
|
||
在线备份 API 仍只适用于 SQLite,Postgres 备份请用 pg_dump。
|
||
- 工厂 `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 部署步骤(不要只改一个环境变量)
|
||
|
||
改 `CLOUD_DB_BACKEND=postgres` 本身不会迁移任何数据,也不会
|
||
建库——只改它、数据还在旧 SQLite 文件里,新库是空的。完整
|
||
步骤是:
|
||
|
||
1. 装驱动(psycopg v3 优先):
|
||
```bash
|
||
pip install "psycopg[binary]"
|
||
# 其次才用 psycopg2:pip install psycopg2-binary
|
||
```
|
||
2. 在 Postgres 里建库与用户(示例库名与用户名请按实际部署改,
|
||
密码绝不写进仓库文件):
|
||
```sql
|
||
CREATE DATABASE cloud_control_plane;
|
||
CREATE USER cloud_app WITH PASSWORD '<仅在此处输入>';
|
||
GRANT ALL PRIVILEGES ON DATABASE cloud_control_plane TO cloud_app;
|
||
```
|
||
注意:表由应用启动时 `init_postgres_schema` 建(
|
||
`cloud/db.py` 的 `POSTGRES_SCHEMA`,与 SQLite 表名/列名
|
||
一一对应,含 tasks 的 6 个结果列;KB 在 PG 上无 FTS 表,
|
||
走 LIKE 搜索)。若数据库用户不是库 owner,另需对
|
||
public schema 授权(`GRANT ALL ON SCHEMA public TO cloud_app`),
|
||
否则建表会报权限错——这是部署侧问题,不是代码 bug。
|
||
3. 设环境变量并启动(`--db` 参数在 PG 模式下被忽略,连接
|
||
参数只走环境变量;`--db-backend postgres` 与环境变量等价):
|
||
```bash
|
||
export CLOUD_DB_BACKEND=postgres
|
||
export CLOUD_DB_HOST=127.0.0.1 CLOUD_DB_PORT=5432
|
||
export CLOUD_DB_NAME=cloud_control_plane
|
||
export CLOUD_DB_USER=cloud_app
|
||
export CLOUD_DB_PASSWORD='<仅在此处输入>'
|
||
python3 -m cloud.server --host 127.0.0.1 --port 8765
|
||
```
|
||
4. 迁旧数据(可选,须在启动建表之后、切流量之前跑;跑两次
|
||
不重复插,第二次各表 inserted 应为 0):
|
||
```bash
|
||
python3 -m cloud.migrate_sqlite_to_postgres \
|
||
--sqlite /path/to/cloud-control-plane.db
|
||
# 输出示例:projects: 3 / tasks: 12 / ... / total inserted: N
|
||
# (只打印表名与行数,不打印任何密码或密钥)
|
||
```
|
||
迁移脚本逻辑在 `cloud/migrations.py`(
|
||
`migrate_sqlite_to_postgres`),身份列的序列会在迁完后
|
||
拨到当前最大 id,避免后续自动生成撞主键。
|
||
5. 回退到 SQLite:把 `CLOUD_DB_BACKEND` 改回 `sqlite`(或
|
||
删掉它,默认即 sqlite),并把 `--db` 指回原来的 SQLite
|
||
文件即可。但注意:切到 Postgres 之后新写的数据只在
|
||
Postgres 里,SQLite 文件里没有——回退不会自动把 PG 数据
|
||
导回 SQLite;如需回迁,须另写 PG -> SQLite 脚本(本轮
|
||
未提供,不要假装存在)。
|
||
6. 备份:Postgres 侧用 `pg_dump`,不要用 `cloud/backup.py`
|
||
(那是 SQLite 在线备份 API,只适用于 SQLite 文件)。
|
||
|
||
> 诚实边界(仍需在真实服务器上验证,见下文“未验证清单”):
|
||
> 本机 VM 既无 psycopg 驱动、也无 Postgres 服务器,因此真实
|
||
> PG 路径在本机只能 skip(见测试一节),不能谎称已在真实
|
||
> PG 上跑通过。已验证的是:适配层翻译逻辑(fake 驱动)、
|
||
> schema 语句覆盖、SQLite 全量回归不坏。
|
||
|
||
| 方法与路径 | 权限 | 说明 |
|
||
| --- | --- | --- |
|
||
| `GET /health` | 无需认证 | `200 {"ok": true, "service": "cloud-control-plane"}` |
|
||
| `POST /projects` | 认证即可 | body `{name, id?}`;无 id 则生成 uuid;调用者记为 owner(调 `AuthStore.create_project`,同时写 projects 表并记审计 `create_project`);返回 `201` + 项目 |
|
||
| `GET /projects` | 认证即可 | 只返回调用者是成员的项目:`{"projects": [...]}` |
|
||
| `POST /tasks` | `create_task` | body `{project_id, prompt, depends_on?, priority?, idempotency_key?, executor?, goal?, verify_command?, goal_max_rounds?, model?, effort?, approval?}`;无权限 `403`;同 `project_id + idempotency_key` 已存在则返回已有任务(`200`,不建重复);否则建 `queued` 任务并记审计 `create_task`,返回 `201`;`verify_command` 非空即 goal 模式(见下文“goal 模式”),`goal_max_rounds` 缺省 3、须 ≥1;`model` 不做白名单(中转目录会变,console 下拉只是建议值),`effort` 只能是 `low/medium/high/xhigh/max`,`approval` 只能是 `ask/auto/full`,三者缺省均为 NULL(= 跟随 worker 配置默认值,行为与过去一致) |
|
||
| `GET /tasks?project_id=...` | 该项目 `read` | 返回该项目任务列表:`{"tasks": [...]}`;无权限 `403` |
|
||
| `GET /relay/models` | 任意已认证用户 | 中转站可用模型列表:服务端凭环境变量 `XCM_API_KEY` 现取 `XCM_RELAY_BASE_URL`(缺省 `https://xcmapi.org/v1`)的 `/v1/models`,只返回模型 id(1 小时缓存),key 绝不进响应;中转挂了或未配 key 时 `502`,不编造列表;console 的模型下拉用它,取不到时回退手动输入 |
|
||
| `GET /tasks/{id}` | 该任务所属项目 `read` | 无权限 `403`;不存在 `404` |
|
||
| `POST /tasks/{id}/cancel` | 该项目 `create_task` | `queued` / `running` 改为 `interrupted` 并记审计 `cancel_task`;已是终态(completed / failed / interrupted / merged / merge-failed)返回 `400`;不存在 `404` |
|
||
| `POST /tasks/{id}/merge` | 该项目 `approve_merge`(maintainer 以上) | 只同步状态,不执行合并:仅 `completed` 可改为 `merged` 并记审计 `merge_task`,其它状态(含已 merged 的重复调用)返回 `409`;不存在 `404`,越权 `403`,无 key `401` |
|
||
| `POST /tasks/{id}/heartbeat` | 该项目 `create_task` | 更新 `last_heartbeat = now`;状态为 `queued` 则改为 `running`;返回任务 |
|
||
| `POST /admin/sweep` | 任一项目 maintainer+(与备份管理同口径),否则 `403`;扫完记审计 `admin_sweep` | 调 `sweep_stale_tasks(db_path, threshold_seconds=300)`(body 可传 `threshold_seconds` 覆盖默认值,下限钳到 60 秒);把 `running` 且 `last_heartbeat`(无心跳则用 `updated_at`)早于 `now - threshold` 的任务改为 `interrupted` 并记审计 `sweep_interrupted`;返回 `{"swept": [ids]}` |
|
||
| `POST /admin/backup` | maintainer 以上 | Postgres 时调 `backup_postgres`、SQLite 时调 `backup_db`(body 可传 `out_dir`,否则用 `CLOUD_BACKUP_DIR` 或默认 `cloud-backups`);返回 `{backend, path, size_bytes, task_count}`,绝不返回数据库密码或备份文件内容 |
|
||
| `GET /tasks/{id}/cost` | 该任务所属项目 `read` | 成本聚合:`{task_id, project_id, total_tokens, usd_micros, turns, fallback_count, actual_tokens, estimated_tokens, cached_input_tokens, unpriced_actual_tokens}`;真数与估算分开,无价 actual 行进 `unpriced_actual_tokens`;不存在 `404`,越权 `403` |
|
||
| `POST /projects/{id}/memory` | 该项目 `create_task` | 写线程摘要 body `{thread_id?, goal, conclusion, files_changed?, open_issues?}`,返回 `201 {id}`,并记审计 `create_memory` |
|
||
| `GET /projects/{id}/memory?top_k=5` | 该项目 `read` | 最近 active 摘要(默认 top_k=5)+ `prompt_context`(只含 top_k,不全量注入);不存在 `404`,越权 `403` |
|
||
| `POST /projects/{id}/memory/{mid}/supersede` | 该项目 `create_task` | 把摘要标废弃(deprecated,不是物理删除),并记审计 `supersede_memory`;摘要不属于该项目则 `404` |
|
||
| `POST /projects/{id}/kb/index` | 该项目 `create_task` | 索引文档 body `{path, content, kind?}`,同项目同 path 重复索引会替换旧文档,返回 `201 {id}` |
|
||
| `GET /projects/{id}/kb/search?q=&top_k=5` | 该项目 `read` | FTS + LIKE 回退,仍按项目过滤,跨项目文档绝不返回;`q` 缺失 `400` |
|
||
| `GET /projects/{id}/cost` | 该项目 `read` | 项目成本总览 + 按天:`?day=YYYY-MM-DD` 查单天,`?days=N` 查最近 N 天;无参数只给总览 |
|
||
| `GET /models` | 认证即可 | 优先级列表(环境变量 `MODELS_PRIORITY` 逗号分隔,未设用默认)+ 只含调用者成员项目的模型统计与 fallback 事件(`fallback_count > 0` 的任务);不返回 prompt 内容 |
|
||
| `GET /oauth/{provider}/login?client_id=&redirect_uri=&scope=` | 无需 X-API-Key(pre-auth) | 返回 `{provider, state, authorization_url}`,只返回 URL 不跳转;provider 仅 github/google,未知 `400`;state 已入库,10 分钟过期 |
|
||
| `GET /oauth/{provider}/callback?code=&state=` | 无需 X-API-Key(pre-auth) | 真实浏览器回调:验 state(从 state 记录取回 client_id / redirect_uri)→ 换 token → 真实 userinfo 取身份 → 映射本站用户并签发 API key;成功返回 HTML 成功页(login、user_id、是否新用户、新 API key 一次,含保存与轮换提示,不含 access_token / client_secret / 旧 key);state 无效/过期/不匹配、换 token 失败、userinfo 失败、无 secret 一律 `400` 错误页且不建用户、不发 key |
|
||
| `POST /oauth/{provider}/callback` | 无需 X-API-Key(pre-auth) | JSON 版回调:body `{code, state, client_id?, redirect_uri?}`(后两者可省、从 state 取回);验 state → 换 token → 真实 userinfo 取身份 → 映射到本站用户(复用 users 表,用户名 `oauth:<provider>:<login>`)并签发 API key、记审计 `oauth_login`(轮换时另记 `api_key_rotated`);state 无效/过期、换 token 失败、userinfo 失败、无 secret 一律 `400` 且不建用户、不发 key;成功响应里返回一次新 API key,之后任何接口都不再返回它 |
|
||
| `POST /projects/{id}/push/subscriptions` | 该项目 `create_task` | 记订阅 body `{channel, target, preference?}`,channel 为 webhook/slack/webpush/apns/fcm,preference 为 instant/digest/silent,返回 `201` |
|
||
| `GET /projects/{id}/push/subscriptions` | 该项目 `read` | 只列调用者自己的订阅(不泄露他人 target) |
|
||
| `DELETE /projects/{id}/push/subscriptions/{sid}` | 该项目 `create_task` | 只能删自己的订阅,项目 owner 可删任意;不存在 `404`,他人订阅 `403` |
|
||
| `POST /admin/push/dispatch` | 该项目 `read` | body `{event_type, project_id, payload?}`;只发状态变化事件(completed/failed/goal-unmet/merge-failed/review-requested);sender=None 时用内置真实发送端(webhook/slack 真 POST,webpush/apns/fcm 报缺凭证),都不静默成功;自动触发:worker 在任务终态时调分发(completed/failed/goal-unmet,见“goal 模式”),PR 端点在建 PR 成功时发 review-requested、落盘失败时发 merge-failed,不依赖手动调本端点 |
|
||
| `POST /webhooks/github` | GitHub 签名 | 见“webhook 端点” |
|
||
| `POST /webhooks/slack` | Slack 签名 | 见“webhook 端点” |
|
||
|
||
单个项目 / 任务以裸对象返回;列表以 `{"projects": [...]}` /
|
||
`{"tasks": [...]}` 包装。任务对象字段:`id`、`project_id`、`prompt`、
|
||
`status`、`idempotency_key`、`depends_on`(list)、`priority`、
|
||
`created_by`、`created_at`、`updated_at`、`last_heartbeat`。
|
||
|
||
函数 `sweep_stale_tasks(db_path, threshold_seconds=300) -> list` 独立
|
||
可单测,不依赖 HTTP;回收动作的审计 `user_id` 记为 `"system"`。
|
||
|
||
### webhook 端点(GitHub 已从“只解析”升级为“可执行且幂等”;Slack 仍只解析)
|
||
|
||
- `POST /webhooks/github`:用 `webhooks.verify_github_signature`
|
||
验签(请求头 `X-Hub-Signature-256`),secret 从环境变量
|
||
`GITHUB_WEBHOOK_SECRET` 读。未设 secret 返回 `503`;验签失败
|
||
返回 `401`(两者都在去重之前,避免未验签的重复送达污染去重表)。
|
||
顺序:验签 -> delivery 去重 -> 解析 -> 执行建任务。
|
||
- delivery 去重:请求头 `X-GitHub-Delivery` 做幂等,去重记录持久化
|
||
到数据库新表 `webhook_deliveries`(delivery_id 主键、event、repo、
|
||
created_at),不是只放内存;同一 delivery 第二次送达直接返回
|
||
`200 {"duplicate": true, ...}`,不重复建任务、不重复记审计。
|
||
并发/去重兜底:任务另有 `idempotency_key = webhook:<delivery_id>`,
|
||
冲突时回读已建任务而不重复建。
|
||
- PR 评论 `/fix-review` 执行:解析成功后在控制面数据库里建一个
|
||
`queued` 任务,prompt 为 `[webhook /fix-review] repo=<repo> pr=<PR号>
|
||
author=<作者>\n<评论原文>`,并记审计 `webhook_fix_review`,返回
|
||
`task_id` 与 `project_id`;webhook 处理里不直接跑模型,模型执行
|
||
仍是 worker 认领后做的事。任务归属按项目名 == repo full_name 匹配;
|
||
没有匹配项目时不凭空建项目、不建任务,返回 `task_id: null` 并说明
|
||
原因(worker 的 repo 仍是配置里的 repo,不是任意 GitHub 仓库的
|
||
自动克隆)。
|
||
- `check_run` / `check_suite` 解析:`parse_github_check_run` 只在
|
||
conclusion 已定(success / failure)时返回结构化结果(repo、
|
||
head_sha、conclusion、check_name),handler 直接返回该结果而不建
|
||
任务、不改云端任务状态;结论进行中(None / queued / in_progress /
|
||
neutral / cancelled 等)时返回 `{"result": null}`。CI 状态回填仍由
|
||
`pr-checks` 主动拉取,webhook 只做通知解析,不要假装 webhook 已
|
||
自动更新了云端任务状态。
|
||
- `POST /webhooks/slack`:用 `verify_slack_signature` 验签(请求头
|
||
`X-Slack-Signature` / `X-Slack-Request-Timestamp`,含 300 秒防重放),
|
||
secret 从环境变量 `SLACK_SIGNING_SECRET` 读。未设返回 `503`;
|
||
验签失败返回 `401`;成功返回 `parse_slack_command` 的结果,不执行任务。
|
||
|
||
## 权限表
|
||
|
||
角色高低:viewer < contributor < maintainer < owner。未加入项目一律
|
||
无权限。动作的最低角色(`AuthStore.check_permission` 的实际规则):
|
||
|
||
| 动作 | viewer | contributor | maintainer | owner |
|
||
| --- | --- | --- | --- | --- |
|
||
| `read`(读项目任务) | ✅ | ✅ | ✅ | ✅ |
|
||
| `create_task`(建任务 / 取消 / 心跳) | ❌ | ✅ | ✅ | ✅ |
|
||
| `approve_merge`(批合并,worker 侧用) | ❌ | ❌ | ✅ | ✅ |
|
||
| `manage_project`(仅 owner,改成员等) | ❌ | ❌ | ❌ | ✅ |
|
||
|
||
说明:`manage_project` 的最低角色是 owner 本人,不是 maintainer;
|
||
改成员(`AuthStore.set_member`)只有 owner 能做,否则抛
|
||
`PermissionError`。审计(`audit` / `list_audit`)只追加、只读,本模块
|
||
不提供删除或修改审计记录的方法。
|
||
|
||
注意一个实际签名细节:`AuthStore.create_project(project_id,
|
||
owner_user_id)` 只写 `project_members` 的 owner 成员记录,不建项目
|
||
记录本身;项目记录(name 等)由 server 写 `projects` 表。两者配合
|
||
才算一个完整项目,不要只调一处。
|
||
|
||
## 命令台(v1)
|
||
|
||
`GET /console` 出一个纯静态单页(`cloud/console.html`,无构建步骤),
|
||
与控制面同源挂载。页面本身不走鉴权(只是个壳,没有 key 时调不到
|
||
任何数据);数据一律凭用户在浏览器里填的 X-API-Key 调接口,key 只
|
||
存在本浏览器 localStorage。v1 功能:项目列表/新建、任务列表(10 秒
|
||
自动刷新)/新建(含 goal 模式)/详情(result/error、成本)/重试/
|
||
取消/合并/提 PR、workers、健康状态。访问方式:公网入口已开
|
||
(2026-10-27):`https://api.diante.me/console`,nginx 在 443 块加了
|
||
5 个 location(`/console`、`/tasks`、`/projects`、`/workers`、
|
||
`/health`,全部指向双机上游;页面用绝对路径调 API,所以 API 路径
|
||
必须同批代理,否则页面能打开但数据全失败),改前备份为
|
||
`api.diante.me.conf.pre-console`。注意宝塔重生成 vhost 会吃掉这些
|
||
location,被吃时从备份贴回再 reload。数据仍凭 X-API-Key,
|
||
`/health` 本来就是无鉴权设计且不含敏感信息。
|
||
|
||
## 任务附件(放进任务工作区)
|
||
|
||
命令台新建任务时可附文件、文件夹、图片(三路输入),提交时先
|
||
`POST /uploads`(multipart:`paths` JSON 与 `file0…` 一一对应)
|
||
拿 `upload_id`,再 `POST /tasks` 带 `upload_id` 绑定;图片与普通
|
||
文件不区分,一律是 worktree 里的文件(模型能否“看懂”图片取决于
|
||
模型多模态能力,本链路不保证)。派发时 worker 经 host.py
|
||
`--seed-dir` 把暂存目录拷进 worktree(dispatch/goal 首轮才 seed,
|
||
run 续跑不重复拷,文件已在 worktree 里);Docker 任务把暂存目录
|
||
只读挂载进容器(`/tmp/codex-seed:ro`),cloud 执行器拒绝带附件的
|
||
任务。限制:单包最多 200 文件、单文件 20MB、合计 100MB、整个
|
||
multipart body 200MB(超了 413);路径拒绝对路径、`..`、空段、
|
||
反斜杠、NUL、盘符;包 24 小时过期、建任务绑定后置 used(一包
|
||
只能用一次,他人包 403、cloud 任务 400);任务终局后 worker 删
|
||
暂存目录,取消时 server 侧也删(删不到不报错)。暂存根默认
|
||
`/opt/codex-app-server/work/uploads`,`CODEX_UPLOAD_DIR` 可覆盖,
|
||
server 与 worker 两边必须一致(否则 worker 找不到包,派发静默无
|
||
附件)。公网 nginx 须代理 `/uploads`(与 `/tasks` 同批),否则页
|
||
面上传 404。注意:popen 路径的 goal 命令此前强制要求
|
||
verify_command,goal 新语义(无验收)任务到本地 worker 会直接
|
||
报错,本批顺手修成与同步 goal_task 一致(无验收不传 --verify,
|
||
轮次 0 原样传)。
|
||
|
||
## 旧任务继续会话(终端任务接回来再跑一轮)
|
||
|
||
详情页对终端任务(completed/failed/interrupted/merge-failed)
|
||
提供“继续会话”:填本轮消息 + 模型/强度/权限/计划/goal 全可选
|
||
(不传的沿用任务原值),`POST /tasks/{id}/continue` 把任务重
|
||
新排队,worker 认领后在同一会话线程上再跑一轮——不是新任务
|
||
(原 prompt 不动,只换本轮输入),也不是分支(不建新线程)。
|
||
新消息存在 tasks.resume_message 列,worker 跑完清掉;进程中途
|
||
被杀时清不到,下次认领重跑同一条消息,不静默丢消息。
|
||
goal 模式的继续:server 把任务转成新 goal 语义(goal=新消息、
|
||
验收清空、轮次 0),worker 走 host.py `goal-resume --goal-text`
|
||
在旧线程上开新目标循环(不限轮次,模型宣告完成才结束);
|
||
普通任务的继续走 dispatch 撞已存在 → run 续跑(与重试同款)。
|
||
不适用:进行中任务请用后续消息(queued/running 各 409),已
|
||
合并任务 worktree 已删、请分支(merged 409);冲突与新建一致
|
||
(plan + approval=full、plan + goal 各 400)。
|
||
|
||
## 主线 /goal 命令(发给主线一句话就自动建 goal)
|
||
|
||
不用先知道主线是进行中还是已终态,也不用勾选项:给主线发
|
||
一条含触发词的消息即可,例如“请在这条主线开启一个
|
||
持久 /goal,目标是收xxx”,或直接写“/goal 收xxx”。斜杠
|
||
不用:单发 `goal` 也同款触发,例如“goal 收xxx”、
|
||
“goal,目标是收xxx”。解析在
|
||
`cloud/goal_command.py`(server 与 worker 共用同一份,防两端
|
||
判断不一致);只是在讨论里提到“/goal 的用法”“goal 是什么”
|
||
这种不算命令。
|
||
新建目标也同款:`POST /tasks` 的 prompt 里直接写 `/goal
|
||
收xxx`(或上面那句整话)即新建 goal 任务,prompt 与 goal
|
||
都存解析出的目标,不用勾 goal 选项;空目标、plan 冲突、
|
||
带验收命令各 400。入口三处同款:`POST /tasks/{id}/followups`、
|
||
`POST /tasks/{id}/thread/message`、`POST /tasks/{id}/continue`
|
||
(message 字段);响应里 `delivery` 说明实际走了哪条路:
|
||
queued 主线原地转 goal(`goal_converted`)、已终态复活并转
|
||
goal(`goal_revived`)、running 主线先排队(`goal_queued`,
|
||
本轮不打断,worker 在本轮完成、排空到这条时把主线转成 goal
|
||
并回 queued,下次认领走 goal-resume 在同一 thread 上开新
|
||
目标循环;本轮若失败,这条留在 pending,等下次成功轮次再转)。
|
||
拒绝口径:已是 goal 任务 409(不重复创建)、plan 任务 400、
|
||
merged 409、空目标 400。goal 语义与新建 goal 模式一致:
|
||
不限轮次、模型宣告“目标已达成”才结束,宣告后即终态,不
|
||
是永不结束的常驻采集——需要继续收时再发一次 /goal 续开
|
||
新循环。
|
||
|
||
不靠关键词的另一条路(LLM 自己判断):会话里的 agent
|
||
也可以在回合输出里写 `goal-convert` 块把本会话转成 goal,
|
||
或写 `automation-create` 块新建自动化定义(首次运行时刻
|
||
`first_run_at`、只跑一次 `once`、绑回本会话 `bind:
|
||
"heartbeat"`),worker 在轮次边界执行,格式与口径见
|
||
`docs/marker-tools.md` 新增的两节。本批刻意不向 LLM 自动
|
||
注入这两个工具的指南(避免 prompt 臃肿),需要时由用户在
|
||
会话里手动告诉 agent;服务端不按关键词再猜意图。
|
||
|
||
## 任务间互发消息(peer messages,Muse 式通用互发)
|
||
|
||
任意两个同项目任务可以互发消息、对方做完可以回消息,不
|
||
用经过用户中转。两个发送方:
|
||
|
||
- API:`POST /tasks/<目标>/messages`,body
|
||
`{from_task_id, body, mode}`(mode 缺省 queued);调用方
|
||
须对目标项目有 create_task、对发送任务项目有 read(防冒
|
||
名)。`GET /tasks/<任务>/messages` 列出该任务发出的消息
|
||
(收到的在 `/followups` 里,带 sender_task_id)。
|
||
- Agent:任务里跑的 agent 在回合输出里写标记块,worker 在
|
||
回合结束后解析并投递——agent 不用联网、不用凭证,本地
|
||
与 Docker 执行器都通。块格式见
|
||
`examples/peer-message-prompt.txt`;每轮最多 5 块。
|
||
|
||
投递语义(核心约束:互发消息不中断任务):
|
||
|
||
- 目标排队中 / 运行中:消息排成一条后续消息(带
|
||
sender_task_id),worker 排空循环在轮次边界执行——direct
|
||
优先、队内 FIFO,与用户后续消息同款,当前轮绝不被打断。
|
||
接收方 agent 看到的 prompt 带发送方前缀
|
||
`[来自任务 <id> 的消息]`,凭此知道回给哪个任务。
|
||
- 目标已终态(completed / failed / interrupted):消息复活
|
||
它——把带前缀的正文写进 resume_message 并把状态置回
|
||
queued,下次认领按续跑跑这一轮。没有复活,B 回给已结束
|
||
的 A 的消息会直接丢失,A→B→A 的往返没法成立。
|
||
- 一律拒绝(409 或投递层跳过,只记审计):发给自己、跨
|
||
项目、目标是 goal 任务、目标已合并、目标已有待续跑消息
|
||
(复活位被占)、目标不存在。
|
||
- 只有 completed 轮次的标记块被投递(失败 / 超时 / 取消 /
|
||
配额中断的轮次不投递);goal 任务的回合不解析标记块,且
|
||
目标是 goal 时直接拒绝——所以 goal 任务既不收也不发。
|
||
- 投递不查发送方配额(发送本身不花钱),但目标任务执行时
|
||
的配额门照常生效:排空前查配额,超限时该条回 pending、
|
||
任务走配额中断;复活任务认领时认领门同样生效。互发消息
|
||
不构成配额绕过。
|
||
- 边界:标记块正文不能含围栏(非贪婪匹配会把块截断成非法
|
||
JSON 而被跳过);worker 投递的 direct 消息只有排空优先
|
||
权,没有 API 直接发送那种“立即进链路”的标记(worker 侧
|
||
链路目录不一定在本机);复活投递的消息没有 followup 行,
|
||
发出列表里查不到,查审计 peer_message_sent 与目标的续跑
|
||
轮次;控制台“给其他任务发消息”框走 /tasks 下的
|
||
/messages,不用新增 nginx location。
|
||
|
||
## 测试
|
||
|
||
```bash
|
||
cd ~/workspace/codex-app-server
|
||
python3 -m unittest discover -s cloud/tests -v # 模块单测 + 集成单测
|
||
python3 -m unittest discover -s tests -v # 原有 host.py 测试
|
||
```
|
||
|
||
`cloud/tests/test_server.py` 用 http.client 对本地临时端口的 server
|
||
做真实 HTTP 测试(临时 SQLite 文件与临时端口,跑完关闭 server),
|
||
覆盖:无 key / 错误 key 401、非成员读项目 403、viewer 建任务 403、
|
||
幂等键第二次返回同一任务且任务数不增加、cancel 把 running 任务变
|
||
interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳的
|
||
任务、sweep 扫掉心跳过期(手动改旧 last_heartbeat 或 threshold=0)
|
||
的 running 任务、GET /health 不认证也通、GET /projects 只返回成员项目,
|
||
以及两个 webhook 端点的 503 / 401 / 验签成功解析。
|
||
|
||
## 生产前仍缺什么
|
||
|
||
以下每一项都是外部条件,缺了就不能算生产可用,不要绕过去:
|
||
|
||
- **常驻机器 / 托管服务**:本 VM 会被平台定时回收,跑在这里只能算
|
||
演示。生产需要一台常驻机器或托管服务(以及相应的进程守护与
|
||
重启策略),否则控制面会随 VM 回收一起消失。
|
||
- **GitHub 远端 + PAT**:codex-app-server 目前仍无 git 远端,`gh`
|
||
也未登录;真实 PR 流程(host.py 的 `pr` / `pr-checks` / `pr-merge`)
|
||
需要先建远端并用 Secure Vault 供给 PAT,PAT 绝不写文件。
|
||
- **Slack App**:需要真实 Slack App(签名密钥、斜杠命令、事件订阅)
|
||
才能接通 `SLACK_SIGNING_SECRET` 背后的入站;目前只有验签与解析,
|
||
且 `NotificationService` 的 Slack 发送端仍是占位注入。
|
||
- **云容器账号**:`CloudExecutor` 仍是占位(无账号即抛错)。
|
||
Docker 执行已接通(见下文“Docker 沙箱执行器”),但真实
|
||
docker run、镜像构建、资源限额实测仍需在外部主机上做:
|
||
本 VM 没有 Docker,本轮未连外部主机、未向真实外部服务发
|
||
请求、未跑真实模型回合,不要宣称 Docker 沙箱已在真实环境验证。
|
||
- **模型 key 供给方式**:这是当前最实在的卡点。上次
|
||
real-nightly-tests automation 就卡在这里:定时进程手里没有中转站
|
||
key,key 从不存文件、只在进程环境里 transient 供给。控制面要跑
|
||
真实模型回合,必须先由用户定下供给方式(例如常驻进程的环境、
|
||
Secure Vault 注入、或其它经用户明确同意的渠道),而不是把 key
|
||
写进配置文件、数据库或代码里凑合。定下之前,定时自动化与 worker
|
||
的模型回合都只能停在“失败路径可用、模型跑不起来”的状态。
|
||
- **其它待接线(非外部条件,但同样未完成)**:OAuth 真实浏览器
|
||
登录已接通(GET 回调 + 真实 userinfo + 回调签发 API key),但
|
||
真实应用注册、回调公网 https 入口、已有账号绑定仍缺(见上文
|
||
“OAuth 真实浏览器登录”一节的部署前置);推送已接成订阅 +
|
||
分发端点,但 Web Push / APNs / FCM 的真实凭证与发送端仍缺;
|
||
单机 SQLite 在高并发写下仍有上限,生产量上来后需换 Postgres
|
||
(Postgres 后端已接通,真实服务器部署与验证见上文“数据库后端
|
||
选择”);
|
||
worker 执行已接通(见下文“worker 常驻与真取消”),云端执行、
|
||
真实通知与真实模型回合仍受 key 供给与云账号卡点限制。
|
||
|
||
## worker 常驻与真取消(本轮新增)
|
||
|
||
- 单次:`python3 -m cloud.worker --db <库> --host-py ../host.py`;
|
||
常驻:`python3 -m cloud.worker --db <库> --loop --interval 300`,
|
||
每轮 run_all,SIGTERM/SIGINT 时优雅退出,正在跑的任务先标
|
||
interrupted 再退出;CLI 起 worker 前先 init_db,全新库不再因
|
||
缺表而炸,循环内一轮异常记日志后继续,不带走整个 worker。
|
||
- 真取消:子进程以进程组启动(start_new_session),取消时先
|
||
SIGTERM 给整个进程组、等 2 秒、仍活着再 SIGKILL,杀完任务
|
||
保持 interrupted,执行结果不覆盖它;取消竞态(run_one 已先
|
||
标 interrupted)时 cancel 仍返回 True,因为中断确由本次取消触发。
|
||
- 任务结果列:tasks 表新增 result_text、error_text、actual_model、
|
||
started_at、finished_at、child_pid,init_db 用 PRAGMA 检查后
|
||
ALTER TABLE 幂等迁移,老库升级不丢数据;GET /tasks/{id} 返回
|
||
它们,缺 key 任务的 error_text 含 Missing environment variable。
|
||
|
||
## 可观测性与备份(本轮新增)
|
||
|
||
- 结构化日志:cloud/logging_config.py,JSON 行日志(时间、级别、
|
||
服务、任务 id、事件),worker 的认领/完成/失败/取消/fallback/
|
||
缺 key 记事件日志,绝不写密钥与环境变量值。
|
||
- 指标:`GET /metrics`(需 X-API-Key,任意项目成员可读,只统计
|
||
调用者是成员的项目),返回任务总数、各状态计数、成功率、
|
||
平均耗时、fallback 总次数、成本合计(actual 与 estimated 分开)。
|
||
- 备份:`python3 -m cloud.backup --db <库> --out <目录> [--keep 3]`,
|
||
用 SQLite backup API 在线备份,轮转只留最近 3 份(命名含时间戳),
|
||
失败明确报错不静默;备份文件本身就是可打开的 SQLite 库。
|
||
- DB 接口迁移:server 及 auth/memory/kb/cost/oauth/push/webhooks
|
||
的 Store 连接已统一走 cloud.db 的 WAL + busy_timeout 连接
|
||
(Postgres 后端已接通,驱动缺失时启动即失败、不先建 SQLite
|
||
文件,这一点迁移后未退化)。
|
||
|
||
## Docker 沙箱执行器(本轮新增)
|
||
|
||
- 用法:worker 选 docker 执行器——
|
||
`python3 -m cloud.worker --db <库> --executor docker
|
||
[--docker-image codex-app-server:latest] [--docker-cpus 2]
|
||
[--docker-memory 2g] [--docker-user 1000:1000]
|
||
[--docker-worktrees-root <目录>] [--docker-timeout <秒>]`;
|
||
或在代码里直接传 `DockerExecutor(...)` 给 `Worker(executor=...)`。
|
||
选 docker 但本机无 docker 时任务明确失败,error_text 含
|
||
docker 缺失,绝不静默降级为本地执行;选 local 时行为不变。
|
||
- 命令构造(示例,任务 id 为 t1、worktree 为 /tmp/wt-t1 时):
|
||
`docker run --rm --name codex-t1 --user 1000:1000 --cpus 2
|
||
--memory 2g -v /tmp/wt-t1:/work -v <代码目录>:/app:ro -w /work
|
||
-e XCM_API_KEY codex-app-server:latest python3 /app/host.py
|
||
dispatch --name t1 --task <提示>`。要点:只挂任务自己的
|
||
worktree(可写)和代码目录(只读),绝不挂宿主根目录、绝不
|
||
挂任何含密钥的目录;容器名由任务 id 派生(仅安全字符);
|
||
`-e` 只传 env_key 名,不带值,key 缺失时容器根本不启动;
|
||
资源限额默认 2 核 / 2G,可配置;容器内进程以非 root 用户跑。
|
||
- 镜像:`cloud/docker/Dockerfile`,基于 python:3.12-slim,装
|
||
git,复制调用方提供的配对 codex 二进制(codex +
|
||
codex-code-mode-host + codex-resources)。构建命令示例(在
|
||
仓库根目录,构建上下文就是仓库根):
|
||
`docker build -f cloud/docker/Dockerfile
|
||
-t codex-app-server:latest --build-arg CODEX_BIN_DIR=bin .`
|
||
bin/ 不进仓库,构建主机上没有配对二进制就构建失败;代码
|
||
不在镜像里,运行时只读挂载到 /app,代码改了不用重新构建,
|
||
codex 版本变了才需要重新构建;镜像里不含任何 key。
|
||
- 清理与取消:`docker run --rm`,正常失败也自动删容器;超时
|
||
时先 docker kill 再 docker rm -f;worker 取消时按任务派生的
|
||
容器名 docker kill,不只改数据库状态。
|
||
- 多租户隔离(`--tenant-isolation`,仅 Docker 执行器):开启
|
||
后每个项目用独立的宿主目录 `tenants_root/<项目>/work`、
|
||
`codex-home`、test-repo 副本与 repo 检出,容器内路径与
|
||
非租户模式完全一致(`/app/work`、codex-home、repos/<项目>、
|
||
test-repo),只是宿主挂载源按项目分开——容器内看不到其它
|
||
项目的任何文件;同时每个项目进独立的 Docker 网络
|
||
`codex-<项目>`(容器跨项目网络隔离),容器统一加
|
||
`--cap-drop=ALL --security-opt=no-new-privileges`。codex-home
|
||
只播种模板的 config.toml(relay 端点与 provider 名,不含
|
||
密钥值),绝不复制 thread_history。注意:内层 codex 沙箱仍
|
||
是 danger-full-access(bwrap 在 Docker 容器内不可用:嵌套
|
||
pidns 下 fresh proc mount 内核 EPERM,宿主级则可用,见下),
|
||
隔离边界是容器 + 挂载 + 网络三层,不是内层沙箱。
|
||
- 外层加固(内层保持 danger-full-access 时的补偿层):
|
||
`--docker-security-opt`(可重复,如 `apparmor=docker-codex`,
|
||
见 `deploy/apparmor/docker-codex`:在 Docker 默认 profile
|
||
基础上禁 raw 网络、ptrace、mount 与危险 capability;注意
|
||
它不含 `userns`,即不启用 bwrap 内层沙箱路线);
|
||
`--docker-pids-limit`(默认 512,防 fork 炸弹);
|
||
`--docker-runtime`(不传 = docker 默认 runc;传 `runsc` =
|
||
任务容器走 gVisor syscall 沙箱,需 daemon 已注册该 runtime,
|
||
否则起容器失败;生产 autoscaler 已配 runsc:2026-10-26
|
||
一次性 worker 模型任务验证通过,60 秒 completed,
|
||
AppArmor docker-codex + 租户隔离 + egress 代理组合可用);
|
||
`--egress-proxy-port`(0 = 不启用;生产配 8888):启用后
|
||
Docker 任务容器的 HTTP(S) 出口全部走宿主上的 Tinyproxy
|
||
(见 `deploy/egress/`:FilterDefaultDeny 白名单,只放行
|
||
relay、github、pypi、npm 域名,其余 403;网关查不到时
|
||
任务直接 failed,绝不无代理运行)。seccomp 现状诚实说明:
|
||
Docker 默认 seccomp profile 已在所有任务容器上生效(这是
|
||
不弱的基线),本轮没有新写一个更严的 seccomp allowlist——
|
||
从记忆里拼一个 allowlist 极易漏掉 codex 工具链要的系统调用
|
||
而静默破坏任务,正确的更严 profile 需要先对 codex 做系统
|
||
调用画像,那是下一项工作,不是本轮假装做到的。
|
||
- 宿主级沙箱任务(与 Docker 任务二选一,绝不叠加):
|
||
codex 的 workspace-write(bwrap)在 Docker 容器内不可行
|
||
(嵌套 pidns 下 fresh proc mount 内核 EPERM,宿主级则可用),
|
||
所以需要内层沙箱的任务改走宿主级:worker 用 LocalExecutor +
|
||
`host-config-host.json`(sandbox workspace-write,state 独立
|
||
为 work/host-state-host.json),以 `aa-exec -p codex-host`
|
||
运行(见 `deploy/apparmor/codex-host`:docker-codex 基础 +
|
||
userns/mount/pivot_root,仍禁 raw 网络与 ptrace;capability
|
||
必须放行——codexsvc 本身无宿主 capability,放行的只是 bwrap
|
||
的 userns 内假 root capability,实测 deny sys_admin 会让
|
||
bwrap 在 userns 创建处失败),
|
||
并带 `HTTP(S)_PROXY=http://127.0.0.1:8888` 让 relay/git 出口
|
||
同样走 Tinyproxy 白名单。路由靠任务的 executor 字段
|
||
(POST /tasks 已支持 local/docker/cloud):宿主 worker 必须
|
||
开 `--claim-executor-only`,只认领 executor='local' 的任务,
|
||
绝不抢 executor 为 NULL 的任务(否则会把本该进 Docker 的
|
||
任务拉到宿主上跑,丢了 Docker 外层)。宿主任务放弃 Docker
|
||
外层(无容器、无租户挂载/网络隔离),换来的是 bwrap 内层。
|
||
实测过的沙箱边界(诚实版):agent 只能写自己 worktree,
|
||
写宿主其它目录与其它任务 worktree 均被挡(报文件系统只读);
|
||
但 /tmp 不隔离——agent 写 /tmp 会落到宿主 /tmp 上(codex
|
||
的 workspace-write 不把 /tmp 做成隔离 tmpfs),所以 /tmp
|
||
是任务间的共享区,不能当作隔离边界。
|
||
- 网络白名单的诚实边界:docker run 默认 bridge 网络,本执行
|
||
器不做 egress 白名单——真正的“只放行模型 API”需要宿主防火
|
||
墙或代理规则,那是部署项,不是本轮代码假装做到的,不要宣称
|
||
已实现网络白名单。另有一项待实测:codex 自带的 bubblewrap
|
||
沙箱在容器内是否可用,取决于宿主的容器权限配置,本镜像不
|
||
假装解决;若 bwrap 不可用,host.py 的 sandbox 参数仍可退到
|
||
read-only / workspace-write 的控制面约束。
|
||
- 仍需在外部主机上实测(本 VM 没有 Docker,本轮未连外部主机):
|
||
真实 docker build、真实 docker run、资源限额在真实负载下的
|
||
表现、bwrap 在容器内的可用性、以及取消时容器确实消失。
|
||
|
||
## 生产级补齐(本轮新增:webhook 执行、备份、限流、超时/重试、清理兜底)
|
||
|
||
- Postgres 备份:`cloud/backup.py` 的 `backup_postgres(out_dir, keep=3,
|
||
runner=None, pg_dump_cmd="pg_dump")`,数据库名/用户/密码只从
|
||
`CLOUD_DB_NAME` / `CLOUD_DB_USER` / `CLOUD_DB_PASSWORD` 环境变量取,
|
||
密码只经 `PGPASSWORD` 环境变量传给 pg_dump 子进程,绝不出现在
|
||
命令参数、返回值或日志里;pg_dump 缺失或非零退出时明确抛错;
|
||
成功返回备份文件路径(轮转只留最近 `keep` 份 `.sql`),server 的
|
||
`POST /admin/backup`(需 maintainer 以上权限,body 可传 `out_dir`,
|
||
否则用 `CLOUD_BACKUP_DIR` 或默认 `cloud-backups`)返回 backend、
|
||
path、size_bytes、task_count,绝不返回数据库密码或备份文件内容;
|
||
SQLite 分支(`backup_db`)原样不动。Postgres 备份仍需在真实环境
|
||
验证一次真实 `pg_dump`(本轮只用 fake runner 单测)。
|
||
- 限流:server 的 `RateLimiter`(内存滑动窗口,短锁只围住 deque
|
||
剪枝/计数,不做全局锁、不串行正常请求),默认每分钟 120 次
|
||
(环境变量 `CLOUD_RATE_LIMIT_PER_MINUTE` /
|
||
`CLOUD_RATE_LIMIT_WINDOW_SECONDS` 可调,默认 120 / 60 秒);
|
||
限流在健康/OAuth/webhook 之后、认证之前,超限返回 `429
|
||
{"error": "rate_limited"}`,不记为认证失败;key 按 API key 的 sha256
|
||
计(原始 key 不存),无 key 时按客户端 IP 计;测试可用
|
||
`server.rate_limit_per_minute` 注入小阈值验证 429。
|
||
- 健康与指标补齐:`GET /health`(不认证也通)除 `ok` 外返回
|
||
`backend`(sqlite / postgres,取 `CLOUD_DB_BACKEND`,绝不返回
|
||
连接串或密码)与 `db_ok`(一次轻量 `SELECT 1` 成功与否);
|
||
`GET /metrics` 的 `by_status` 恒含七个状态键(queued / running /
|
||
completed / failed / interrupted / merged / merge-failed),缺失
|
||
状态为 0 而不是缺键。
|
||
- 任务级超时:worker 的 `--task-timeout`(默认 1800 秒,0 表示
|
||
不限时、与 host `--turn-timeout 0` 同约定,worker 级不设截止,
|
||
但 Docker 路径仍受 `--docker-timeout` 约束),与另外
|
||
两个参数不要混淆:`--docker-timeout` 是执行器级的单次 docker run
|
||
超时,`--max-seconds` 是 `run_loop` 的整体墙钟上限;三者互不覆盖,
|
||
Docker 路径下 worker 取 `min(task_timeout, docker_timeout)`
|
||
(docker_timeout 未设时取 task_timeout)作为本次 docker run 超时,
|
||
Local popen 路径下 worker 在轮询循环里到截止时间就杀进程组;
|
||
超时后真杀容器/进程组、任务置 `failed`(超时是失败,不是取消,
|
||
绝不标 interrupted),并记 `worker_timeout` 审计与错误文本
|
||
`task timeout after <秒> seconds`;Local 同步路径(假 runner / 无
|
||
超时的 subprocess.run)无法中途抢占一个已阻塞的调用,超时只能靠
|
||
执行器自身的 timeout,这是诚实边界,不要假装同步路径也能中途杀掉。
|
||
- 失败重试与退避:worker 的 `--max-retries`(默认 1 次重试,0 表示
|
||
不重试)与 `--retry-backoff-seconds`(默认 5 秒,测试可注入 0);
|
||
取消不重试,其它失败(含 worker 超时)重试前按固定退避等待;
|
||
重试次数记在审计(`worker_retry`)与最终 `error_text`
|
||
的 `[retries=N]` 前缀里,不另起新表;重试耗尽后任务才最终 failed,
|
||
成本记录与 usage_turns 只按最终结果记一次,不按尝试次数重复记。
|
||
- 模型 fallback(显式配置时真换模型重试一次):worker 的
|
||
`--fallback-models`(逗号分隔的模型列表,默认空 = 关闭)。
|
||
未配时保持老行为:可 fallback 的失败只记 `model_fallback` 事件
|
||
与 `worker_fallback` 审计、`fallback_count=1`,然后直接 failed,
|
||
不换模型。显式配了时,一次尝试失败且错误可 fallback(404/429/
|
||
5xx/timeout;401/400 绝不 fallback,取消绝不 fallback),就先记
|
||
事件与审计,再用列表里的第一个模型在同一任务、同一 worktree/分支
|
||
上再跑一次(dispatch 报“任务已存在”时走现有的 run 续跑路径,
|
||
模型经 host.py 的顶层 `--model` 参数传过去,不改 host.py 签名);
|
||
fallback 总次数最多 1 次,配两个模型也只试第一个,不会无限换下去
|
||
烧额度,且与 `--max-retries` 无关(`--max-retries 0` 时 fallback
|
||
照样跑)。第二次的结果就是最终结果:成功则任务 completed,
|
||
`actual_model` 写成 fallback 后的模型,成本记录 `fallback_count=1`;
|
||
第二次也失败则任务 failed,`error_text` 同时写清主模型失败原因与
|
||
fallback 模型失败原因。成本来源与 fallback 无关(见下文“真实成本”)。
|
||
- 真实成本(逐任务 actual):host.py 在每回合结束时从 app-server
|
||
的 `thread/tokenUsage/updated` 事件里汇总上游真实用量(每个上游
|
||
补全发一次,`last` 是该次补全的用量,同一 turn 内累加;中继实测
|
||
五次 `last` 之和等于最后一次的 `total`,input/output/cached/
|
||
reasoning 四路都对得上)。host.py dispatch/run 打印的 JSON 带
|
||
`usage`,goal 循环在结论行后另打一行 `usage input=… cached=…
|
||
output=… total=… completions=…`;执行器解析后 worker 每任务记
|
||
一条 `source="actual"`(重试/fallback 各次尝试的用量都累加进
|
||
同一条,不按尝试次数重复记行)。没带回用量时(老版本 server、
|
||
回合未产生补全)回落字符估算记 `estimated`,两者在报表里分开。
|
||
金额 = 实测 token × 价目表:默认是 OpenAI 挂牌价(gpt-6.1-sol
|
||
$2.00/$0.10/$10.00、gpt-6-sol $2.00/$0.20/$10.00,单位美元/百万
|
||
token,依次为输入/缓存输入/输出;缓存是输入的子集,不另加;
|
||
推理 token 已含在输出里,不重复算),中继的实际账单可能不同,
|
||
可用 `CLOUD_MODEL_PRICES_JSON` 或 `CLOUD_MODEL_PRICES_FILE`
|
||
按模型覆盖;无价模型记 `usd_micros=None`,报表里进
|
||
`unpriced_actual_tokens` 而不是 0 美元。`GET /tasks/{id}/cost` 与
|
||
`GET /projects/{id}/cost` 的聚合新增 `cached_input_tokens` 与
|
||
`unpriced_actual_tokens`;按天行同样新增这两列。注意死胡同:
|
||
app-server 的 `account/usage/read` 在中继上不可用(要求 ChatGPT
|
||
账号登录,中继 API key 调它报 `codex account authentication
|
||
required`),账户级用量不是本功能的来源,不要重走这条路。
|
||
- 容器清理兜底:`DockerExecutor.list_containers(prefix="codex-")`(
|
||
`docker ps -a --format {{.Names}}`,失败返回 `[]`)与
|
||
`cleanup_stale_containers()`(列出并逐个 kill+rm,单个失败只跳过,
|
||
绝不抛异常),`Worker.cleanup_stale_containers()` 在 run_loop 启动
|
||
与 CLI 启动时各调一次;取消/超时/失败路径上已有的容器清理保留
|
||
(docker run `--rm`、超时 kill+rm、取消按名 kill)。
|
||
## 项目仓库(任意仓库任务)
|
||
|
||
- 项目级 `repo_url`:`POST /projects` 接受可选 `repo_url`,
|
||
`GET /projects` 列表与 `POST /projects` 的 201 返回里都含它,
|
||
不传时为 `None`(老行为不变)。校验与 worker 共用同一套
|
||
`cloud.repos.validate_repo_url`:只接受 `https://` 开头(且不含
|
||
userinfo,即不含用户名/密码)或 `git@` 开头(`git@host:path` 形);
|
||
`file://`、`ssh://`、本地路径、带密码的 URL 一律 400 且不建项目。
|
||
- 存储与迁移:`projects` 表新增 `repo_url TEXT` 可空列;SQLite 的
|
||
`init_db` 建表时就含该列,老库用 `ALTER TABLE` 幂等补列(仿照
|
||
`migrate_tasks_table` 的写法);Postgres 的 `POSTGRES_SCHEMA`
|
||
同样加该列,`init_postgres_schema` 也对老库补列。
|
||
- worker 派发:`run_one` 派发前查任务所属项目的 `repo_url`,非空时
|
||
经 `cloud.repos.resolve_repo` 在 `--repos-dir`(默认 `./repos`)下
|
||
解析:目录不存在时 `git clone`;已存在时校验它是 git 仓库且
|
||
remote origin 与 `repo_url` 一致(不一致时报错,绝不强制改
|
||
remote),一致时 `git fetch origin`(失败明确报错,不静默用旧代码;
|
||
fetch 成功后不 checkout/pull/reset/clean,工作树由 host.py 的
|
||
worktree 逻辑处理)。解析出的路径设为 `task["repo"]` 再交给执行器,
|
||
Local 与 Docker 的 `dispatch_task` 都已读 `task["repo"]`,同一条路径;
|
||
查不到项目或 `repo_url` 为空时走原有默认 repo,不报错;解析失败时
|
||
任务置 `failed`,错误文本写清是仓库解析失败,绝不标 completed。
|
||
- Docker 下的路径前提:容器内 `task["repo"]` 路径必须与宿主机一致,
|
||
所以 `repos_dir` 必须放在 `--docker-extra-mounts` 已挂载的目录下
|
||
(例如把 `/var/lib/codex-app-server/repos` 以同路径挂载进容器),
|
||
否则容器内 repo 路径不存在——不要假装任意目录都行。生产部署示例:
|
||
`python3 -m cloud.worker --db <库> --executor docker
|
||
--repos-dir /var/lib/codex-app-server/repos
|
||
--docker-extra-mounts /var/lib/codex-app-server:/var/lib/codex-app-server,…`。
|
||
- 仍未做(不要宣称已做):推送到远端明确不在本轮范围——任务在本地
|
||
检出上执行、本地 worktree 合并仍按 host.py 现有流程,`git push`
|
||
到远端需要仓库写凭证,控制面不存、不推,所以任意仓库的自动 PR
|
||
仍未实现;私有仓库 clone 会失败,错误文本写明“需要仓库访问凭证,
|
||
控制面不存仓库凭证”,绝不尝试用任何 key 去 clone;webhook 建的
|
||
任务仍需 worker 认领执行;`CloudExecutor` 仍是占位符;公网 https
|
||
入口仍需部署侧反向代理 + TLS。
|
||
|
||
## 云端环境(选、建、管)
|
||
|
||
环境是项目仓库的一个命名基准检出,不是官方 Codex 的云端环境
|
||
(那是官方产品自己的后端,本控制面没有它的公开调用口,不宣称
|
||
能建它)。本控制面的环境管的是主机/Docker 这边的基准:项目 +
|
||
仓库快照 + 分支 + 执行器,任务选了环境后仍是一任务一 worktree
|
||
(host.py 从环境检出 HEAD 为任务建分支),任务隔离、合并、提 PR
|
||
全都不变。
|
||
|
||
- API:`POST /environments`(project_id、name 必填,branch 可选
|
||
空=默认分支,executor 可选 local/docker;建完 status=creating)、
|
||
`GET /environments?project_id=`(默认排除已归档,
|
||
`include_archived=1` 才含归档)、`GET /environments/{id}`、
|
||
`POST /environments/{id}/archive`。写操作(建/归档)要求项目
|
||
create_task 权限,读要求 read 权限。
|
||
- 创建规则:项目没有 repo_url 时 400(没有仓库就没有环境);
|
||
同项目未归档环境名唯一(409,归档过的名字可重用,部分唯一
|
||
索引保证);分支名只允许字母数字与 `_ . - /`,不以 `-` 开头、
|
||
不含 `..`、不以 `.lock` 结尾;executor 只接受 local/docker。
|
||
- 准备:worker 在 `run_all` 每轮开头准备所有 creating 的环境:
|
||
目录不存在时 `git clone`,存在时校验 git 仓库且 origin 与环境
|
||
repo_url 一致再 `fetch`,分支非空时 `checkout` 该分支(不存在
|
||
则失败,绝不自动建分支),成功变 ready、失败变 failed 并写
|
||
error_text。准备本身幂等,没有 preparing 中间态——worker 中途
|
||
死了、环境仍是 creating 时下一轮会重新准备;准备期间用户归档
|
||
了环境时 CAS 写回改不到行,状态保持 archived。
|
||
- 任务绑定:`POST /tasks` 接受可选 `environment_id`,必须是本项目
|
||
已 ready 的环境(creating/failed/archived 一律 400 并写明状态);
|
||
环境 executor 非空且任务 executor 已指定时必须一致,任务
|
||
executor 未指定时继承环境 executor(认领过滤靠它);cloud
|
||
执行器没有环境检出路径,一律拒绝。worker 派发时环境任务不走
|
||
项目仓库路径,直接把 `task["repo"]` 指到环境检出(租户 Docker
|
||
为容器内路径 `/var/lib/codex-app-server/envs/<id>`,租户挂载里
|
||
整目录挂载该项目 envs 目录;非租户为 `repos_dir/envs/<id>`)。
|
||
- 管理:归档幂等(已归档再归档返回 200);有 queued/running
|
||
任务正在用环境时归档 409;归档只改状态,不删检出目录(已完成
|
||
任务的 worktree 独立,检出留着可查,磁盘清理另行手动做)。
|
||
重试(retry)只改任务状态,environment_id 原样保留;会话分支
|
||
(fork)不继承 environment_id——分支是 thread/worktree 分叉,
|
||
不是在环境上重开任务。
|
||
- 命令台:项目卡下方新增“环境”卡(列表/新建/归档,creating→
|
||
ready 的过程靠 10 秒自动刷新可见,失败原因直接显示在列表里);
|
||
新建任务栏新增环境下拉(未 ready 的置灰、不可选),任务列表里
|
||
带环境任务会标 `[环境 <短 id>]`。公网 nginx 若要让页面调通环境
|
||
接口,须把 `/environments` 同批代理到双机上游(与 `/console`、
|
||
`/tasks`、`/projects`、`/workers`、`/health` 同款,否则页面能打开
|
||
但环境区永远加载失败)。
|
||
|
||
## GitHub 仓库选择(建项目 / 给已有项目补仓库)
|
||
|
||
控制台以前没有仓库选择菜单:建项目时 repo_url 只是个可选的
|
||
手填文本框,不填也能建出项目,之后建环境必被 400 挡住
|
||
(“project has no repo_url”),且没有修改接口,死结只能重建
|
||
项目。本节补上选择菜单与补仓库接口。
|
||
|
||
- 列表来源:`GET /github/repos`(需认证)列出本服务 GitHub
|
||
App 安装记录里的仓库(installation_repositories 事件同步进
|
||
`github_app_installations` 表,字段 installation_id /
|
||
account_login / repo_full_name,clone_url 由 full_name 拼出
|
||
`https://github.com/<full_name>.git`)。只含仓库名,不含密钥。
|
||
- 建项目:仓库下拉(从上述接口加载)+ 手填文本框并存:选下拉
|
||
即把 clone_url 填进文本框,仍按原有 body 提交;没进 App 安装
|
||
的公开仓库继续手动填 URL,不受影响。
|
||
- 补仓库:`PATCH /projects/{id}`(项目 owner 才能调, maintainer
|
||
也不行——改项目归属配置按 manage_project 计),body 必须含
|
||
repo_url:字符串走与创建时同一套 validate_repo_url 校验(带凭
|
||
证的 URL 拒绝),null 或空串表示清空。改完记 update_project
|
||
审计。环境区在当前项目无仓库时直接亮出同一下拉 + “保存仓库”
|
||
按钮,保存后立即可以建环境(截图里的死结就卡在这里)。
|
||
- 快照语义不变:补仓库只改 projects 行,已建环境的 repo_url
|
||
快照不动(仍用建环境时的仓库),新环境才用新值。
|
||
- 信任模型说明(诚实边界):下拉没有放宽任何权限——手填 URL
|
||
本来就能填任意地址,选下拉与手填是同一能力;按用户隔离仓库
|
||
可见范围(例如只列该用户 GitHub 身份名下的安装仓库)是后续
|
||
工作,本轮不做。公网部署时 nginx 须同批加
|
||
`location ^~ /github/repos`(`/projects` 已有 location,PATCH
|
||
走它,不用重复加)。
|
||
|
||
## 租户配额与计费
|
||
|
||
- 口径:配额是按用户、按 UTC 自然月的 USD 上限(`user_quotas`
|
||
表,`quota_usd_micros` 为 NULL 表示不限额,0 表示一分钱也不
|
||
能花;金额单位与 cost_records 一致,都是微美元,避免浮点)。
|
||
用量只算 `usd_micros` 非空的实际成本行(estimated 行 usd 为
|
||
NULL,SUM 自动跳过),按任务创建者 `created_by` 归属——一个
|
||
用户在多个项目里花的钱都算进他自己的月度账单;项目维度的
|
||
拆分由项目成本接口另行提供。
|
||
- 准入:建任务、会话分支、重试、续跑、goal 断点续跑在权限
|
||
校验通过后、写库之前查配额,本月用量 >= 配额时回 402
|
||
`quota_exceeded`(附 month、quota_usd_micros、used_usd_micros),
|
||
不建任务、不改状态。老用户与未设配额的用户默认不限额,
|
||
行为与过去一致。但 OAuth 新用户不再是“建号即不限额”:
|
||
建号(created=True)时写一行默认配额
|
||
(`CLOUD_DEFAULT_QUOTA_USD_MICROS`,默认 10_000_000,
|
||
即 $10/月;`INSERT OR IGNORE` 幂等,不覆盖管理员后来改过
|
||
的额度),并记 `quota_default` 审计。goal 断点续跑端点
|
||
(G10):`POST /tasks/{id}/goal-resume`(权限 approve_merge,
|
||
同重试),仅 interrupted 的 goal 任务可续(goal-met/goal-unmet
|
||
已终局、failed 按旧规则新建、非 goal 任务一律 409),走
|
||
host.py goal-resume(无 --goal-text)的断点语义——从
|
||
goal_rounds_done 的下一轮接着跑同一循环,保留原线程与
|
||
worktree,状态回 queued 并置 goal_resume_pending(worker
|
||
认领后清掉),写 `task_goal_resumed` 审计;`/continue`
|
||
开新循环的语义不动,不要把两者混用。重试与续跑按任务
|
||
创建者查配额(费用本来就记在创建者名下;维护者替他人
|
||
重试/续跑时,操作人超限不应拦、创建者超限才应拦——旧版
|
||
重试按操作人查是错位,本轮已改)。后续消息
|
||
(followup)创建不查配额:排队本身不花钱,执行时轮次边界
|
||
的配额门照常生效,不要把 followup 也改成准入校验。
|
||
- 费用归属与豁免(G13/G14,写死口径):系统任务豁免——
|
||
webhook /fix-review 等 created_by='system' 的任务在服务端
|
||
准入与 worker 认领两处都不查配额(`_check_quota` 与
|
||
`_quota_exceeded` 里 user_id == "system" 直接放行,单测
|
||
覆盖);这是写死的约定,不是可配置项。反过来,由用户动
|
||
作触发的 /pr 任务不再走 system 豁免:`POST /tasks/{id}/pr`
|
||
在 merged 校验通过后、执行/排队之前先按请求用户查配额
|
||
(超限 402、查询异常见下 503,均不改状态、不排队),
|
||
队列路把请求用户写进 pr_jobs.created_by(老表缺列时
|
||
ensure 时 ALTER 补列,老行 created_by 为 NULL 时 worker
|
||
跑它记审计回落 "system"),worker 的 pr 任务循环把
|
||
created_by 当审计操作人传给 execute_task_pr(同步路本
|
||
就传调用方,不动)。注意这是一个有意收紧的口径,供合
|
||
并前推翻:PR 本身不跑模型回合,纯 git/gh 操作几乎不花
|
||
钱,若认为“已完成工作的 PR 不应被配额挡住”,可把 /pr
|
||
的配额门改回豁免而不动归属列(created_by 照常记录)。
|
||
- 配额查询异常的分层(G7,写死口径):新建/认领类门
|
||
(建任务、分支、重试、续跑、goal-resume、/pr)查配额抛
|
||
异常时 fail-closed——服务端回明确的 503
|
||
`quota_check_unavailable`(不是 500 断连),不建任务、
|
||
不改状态、不排队;worker 认领门同样标 interrupted,错
|
||
误含 `quota_check_unavailable`,可重试/续跑,绝不在“不
|
||
知道超没超”时开跑。执行中轮询(Local 30 秒、Docker 轮
|
||
询见下)查配额抛异常时 fail-open——只记 warning,不杀
|
||
在跑任务,只有确认超限才掐断;轮次边界的复查同款
|
||
fail-open。实现上查询函数 `_quota_limit_and_used` 自己
|
||
绝不吞异常冒充“不限额”,分层由调用方 on_error 参数决定
|
||
("closed" 门侧、"open" 轮询/边界,缺省 open)。
|
||
- 无价模型 token 上限:价目表目前只有两个 sol 模型有价,默认
|
||
模型 gpt-5.6-terra 等中转模型无价、成本行 usd 为 NULL,USD
|
||
配额对它们永远不生效(按 0 计)。因此有配额行的用户另受一
|
||
道体积护栏:当月无价模型 token(input + output,已记账 +
|
||
本轮,usd_micros IS NULL 的行)达到
|
||
`CLOUD_QUOTA_UNPRICED_TOKEN_CAP`(默认 20_000_000/月)即
|
||
停——服务端建任务回 402 `quota_exceeded`(附
|
||
unpriced_token_cap、unpriced_tokens_used),worker 侧认领/
|
||
重试/后续消息轮次同口径拦截。这道是防滥用的体积上限,不
|
||
是计价:成本报表里无价 token 仍进 unpriced_actual_tokens,
|
||
绝不折算成假美元;无配额行的用户不受限。
|
||
- 执行时加固(worker 侧,`Worker._quota_exceeded`,口径与准入
|
||
一致:当月已记账 + 本轮已累计用量,按价目表换算):认领后
|
||
开跑前再查一次(排队期间配额被花完/调低时不跑,任务标
|
||
interrupted + 配额原因、不记成本、续跑消息不消费);重试与
|
||
fallback 换模型前各查一次;后续消息每轮开跑前查一次(超限
|
||
时该条回 pending、任务 interrupted,不再排空剩余;建消息时
|
||
不查,排队本身不花钱);Local popen 路径在轮询里每 30 秒查
|
||
一次,超限时杀进程组(goal 长循环也一样,杀的是整个子进程
|
||
组);Docker 路径同款粒度(G8):`_docker_call_with_quota_poll`
|
||
把阻塞式 docker 调用放后台线程,主循环每
|
||
`quota_poll_interval_seconds`(缺省 30 秒,单测可传小值,
|
||
生产不要改小)查一次,确认超限时按任务容器名真杀容器,
|
||
结果改写成内部标记 `quota_exceeded` 后走统一收尾——状态
|
||
一律 interrupted、已花成本照记 actual、续跑消息保留,配额
|
||
命中后不走 ALREADY_EXISTS 回落重跑。同步路径(非 popen、
|
||
非 Docker)仍只有边界检查,单轮内的超支要到轮次结束才停。
|
||
配额中断的任务一律 interrupted(可续跑:顶上来后重试
|
||
/续跑继续),错误文本带 `quota_exceeded`,已花成本照记
|
||
actual,不发终态推送(与取消同款)。诚实边界:轮询查的是
|
||
已记账用量,单轮本身花的钱无法中途按 token 流式截断(中转
|
||
用量只在回合结束才回传),所以“掐断”粒度是轮次边界 + 长轮
|
||
询中的 30 秒检查,不是 token 级;真 Docker 掐断未实弹(只
|
||
由假执行器单测覆盖),部署后需实弹;并发下两个任务同时查
|
||
到未超限再同时开跑会有少量超支(与限流器同款 best-effort,
|
||
非精确配额)——G15 并发预留不在本批,单独立项。另两条
|
||
接受现状的边界:配额按 UTC 自然月切分,与 host 本地日期
|
||
不一致时以 UTC 为准(月末跨月任务的用量按记账日归属);
|
||
配额改动对在跑任务只在轮询/轮次边界生效,不会立刻掐断
|
||
已经开跑的回合(除非下一次轮询查到超限)。
|
||
- host 预算与云端配额的两套门(G9):worker 路径默认忽略
|
||
host 的 `max_daily_turns` 预算——worker 调 host.py 时传顶层
|
||
`--ignore-host-budget`(LocalExecutor 与 DockerExecutor 都
|
||
在子命令之前追加该标志;worker CLI 缺省开,可用
|
||
`--no-ignore-host-budget` 关掉),host.py 里只清
|
||
`max_daily_turns` 这一项(`_effective_cfg`,notify_command
|
||
等其它配置不动),dispatch 前置门、重试前检查、goal 循环
|
||
顶检查一并放行;云端 per-user USD 配额 + worker 认领/轮询
|
||
门才是 worker 路径的口径,host 单机 turn 数预算再拦一次只
|
||
会误拦云端已批的任务。standalone 直接跑 host.py 时不传
|
||
该标志,预算照常生效。两套门同时开(--no-ignore-host-budget)
|
||
时任务要同时通过两道门,超任一道都停。
|
||
- 接口:`GET /users/me/quota`(自查,返回 month、quota、
|
||
used、remaining,以及当月无价实际 token 数
|
||
`unpriced_actual_tokens`——价目表里没有该模型价格的实际
|
||
token 只上报、不计价、不拦任务,绝不编造一个价格把它折
|
||
算进 used;不限额时 quota 与 remaining 为 null);管理端点
|
||
的配额返回同样含 `unpriced_actual_tokens`;
|
||
`GET /admin/quotas?user_id=` 与 `POST /admin/quotas`
|
||
(body:user_id、quota_usd_micros,null=不限额,负数 400,
|
||
用户不存在 404,改完记 `quota_set` 审计)。管理口径沿用
|
||
`/admin/backup` 的做法:在任一项目里是 maintainer/owner 即
|
||
算管理员,没有任何项目角色的用户无权改任何人的配额。
|
||
这还不是真正的收款:没有接支付网关、不开发票、不扣款,
|
||
只有记账 + 配额 + 超限拦截;要收钱需另接支付(价格表在
|
||
`cloud/cost.py`,注意那是 OpenAI 牌价、不是中转站的账单)。
|
||
- 命令台:API Key 卡下方显示本月用量与配额(10 秒自动刷新;
|
||
超限建任务时接口的 402 会带“本月配额已用完”提示)。管理
|
||
员改配额目前只走 API,控制台没有改配额的表单。
|
||
- 公网 nginx:注意,443 块的默认 `location /` 是代理到主机
|
||
`127.0.0.1:28080`(不是控制面),而 `/users`、`/admin`
|
||
目前没有专属 location——配额接口部署上生产时须同批加
|
||
`location ^~ /users/me/quota` 与 `location ^~ /admin/quotas`
|
||
(只放这两个精确前缀,不要整段放行 `/users` 或 `/admin`,
|
||
否则会把 `/admin/sweep`、`/admin/backup` 也暴露到公网),
|
||
否则公网页面调配额接口会落到 28080 而不是控制面。
|
||
|
||
## sub2api 网关(每用户独立网关账号)
|
||
|
||
- 开关:`CLOUD_GATEWAY`(默认 `xcmapi`;设为 `sub2api` 即切流)。
|
||
切流前 xcmapi 保持现网;切流本身只是改环境变量 + 重启服务,
|
||
不动任务数据。`SUB2API_BASE_URL`(网关 origin,如
|
||
`http://172.17.0.1:28084`,桥接 IP 供宿主与容器同达)、
|
||
`SUB2API_ADMIN_EMAIL` / `SUB2API_ADMIN_PASSWORD`(系统管理员
|
||
账号)、`SUB2API_GROUP_NAME`(默认 `codex-system`;或用
|
||
`SUB2API_GROUP_ID` 直接指定组 id)是开通必需的配置。
|
||
- 开通:OAuth 建号时 best-effort 调管理 API 开通(失败记
|
||
`sub2api_provision_failed` 审计,建号本身不失败);任务开跑
|
||
前 worker 按 `created_by` 幂等开通(`user_gateway_keys` 表:
|
||
user_id → sub2api_user_id、api_key、refresh_token),失败则
|
||
任务直接 failed,绝不跑错网关。协调员子任务无 created_by 时
|
||
跟父任务属主走。key 明文存 PG,与 GitHub App 私钥同一信任
|
||
边界(库只在宿主 loopback 可达);at-rest 加密是后续项。
|
||
开通同时授初始余额:`CLOUD_SUB2API_OPENING_BALANCE_USD`
|
||
(默认 10 美元,显式配 0 表示不授;非法/负值回落默认)——
|
||
网关鉴权层在余额 ≤ 0 时直接 403,不授余额的 key 立刻不可
|
||
用;授余额失败时 key 与账号一并清理再抛错,不留孤儿。
|
||
- 注入:worker 在三条执行路径(Local popen、Local 同步、Docker
|
||
同步)都注入任务级 env:`SUB2API_API_KEY`(属主的 key)、
|
||
`SUB2API_BASE_URL`,并把网关 host 追加进 NO_PROXY(否则
|
||
codex 经 egress 代理访问内网网关会被挡)。host.py 以 `-c`
|
||
注入 provider(`model_provider=sub2api` + provider 表,不改
|
||
任何租户的 `config.toml`,多网关任务同项目并发互不干扰),
|
||
未配 `SUB2API_BASE_URL` 时 loud fail。#86 的 shell exclude
|
||
模式已覆盖 `*_KEY`,agent shell 看不到该 key。
|
||
- Docker 密钥通道:`SUB2API_API_KEY` 在 docker 命令里只传名
|
||
(`-e SUB2API_API_KEY`),值由 docker CLI 子进程环境提供
|
||
(`_docker_cli_env`),绝不进 argv(ps/docker inspect 可见
|
||
argv);runner 不支持 env 时 loud fail。其它 extra_env 仍按
|
||
`-e NAME=VALUE`(非密钥变量)。
|
||
- 模型目录:`/relay/models` 在 sub2api 下返回调用者自己的网关
|
||
模型(key 绑定的组决定);无 key 时返回空列表 +
|
||
`provisioning: pending`,不在页面加载时悄悄开通。任务未指定
|
||
模型时:`CLOUD_SUB2API_DEFAULT_MODEL` 优先,否则取该用户
|
||
网关模型首个;组里无模型时任务 failed( loud fail,不拿
|
||
xcmapi 的模型名去撞网关)。sub2api 模型目前无价目表,成本
|
||
走无价 token 上限那道护栏(见上节),报表记 unpriced。
|
||
- 诚实边界:本批只做到“网关可配置 + 任务注入 + 模型目录走
|
||
网关”;真模型调用实弹需上游账号挂到组里后才做(切流前置
|
||
条件),fallback 模型名在 sub2api 下须配网关侧的名字。
|
||
|
||
## 运维批(本轮新增:部署、恢复演练、监控、心跳、多用户验证)
|
||
|
||
细则在 `deploy/README.md`,这里只记代码侧的接口与默认值:
|
||
|
||
- 部署与回滚(`deploy/deploy.sh` / `deploy/rollback.sh`,VM 侧跑,
|
||
不碰 `cloud/` 代码):部署前在主机上备份当前 `/opt/codex-app-server`
|
||
到 `/opt/codex-app-server-backups/`(时间戳命名,只留最近 3 份,排除
|
||
work/),解包新代码、chown codexsvc、重启 server+worker、循环验
|
||
`localhost:8765/health` 最多 60 秒(要求 ok=true、backend=postgres、
|
||
db_ok=true),失败自动恢复备份并重启;回滚只回代码,不回数据库
|
||
(迁移只加列、幂等,老代码仍能读新列,见 deploy/README 的回滚边界)。
|
||
两个脚本都不接受密码参数、不读 env 文件、不打印密钥;需经代理时用
|
||
环境变量 `SSH_PROXY_CMD` 传入 ProxyCommand,未设则直连。
|
||
- 备份恢复演练(`cloud.backup.restore_postgres` + `deploy/restore-drill.sh`,
|
||
主机侧跑):`restore_postgres(backup_file, target_dbname, runner=None)`,
|
||
用 psql 把 .sql 备份恢复到目标库;目标库名等于 `CLOUD_DB_NAME` 时
|
||
直接拒绝(除非显式传 `allow_production=True`,演练脚本永远不设它);
|
||
备份文件不存在或为空时报错;密码只经 PGPASSWORD 环境变量传,绝不进
|
||
命令行、返回值或日志。演练脚本先跑一次 `backup_postgres` 生成新鲜
|
||
备份,再恢复到 `codex_control_restore_test_<时间戳>` 测试库,比对
|
||
tasks/projects/users/audit_log 四张表行数(只比行数,不打印行内容),
|
||
比对完立即删测试库(trap 保证中途失败也删);绝不恢复到生产库,
|
||
绝不删生产库。
|
||
- 监控与心跳:worker 的 `run_loop` 每次循环结束时写心跳文件,内容只有
|
||
`{"last_seen": epoch秒, "pid": 进程号}`,写失败只记日志不炸 worker;
|
||
路径由 `--heartbeat-file` 指定,未指定时看环境变量
|
||
`CLOUD_WORKER_HEARTBEAT_FILE`,再未指定则为 `./worker-heartbeat.json`
|
||
(VM 本地默认,主机路径不在代码里硬编码;主机部署时两边指到同一个
|
||
`/var/lib/codex-app-server/worker-heartbeat.json`,server 侧在 env 文件
|
||
里加 `CLOUD_WORKER_HEARTBEAT_FILE` 一行,worker 单元加
|
||
`--heartbeat-file` 参数,deploy.sh 不自动改 env 文件)。`GET /health`
|
||
在原有 ok/service/backend/db_ok 之外加 `worker_last_seen_seconds_ago`
|
||
(无文件或读失败时为 None)与 `worker_ok`(心跳 180 秒内为 true,
|
||
阈值可经 `CLOUD_WORKER_HEARTBEAT_MAX_AGE_SECONDS` 调,默认 180);
|
||
health 本身仍返回 200,worker 不 ok 由调用方看字段判断,不要改成 503。
|
||
`deploy/monitor.sh`(主机侧,codexsvc 可读,不读 env 文件)检查
|
||
server/worker 的 systemctl 状态、health 四项、pg_isready、docker ps 里
|
||
codex-postgres 在运行、磁盘 <90%、app 目录存在,输出一行 JSON,任一
|
||
项 fail 则退出非零;`--self-test` 只验证脚本机制(health 解析 + JSON
|
||
合法),不验证主机健康。`deploy/monitor.service` /
|
||
`deploy/monitor.timer` 每 5 分钟跑一次,日志进 journal。
|
||
- 多用户真实验证(`deploy/verify-multiuser.py`,主机侧以 codexsvc 身份
|
||
跑):内存里建 4 个测试用户与 2 个测试项目(P2 只有 owner 是成员),
|
||
用 HTTP API 验证建任务、读任务、merge 的角色权限与跨项目隔离;
|
||
merge 用的 completed 测试任务是直接在 DB 里置 completed,不跑模型;
|
||
测试任务全部带永不存在的依赖 `multiuser-test-never-completes`,生产
|
||
worker 只派依赖全部 completed 的任务,所以测试任务永远不会被认领去
|
||
跑模型,不花模型钱、不改仓库;测试行全部用 `multiuser-test-` 前缀,
|
||
清理时只删这些行,清理失败退出非零并列出残留 id(不含 key);审计
|
||
日志是 append-only 的,验证审计条目会留下,脚本不删审计表。
|
||
- 仍未做(不要宣称已做):推送告警通道(monitor 失败时通知到 Muse 聊天
|
||
的通道还没定);回滚不回数据库(改类型/删列的迁移需单独评估);恢复
|
||
演练只比行数,恢复到生产库的真实灾难恢复流程还没演练过;部署脚本、
|
||
恢复演练、monitor、多用户验证四项的主机实跑还没做(本轮只在 VM 上
|
||
跑了单测、deploy 干跑、monitor 自测与 VM 实跑)。
|
||
|
||
## 云端派发与自动 PR(本轮新增:多 worker 池、通用 runner、默认关闭的自动 PR)
|
||
|
||
- 多 worker 池(`cloud/workers.py` + worker 注册/心跳):worker 启动
|
||
时向控制面注册(workers 表:worker_id、executor、capacity、
|
||
last_seen),`run_loop` 每轮结束写注册表心跳;`GET /workers` 列出
|
||
全部 worker 及其 alive(last_seen 在 120 秒内,阈值可经环境变量
|
||
`CLOUD_WORKER_ALIVE_SECONDS` 调);`POST /workers/heartbeat`
|
||
要求 body 含 worker_id(缺了 400),心跳本身就是注册——worker_id
|
||
不存在时自动注册,绝不 404。worker_id 可经 CLI `--worker-id`
|
||
指定(生产部署应配稳定 id),未指定时默认“主机名-进程号”
|
||
(重启后进程号会变,旧行自然变 stale,不要用随机 uuid 让注册表
|
||
每次重启都多一行无法对应到同一台机器);capacity 经
|
||
`--worker-capacity` 指定(默认 1,注册表展示用,本轮 run_loop
|
||
仍是串行一轮一任务)。两个端点都要 X-API-Key(与其它已认证
|
||
端点一致);worker 直接写库注册时不需要 key,HTTP 心跳是给有
|
||
key 的外部 worker 用的另一条路。
|
||
- 任务按执行器池派发:tasks 表新增 `executor` 列(可空,老库幂等
|
||
补列);`POST /tasks` 接受可选 executor(local/docker/cloud,
|
||
非法值 400),未传时为 None 表示任意 worker 可认领;worker
|
||
认领时只认领 executor 为空或等于自己执行器类型的任务——
|
||
pick_next 先过滤,`_claim` 的 WHERE 里再加
|
||
`(executor IS NULL OR executor = ?)` 兜底,CAS 语义不变(仅当
|
||
queued 时标 running,两个同池 worker 抢同一任务时只有一个拿到
|
||
rowcount == 1);认领日志与审计里含 executor。注意
|
||
`_executor_name` 的修正:CloudExecutor 以前会被算成 local,
|
||
现在明确返回 cloud,否则 cloud worker 会认领 local 池的任务。
|
||
- goal 模式(`POST /tasks` 带 `verify_command` 即开启,本地
|
||
host.py `goal` 循环的云端版):worker 不走普通 dispatch,
|
||
改调执行器的 goal_task(local/Docker 均已接,CloudExecutor
|
||
没有 goal 概念、goal 任务派给它会如实失败,不静默降级);
|
||
循环内每轮 turn 后跑验收命令、失败输出喂回下一轮,最多
|
||
`goal_max_rounds` 轮(缺省 3,与 host.py 默认一致),goal
|
||
文本缺省用 prompt。达标时任务 completed;未达标时任务
|
||
failed(错误文本为最后一轮验收输出),且只发 goal-unmet
|
||
推送、不再重复发 failed。goal 模式下 worker 级重试与模型
|
||
fallback 关闭(循环内部已重试,再叠加只会污染错误文本);
|
||
goal 任务失败后不要用 `/retry` 同任务重跑——host 任务名由
|
||
任务 id 派生,重跑会撞“任务已存在”而失败,应新建任务。
|
||
- CloudExecutor 通用 runner 接口(厂商无关,`cloud/sandbox.py`):
|
||
本轮不假装接通某个云厂商(ECS、Cloud Run 等专属适配明确不在
|
||
范围)。CloudExecutor 接受一个 runner endpoint(显式参数或
|
||
环境变量 `CLOUD_RUNNER_ENDPOINT`);未设时 execute 一律明确
|
||
失败“未配置云端 runner”,绝不静默降级到 Docker/Local;已设时
|
||
以 HTTP POST 把任务发给该 endpoint,请求体只有 id、prompt、
|
||
repo_url(task["repo"]),不含任何密钥;endpoint 返回的 status
|
||
为 completed 才算 completed,否则算 failed。认证只走 header
|
||
名:`CLOUD_RUNNER_AUTH_HEADER` 给 header 名,token 值只从环境
|
||
变量取(`CLOUD_RUNNER_AUTH_TOKEN_ENV` 指定的变量名,默认
|
||
`CLOUD_RUNNER_AUTH_TOKEN`),只放进请求头,绝不写进日志、
|
||
错误文本或返回结果。worker CLI 的 `--executor` 新增 cloud 选项,
|
||
构造 CloudExecutor;worker 的 run_one 走 dispatch_task,
|
||
CloudExecutor 的 dispatch_task/run_task 直接委托给 execute。
|
||
- CloudHypervisorExecutor(生产级 v1,`cloud/chv/`,旧路径
|
||
`cloud/sandbox.py` 只留兼容 re-export,执行器名
|
||
`cloud-hypervisor`,绝不用 `cloud` 缩写):
|
||
单任务单 VM:一个任务一个 VM、独立内核/文件系统/网络,
|
||
任务结束销毁,租户之间无共享路径、无东西向互访;VM 内
|
||
agent 可有 root,但只能影响本 VM。密钥按现有惯例走运行
|
||
时环境变量名注入,不进镜像、不进代码、不进任务 prompt
|
||
落库(SSH argv 里只有 key 文件路径,绝无 key 值)。
|
||
唤醒是 API 模式:preflight(见下)→ 容量门 → 磁盘配额门
|
||
→ 每任务磁盘供给(copy 模式复制基础镜像)→ 只以
|
||
`--api-socket` 起 VMM → 等 `vmm.ping` 通 → `PUT vm.create`
|
||
(VmConfig)→ `PUT vm.boot` → 等 guest SSH 就绪(默认
|
||
120 秒)才跑 host.py;超时关 VM 并报分类失败。直接启动
|
||
命令(`build_vm_command`)只作手动等价写法,执行器不走它。
|
||
暂停/快照/休眠:`pause_vm`/`resume_vm` 走 ch-remote;
|
||
`restore_vm` 先 restore 再显式 resume(恢复后默认 paused,
|
||
不 resume 不算醒);CHV 无原生 hibernate 端点,`hibernate_vm`
|
||
用 pause + snapshot + shutdown 等价实现,恢复走 restore。
|
||
预热池 `WarmPool`:预热条目是一次性干净快照 VM,acquire
|
||
消耗、release 一律销毁,绝不跨任务复用脏 VM;池空回落
|
||
冷启动,绝不降级 Docker/Local;池状态另落
|
||
`state_dir/warm-pool.json` registry,worker 重启后载入的
|
||
条目一律当孤儿、只能 `reconcile_orphans()` 清理,绝不
|
||
重新发给任务,registry 损坏时 quarantine 成 `.corrupt` 并
|
||
从空池开始。SSH host key 按 VM 钉死(#216 的
|
||
`StrictHostKeyChecking=no` + `/dev/null` 已废除):每 VM
|
||
在 `state_dir/<vm>/known_hosts` 有独立文件,首次启动经
|
||
ssh-keyscan 在本机回环端口捕获 key 写入该文件(首次信任
|
||
TOFU,只防首次之后的替换,不验证首次本身),此后 SSH
|
||
一律 `StrictHostKeyChecking=yes` + 该文件校验;客机用户
|
||
(`CHV_SSH_USER`,默认 root,显式空串直接拒绝)与端口
|
||
(`CHV_SSH_PORT`,默认 2222,1-65535)为显式配置。
|
||
`execute()` 只读已运行 VM:未运行报 `vm_not_running`
|
||
分类失败,不为一次 status 起 VM。长任务中途死亡
|
||
(传输异常、或结果已 failed 且登记 VMM 进程已死)判
|
||
`interrupted` 而非笼统 failed;中途心跳经评估未做
|
||
(host.py 单次 SSH 长回合无客内进度信号可采,另起轮询
|
||
只能证明 VM 活着)。关闭与清理:任务结束/
|
||
失败/取消时 `shutdown_vm`(ch-remote shutdown + 杀进程 +
|
||
删 socket),`cleanup_stale_vms` 处理已死/已关/socket 残留
|
||
三种情况。审计:VM 的 create/boot/ready/pause/resume/
|
||
snapshot/restore/shutdown/destroy/failed/recycled 均经
|
||
audit_callback 发出,worker 接线后转 AuthStore 审计;失败
|
||
带 kind 分类(kvm_missing/image_missing/guest_not_ready/
|
||
api_timeout/host_capacity_exceeded/disk_quota_exceeded 等),
|
||
不笼统 failed。资源与成本:每 VM 的 CPU/内存/磁盘配额、
|
||
超时、最大轮次(goal 上限 100)复用 worker 现有 quota 语义,
|
||
不另起一套计费;空闲休眠省成本;autoscaler 按 executor 过
|
||
滤队列深度并透传 `--executor` 给拉起的 worker。网络与治理:
|
||
VM 出口默认必须过 CHV_EGRESS_PROXY 白名单代理(未设且未
|
||
显式允许直连时 preflight 直接失败);审批门仍走任务的
|
||
approval 与 server 的 merge_gate,VM 内 agent 不能自批。
|
||
配置(环境变量,见该类 docstring 全表):CHV_BINARY、
|
||
CHV_KERNEL、CHV_ROOTFS(或 CHV_IMAGE 别名)、CHV_CPU、
|
||
CHV_MEM、CHV_STATE_DIR、CHV_SSH_KEY/USER/PORT、CHV_CMDLINE、
|
||
CHV_NET(直接命令用)/CHV_NET_JSON(API 用)、CHV_REMOTE、
|
||
CHV_MAX_VMS、CHV_DISK_MODE(copy 默认/reuse 仅实验)、
|
||
CHV_DISK_GB、CHV_EGRESS_PROXY、CHV_TASK_TIMEOUT、
|
||
CHV_SNAPSHOT_URL、CHV_WARM_POOL_SIZE、CHV_API_TIMEOUT、
|
||
CHV_READY_TIMEOUT/INTERVAL。默认关闭:server 默认执行器仍
|
||
是 docker,worker 默认 local,CHV 只在任务创建时显式选
|
||
`executor=cloud-hypervisor` 且有 CHV worker 认领时才跑;
|
||
任务内 LLM 绝不能发现/调用它(调度层选择,只在创建层可见:
|
||
POST /tasks 可选值、本文档、控制台选项,不新增任务内 MCP/
|
||
工具)。真实验证边界:未做真实 VM 启动验证——生产主机
|
||
本身是 CVM、无 /dev/kvm、无嵌套虚拟化,本 VM 容器内同样
|
||
无 /dev/kvm;真 VM、快照恢复、压测、安全审计均在
|
||
`docs/chv-production-readiness.md` 里标为待办,绝不声称
|
||
已生产可用。客机镜像内容清单与构建骨架见 `chv-guest/`。
|
||
- 自动 PR(默认关闭,显式触发,`POST /tasks/{id}/pr`,需项目
|
||
maintainer 以上权限 approve_merge):仅当任务状态为 merged 时
|
||
可调(其它状态 409;任务不存在 404;权限不足 403)。逻辑按顺序
|
||
检查(见 `cloud/pr.py`):项目 auto_pr 不为 1 时返回 200 且
|
||
pr_skipped=true、reason=auto_pr_disabled,不失败、不推;本地
|
||
没有对应 host 任务 worktree(`<仓库根>/work/host-worktrees/
|
||
<host 任务名>`,host 任务名 = cloud 任务 id 经安全字符过滤,
|
||
分支名 = host/<host 任务名>,与 sandbox._host_name 同规则)时
|
||
reason=no_worktree;worktree 里没有 git remote origin 时
|
||
reason=no_remote;本机没有 gh 二进制时 reason=no_gh;gh 未登录
|
||
(gh auth status 非零)时 reason=gh_not_authenticated;以上全过
|
||
才先把 worktree 改动提交到分支上(worktree 必须正好在
|
||
host/<host 任务名> 上,否则直接失败;nothing to commit 时跳过;
|
||
缺 git 身份时回退 cloud/cloud@local 再试),再 push 分支 +
|
||
建 PR,成功后把 pr_url/pr_number 写回任务
|
||
记录并记审计 pr_created,返回 200 含 pr_url。App-token
|
||
路径:项目 repo_url 能解析出 github.com 仓库名且库里有该
|
||
仓库的 App 安装记录时,端点现换 installation token 走 App
|
||
路径(响应里 auth_via=app):push 用 git credential helper,
|
||
token 只进子进程环境变量 GIT_TOKEN(不进命令行参数、不进
|
||
文件,helper 命令里只有变量名),建 PR 走 GitHub REST API
|
||
(先读仓库 default_branch 当 base,不猜 main);没有安装
|
||
记录或换 token 失败时自动回落原来的 gh + PAT 路径
|
||
(auth_via=pat),不因此报错;App 没装的仓库与 gh 路径
|
||
的行为与原来完全一致。子进程超时:push、pr create、
|
||
commit、remote 查询、gh 检查全部走带超时的子进程调用
|
||
(默认 120 秒,`CLOUD_PR_SUBPROCESS_TIMEOUT` 可改);
|
||
远端通路卡死时 handler 线程在时限内结束、返回 502,
|
||
任务保持 merged、可安全重调,不再永久挂住。push 或 pr create
|
||
失败时返回 502 且错误文本写清是哪一步(commit、push 还是
|
||
pr_create),
|
||
任务状态保持 merged 不变,绝不把任务标 failed。顺序:任务
|
||
completed -> POST /tasks/{id}/merge 改状态 -> POST
|
||
/tasks/{id}/pr,本地 host.py merge 会删分支和 worktree、须在
|
||
PR 之后做,此后调 PR 会跳过 no_worktree。worker 不自动推:
|
||
自动 PR 只由显式调本端点触发,默认自动推会把测试任务也推到远端。
|
||
projects 表新增 `auto_pr` 列(默认 0,老库幂等补列);
|
||
`POST /projects` 接受可选 auto_pr(只接受 0/1,非法值 400);
|
||
本轮没有现成的 PATCH /projects/{id} 路由,auto_pr 只能在创建时
|
||
指定,建后改它需直接改库或重建项目,不要新造一个只为改 auto_pr
|
||
的路由。gh 的登录凭证由 gh 自己管,控制面不存、不打任何 token。
|
||
- 仍未做(不要宣称已做):厂商专属适配(ECS、Cloud Run 等,需
|
||
用户选厂商后另做);默认自动推(本轮明确不做);主机上 gh 的安装
|
||
与登录、远端写凭证(控制面不存;PAT 路径的自动 PR 真实成功
|
||
仍需它们,App-token 路径不需要 gh);
|
||
多 worker 的并发容量 enforcement(capacity 目前只是注册表展示,
|
||
run_loop 仍串行);worker 经 HTTP 发心跳的凭证供给(worker 直接
|
||
写库时不需要,外部 worker 走 HTTP 时需自带 API key)。
|
||
|