HA 阶段 1:worktree_host + 跨主机 /pr 队列化 (#50)

* 部署自动化:唯一发布链路 deploy.py + host_deploy.sh,sha/commit 双重验真,失败回滚

旧链路三次栽过的坑:通用 tar 路径导致旧包误部署、传输不验 sha、
主机脚本多份漂移、health 通过不等于新代码上线。本单把四点变硬门禁:
- 产物唯一命名 codex-app-server-<commit>.tar.gz + .sha256,远端唯一路径
- git archive 产物 + DEPLOYED_COMMIT 清单,默认拒绝脏工作区
- 传输链 scp -> scp_run -> chunk 分片,每路传完远端验 sha,不符删掉重来
- 主机侧唯一脚本:先验 sha/清单,staging 解包 + 编译检查,换目录上线,
  work/ 原样保留,health + 上线 commit 不符即回滚并退出非零
- deploy.py 成功后独立 ssh 再读一次 live 清单二次核对
- CI:host-verify 加 deploy/tests;release.yml 只在手动/tag + production
  environment 审批时部署,主干推送绝不自动部署生产
- deploy.sh 标记为旧链路弃用
- health 的 commit 字段已在父提交 HA 阶段 0 里随 server.py 落地,
  本分支整体包含它;commit 为空时只靠文件核对,门禁同样成立
测试:host 32、cloud 487(4 跳过)、deploy 9,全部通过;
host_deploy.sh 仿真覆盖成功/坏 sha/清单不符/health commit 不符回滚。
生产实机部署未做,等确认后再用新链路推。

* HA stage 1: worktree_host + queued cross-host PR jobs

---------

Co-authored-by: host <host@test.local>
This commit is contained in:
openqjandhost authored and GitHub committed 2026-10-27 00:05:18 +08:00
1 parent 455513dd33
commit fc8e337d0f
14 files changed
+1639 -67

No files matched your search

+34 -57
View File
@@ -42,6 +42,7 @@ import html
import json
import logging
import os
import socket
import sqlite3
import threading
import time
@@ -61,8 +62,9 @@ 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,
parse_git_remote_owner_repo)
from cloud.pr import PrSkipped, PrStepError
from cloud.pr_exec import (enqueue_pr_job, execute_task_pr,
find_open_pr_job)
from cloud.workers import (
heartbeat_worker as _registry_heartbeat,
list_workers as _registry_list,
@@ -103,7 +105,6 @@ from cloud.github_app import (
record_installation_repos,
remove_installation,
remove_installation_repos,
resolve_app_token,
save_app_config,
set_fix_review_task_id,
)
@@ -322,6 +323,9 @@ def migrate_tasks_table(conn):
"goal": "TEXT",
"verify_command": "TEXT",
"goal_max_rounds": "INTEGER",
# HA 阶段 1:认领任务的 worker 所在主机名;/pr 据此判断
# worktree 在本机(同步跑)还是别机(排 pr 任务给该主机)。
"worktree_host": "TEXT",
}
for name, coltype in new_cols.items():
if name not in cols:
@@ -456,6 +460,7 @@ def _task_to_dict(row):
"goal": _row_get(row, "goal"),
"verify_command": _row_get(row, "verify_command"),
"goal_max_rounds": _row_get(row, "goal_max_rounds"),
"worktree_host": _row_get(row, "worktree_host"),
}
@@ -1638,6 +1643,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
成功时把 pr_url/pr_number 写回任务并记审计 pr_created。
推送:成功时发 review-requested,落盘失败时发 merge-failed
(均为 best-effort,不影响 PR 响应;跳过时不发)。
HA 阶段 1:任务的 worktree_host 与本机不同时,不直接跑,
而是排 pr 任务给该主机(返回 200 + pr_queued,同一任务
未完成的任务去重),由该主机上的 local worker 执行;
worktree 在本机(或未记录)时同步跑,与旧行为一致。
App-token:项目 repo_url 能解析出 github.com 仓库名且
库里有该仓库的 App 安装记录时,现换 installation token
走 App 路径(响应里 auth_via=app),否则走原来的 gh +
@@ -1660,51 +1669,36 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"status": task["status"],
})
return
conn = self._db()
try:
prow = conn.execute(
"SELECT auto_pr, repo_url FROM projects WHERE id = ?",
(task["project_id"],)).fetchone()
finally:
conn.close()
auto_pr = 0
repo_url = None
if prow is not None:
try:
auto_pr = prow["auto_pr"] or 0
except (KeyError, IndexError, TypeError):
auto_pr = 0
try:
repo_url = prow["repo_url"]
except (KeyError, IndexError, TypeError):
repo_url = None
app_token = None
auth_via = "pat"
repo_full_name = None
if repo_url:
parsed = parse_git_remote_owner_repo(repo_url)
if parsed:
repo_full_name = "%s/%s" % parsed
if repo_full_name:
# HA 阶段 1:worktree 在别机时不直接跑,排 pr 任务给该主机;
# 本机(或 worktree_host 未记录的老任务)走同步路。
worktree_host = task.get("worktree_host")
if worktree_host and worktree_host != socket.gethostname():
conn = self._db()
try:
app_token = resolve_app_token(
conn, repo_full_name,
http_client=getattr(
self.server, "pr_app_token_client", None))
existing = find_open_pr_job(conn, task_id)
if existing is not None:
job_id = existing["id"]
else:
job_id = enqueue_pr_job(
conn, task_id, task["project_id"], worktree_host)
finally:
conn.close()
if app_token:
auth_via = "app"
self._send_json(200, {
"task_id": task_id, "status": task["status"],
"pr_queued": True, "pr_job_id": job_id,
"worktree_host": worktree_host,
})
return
try:
result = create_task_pr(
task_id, auto_pr,
result = execute_task_pr(
self.server.db_path, task_id, user_id,
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),
app_token=app_token,
app_token_client=getattr(
self.server, "pr_app_token_client", None),
github_api=getattr(self.server, "pr_github_api", None))
except PrSkipped as exc:
self._send_json(200, {
@@ -1713,41 +1707,24 @@ 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})
# merge-failed 事件已在 execute_task_pr 内发出,这里只映射响应。
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._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"],
auth_via=auth_via))
auth_via=result["auth_via"]))
def _heartbeat_task(self, user_id, task_id):
row = self._fetch_task(task_id)