云端 goal/merge/review 三事件:goal 循环 + PR 事件通知

此前 goal-unmet/merge-failed/review-requested 只有允许列表,
云端没有生产者(概念只在本地 host.py)。本轮给三者接真生产者:
- goal:POST /tasks 接受 goal/verify_command/goal_max_rounds
  (迁移补列,SQLite 与 Postgres 两支);带 verify_command 的
  任务走 host.py goal 循环(Local/Docker 执行器 goal_task,
  turn→验测→喂回重试,缺省 3 轮);达标 completed,未达标
  failed 且只发 goal-unmet、不重复发 failed;goal 模式下 worker
  级重试/fallback 关闭,同任务 /retry 会撞 host 任务名已存在
  (已在文档写明,须新建任务);CloudExecutor 无 goal 概念,
  如实失败不降级
- review-requested:POST /tasks/{id}/pr 建 PR 成功时发
  (payload 含 pr_url/pr_number)
- merge-failed:PR 落盘(push/建 PR)失败 502 时发(payload
  含 step/error),任务保持 merged、可重调 /pr;跳过时不发
测试:host 29、cloud 469 全过。
This commit is contained in:
host committed 2026-10-26 18:11:12 +08:00
1 parent 26e73124fb
commit 5ee9d291dd
8 files changed
+965 -27

No files matched your search

+230 -1
View File
@@ -33,6 +33,59 @@ class Executor:
def execute(self, task) -> dict:
raise NotImplementedError("子类必须实现 execute(task)")
def goal_task(self, task) -> dict:
"""目标循环(host.py goal):turn→验测→喂回重试。
子类实现;默认不支持(CloudExecutor 的通用 HTTP 协议里
没有 goal/验测概念,调它会如实失败,不静默降级成普通
dispatch)。结果 dict 除 execute() 的字段外另含:
goal_met(True/False/None=无结论)、goal_error。
"""
raise NotImplementedError("该执行器不支持 goal 模式")
def _parse_goal_output(stdout):
"""解析 host.py goal 的 stdout,返回 (met, goal_error)。
host.py goal 两种结局都 exit 0,结论只看末尾打印的
"goal met/unmet after rounds",不能沿用普通结果解析
(它无 JSON 时一律 completed,会把 unmet 误判成完成)。
met True=达标,False=未达标,None=输出里无结论。
goal_error 取最后一轮的 verify_out(无则取 stdout 尾部)。
"""
if not stdout:
return None, None
if "goal met after rounds" in stdout:
return True, None
if "goal unmet after rounds" in stdout:
last_verify = None
rounds = 0
for line in stdout.splitlines():
if line.startswith("round "):
rounds += 1
if "verify_out=" in line:
last_verify = line.split("verify_out=", 1)[1]
err = "goal verification failed"
if rounds:
err += " after %d rounds" % rounds
detail = (last_verify or "").strip()[:500]
if detail:
err += ": " + detail
return False, err
return None, None
def _goal_stdout_tail(stdout):
"""goal 结果存库的 stdout:结论在末尾,截尾不截头。
普通结果截头(_truncate_stdout)会把末尾的结论行截掉,
goal 输出必须留尾部,否则 met/unmet 结论在库里查不到。
"""
text = str(stdout or "")
if len(text) > MAX_STDOUT_CHARS:
return text[-MAX_STDOUT_CHARS:]
return text
def _truncate_stdout(text):
if text is None:
@@ -184,13 +237,88 @@ class LocalExecutor(Executor):
return self._result_from_runner(
task["id"], self.runner(cmd))
def goal_task(self, task) -> dict:
"""调 host.py goal:目标循环(turn→验测→喂回重试)。
命令:host.py [--config CFG] [--model M] goal --name <host 名>
--task <goal 或 prompt> --verify <验收命令>
[--max-rounds N] [--repo <repo>]。goal 文本缺省用 prompt;
轮次缺省 3,与 host.py 默认一致;repo 取任务的 repo 字段,
没有则取构造时传的 repo。
结论解析见 _goal_result_from_runner:host.py goal 两种结局
都 exit 0,不能沿用 _result_from_runner。
"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
verify = task.get("verify_command")
if not verify:
raise ValueError("goal 任务缺少 verify_command")
goal_text = task.get("goal") or task.get("prompt")
if not goal_text:
raise ValueError("任务缺少 prompt")
rounds = task.get("goal_max_rounds") or 3
cmd = self._base_command(model=task.get("model"))
cmd += ["goal", "--name", self._host_name(task),
"--task", goal_text, "--verify", verify,
"--max-rounds", str(rounds)]
repo = task.get("repo") or self.repo
if repo:
cmd += ["--repo", repo]
return self._goal_result_from_runner(
task["id"], self.runner(cmd))
def _goal_result_from_runner(self, task_id, result):
"""把 host.py goal 的 runner 返回值解析成 goal 结果 dict。
解析用完整 stdout(结论在末尾),存库的 stdout 才截尾;
returncode 非 0 一律 failed;为 0 时无结论也判 failed,
绝不把“没打印结论”当成达标。host 任务已存在时(同任务
/retry 撞名,goal 没有 run 续跑语义)错误写明请新建任务。
"""
returncode = getattr(result, "returncode", 1)
stdout_full = getattr(result, "stdout", "") or ""
stderr = getattr(result, "stderr", "") or ""
met, goal_error = _parse_goal_output(stdout_full)
combined = stdout_full + stderr
if "已存在" in combined:
met, goal_error = None, (
"host 任务已存在:goal 任务不支持同任务重试,"
"请新建任务")
status = "failed"
elif returncode != 0:
status = "failed"
if met is None:
goal_error = ((stderr.strip()[-500:] if stderr.strip()
else stdout_full.strip()[-500:])
or "host goal failed")
elif met is True:
status = "completed"
elif met is False:
status = "failed"
else:
status = "failed"
goal_error = goal_error or "host goal 输出无结论"
return {
"executor": "local",
"task_id": task_id,
"status": status,
"stdout": _goal_stdout_tail(stdout_full),
"stderr": stderr,
"returncode": returncode,
"host_status": ("goal-met" if met is True else
"goal-unmet" if met is False else None),
"goal_met": met,
"goal_error": goal_error,
}
def popen_task(self, task, kind="dispatch"):
"""进程组启动 host.py 子进程,返回 Popen(供真取消用)。
start_new_session=True 让子进程自成进程组,worker 取消时
killpg 才能连同子孙一起杀掉。注意:只有默认 runner(真实
subprocess)时才用本方法;单测注入的假 runner 走同步路径,
不走这里。
不走这里。kind=goal 时建 goal 命令(结论解析由 worker 的
_execute_via_process 按 kind 分发到 _goal_result_from_runner)。
"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
@@ -200,6 +328,20 @@ class LocalExecutor(Executor):
if kind == "run":
cmd += ["run", "--task", self._host_name(task),
"--prompt", task["prompt"]]
elif kind == "goal":
verify = task.get("verify_command")
if not verify:
raise ValueError("goal 任务缺少 verify_command")
goal_text = task.get("goal") or task.get("prompt")
if not goal_text:
raise ValueError("任务缺少 prompt")
rounds = task.get("goal_max_rounds") or 3
cmd += ["goal", "--name", self._host_name(task),
"--task", goal_text, "--verify", verify,
"--max-rounds", str(rounds)]
repo = task.get("repo") or self.repo
if repo:
cmd += ["--repo", repo]
else:
cmd += ["dispatch", "--name", self._host_name(task),
"--task", task["prompt"]]
@@ -625,6 +767,93 @@ class DockerExecutor(Executor):
network=network,
extra_env=extra_env)
def goal_task(self, task, timeout=None, extra_mounts=None,
network=None, extra_env=None) -> dict:
"""容器内调 host.py goal:目标循环(turn→验测→喂回重试)。
不走 _run_in_container:它经 _parse_host_result 截头
stdout,而 goal 结论在末尾,截头会把结论截掉;这里直接
调 build_run_command + _call_runner,结论用完整 stdout
解析,存库的 stdout 截尾。超时与异常处理与
_run_in_container 同款(超时先 kill+rm 容器)。
"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
verify = task.get("verify_command")
if not verify:
raise ValueError("goal 任务缺少 verify_command")
goal_text = task.get("goal") or task.get("prompt")
if not goal_text:
raise ValueError("任务缺少 prompt")
rounds = task.get("goal_max_rounds") or 3
inner = self._inner_host_command(
"goal", "--name", self._host_name(task),
"--task", goal_text, "--verify", verify,
"--max-rounds", str(rounds), model=task.get("model"))
repo = task.get("repo")
if repo:
inner += ["--repo", repo]
cmd = self.build_run_command(task, inner,
extra_mounts=extra_mounts,
network=network,
extra_env=extra_env)
try:
result = self._call_runner(cmd, timeout=timeout)
except Exception as exc:
if exc.__class__.__name__ == "TimeoutExpired":
self.kill_container(task)
return {
"executor": "docker", "task_id": task["id"],
"status": "failed", "stdout": "docker run timeout",
"returncode": None, "host_status": None, "stderr": "",
"container": self.container_name(task),
"goal_met": None, "goal_error": "docker run timeout",
}
return {
"executor": "docker", "task_id": task["id"],
"status": "failed",
"stdout": _truncate_stdout(str(exc)),
"returncode": None, "host_status": None, "stderr": "",
"container": self.container_name(task),
"goal_met": None, "goal_error": str(exc)[:500],
}
returncode = getattr(result, "returncode", 1)
stdout_full = getattr(result, "stdout", "") or ""
stderr = getattr(result, "stderr", "") or ""
met, goal_error = _parse_goal_output(stdout_full)
combined = stdout_full + stderr
if "已存在" in combined:
met, goal_error = None, (
"host 任务已存在:goal 任务不支持同任务重试,"
"请新建任务")
status = "failed"
elif returncode != 0:
status = "failed"
if met is None:
goal_error = ((stderr.strip()[-500:] if stderr.strip()
else stdout_full.strip()[-500:])
or "host goal failed")
elif met is True:
status = "completed"
elif met is False:
status = "failed"
else:
status = "failed"
goal_error = goal_error or "host goal 输出无结论"
return {
"executor": "docker",
"task_id": task["id"],
"status": status,
"stdout": _goal_stdout_tail(stdout_full),
"stderr": stderr,
"returncode": returncode,
"host_status": ("goal-met" if met is True else
"goal-unmet" if met is False else None),
"container": self.container_name(task),
"goal_met": met,
"goal_error": goal_error,
}
def network_gateway(self, network_name):
"""某 Docker 网络的网关 IP(宿主在该网桥上的地址)。