Files
codex-app-server/cloud/README.md
T
host 63823fc2e3 逐任务真实成本:上游用量事件 + 价目表计价
此前每任务只记一条 estimated(字符数/4,金额为空),注释称
host 协议不暴露真实 token 数。本轮实测推翻了该前提:
- account/usage/read 在中继上是死的(要求 ChatGPT 账号登录),
  不是来源,已在 cost.py docstring 写明死胡同
- app-server 每上游补全发 thread/tokenUsage/updated,last 是该次
  补全的用量;中继 5 调用回合实测 last 之和等于最后 total
  (input/output/cached/reasoning 四路都对得上)

实现:host.py run_turn 从事件窗口按 threadId+turnId 汇总用量,
重试/goal 各轮累加,dispatch/run JSON 与 goal 用量行透传;
sandbox 三路解析(dispatch JSON 解析改用完整 stdout,顺带修
了长 text 下 failed 被误判 completed 的潜 bug);worker 每任务
记一条 actual(重试/fallback 用量累加进同一条),无用量时回落
estimated;cost.py 价目表(默认 OpenAI 挂牌价,env 可覆盖,
无价模型 usd 记 None、报表进 unpriced_actual_tokens 而非 0 美元),
cost_records 加 cached_input_tokens 列(SQLite/PG 两支迁移);
报表与按天行新增 cached_input_tokens/unpriced_actual_tokens。
测试:host 32、cloud 483 全过。
2026-10-26 18:41:27 +08:00

923 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 云端控制面(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,未传表示任意 worker 可认领);
`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 优先于内置端。
- 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?}`;无权限 `403`;同 `project_id + idempotency_key` 已存在则返回已有任务(`200`,不建重复);否则建 `queued` 任务并记审计 `create_task`,返回 `201`;`verify_command` 非空即 goal 模式(见下文“goal 模式”),`goal_max_rounds` 缺省 3、须 ≥1 |
| `GET /tasks?project_id=...` | 该项目 `read` | 返回该项目任务列表:`{"tasks": [...]}`;无权限 `403` |
| `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` | 认证即可 | 调 `sweep_stale_tasks(db_path, threshold_seconds=300)`(body 可传 `threshold_seconds` 覆盖默认值);把 `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` 表。两者配合
才算一个完整项目,不要只调一处。
## 测试
```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 秒),与另外
两个参数不要混淆:`--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。
## 运维批(本轮新增:部署、恢复演练、监控、心跳、多用户验证)
细则在 `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。
- 自动 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)。