Project repo_url: worker can run tasks from any git repo
Projects gain repo_url (validated: https:// or git@ only, no userinfo/password, no file:// or local paths; old DBs get the column via idempotent ALTER in both SQLite and Postgres init). Worker resolves a project's repo_url to a local checkout under --repos-dir (default ./repos) before dispatch: clone if absent, verify remote origin matches and fetch if present, never force reset/clean, never push (push needs write credentials the control plane does not store). Resolve failure fails the task with a clear error, never completed. Docker workers need repos_dir under a same-path extra mount (e.g. /var/lib/codex-app-server/repos). Also: driver-missing test is now mocked instead of depending on the host really having no psycopg driver installed. Deploy package: Caddyfile template exposing only /webhooks/* and /oauth/* (tasks/projects/admin stay 403) plus a webhook-secret generator script that never prints the secret. Tests: host 27 pass, cloud 286 pass (8 skipped are environment-dependent real-Postgres tests).
This commit is contained in:
1 parent
71510a32d4
commit
bed3fc0dff
10 files changed
+788
-17
No files matched your search
+95
-2
@@ -82,6 +82,18 @@ interrupted,不用执行结果覆盖它。
|
||||
按任务派生的容器名 docker kill,不只改数据库状态;本机无
|
||||
docker 时 DockerExecutor 在调 runner 前就明确报错,任务标
|
||||
failed 且 error_text 含 docker 缺失,绝不静默降级为本地执行。
|
||||
|
||||
项目仓库:run_one 派发前查任务所属项目的 repo_url(projects
|
||||
表),非空时经 cloud.repos.resolve_repo 在 repos_dir(CLI
|
||||
--repos-dir,默认 ./repos)下 clone/fetch 该项目仓库,设
|
||||
task["repo"] 再交给执行器;Local 与 Docker 执行器都已读
|
||||
task["repo"],同一条路径。repo_url 为空时走原有默认 repo;
|
||||
解析失败时任务直接 failed,错误文本写清是仓库解析失败。
|
||||
Docker 下 repos_dir 必须放在 extra-mounts 已挂载的目录下,
|
||||
否则容器内 repo 路径不存在(见 cloud/README.md)。
|
||||
推送到远端不在本轮范围:任务在本地检出上执行、本地 worktree
|
||||
合并仍按 host.py 现有流程,git push 需要仓库写凭证,控制面
|
||||
不存、不推。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -100,6 +112,7 @@ from cloud.db import connect as _db_connect
|
||||
from cloud.logging_config import get_logger, log_event, setup_logging
|
||||
from cloud.models import ModelRouter, is_fallbackable, record_fallback
|
||||
from cloud.queue import budget_exceeded, ready_tasks
|
||||
from cloud.repos import RepoError, resolve_repo
|
||||
from cloud.sandbox import DockerExecutor, LocalExecutor
|
||||
from cloud.sandbox import _default_runner as _sandbox_default_runner
|
||||
|
||||
@@ -194,7 +207,8 @@ class Worker:
|
||||
max_turns=None, max_cost_usd=None, executor=None,
|
||||
cost_store=None, process_factory=None,
|
||||
task_timeout=1800, max_retries=1,
|
||||
retry_backoff_seconds=5.0):
|
||||
retry_backoff_seconds=5.0, repos_dir="./repos",
|
||||
repos_runner=None):
|
||||
self.db_path = str(db_path)
|
||||
self.host_py_path = host_py_path
|
||||
self.config_path = config_path
|
||||
@@ -232,6 +246,12 @@ class Worker:
|
||||
self.task_timeout = task_timeout
|
||||
self.max_retries = max_retries
|
||||
self.retry_backoff_seconds = retry_backoff_seconds
|
||||
# 项目仓库检出根目录:CLI --repos-dir 可调(默认 ./repos);
|
||||
# Docker 执行器下它必须放在 extra-mounts 已挂载的目录下,
|
||||
# 否则容器内 task["repo"] 路径不存在——见 cloud/README.md。
|
||||
# repos_runner 可注入(单测用假 git runner),缺省走真实 git。
|
||||
self.repos_dir = repos_dir
|
||||
self.repos_runner = repos_runner
|
||||
|
||||
# -- 成本存储(惰性建表,避免只认领不执行时也建表)--------------
|
||||
@property
|
||||
@@ -615,6 +635,53 @@ class Worker:
|
||||
"stdout": out or "", "stderr": err or "",
|
||||
"returncode": proc.returncode, "host_status": None}
|
||||
|
||||
# -- 项目仓库 ------------------------------------------------------
|
||||
def _project_repo_url(self, project_id):
|
||||
"""查任务所属项目的 repo_url;查不到项目、列缺失、表缺失时
|
||||
一律返回 None(走原有默认 repo,不报错、不炸掉派发)。
|
||||
repo_url 本身只在 projects 表里,绝不从任务 prompt 里猜。"""
|
||||
try:
|
||||
conn = _db_connect(self.db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT repo_url FROM projects WHERE id = ?",
|
||||
(project_id,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
return None
|
||||
if row is None:
|
||||
return None
|
||||
try:
|
||||
url = row["repo_url"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return None
|
||||
return url or None
|
||||
|
||||
def _apply_project_repo(self, task):
|
||||
"""派发前按项目 repo_url 解析仓库,返回错误文本或 None。
|
||||
|
||||
repo_url 为空:不动 task,返回 None(走原有默认 repo)。
|
||||
repo_url 非空:调 cloud.repos.resolve_repo 得到本地检出,
|
||||
设 task["repo"] 再交给执行器——LocalExecutor 与
|
||||
DockerExecutor 的 dispatch_task 都已读 task["repo"],同一条
|
||||
路径,不另起分支。resolve 失败返回错误文本,调用方据此把
|
||||
任务置 failed(仓库解析失败绝不标 completed,也不在此重试,
|
||||
避免对不可用的仓库反复 clone 烧时间)。
|
||||
"""
|
||||
repo_url = self._project_repo_url(task.get("project_id"))
|
||||
if not repo_url:
|
||||
return None
|
||||
try:
|
||||
task["repo"] = resolve_repo(
|
||||
repo_url, self.repos_dir, task.get("project_id"),
|
||||
runner=self.repos_runner)
|
||||
except (RepoError, ValueError) as exc:
|
||||
return "仓库解析失败: %s" % exc
|
||||
except Exception as exc:
|
||||
return "仓库解析失败: %s" % exc
|
||||
return None
|
||||
|
||||
# -- 执行 ----------------------------------------------------------
|
||||
def _run_attempt(self, task):
|
||||
"""单次执行尝试(不含认领、key 门、重试、成本与最终审计)。
|
||||
@@ -735,6 +802,25 @@ class Worker:
|
||||
"stdout": "", "error": error, "events": [],
|
||||
"cost_record_id": None}
|
||||
|
||||
# 项目仓库解析:认领 + 缺 key 门之后、真正执行之前。
|
||||
# repo_url 为空时不动(默认 repo);解析失败时任务直接 failed,
|
||||
# 错误文本写清是仓库解析失败,绝不标 completed,也不在此重试。
|
||||
repo_error = self._apply_project_repo(task)
|
||||
if repo_error is not None:
|
||||
self._set_status(task_id, "failed")
|
||||
self._write_execution_result(
|
||||
task_id, error_text=repo_error, actual_model=self.model,
|
||||
started_at=started_at, finished_at=time.time())
|
||||
self._audit(task["project_id"], "worker_repo_failed", repo_error)
|
||||
log_event(self.logger, "worker_repo_failed", task_id=task_id,
|
||||
project_id=task["project_id"], status="failed")
|
||||
return {"dispatched": True, "task_id": task_id,
|
||||
"host_name": self.executor._host_name(task)
|
||||
if hasattr(self.executor, "_host_name") else task_id,
|
||||
"status": "failed", "executor": self._executor_name(),
|
||||
"stdout": "", "error": repo_error, "events": [],
|
||||
"cost_record_id": None}
|
||||
|
||||
events = []
|
||||
fallback_count = 0
|
||||
result = None
|
||||
@@ -939,6 +1025,12 @@ def main(argv=None):
|
||||
help="host.py 路径,缺省为 cloud/ 上一级的 host.py")
|
||||
parser.add_argument("--config", default=None)
|
||||
parser.add_argument("--repo", default=None)
|
||||
parser.add_argument("--repos-dir", default="./repos",
|
||||
help="项目仓库检出根目录(默认 ./repos);项目有"
|
||||
" repo_url 时 worker 在此 clone/fetch 各项目"
|
||||
"仓库;Docker 执行器下它必须放在"
|
||||
" --docker-extra-mounts 已挂载的目录下,"
|
||||
"否则容器内 task repo 路径不存在")
|
||||
parser.add_argument("--env-key", default=None)
|
||||
parser.add_argument("--codex-config", default=None,
|
||||
help="含 env_key 行的 codex 配置文件(只读名字)")
|
||||
@@ -988,7 +1080,8 @@ def main(argv=None):
|
||||
executor=_build_cli_executor(args, host_py),
|
||||
task_timeout=args.task_timeout,
|
||||
max_retries=args.max_retries,
|
||||
retry_backoff_seconds=args.retry_backoff_seconds)
|
||||
retry_backoff_seconds=args.retry_backoff_seconds,
|
||||
repos_dir=args.repos_dir)
|
||||
# CLI 启动清理兜底(常驻与单次都做):清掉上一轮残留容器。
|
||||
worker.cleanup_stale_containers()
|
||||
# CLI 起 worker 前先建表/迁移:全新库直接起循环不应因缺表而炸。
|
||||
|
||||
Reference in new issue
Block a user