云端 goal/merge/review 三事件:goal 循环 + PR 事件通知

此前 goal-unmet/merge-failed/review-requested 只有允许列表,
云端没有生产者(概念只在本地 host.py)。本轮给三者接真生产者:
- goal:POST /tasks 接受 goal/verify_command/goal_max_rounds
  (迁移补列,SQLite 与 Postgres 两支);带 verify_command 的
  任务走 host.py goal 循环(Local/Docker 执行器 goal_task,
  turn→验测→喂回重试,缺省 3 轮);达标 completed,未达标
  failed 且只发 goal-unmet、不重复发 failed;goal 模式下 worker
  级重试/fallback 关闭,同任务 /retry 会撞 host 任务名已存在
  (已在文档写明,须新建任务);CloudExecutor 无 goal 概念,
  如实失败不降级
- review-requested:POST /tasks/{id}/pr 建 PR 成功时发
  (payload 含 pr_url/pr_number)
- merge-failed:PR 落盘(push/建 PR)失败 502 时发(payload
  含 step/error),任务保持 merged、可重调 /pr;跳过时不发
测试:host 29、cloud 469 全过。
This commit is contained in:
host committed 2026-10-26 18:11:12 +08:00
1 parent 26e73124fb
commit 5ee9d291dd
8 files changed
+965 -27

No files matched your search

+73 -6
View File
@@ -40,6 +40,7 @@ import argparse
import hashlib
import html
import json
import logging
import os
import sqlite3
import threading
@@ -114,6 +115,10 @@ from cloud.webhooks import (
verify_slack_signature,
)
# PR 事件推送失败时记日志(systemd 下进 journal);handler 的
# log_message 被重写为静默(测试输出干净),所以不用它。
_server_log = logging.getLogger("cloud.server")
TERMINAL_STATUSES = frozenset(
{"completed", "failed", "interrupted", "merged", "merge-failed"}
)
@@ -245,9 +250,11 @@ def migrate_tasks_table(conn):
"""幂等迁移:给老库 tasks 表补新列,不丢数据。
新列:result_text、error_text、actual_model、started_at、
finished_at、child_pid。PRAGMA table_info 查现有列,缺哪个
补哪个;已迁移的库再跑一次是 no-op。worker 写执行结果、
server 读任务时都依赖这些列存在。
finished_at、child_pid、executor、pr_url、pr_number,以及
goal、verify_command、goal_max_rounds(goal 模式用)。
PRAGMA table_info 查现有列,缺哪个补哪个;已迁移的库再跑
一次是 no-op。worker 写执行结果、server 读任务时都依赖
这些列存在。
"""
cols = {row[1] for row in conn.execute(
"PRAGMA table_info(tasks)").fetchall()}
@@ -261,6 +268,9 @@ def migrate_tasks_table(conn):
"executor": "TEXT",
"pr_url": "TEXT",
"pr_number": "INTEGER",
"goal": "TEXT",
"verify_command": "TEXT",
"goal_max_rounds": "INTEGER",
}
for name, coltype in new_cols.items():
if name not in cols:
@@ -392,6 +402,9 @@ def _task_to_dict(row):
"executor": _row_get(row, "executor"),
"pr_url": _row_get(row, "pr_url"),
"pr_number": _row_get(row, "pr_number"),
"goal": _row_get(row, "goal"),
"verify_command": _row_get(row, "verify_command"),
"goal_max_rounds": _row_get(row, "goal_max_rounds"),
}
@@ -1222,6 +1235,29 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(400, {
"error": "executor must be local, docker or cloud"})
return
# goal 字段:verify_command 非空即 goal 模式(worker 走
# host.py goal 循环);goal 文本缺省用 prompt;轮次缺省 3
# (与 host.py 默认一致),非法值 400 且不建任务。
goal = body.get("goal")
if goal is not None and not isinstance(goal, str):
self._send_json(400, {"error": "goal must be a string"})
return
verify_command = body.get("verify_command")
if verify_command is not None and not isinstance(
verify_command, str):
self._send_json(400, {
"error": "verify_command must be a string"})
return
try:
goal_max_rounds = int(body.get("goal_max_rounds", 3))
except (TypeError, ValueError):
self._send_json(400, {
"error": "goal_max_rounds must be an integer"})
return
if goal_max_rounds < 1:
self._send_json(400, {
"error": "goal_max_rounds must be >= 1"})
return
idempotency_key = body.get("idempotency_key")
if idempotency_key is not None and not isinstance(idempotency_key, str):
self._send_json(400, {"error": "idempotency_key must be a string"})
@@ -1247,11 +1283,13 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
conn.execute(
"INSERT INTO tasks(id, project_id, prompt, status,"
" idempotency_key, depends_on, priority, created_by,"
" created_at, updated_at, last_heartbeat, executor)"
" VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL, ?)",
" created_at, updated_at, last_heartbeat, executor,"
" goal, verify_command, goal_max_rounds)"
" VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL, ?,"
" ?, ?, ?)",
(task_id, project_id, prompt, idempotency_key,
json.dumps(depends_on), priority, user_id, now, now,
executor),
executor, goal, verify_command, goal_max_rounds),
)
conn.commit()
except (sqlite3.IntegrityError, _PgIntegrityError):
@@ -1499,6 +1537,25 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(200, worker)
# -- 自动 PR(默认关闭,显式触发)----------------------------------
def _notify_pr_event(self, project_id, event_type, payload):
"""PR 事件推送:best-effort,失败只记日志,绝不破坏 PR 响应。
review-requested = PR 建成功(PR 在等评审);
merge-failed = 已 merged 任务的落盘(push/建 PR)失败,
任务状态保持 merged、可重调 /pr——事件说的是落盘失败,
不是状态变 merge-failed(云端 merge 端点只改状态,
没有真合并可失败)。
"""
try:
result = _push_dispatch(
self.server.db_path, event_type, project_id, payload)
except Exception as exc: # noqa: BLE001 -- 通知失败绝不炸 PR
_server_log.warning("pr notify %s failed: %s", event_type, exc)
return
if result.get("errors"):
_server_log.warning("pr notify %s errors: %s",
event_type, result["errors"])
def _task_pr(self, user_id, task_id):
"""给已 merged 的任务建 PR(见 cloud/pr.py 的跳过规则)。
@@ -1507,6 +1564,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
pr_skipped 与 reason,任务状态保持 merged 不变;push/pr
创建失败时返回 502 并写清哪一步失败,任务同样保持 merged。
成功时把 pr_url/pr_number 写回任务并记审计 pr_created。
推送:成功时发 review-requested,落盘失败时发 merge-failed
(均为 best-effort,不影响 PR 响应;跳过时不发)。
App-token:项目 repo_url 能解析出 github.com 仓库名且
库里有该仓库的 App 安装记录时,现换 installation token
走 App 路径(响应里 auth_via=app),否则走原来的 gh +
@@ -1582,6 +1641,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
})
return
except PrStepError as exc:
self._notify_pr_event(
task["project_id"], "merge-failed",
{"task_id": task_id, "step": exc.step,
"error": exc.message})
self._send_json(502, {
"error": exc.message, "step": exc.step,
"task_id": task_id, "status": task["status"],
@@ -1605,6 +1668,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"], "pr_created",
result["pr_url"])
self._notify_pr_event(
task["project_id"], "review-requested",
{"task_id": task_id, "pr_url": result["pr_url"],
"pr_number": result["pr_number"]})
self._send_json(200, dict(task, pr_url=result["pr_url"],
pr_number=result["pr_number"],
branch=result["branch"],