云端 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:
1 parent
26e73124fb
commit
5ee9d291dd
8 files changed
+965
-27
No files matched your search
+230
-1
@@ -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(宿主在该网桥上的地址)。
|
||||
|
||||
|
||||
Reference in new issue
Block a user