DockerExecutor gains a runtime parameter (passed as docker --runtime in build_run_command, after --pids-limit); worker CLI gains --docker-runtime, wired through _build_cli_executor. Unset or empty means no --runtime flag, so existing tasks keep docker default (runc); an unregistered runtime fails container start (fail closed), it never silently falls back. Tests: runsc appears in the command, default/empty omit it, CLI wiring passes through. README documents the flag and corrects a stale multi-tenant note (host kernel does not ban userns; bwrap fails inside Docker containers on nested-pidns proc mount).
云端控制面(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 时自动改主分支。
启动方式
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):
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 写进文件或聊天记录):
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 授权码流程):
GET /oauth/{provider}/login?client_id=...&redirect_uri=...返回{provider, state, authorization_url};state 已入库 (10 分钟过期、一次性,provider / redirect_uri / client_id 都随 state 一起存下),把用户送到 provider 授权页。- 用户授权后 provider 重定向到本站
GET /oauth/{provider}/callback?code=...&state=...(真实浏览器 流程只带 code + state;client_id / redirect_uri 由 server 从 state 记录取回,不需要 provider 额外带回)。 - 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)。 - 成功页(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、GoogleGOOGLE_OAUTH_CLIENT_SECRET),绝不写文件、绝不硬编码、 绝不出现在授权 URL 或成功页里;secret 缺失时回调明确报client_secret_missing,不会以空 secret 去撞真实端点。 - 本站控制面必须有公网可达的 https 回调地址:目前 server 默认
只绑
127.0.0.1:8765,没有公网入口、没有 TLS,这一步是部署 前置——需要在部署侧配反向代理 + TLS 证书并把公网域名指到 server,再把该域名下的回调地址填进 OAuth 应用的注册信息里。 - 登录后 API key 的保存与轮换规则:新用户拿到创建时生成的 key; 已存在用户每次 OAuth 登录都会轮换 key(旧 key 立即失效), 因此不要把旧 key 存到多处长期复用;key 明文只在回调成功页/ 响应里出现一次,丢了只能重新走一次 OAuth 登录拿新 key。
- 仍缺(非部署前置,但同样未完成):已有本站账号的绑定流程 (目前是确定性用户名新建/复用,不做账号绑定)。
推送凭证清单
- webhook / slack:需要可注入 sender(worker 侧),HTTP 层没有 sender 时明确报错,不静默成功。
- webpush:需要 VAPID 密钥对(私钥绝不进仓库)。
- apns:需要 APNs 证书。
- fcm:需要 FCM key。 以上三者目前只有接口 + 缺凭证报错,没有真实发送端。
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 文件里,新库是空的。完整
步骤是:
- 装驱动(psycopg v3 优先):
pip install "psycopg[binary]" # 其次才用 psycopg2:pip install psycopg2-binary - 在 Postgres 里建库与用户(示例库名与用户名请按实际部署改,
密码绝不写进仓库文件):
注意:表由应用启动时
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。 - 设环境变量并启动(
--db参数在 PG 模式下被忽略,连接 参数只走环境变量;--db-backend postgres与环境变量等价):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 - 迁旧数据(可选,须在启动建表之后、切流量之前跑;跑两次
不重复插,第二次各表 inserted 应为 0):
迁移脚本逻辑在
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,避免后续自动生成撞主键。 - 回退到 SQLite:把
CLOUD_DB_BACKEND改回sqlite(或 删掉它,默认即 sqlite),并把--db指回原来的 SQLite 文件即可。但注意:切到 Postgres 之后新写的数据只在 Postgres 里,SQLite 文件里没有——回退不会自动把 PG 数据 导回 SQLite;如需回迁,须另写 PG -> SQLite 脚本(本轮 未提供,不要假装存在)。 - 备份: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?};无权限 403;同 project_id + idempotency_key 已存在则返回已有任务(200,不建重复);否则建 queued 任务并记审计 create_task,返回 201 |
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},绝不返回数据库密码或备份文件内容 |
POST /tasks/{id}/cost |
该任务所属项目 read |
成本聚合:{task_id, project_id, total_tokens, usd_micros, turns, fallback_count, actual_tokens, estimated_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);HTTP 层无注入 sender:webhook/slack 报“无 sender”,webpush/apns/fcm 报缺凭证,都不静默成功;真实发送端由 worker 注入 sender 后调 push.dispatch_event |
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 表。两者配合
才算一个完整项目,不要只调一处。
测试
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, 否则起容器失败;生产切换前须经一次性 worker 模型任务验证);--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 模型失败原因。边界:真实 token 成本仍是估算(host 协议 不暴露真实 token 数),fallback 不改变成本来源,只改变实际跑的 模型与actual_model/fallback_count的记录。 - 容器清理兜底:
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 池的任务。 - 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;以上全过 才 push 分支 + gh pr create,成功后把 pr_url/pr_number 写回任务 记录并记审计 pr_created,返回 200 含 pr_url。push 或 pr create 失败时返回 502 且错误文本写清是哪一步(push 还是 pr_create), 任务状态保持 merged 不变,绝不把任务标 failed。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 的安装 与登录、远端写凭证(控制面不存,自动 PR 的真实成功路径仍需它们); 多 worker 的并发容量 enforcement(capacity 目前只是注册表展示, run_loop 仍串行);worker 经 HTTP 发心跳的凭证供给(worker 直接 写库时不需要,外部 worker 走 HTTP 时需自带 API key)。