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:
1 parent
d3e588e4b3
commit
306cdf0d0c
8 files changed
+1256
-34
No files matched your search
+91
-7
@@ -593,14 +593,98 @@ def _docker_default_runner(cmd, timeout=None):
|
||||
|
||||
|
||||
class CloudExecutor(Executor):
|
||||
"""云端执行器(占位,未接通)。
|
||||
"""云端执行器:可插拔的通用 HTTP runner 接口(厂商无关)。
|
||||
|
||||
execute(task) 一律 raise RuntimeError,绝不假装已接通云端。
|
||||
接通前提:真实云账号 + 预算上限配置,届时由控制面注入凭证与
|
||||
限额后再替换本占位实现。
|
||||
本轮不假装接通某个云厂商(ECS、Cloud Run 等专属适配明确
|
||||
不在范围)。它只做一件事:把任务 POST 到一个配置好的通用
|
||||
runner endpoint,endpoint 返回任务状态,本类据此返回结果。
|
||||
|
||||
配置(只走参数或环境变量,不写文件):
|
||||
- endpoint:显式参数 > CLOUD_RUNNER_ENDPOINT;未设时 execute
|
||||
一律 raise RuntimeError,绝不静默降级到 Docker/Local。
|
||||
- 认证:auth_header 参数或 CLOUD_RUNNER_AUTH_HEADER 给 header
|
||||
名;token 值只从环境变量取(CLOUD_RUNNER_AUTH_TOKEN_ENV
|
||||
指定的变量名,默认 CLOUD_RUNNER_AUTH_TOKEN),只放进请求头,
|
||||
绝不写进日志、错误文本或返回结果。
|
||||
- 请求体只有 id、prompt、repo_url(task["repo"]),不含任何密钥。
|
||||
- http_client 供单测注入:http_client(url, payload, headers)
|
||||
返回带 status 字段的 dict(或具 .status/.json 的对象);
|
||||
真实路径走 urllib.request。
|
||||
- dispatch_task/run_task:worker 的 run_one 走 dispatch_task,
|
||||
这里直接委托给 execute,保证 cloud worker 池能真正派发。
|
||||
"""
|
||||
|
||||
def __init__(self, endpoint=None, http_client=None, auth_header=None,
|
||||
auth_token_env=None, env=None):
|
||||
self._env = env if env is not None else os.environ
|
||||
self.endpoint = endpoint or self._env.get("CLOUD_RUNNER_ENDPOINT")
|
||||
self.http_client = http_client
|
||||
self.auth_header = auth_header or self._env.get(
|
||||
"CLOUD_RUNNER_AUTH_HEADER")
|
||||
self.auth_token_env = auth_token_env or self._env.get(
|
||||
"CLOUD_RUNNER_AUTH_TOKEN_ENV") or "CLOUD_RUNNER_AUTH_TOKEN"
|
||||
|
||||
def _headers(self):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.auth_header:
|
||||
try:
|
||||
token = self._env.get(self.auth_token_env)
|
||||
except Exception:
|
||||
token = None
|
||||
if token:
|
||||
headers[self.auth_header] = token
|
||||
return headers
|
||||
|
||||
def _post(self, payload):
|
||||
if self.http_client is not None:
|
||||
return self.http_client(self.endpoint, payload, self._headers())
|
||||
import urllib.request
|
||||
req = urllib.request.Request(
|
||||
self.endpoint,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers=self._headers(), method="POST")
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
def execute(self, task) -> dict:
|
||||
raise RuntimeError(
|
||||
"cloud executor not connected: missing cloud account "
|
||||
"and budget limit")
|
||||
if not isinstance(task, dict) or task.get("id") is None:
|
||||
raise ValueError("任务缺少 id")
|
||||
if not self.endpoint:
|
||||
raise RuntimeError(
|
||||
"cloud runner not configured: set CLOUD_RUNNER_ENDPOINT "
|
||||
"to a generic runner endpoint; no cloud account/runner "
|
||||
"configured, refusing to silently fall back to "
|
||||
"Docker/Local")
|
||||
payload = {
|
||||
"id": task["id"],
|
||||
"prompt": task.get("prompt") or "",
|
||||
"repo_url": task.get("repo"),
|
||||
}
|
||||
result = self._post(payload)
|
||||
if isinstance(result, dict):
|
||||
status = result.get("status")
|
||||
stdout = result.get("stdout", "")
|
||||
else:
|
||||
status = getattr(result, "status", None)
|
||||
try:
|
||||
stdout = result.json().get("stdout", "")
|
||||
except Exception:
|
||||
stdout = ""
|
||||
if status == "completed":
|
||||
final = "completed"
|
||||
else:
|
||||
final = "failed"
|
||||
return {
|
||||
"executor": "cloud",
|
||||
"task_id": task["id"],
|
||||
"status": final,
|
||||
"stdout": _truncate_stdout(stdout),
|
||||
"returncode": 0 if final == "completed" else 1,
|
||||
"host_status": None,
|
||||
}
|
||||
|
||||
def dispatch_task(self, task) -> dict:
|
||||
return self.execute(task)
|
||||
|
||||
def run_task(self, task) -> dict:
|
||||
return self.execute(task)
|
||||
Reference in new issue
Block a user