云端 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:
1 parent
26e73124fb
commit
5ee9d291dd
8 files changed
+965
-27
No files matched your search
+73
-6
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user