Production-grade: webhook execution, Postgres backup, rate limit, worker timeout/retry

GitHub webhook: delivery-id dedup persisted in webhook_deliveries,
/fix-review creates a queued task with audit (no model run in the
webhook itself), check_run/check_suite parsing without mutating
task state (CI backfill still via pr-checks).
Control plane: Postgres pg_dump backup branch + POST /admin/backup
(maintainer+, password only via PGPASSWORD env), per-key/IP rate
limit (default 120/min, 429), health returns backend + db_ok,
metrics by_status always has all seven status keys.
Worker: per-task timeout (default 1800s, --task-timeout), timeout
is failed not interrupted, limited retries (default 1, backoff,
fallback failures not re-retried), stale codex- container cleanup
on loop/CLI start.
Tests: cloud 271 (267 pass, 4 skipped are pre-existing real-
Postgres tests), host 27 pass.
This commit is contained in:
host committed 2026-10-26 03:55:07 +08:00
1 parent 86160ad175
commit 33841355b5
9 files changed
+1366 -52

No files matched your search

+82 -4
View File
@@ -273,6 +273,7 @@ psycopg,写明“需安装 psycopg”),绝不静默回落到 SQLite,
| `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` |
@@ -299,14 +300,34 @@ psycopg,写明“需安装 psycopg”),绝不静默回落到 SQLite,
函数 `sweep_stale_tasks(db_path, threshold_seconds=300) -> list` 独立
可单测,不依赖 HTTP;回收动作的审计 `user_id` 记为 `"system"`。
### webhook 端点(加分项,已实现;只解析,不执行任务)
### webhook 端点(GitHub 已从“只解析”升级为“可执行且幂等”;Slack 仍只解析)
- `POST /webhooks/github`:用 `webhooks.verify_github_signature`
验签(请求头 `X-Hub-Signature-256`),secret 从环境变量
`GITHUB_WEBHOOK_SECRET` 读。未设 secret 返回 `503`;验签失败
返回 `401`;成功返回 `parse_github_pr_comment` 的结果(PR 评论含
`/fix-review` 时返回 fix_review 触发点,否则 `{"result": null}`)。
真正执行 fix-review 由控制面后续调 host.py 完成,本端点不执行。
返回 `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`;
@@ -461,3 +482,60 @@ interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳
真实 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);
取消不重试,已记 fallback 事件的失败不做同模型重试(fallback 本身
就是模型级的重试机制),其它失败(含 worker 超时)重试前按固定
退避等待;重试次数记在审计(`worker_retry`)与最终 `error_text`
的 `[retries=N]` 前缀里,不另起新表;重试耗尽后任务才最终 failed,
成本记录与 usage_turns 只按最终结果记一次,不按尝试次数重复记。
- 容器清理兜底:`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)。
- 仍未做(不要宣称已做):webhook 建的任务仍需 worker 认领执行,
worker 的 repo 仍是配置里的 repo,不是任意 GitHub 仓库的自动克隆;
`CloudExecutor` 仍是占位符;公网 https 入口仍需部署侧反向代理 +
TLS;Docker 超时杀容器与真实 `pg_dump`、真实 GitHub webhook 送达
仍需在真实环境验证(本轮只用 fake runner / 本地 HTTP 单测)。