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:
host committed 2026-10-26 04:16:11 +08:00
1 parent 71510a32d4
commit bed3fc0dff
10 files changed
+788 -17

No files matched your search

+95 -2
View File
@@ -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 前先建表/迁移:全新库直接起循环不应因缺表而炸。