Live test found the gap: a nonexistent model fails in thread/start, host.py exits non-zero without JSON, so the executor result has host_status=None and the worker never fell back (task failed, no worker_fallback audit). When host_status is absent, worker now extracts an explicit signal from stdout/stderr/runner error: 404/429/5xx codes, or 'model not found' style text (counted as 404). Generic failures with no explicit code still do not fall back, and 401 in stderr still never falls back. Tests: fallback 9 pass, cloud 333 pass, host 27 pass.
云端控制面(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,不只改数据库状态。 - 网络白名单的诚实边界: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)。