Cloud dispatch pool + auto PR (opt-in, skip clearly)

Worker pool: workers table + register/heartbeat (heartbeat is
registration, no 404), GET /workers with alive (120s default),
POST /workers/heartbeat (X-API-Key, missing worker_id 400).
Tasks gain executor (local/docker/cloud, None = any worker);
worker claim filters by executor in pick_next and in the CAS
WHERE clause, CAS semantics unchanged; CloudExecutor _name
now correctly returns cloud, not local.
CloudExecutor: generic HTTP runner interface (endpoint from
explicit arg or CLOUD_RUNNER_ENDPOINT, auth header name only,
token value only from env into the request header, never into
logs/body/errors); no endpoint configured = explicit failure,
never silent downgrade. Vendor adapters (ECS/Cloud Run) are
out of scope, documented as such.
Auto PR: projects.auto_pr (0/1, create-time only, no PATCH
route exists); POST /tasks/{id}/pr (maintainer+ via
approve_merge) only for merged tasks (else 409); skip reasons
in order: auto_pr_disabled, no_worktree, no_remote, no_gh,
gh_not_authenticated — all return 200 with pr_skipped, task
stays merged, no push attempted; push/pr-create failure
returns 502 with step, task stays merged; success writes
pr_url/pr_number and audits pr_created. No default auto-push
from the worker. Remote URLs in errors are scrubbed of
userinfo; gh credentials are managed by gh itself, never
stored by the control plane.
Tests: host 27 pass, cloud 323 pass (8 skipped are
environment-dependent real-Postgres tests).
This commit is contained in:
host committed 2026-10-26 04:57:37 +08:00
1 parent d3e588e4b3
commit 306cdf0d0c
8 files changed
+1256 -34

No files matched your search

+176 -7
View File
@@ -59,6 +59,11 @@ from cloud.db import PostgresIntegrityError as _PgIntegrityError
from cloud.db import PostgresOperationalError as _PgOperationalError
from cloud.kb import KBStore
from cloud.memory import MemoryStore
from cloud.pr import PrSkipped, PrStepError, create_task_pr
from cloud.workers import (
heartbeat_worker as _registry_heartbeat,
list_workers as _registry_list,
)
from cloud.oauth import (
OAuthError,
OAuthStateStore,
@@ -227,6 +232,9 @@ def migrate_tasks_table(conn):
"started_at": "REAL",
"finished_at": "REAL",
"child_pid": "INTEGER",
"executor": "TEXT",
"pr_url": "TEXT",
"pr_number": "INTEGER",
}
for name, coltype in new_cols.items():
if name not in cols:
@@ -246,6 +254,8 @@ def migrate_projects_table(conn):
"PRAGMA table_info(projects)").fetchall()}
if "repo_url" not in cols:
conn.execute("ALTER TABLE projects ADD COLUMN repo_url TEXT")
if "auto_pr" not in cols:
conn.execute("ALTER TABLE projects ADD COLUMN auto_pr INTEGER")
def _is_postgres_backend():
@@ -275,7 +285,8 @@ def init_db(db_path):
"name TEXT, "
"owner_id TEXT, "
"created_at REAL, "
"repo_url TEXT)"
"repo_url TEXT, "
"auto_pr INTEGER)"
)
conn.execute(
"CREATE TABLE IF NOT EXISTS tasks("
@@ -346,6 +357,9 @@ def _task_to_dict(row):
"started_at": _row_get(row, "started_at"),
"finished_at": _row_get(row, "finished_at"),
"child_pid": _row_get(row, "child_pid"),
"executor": _row_get(row, "executor"),
"pr_url": _row_get(row, "pr_url"),
"pr_number": _row_get(row, "pr_number"),
}
@@ -358,6 +372,9 @@ def _project_to_dict(row):
# 老库行缺 repo_url 列时 _row_get 给 None,不炸(列本身由
# migrate_projects_table 在 init_db 时补,老行读到也是 None)。
"repo_url": _row_get(row, "repo_url"),
# auto_pr 默认 0(关闭):老行缺列时 None 按 0 计,绝不因
# 迁移就把老项目默认改成自动推。
"auto_pr": _row_get(row, "auto_pr") or 0,
}
@@ -569,6 +586,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._create_project(user_id)
elif path == "/projects" and method == "GET":
self._list_projects(user_id)
elif path == "/workers" and method == "GET":
self._workers_list(user_id)
elif path == "/workers/heartbeat" and method == "POST":
self._workers_heartbeat(user_id)
elif path == "/metrics" and method == "GET":
self._metrics(user_id)
elif path == "/models" and method == "GET":
@@ -595,6 +616,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._cancel_task(user_id, rest[: -len("/cancel")])
elif rest.endswith("/merge") and method == "POST":
self._merge_task(user_id, rest[: -len("/merge")])
elif rest.endswith("/pr") and method == "POST":
self._task_pr(user_id, rest[: -len("/pr")])
elif rest.endswith("/heartbeat") and method == "POST":
self._heartbeat_task(user_id, rest[: -len("/heartbeat")])
else:
@@ -626,6 +649,18 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
# auto_pr 可选,默认 0(关闭):只接受 0/1(布尔 True/False
# 按 1/0 计),其它值 400 且不建项目。本轮没有现成的
# PATCH /projects/{id} 路由,auto_pr 只能在创建时指定,
# 建后改它需直接改库或重建项目(见 README)。
auto_pr_raw = body.get("auto_pr", 0)
if isinstance(auto_pr_raw, bool):
auto_pr = 1 if auto_pr_raw else 0
elif isinstance(auto_pr_raw, int) and auto_pr_raw in (0, 1):
auto_pr = auto_pr_raw
else:
self._send_json(400, {"error": "auto_pr must be 0 or 1"})
return
now = time.time()
conn = self._db()
try:
@@ -637,8 +672,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return
conn.execute(
"INSERT INTO projects(id, name, owner_id, created_at,"
" repo_url) VALUES(?, ?, ?, ?, ?)",
(project_id, name, user_id, now, repo_url),
" repo_url, auto_pr) VALUES(?, ?, ?, ?, ?, ?)",
(project_id, name, user_id, now, repo_url, auto_pr),
)
conn.commit()
project = {
@@ -647,6 +682,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"owner_id": user_id,
"created_at": now,
"repo_url": repo_url,
"auto_pr": auto_pr,
}
except (sqlite3.IntegrityError, _PgIntegrityError):
self._send_json(409, {"error": "project exists"})
@@ -671,7 +707,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
).fetchall()
}
rows = conn.execute(
"SELECT id, name, owner_id, created_at, repo_url"
"SELECT id, name, owner_id, created_at, repo_url, auto_pr"
" FROM projects ORDER BY created_at, id"
).fetchall()
finally:
@@ -1095,6 +1131,14 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
except (TypeError, ValueError):
self._send_json(400, {"error": "priority must be an integer"})
return
# executor 可选:None 表示任意 worker 可认领;指定时只能是
# local/docker/cloud,非法值 400 且不建任务。
executor = body.get("executor")
if executor is not None and executor not in (
"local", "docker", "cloud"):
self._send_json(400, {
"error": "executor must be local, docker or cloud"})
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"})
@@ -1120,10 +1164,11 @@ 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)"
" VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL)",
" created_at, updated_at, last_heartbeat, executor)"
" VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL, ?)",
(task_id, project_id, prompt, idempotency_key,
json.dumps(depends_on), priority, user_id, now, now),
json.dumps(depends_on), priority, user_id, now, now,
executor),
)
conn.commit()
except (sqlite3.IntegrityError, _PgIntegrityError):
@@ -1270,6 +1315,124 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
store.audit(user_id, task["project_id"], "merge_task", task_id)
self._send_json(200, task)
# -- worker 池 -----------------------------------------------------
def _workers_list(self, user_id):
"""列出注册 worker:任何已认证用户可读(worker 是基础设施
信息,不属于某个项目的数据,不按项目成员过滤)。"""
workers = _registry_list(self.server.db_path)
self._send_json(200, {"workers": workers})
def _workers_heartbeat(self, user_id):
"""worker 心跳:body 含 worker_id(必填,缺了 400)与可选
executor/capacity。心跳本身就是注册:worker_id 不存在时
自动注册,绝不 404。"""
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
worker_id = body.get("worker_id")
if not worker_id or not isinstance(worker_id, str):
self._send_json(400, {"error": "worker_id required"})
return
executor = body.get("executor")
capacity = body.get("capacity")
if capacity is not None:
try:
capacity = int(capacity)
except (TypeError, ValueError):
self._send_json(400, {"error": "capacity must be an integer"})
return
try:
worker = _registry_heartbeat(
self.server.db_path, worker_id,
executor=executor, capacity=capacity)
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
worker["alive"] = True
self._send_json(200, worker)
# -- 自动 PR(默认关闭,显式触发)----------------------------------
def _task_pr(self, user_id, task_id):
"""给已 merged 的任务建 PR(见 cloud/pr.py 的跳过规则)。
权限:项目 maintainer 以上(approve_merge,403);任务
不存在 404;非 merged 状态 409;跳过时返回 200 +
pr_skipped 与 reason,任务状态保持 merged 不变;push/pr
创建失败时返回 502 并写清哪一步失败,任务同样保持 merged。
成功时把 pr_url/pr_number 写回任务并记审计 pr_created。
"""
row = self._fetch_task(task_id)
if row is None:
self._send_json(404, {"error": "task not found"})
return
task = _task_to_dict(row)
with _auth_store(self.server.db_path) as store:
if not store.check_permission(
user_id, task["project_id"], "approve_merge"):
self._send_json(403, {"error": "forbidden"})
return
if task["status"] != "merged":
self._send_json(409, {
"error": "only merged tasks can create a PR",
"status": task["status"],
})
return
conn = self._db()
try:
prow = conn.execute(
"SELECT auto_pr FROM projects WHERE id = ?",
(task["project_id"],)).fetchone()
finally:
conn.close()
auto_pr = 0
if prow is not None:
try:
auto_pr = prow["auto_pr"] or 0
except (KeyError, IndexError, TypeError):
auto_pr = 0
try:
result = create_task_pr(
task_id, auto_pr,
worktrees_root=getattr(
self.server, "host_worktrees_dir", None),
git_runner=getattr(self.server, "pr_git_runner", None),
gh_runner=getattr(self.server, "pr_gh_runner", None),
gh_which=getattr(self.server, "pr_gh_which", None))
except PrSkipped as exc:
self._send_json(200, {
"task_id": task_id, "status": task["status"],
"pr_skipped": True, "reason": exc.reason,
})
return
except PrStepError as exc:
self._send_json(502, {
"error": exc.message, "step": exc.step,
"task_id": task_id, "status": task["status"],
})
return
now = time.time()
conn = self._db()
try:
conn.execute(
"UPDATE tasks SET pr_url = ?, pr_number = ?,"
" updated_at = ? WHERE id = ?",
(result["pr_url"], result["pr_number"], now, task_id),
)
conn.commit()
task = _task_to_dict(
conn.execute(
"SELECT * FROM tasks WHERE id = ?",
(task_id,)).fetchone())
finally:
conn.close()
with _auth_store(self.server.db_path) as store:
store.audit(user_id, task["project_id"], "pr_created",
result["pr_url"])
self._send_json(200, dict(task, pr_url=result["pr_url"],
pr_number=result["pr_number"],
branch=result["branch"]))
def _heartbeat_task(self, user_id, task_id):
row = self._fetch_task(task_id)
if row is None:
@@ -1857,6 +2020,12 @@ def create_server(db_path, host="127.0.0.1", port=8765, backend=None):
# 测试可注入 fake http_client / userinfo_client;绝不注入真实 secret。
server.oauth_http_client = None
server.oauth_userinfo_client = None
# 自动 PR 注入点:生产为 None(走真实 git/gh),测试可注入
# fake git/gh runner 与 worktree 根;绝不注入真实凭证。
server.pr_git_runner = None
server.pr_gh_runner = None
server.pr_gh_which = None
server.host_worktrees_dir = None
return server