云端 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
@@ -18,7 +18,8 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__))))
|
||||
|
||||
from queue import TaskQueue, budget_exceeded, order_tasks, ready_tasks
|
||||
from sandbox import CloudExecutor, DockerExecutor, Executor, LocalExecutor
|
||||
from sandbox import (CloudExecutor, DockerExecutor, Executor,
|
||||
LocalExecutor, _parse_goal_output)
|
||||
|
||||
|
||||
def _task(task_id, depends_on=None, priority=0, status="queued"):
|
||||
@@ -219,5 +220,170 @@ class TestExecutors(unittest.TestCase):
|
||||
self.assertIn("cloud account", str(ctx.exception))
|
||||
|
||||
|
||||
class TestGoalExecutors(unittest.TestCase):
|
||||
def test_parse_goal_output(self):
|
||||
met, err = _parse_goal_output(
|
||||
"round 1: turn=completed verify_rc=0 verify_out=ok\n"
|
||||
"goal met after rounds\n")
|
||||
self.assertTrue(met)
|
||||
self.assertIsNone(err)
|
||||
met, err = _parse_goal_output(
|
||||
"round 1: turn=completed verify_rc=1 verify_out=file missing\n"
|
||||
"round 2: turn=completed verify_rc=1 verify_out=file missing\n"
|
||||
"goal unmet after rounds\n")
|
||||
self.assertFalse(met)
|
||||
self.assertIn("file missing", err)
|
||||
self.assertIn("2 rounds", err)
|
||||
met, err = _parse_goal_output("some other output\n")
|
||||
self.assertIsNone(met)
|
||||
met, err = _parse_goal_output("")
|
||||
self.assertIsNone(met)
|
||||
|
||||
def test_local_goal_task_met(self):
|
||||
seen = []
|
||||
|
||||
def fake(cmd):
|
||||
seen.append(cmd)
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout="round 1: turn=completed verify_rc=0 verify_out=ok\n"
|
||||
"goal met after rounds\n",
|
||||
stderr="")
|
||||
|
||||
executor = LocalExecutor("/fake/host.py", runner=fake)
|
||||
result = executor.goal_task(
|
||||
{"id": "t1", "prompt": "do it", "verify_command": "test -f x",
|
||||
"goal_max_rounds": 2, "repo": "/r"})
|
||||
self.assertEqual(result["status"], "completed")
|
||||
self.assertTrue(result["goal_met"])
|
||||
self.assertIsNone(result["goal_error"])
|
||||
self.assertEqual(result["host_status"], "goal-met")
|
||||
self.assertIn("goal", seen[0])
|
||||
self.assertIn("--verify", seen[0])
|
||||
self.assertIn("test -f x", seen[0])
|
||||
self.assertIn("/r", seen[0])
|
||||
self.assertEqual(seen[0][seen[0].index("--max-rounds") + 1], "2")
|
||||
|
||||
def test_local_goal_task_unmet(self):
|
||||
def fake(cmd):
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout="round 1: turn=completed verify_rc=1 "
|
||||
"verify_out=file missing\n"
|
||||
"goal unmet after rounds\n",
|
||||
stderr="")
|
||||
|
||||
result = LocalExecutor("/fake/host.py",
|
||||
runner=fake).goal_task(
|
||||
{"id": "t1", "prompt": "do it",
|
||||
"verify_command": "test -f x"})
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertFalse(result["goal_met"])
|
||||
self.assertIn("file missing", result["goal_error"])
|
||||
self.assertEqual(result["host_status"], "goal-unmet")
|
||||
|
||||
def test_local_goal_task_defaults_prompt_and_rounds(self):
|
||||
seen = []
|
||||
|
||||
def fake(cmd):
|
||||
seen.append(list(cmd))
|
||||
return SimpleNamespace(
|
||||
returncode=0, stdout="goal met after rounds\n", stderr="")
|
||||
|
||||
LocalExecutor("/fake/host.py", runner=fake).goal_task(
|
||||
{"id": "t1", "prompt": "do it", "verify_command": "true"})
|
||||
self.assertIn("do it", seen[0])
|
||||
self.assertEqual(seen[0][seen[0].index("--max-rounds") + 1], "3")
|
||||
|
||||
def test_local_goal_task_no_conclusion_is_failed_not_completed(self):
|
||||
def fake(cmd):
|
||||
return SimpleNamespace(returncode=0, stdout="nothing\n",
|
||||
stderr="")
|
||||
|
||||
result = LocalExecutor("/fake/host.py",
|
||||
runner=fake).goal_task(
|
||||
{"id": "t1", "prompt": "do it",
|
||||
"verify_command": "true"})
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertIsNone(result["goal_met"])
|
||||
self.assertIn("无结论", result["goal_error"])
|
||||
|
||||
def test_local_goal_task_nonzero_is_failed(self):
|
||||
def fake(cmd):
|
||||
return SimpleNamespace(returncode=1, stdout="", stderr="boom")
|
||||
|
||||
result = LocalExecutor("/fake/host.py",
|
||||
runner=fake).goal_task(
|
||||
{"id": "t1", "prompt": "do it",
|
||||
"verify_command": "true"})
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertIn("boom", result["goal_error"])
|
||||
|
||||
def test_local_goal_task_already_exists_names_retry(self):
|
||||
def fake(cmd):
|
||||
return SimpleNamespace(
|
||||
returncode=1, stdout="",
|
||||
stderr="Traceback\nRuntimeError: 任务已存在")
|
||||
|
||||
result = LocalExecutor("/fake/host.py",
|
||||
runner=fake).goal_task(
|
||||
{"id": "t1", "prompt": "do it",
|
||||
"verify_command": "true"})
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertIn("请新建任务", result["goal_error"])
|
||||
|
||||
def test_local_goal_task_requires_verify(self):
|
||||
with self.assertRaises(ValueError):
|
||||
LocalExecutor("/fake/host.py",
|
||||
runner=lambda cmd: None).goal_task(
|
||||
{"id": "t1", "prompt": "do it"})
|
||||
|
||||
def test_base_goal_task_not_implemented(self):
|
||||
with self.assertRaises(NotImplementedError):
|
||||
Executor().goal_task({"id": "t1"})
|
||||
|
||||
def test_popen_goal_command(self):
|
||||
seen = []
|
||||
|
||||
class _FakePopen:
|
||||
def __init__(self, cmd, **kwargs):
|
||||
seen.append(list(cmd))
|
||||
|
||||
with mock.patch("sandbox.subprocess.Popen", _FakePopen):
|
||||
LocalExecutor("/fake/host.py").popen_task(
|
||||
{"id": "t1", "prompt": "do it",
|
||||
"verify_command": "test -f x", "goal_max_rounds": 2},
|
||||
kind="goal")
|
||||
self.assertIn("goal", seen[0])
|
||||
self.assertIn("test -f x", seen[0])
|
||||
self.assertEqual(seen[0][seen[0].index("--max-rounds") + 1], "2")
|
||||
|
||||
def test_docker_goal_task_unmet(self):
|
||||
seen = []
|
||||
|
||||
def fake_runner(cmd, timeout=None):
|
||||
seen.append(cmd)
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout="round 1: turn=completed verify_rc=1 "
|
||||
"verify_out=file missing\n"
|
||||
"goal unmet after rounds\n",
|
||||
stderr="")
|
||||
|
||||
with mock.patch("sandbox.shutil.which",
|
||||
return_value="/usr/bin/docker"):
|
||||
executor = DockerExecutor(
|
||||
"/fake/host.py", runner=fake_runner,
|
||||
env={"XCM_API_KEY": "placeholder-not-a-real-key"})
|
||||
result = executor.goal_task(
|
||||
{"id": "t1", "prompt": "do it",
|
||||
"verify_command": "test -f x"})
|
||||
self.assertEqual(result["executor"], "docker")
|
||||
self.assertEqual(result["status"], "failed")
|
||||
self.assertFalse(result["goal_met"])
|
||||
self.assertIn("goal", seen[0])
|
||||
self.assertIn("test -f x", seen[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,267 @@
|
||||
"""goal 字段与 PR 事件推送的端点级单测。
|
||||
|
||||
- POST /tasks 接受 goal/verify_command/goal_max_rounds,非法值 400。
|
||||
- init_db 迁移后 tasks 表含 goal 三列。
|
||||
- POST /tasks/{id}/pr 成功时发 review-requested,落盘失败时发
|
||||
merge-failed(订阅指向本地回环监听,断言真收到)。
|
||||
git/gh 一律 fake,不跑真实命令、不联网。
|
||||
"""
|
||||
|
||||
import http.client
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
try:
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.push import create_subscription
|
||||
from cloud.server import create_server, init_db
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.push import create_subscription
|
||||
from cloud.server import create_server, init_db
|
||||
|
||||
|
||||
def _result(returncode=0, stdout="", stderr=""):
|
||||
return SimpleNamespace(returncode=returncode, stdout=stdout,
|
||||
stderr=stderr)
|
||||
|
||||
|
||||
class _Git:
|
||||
"""假 git:remote/branch/add/commit/push,记录命令。"""
|
||||
|
||||
def __init__(self, remote, branch, push_rc=0):
|
||||
self.calls = []
|
||||
self.remote = remote
|
||||
self.branch = branch
|
||||
self.push_rc = push_rc
|
||||
|
||||
def __call__(self, cmd, cwd):
|
||||
self.calls.append(list(cmd))
|
||||
if "remote" in cmd:
|
||||
return _result(0, self.remote + "\n")
|
||||
if "branch" in cmd:
|
||||
return _result(0, self.branch + "\n")
|
||||
if "add" in cmd:
|
||||
return _result(0, "")
|
||||
if "commit" in cmd:
|
||||
return _result(1, "",
|
||||
"nothing to commit, working tree clean")
|
||||
if "push" in cmd:
|
||||
return _result(self.push_rc, "",
|
||||
"push rejected" if self.push_rc else "")
|
||||
return _result(0, "")
|
||||
|
||||
|
||||
class _Gh:
|
||||
"""假 gh:auth 通过、pr create 回链接。"""
|
||||
|
||||
def __init__(self, pr_url):
|
||||
self.calls = []
|
||||
self.pr_url = pr_url
|
||||
|
||||
def __call__(self, cmd, cwd):
|
||||
self.calls.append(list(cmd))
|
||||
if cmd[:2] == ["gh", "auth"]:
|
||||
return _result(0, "", "")
|
||||
if cmd[:2] == ["gh", "pr"]:
|
||||
return _result(0, self.pr_url + "\n")
|
||||
return _result(0, "")
|
||||
|
||||
|
||||
class _Listener:
|
||||
"""本地回环 webhook 接收器。"""
|
||||
|
||||
def __init__(self):
|
||||
self.requests = []
|
||||
|
||||
outer = self
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = self.rfile.read(length) if length else b""
|
||||
outer.requests.append(json.loads(body.decode("utf-8")))
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", "2")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"{}")
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
self.server = http.server.ThreadingHTTPServer(
|
||||
("127.0.0.1", 0), Handler)
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.url = "http://127.0.0.1:%d/hook" % \
|
||||
self.server.server_address[1]
|
||||
|
||||
def stop(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
|
||||
|
||||
class GoalPrNotifyTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.db_path = os.path.join(self.tmp.name, "control.db")
|
||||
self.auth = AuthStore(self.db_path)
|
||||
self.owner_id, self.owner_key = self.auth.create_user("owner")
|
||||
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.worktrees = os.path.join(self.tmp.name, "worktrees")
|
||||
os.makedirs(self.worktrees, exist_ok=True)
|
||||
self.server.host_worktrees_dir = self.worktrees
|
||||
self.listener = _Listener()
|
||||
|
||||
def tearDown(self):
|
||||
self.listener.stop()
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
try:
|
||||
self.auth.conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.tmp.cleanup()
|
||||
|
||||
def _req(self, method, path, body=None, key=None):
|
||||
conn = http.client.HTTPConnection(
|
||||
"127.0.0.1", self.port, timeout=10)
|
||||
hdrs = {}
|
||||
payload = None
|
||||
if body is not None:
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
hdrs["Content-Type"] = "application/json"
|
||||
if key is not None:
|
||||
hdrs["X-API-Key"] = key
|
||||
try:
|
||||
conn.request(method, path, body=payload, headers=hdrs)
|
||||
resp = conn.getresponse()
|
||||
data = resp.read()
|
||||
finally:
|
||||
conn.close()
|
||||
try:
|
||||
parsed = json.loads(data.decode("utf-8")) if data else None
|
||||
except ValueError:
|
||||
parsed = None
|
||||
return resp.status, parsed
|
||||
|
||||
def _project(self, name="p"):
|
||||
status, project = self._req(
|
||||
"POST", "/projects", {"name": name, "auto_pr": 1},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 201, project)
|
||||
return project["id"]
|
||||
|
||||
def _merged_task(self, project_id, task_id):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO tasks(id, project_id, prompt, status,"
|
||||
" depends_on, priority, created_by, created_at,"
|
||||
" updated_at, last_heartbeat) VALUES(?, ?, ?, 'merged',"
|
||||
" '[]', 0, 'system', 0, 0, NULL)",
|
||||
(task_id, project_id, "do it"))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
os.makedirs(os.path.join(self.worktrees, task_id), exist_ok=True)
|
||||
|
||||
def test_create_task_goal_fields(self):
|
||||
pid = self._project()
|
||||
status, task = self._req(
|
||||
"POST", "/tasks",
|
||||
{"project_id": pid, "prompt": "do it",
|
||||
"goal": "make x", "verify_command": "test -f x",
|
||||
"goal_max_rounds": 2},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 201, task)
|
||||
self.assertEqual(task["goal"], "make x")
|
||||
self.assertEqual(task["verify_command"], "test -f x")
|
||||
self.assertEqual(task["goal_max_rounds"], 2)
|
||||
# 不带 goal 字段:普通任务,字段为空。
|
||||
status, task = self._req(
|
||||
"POST", "/tasks",
|
||||
{"project_id": pid, "prompt": "plain"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 201, task)
|
||||
self.assertIsNone(task["goal"])
|
||||
self.assertIsNone(task["verify_command"])
|
||||
# 非法轮次:0 与非数字都 400,且不建任务。
|
||||
for bad in (0, -1, "x"):
|
||||
status, body = self._req(
|
||||
"POST", "/tasks",
|
||||
{"project_id": pid, "prompt": "bad",
|
||||
"verify_command": "true", "goal_max_rounds": bad},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 400, body)
|
||||
|
||||
def test_migration_adds_goal_columns(self):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
cols = {row[1] for row in conn.execute(
|
||||
"PRAGMA table_info(tasks)").fetchall()}
|
||||
finally:
|
||||
conn.close()
|
||||
self.assertIn("goal", cols)
|
||||
self.assertIn("verify_command", cols)
|
||||
self.assertIn("goal_max_rounds", cols)
|
||||
|
||||
def test_pr_success_notifies_review_requested(self):
|
||||
pid = self._project()
|
||||
create_subscription(
|
||||
self.db_path, pid, self.owner_id, "webhook",
|
||||
self.listener.url, "instant")
|
||||
self._merged_task(pid, "t-pr-ok")
|
||||
self.server.pr_git_runner = _Git(
|
||||
"https://github.com/octo/repo.git", "host/t-pr-ok")
|
||||
self.server.pr_gh_runner = _Gh(
|
||||
"https://github.com/octo/repo/pull/9")
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/t-pr-ok/pr", {}, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(len(self.listener.requests), 1)
|
||||
event = self.listener.requests[0]
|
||||
self.assertEqual(event["event_type"], "review-requested")
|
||||
self.assertEqual(event["task_id"], "t-pr-ok")
|
||||
self.assertIn("pull/9", event["payload"]["pr_url"])
|
||||
|
||||
def test_pr_failure_notifies_merge_failed(self):
|
||||
pid = self._project()
|
||||
create_subscription(
|
||||
self.db_path, pid, self.owner_id, "webhook",
|
||||
self.listener.url, "instant")
|
||||
self._merged_task(pid, "t-pr-fail")
|
||||
self.server.pr_git_runner = _Git(
|
||||
"https://github.com/octo/repo.git", "host/t-pr-fail",
|
||||
push_rc=1)
|
||||
self.server.pr_gh_runner = _Gh(
|
||||
"https://github.com/octo/repo/pull/9")
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/t-pr-fail/pr", {}, self.owner_key)
|
||||
self.assertEqual(status, 502, body)
|
||||
self.assertEqual(body["step"], "push")
|
||||
self.assertEqual(len(self.listener.requests), 1)
|
||||
event = self.listener.requests[0]
|
||||
self.assertEqual(event["event_type"], "merge-failed")
|
||||
self.assertEqual(event["task_id"], "t-pr-fail")
|
||||
self.assertEqual(event["payload"]["step"], "push")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -223,6 +223,157 @@ class WorkerTestCase(unittest.TestCase):
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
# -- goal 模式 -------------------------------------------------------
|
||||
def _set_goal(self, task_id, verify="test -f x", goal=None,
|
||||
rounds=None):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET verify_command = ?, goal = ?,"
|
||||
" goal_max_rounds = ? WHERE id = ?",
|
||||
(verify, goal, rounds, task_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_goal_met_notifies_completed(self):
|
||||
"""goal 达标:任务 completed,发 completed(不发 goal-unmet)。"""
|
||||
import http.server
|
||||
import threading
|
||||
|
||||
requests = []
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = self.rfile.read(length) if length else b""
|
||||
requests.append(json.loads(body.decode("utf-8")))
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", "2")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"{}")
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
server = http.server.ThreadingHTTPServer(
|
||||
("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(
|
||||
target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
from cloud.push import create_subscription
|
||||
url = "http://127.0.0.1:%d/hook" % server.server_address[1]
|
||||
create_subscription(
|
||||
self.db_path, "p1", "user-1", "webhook", url, "instant")
|
||||
_insert_task(self.db_path, "A")
|
||||
self._set_goal("A")
|
||||
calls = []
|
||||
|
||||
def goal_runner(cmd):
|
||||
calls.append(list(cmd))
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout="round 1: turn=completed verify_rc=0 "
|
||||
"verify_out=ok\ngoal met after rounds\n",
|
||||
stderr="")
|
||||
|
||||
worker = self._worker(runner=goal_runner)
|
||||
results = worker.run_all()
|
||||
self.assertEqual(
|
||||
[r["status"] for r in results], ["completed"])
|
||||
self.assertIn("goal", calls[0])
|
||||
self.assertEqual(len(requests), 1)
|
||||
self.assertEqual(requests[0]["event_type"], "completed")
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
def test_goal_unmet_notifies_only_goal_unmet(self):
|
||||
"""goal 未达:任务 failed,只发 goal-unmet,不重复发 failed。"""
|
||||
import http.server
|
||||
import threading
|
||||
|
||||
requests = []
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = self.rfile.read(length) if length else b""
|
||||
requests.append(json.loads(body.decode("utf-8")))
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", "2")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"{}")
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
server = http.server.ThreadingHTTPServer(
|
||||
("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(
|
||||
target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
from cloud.push import create_subscription
|
||||
url = "http://127.0.0.1:%d/hook" % server.server_address[1]
|
||||
create_subscription(
|
||||
self.db_path, "p1", "user-1", "webhook", url, "instant")
|
||||
_insert_task(self.db_path, "A")
|
||||
self._set_goal("A")
|
||||
|
||||
def unmet_runner(cmd):
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout="round 1: turn=completed verify_rc=1 "
|
||||
"verify_out=file missing\n"
|
||||
"goal unmet after rounds\n",
|
||||
stderr="")
|
||||
|
||||
worker = self._worker(runner=unmet_runner)
|
||||
results = worker.run_all()
|
||||
self.assertEqual([r["status"] for r in results], ["failed"])
|
||||
self.assertEqual(len(requests), 1)
|
||||
self.assertEqual(requests[0]["event_type"], "goal-unmet")
|
||||
self.assertIn("file missing",
|
||||
requests[0]["payload"]["error"])
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
def test_goal_mode_runs_once_despite_retries(self):
|
||||
"""goal 模式只跑一次:循环内部已多轮重试,worker 级重试
|
||||
不再叠加(同任务重跑会撞 host 任务名已存在)。"""
|
||||
_insert_task(self.db_path, "A")
|
||||
self._set_goal("A")
|
||||
calls = []
|
||||
|
||||
def counting(cmd):
|
||||
calls.append(cmd)
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout="round 1: turn=completed verify_rc=1 "
|
||||
"verify_out=nope\ngoal unmet after rounds\n",
|
||||
stderr="")
|
||||
|
||||
worker = self._worker(runner=counting, max_retries=2)
|
||||
results = worker.run_all()
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(results[0]["status"], "failed")
|
||||
|
||||
def test_cloud_executor_goal_unsupported(self):
|
||||
"""cloud 执行器没有 goal 概念:goal 任务如实失败,
|
||||
不静默降级成普通 dispatch。"""
|
||||
from cloud.sandbox import CloudExecutor
|
||||
_insert_task(self.db_path, "A")
|
||||
self._set_goal("A")
|
||||
worker = self._worker(executor=CloudExecutor())
|
||||
results = worker.run_all()
|
||||
self.assertEqual(results[0]["status"], "failed")
|
||||
self.assertIn("不支持 goal", results[0]["error"])
|
||||
|
||||
# -- 取消 ------------------------------------------------------------
|
||||
def test_cancel_queued_task_marks_interrupted(self):
|
||||
_insert_task(self.db_path, "A")
|
||||
|
||||
Reference in new issue
Block a user