feat: 线程等待与状态读取 8 项(wait/read/message/handoff/worktree/pr-checks/automation/sleep)
对照 Codex app:wait_threads 首变+afterCursor+8上限、 read_thread 详情摘要、send_message_to_thread 复活失败任务、 handoff 登记与上报轮询、worktree 派生状态、PR checks JSON+ check-runs 日志与注释、automation GET/PATCH、clock.sleep (API+host)。诚实边界见 docs/thread-wait-ops.md:无 worktree 阶段流、无 automation 变化静默、无独立测试结果 记录、handoff 服务端不执行迁移、wait 仅数据库级变化检测。 三套件:cloud 940 OK(4 跳过)、host 91 OK、deploy 29 OK
This commit is contained in:
1 parent
38c0797cb3
commit
755029f220
8 files changed
+2535
No files matched your search
@@ -0,0 +1,668 @@
|
||||
"""线程等待与移交操作单测:cloud/wait_ops 纯逻辑 + server 端点。
|
||||
|
||||
不跑真实模型、不联网、不调真实 gh:thread-read 用 stub
|
||||
host.py(与 test_thread_ops.py 同款契约),pr checks 用
|
||||
注入的 fake gh runner,worktree 状态用临时目录。覆盖鉴权、
|
||||
权限、校验、状态机(移交转移、终态不可动)、超时语义与
|
||||
诚实降级(异机 worktree / Docker 任务的 thread-read 仍回
|
||||
200,只 thread 为空)。
|
||||
"""
|
||||
|
||||
import http.client
|
||||
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.server import create_server
|
||||
from cloud.wait_ops import (build_pr_diagnostics, create_handoff,
|
||||
decode_wait_cursor,
|
||||
detect_significant_change,
|
||||
encode_wait_cursor, interruptible_sleep,
|
||||
latest_handoff, parse_pr_checks,
|
||||
poll_for_first_change, poll_task_statuses,
|
||||
report_handoff, worktree_status)
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.server import create_server
|
||||
from cloud.wait_ops import (build_pr_diagnostics, create_handoff,
|
||||
decode_wait_cursor,
|
||||
detect_significant_change,
|
||||
encode_wait_cursor, interruptible_sleep,
|
||||
latest_handoff, parse_pr_checks,
|
||||
poll_for_first_change, poll_task_statuses,
|
||||
report_handoff, worktree_status)
|
||||
|
||||
STUB = r'''import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
def main():
|
||||
argv = sys.argv[1:]
|
||||
cmd = None
|
||||
rest = []
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a == "--codex-home":
|
||||
i += 2
|
||||
continue
|
||||
if a.startswith("--codex-home="):
|
||||
i += 1
|
||||
continue
|
||||
if cmd is None:
|
||||
cmd = a
|
||||
i += 1
|
||||
continue
|
||||
rest.append(a)
|
||||
i += 1
|
||||
if cmd == "thread-list":
|
||||
print(json.dumps({"data": [
|
||||
{"id": "t-old", "updatedAt": 100, "createdAt": 90},
|
||||
{"id": "t-new", "updatedAt": 200, "createdAt": 190},
|
||||
]}))
|
||||
elif cmd == "thread-read":
|
||||
print(json.dumps({"thread": {"id": "t-new", "updatedAt": 200}}))
|
||||
else:
|
||||
print(json.dumps({"error": "unknown subcommand"}))
|
||||
sys.exit(1)
|
||||
|
||||
main()
|
||||
'''
|
||||
|
||||
|
||||
class WaitOpsPureTestCase(unittest.TestCase):
|
||||
def test_parse_pr_checks_tabular_and_human(self):
|
||||
checks, all_pass, failed, pending = parse_pr_checks(
|
||||
"build\tpass\ntest\tfail\nlint\tpending\n")
|
||||
self.assertEqual([c["state"] for c in checks],
|
||||
["pass", "fail", "pending"])
|
||||
self.assertFalse(all_pass)
|
||||
self.assertEqual(failed, ["test"])
|
||||
self.assertEqual(pending, ["lint"])
|
||||
checks, all_pass, _, _ = parse_pr_checks(
|
||||
"\u2713 build pass\n\u2713 test pass\n")
|
||||
self.assertTrue(all_pass)
|
||||
checks, all_pass, _, _ = parse_pr_checks("no checks reported")
|
||||
self.assertEqual(checks, [])
|
||||
self.assertFalse(all_pass)
|
||||
|
||||
def test_worktree_status_matrix(self):
|
||||
self.assertEqual(
|
||||
worktree_status("running", "/tmp/x", False, False)["status"],
|
||||
"creating")
|
||||
self.assertEqual(
|
||||
worktree_status("completed", "/tmp/x", False, False)["status"],
|
||||
"missing")
|
||||
self.assertEqual(
|
||||
worktree_status("running", "/tmp/x", True, True)["status"],
|
||||
"ready")
|
||||
self.assertEqual(
|
||||
worktree_status("running", "/tmp/x", True, False)["status"],
|
||||
"present_no_git")
|
||||
remote = worktree_status("running", "/tmp/x", False, False,
|
||||
remote_host="host-b")
|
||||
self.assertEqual(remote["status"], "remote")
|
||||
self.assertIsNone(remote["exists"])
|
||||
|
||||
def test_handoff_transitions(self):
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
h = create_handoff(conn, "t1", "p1", "host-b", "u1",
|
||||
source_host="host-a")
|
||||
self.assertEqual(h["status"], "pending")
|
||||
with self.assertRaises(ValueError):
|
||||
create_handoff(conn, "t1", "p1", "host-c", "u1")
|
||||
ok, h = report_handoff(conn, h["id"], "running", progress=40)
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual((h["status"], h["progress"]), ("running", 40))
|
||||
ok, h = report_handoff(conn, h["id"], "completed")
|
||||
self.assertTrue(ok)
|
||||
ok, err = report_handoff(conn, h["id"], "running")
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("cannot move", err)
|
||||
ok, err = report_handoff(conn, h["id"], "bogus")
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(latest_handoff(conn, "t1")["status"], "completed")
|
||||
conn.close()
|
||||
|
||||
def test_wait_cursor_and_first_change(self):
|
||||
base = {"a": {"status": "running", "pending_approvals": 0,
|
||||
"pending_input": 0, "pending_followups": 0},
|
||||
"b": {"status": "running", "pending_approvals": 0,
|
||||
"pending_input": 0, "pending_followups": 0}}
|
||||
cursor = encode_wait_cursor(base)
|
||||
self.assertEqual(decode_wait_cursor(cursor), base)
|
||||
with self.assertRaises(ValueError):
|
||||
decode_wait_cursor("not-a-cursor!!")
|
||||
cur = dict(base)
|
||||
cur["b"] = {"status": "running", "pending_approvals": 0,
|
||||
"pending_input": 1, "pending_followups": 0}
|
||||
found = detect_significant_change(base, cur, ["a", "b"])
|
||||
self.assertEqual(found[0], "b")
|
||||
self.assertEqual(found[1], "needs_input")
|
||||
cur2 = dict(base)
|
||||
cur2["a"] = {"status": "failed", "pending_approvals": 0,
|
||||
"pending_input": 0, "pending_followups": 0}
|
||||
found = detect_significant_change(base, cur2, ["a", "b"])
|
||||
self.assertEqual(found[1], "terminal")
|
||||
# 基线里没有的任务不误报;无游标时已终态立即算变化。
|
||||
found = detect_significant_change(
|
||||
base, {"c": {"status": "completed", "pending_approvals": 0,
|
||||
"pending_input": 0, "pending_followups": 0}},
|
||||
["c"])
|
||||
self.assertIsNone(found)
|
||||
found = detect_significant_change(
|
||||
None, {"c": {"status": "completed", "pending_approvals": 0,
|
||||
"pending_input": 0, "pending_followups": 0}},
|
||||
["c"])
|
||||
self.assertEqual(found[1], "terminal")
|
||||
changed, summaries, _w, timed_out = poll_for_first_change(
|
||||
lambda: {"a": {"status": "completed",
|
||||
"pending_approvals": 0, "pending_input": 0,
|
||||
"pending_followups": 0}},
|
||||
["a"], baseline=None, timeout_seconds=5)
|
||||
self.assertFalse(timed_out)
|
||||
self.assertEqual(changed["kind"], "terminal")
|
||||
|
||||
def test_build_pr_diagnostics_json_and_check_runs(self):
|
||||
def run(cmd):
|
||||
if "--json" in cmd:
|
||||
return (0, json.dumps([
|
||||
{"name": "build", "bucket": "pass",
|
||||
"workflow": "CI",
|
||||
"link": "https://example.invalid/ci/1",
|
||||
"description": "ok"},
|
||||
{"name": "test", "bucket": "fail",
|
||||
"workflow": "CI",
|
||||
"link": "https://example.invalid/ci/2",
|
||||
"description": "1 failed"}]), "")
|
||||
if "view" in cmd:
|
||||
return (0, "abc123\n", "")
|
||||
if any("commits" in c for c in cmd):
|
||||
return (0, json.dumps({"check_runs": [
|
||||
{"id": 99, "name": "test",
|
||||
"conclusion": "failure"}]}), "")
|
||||
if any("check-runs" in c for c in cmd):
|
||||
return (0, json.dumps({
|
||||
"id": 99, "conclusion": "failure",
|
||||
"output": {
|
||||
"title": "Test failed",
|
||||
"summary": "1 test failed",
|
||||
"text": "FAILED test_x: assert failed",
|
||||
"annotations": [{
|
||||
"path": "src/a.py", "start_line": 12,
|
||||
"annotation_level": "failure",
|
||||
"message": "assert failed"}]}}), "")
|
||||
return (1, "", "unknown cmd")
|
||||
diag = build_pr_diagnostics(run, 42, owner_repo="o/r",
|
||||
head_sha="abc123")
|
||||
self.assertFalse(diag["all_pass"])
|
||||
self.assertEqual(diag["failed"], ["test"])
|
||||
self.assertEqual(diag["checks"][1]["workflow"], "CI")
|
||||
self.assertTrue(diag["check_runs"]["available"])
|
||||
run_detail = diag["check_runs"]["runs"][0]
|
||||
self.assertIn("FAILED test_x", run_detail["log_text"])
|
||||
self.assertEqual(run_detail["annotations"][0]["path"], "src/a.py")
|
||||
self.assertEqual(run_detail["annotations"][0]["start_line"], 12)
|
||||
# 制表符回落:--json 失败时仍能解析,且无仓库时不查 API。
|
||||
diag2 = build_pr_diagnostics(
|
||||
lambda cmd: (1, "", "boom") if "--json" in cmd
|
||||
else (0, "build\tpass\ntest\tfail\n", ""),
|
||||
7)
|
||||
self.assertEqual(diag2["failed"], ["test"])
|
||||
self.assertFalse(diag2["check_runs"]["available"])
|
||||
|
||||
def test_poll_and_sleep(self):
|
||||
calls = {"n": 0}
|
||||
|
||||
def fetch():
|
||||
calls["n"] += 1
|
||||
return {"a": "running"} if calls["n"] < 3 else {"a": "completed"}
|
||||
|
||||
ok, statuses, waited, timed_out = poll_task_statuses(
|
||||
fetch, timeout_seconds=5)
|
||||
self.assertTrue(ok)
|
||||
self.assertFalse(timed_out)
|
||||
self.assertEqual(statuses, {"a": "completed"})
|
||||
ok, _, _, timed_out = poll_task_statuses(
|
||||
lambda: {"a": "running"}, timeout_seconds=0)
|
||||
self.assertFalse(ok)
|
||||
self.assertTrue(timed_out)
|
||||
ok, _, _, _ = poll_task_statuses(lambda: {}, timeout_seconds=5)
|
||||
self.assertTrue(ok)
|
||||
slept, interrupted = interruptible_sleep(0)
|
||||
self.assertEqual((slept, interrupted), (0.0, False))
|
||||
|
||||
|
||||
class ThreadWaitServerTestCase(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.reader_id, self.reader_key = self.auth.create_user("reader")
|
||||
self.outsider_id, self.outsider_key = self.auth.create_user(
|
||||
"outsider")
|
||||
self.stub_path = os.path.join(self.tmp.name, "stub_host.py")
|
||||
with open(self.stub_path, "w", encoding="utf-8") as f:
|
||||
f.write(STUB)
|
||||
self._old_host_py = os.environ.get("CODEX_HOST_PY")
|
||||
os.environ["CODEX_HOST_PY"] = self.stub_path
|
||||
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
||||
self.server.tenants_root = os.path.join(self.tmp.name, "tenants")
|
||||
self.server.host_worktrees_dir = os.path.join(
|
||||
self.tmp.name, "worktrees")
|
||||
self.port = self.server.server_address[1]
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
status, self.project = self._req(
|
||||
"POST", "/projects", {"name": "demo"}, self.owner_key)
|
||||
self.assertEqual(status, 201, self.project)
|
||||
self.auth.set_member(self.project["id"], self.owner_id,
|
||||
self.reader_id, "viewer")
|
||||
status, self.project2 = self._req(
|
||||
"POST", "/projects", {"name": "other"}, self.owner_key)
|
||||
self.assertEqual(status, 201, self.project2)
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
if self._old_host_py is None:
|
||||
os.environ.pop("CODEX_HOST_PY", None)
|
||||
else:
|
||||
os.environ["CODEX_HOST_PY"] = self._old_host_py
|
||||
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 _create_task(self, project_id=None, **extra):
|
||||
body = {"project_id": project_id or self.project["id"],
|
||||
"prompt": "做事"}
|
||||
body.update(extra)
|
||||
status, task = self._req("POST", "/tasks", body, self.owner_key)
|
||||
self.assertEqual(status, 201, task)
|
||||
return task
|
||||
|
||||
def _set_task(self, task_id, **fields):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
for key, value in fields.items():
|
||||
conn.execute(
|
||||
"UPDATE tasks SET %s = ? WHERE id = ?" % key,
|
||||
(value, task_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_thread_read_summary_and_docker_downgrade(self):
|
||||
# 默认执行器是 docker(线程守卫会挡),读摘要的 thread
|
||||
# 部分必须用显式 local 任务测;docker 任务测降级分支。
|
||||
task = self._create_task(executor="local")
|
||||
self._set_task(task["id"], status="running")
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/thread/read" % task["id"],
|
||||
None, self.reader_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["status"], "running")
|
||||
self.assertEqual(body["state"], "running")
|
||||
self.assertEqual(body["thread_id"], "t-new")
|
||||
self.assertEqual(body["thread"]["id"], "t-new")
|
||||
self.assertEqual(body["progress"]["trace_entries"], 0)
|
||||
self.assertIsNone(body["result"]["text"])
|
||||
self.assertEqual(body["changed_files"], [])
|
||||
self.assertEqual(body["commands"], [])
|
||||
self.assertEqual(body["recent_activity"], [])
|
||||
self.assertFalse(body["attention"]["blocked"])
|
||||
self.assertFalse(body["attention"]["needs_instruction"])
|
||||
self.assertIsNone(body["test_results"])
|
||||
docker = self._create_task()
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/thread/read" % docker["id"],
|
||||
None, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertIsNone(body["thread"])
|
||||
self.assertIn("docker", body["note"])
|
||||
status, _ = self._req(
|
||||
"GET", "/tasks/%s/thread/read" % task["id"],
|
||||
None, self.outsider_key)
|
||||
self.assertEqual(status, 403)
|
||||
status, _ = self._req(
|
||||
"GET", "/tasks/nope/thread/read", None, self.owner_key)
|
||||
self.assertEqual(status, 404)
|
||||
|
||||
def test_thread_message_followup_semantics(self):
|
||||
task = self._create_task()
|
||||
self._set_task(task["id"], status="running")
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/thread/message" % task["id"],
|
||||
{"body": "下一步改 README"}, self.owner_key)
|
||||
self.assertEqual(status, 201, body)
|
||||
self.assertEqual(body["delivery"], "queued")
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/thread/message" % task["id"],
|
||||
{"body": "立刻改", "mode": "direct"}, self.owner_key)
|
||||
self.assertEqual(status, 201, body)
|
||||
self.assertEqual(body["delivery"], "direct")
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/thread/message" % task["id"],
|
||||
{"body": "坏模式", "mode": "bogus"}, self.owner_key)
|
||||
self.assertEqual(status, 400, body)
|
||||
# 失败任务发修复指令:复活(不是 409),状态回 queued。
|
||||
done = self._create_task()
|
||||
self._set_task(done["id"], status="failed",
|
||||
error_text="compile error")
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/thread/message" % done["id"],
|
||||
{"body": "继续修复编译错误并重跑测试"}, self.owner_key)
|
||||
self.assertEqual(status, 201, body)
|
||||
self.assertEqual(body["delivery"], "revived")
|
||||
status, task_after = self._req(
|
||||
"GET", "/tasks/%s" % done["id"], None, self.owner_key)
|
||||
self.assertEqual(task_after["status"], "queued")
|
||||
self.assertIsNone(task_after["error_text"])
|
||||
# 已合并任务不能复活(worktree 已删),应 409。
|
||||
merged = self._create_task()
|
||||
self._set_task(merged["id"], status="merged")
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/thread/message" % merged["id"],
|
||||
{"body": "再改一下"}, self.owner_key)
|
||||
self.assertEqual(status, 409, body)
|
||||
|
||||
def test_handoff_lifecycle_and_cross_project(self):
|
||||
task = self._create_task()
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/handoff" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertIsNone(body["handoff"])
|
||||
status, h = self._req(
|
||||
"POST", "/tasks/%s/handoff" % task["id"],
|
||||
{"target_host": "host-b"}, self.owner_key)
|
||||
self.assertEqual(status, 201, h)
|
||||
self.assertEqual(h["status"], "pending")
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/handoff" % task["id"],
|
||||
{"target_host": "host-c"}, self.owner_key)
|
||||
self.assertEqual(status, 409, body)
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/handoff/%s/report" % (task["id"], h["id"]),
|
||||
{"status": "running", "progress": 40}, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual((body["status"], body["progress"]),
|
||||
("running", 40))
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/handoff" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(body["handoff"]["status"], "running")
|
||||
other = self._create_task(self.project2["id"])
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/handoff/%s/report" % (other["id"], h["id"]),
|
||||
{"status": "completed"}, self.owner_key)
|
||||
self.assertEqual(status, 404, body)
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/handoff/%s/report" % (task["id"], h["id"]),
|
||||
{"status": "completed"}, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/handoff/%s/report" % (task["id"], h["id"]),
|
||||
{"status": "running"}, self.owner_key)
|
||||
self.assertEqual(status, 409, body)
|
||||
|
||||
def test_worktree_status_derived(self):
|
||||
task = self._create_task()
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/worktree" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["status"], "creating")
|
||||
import re
|
||||
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", task["id"])
|
||||
path = os.path.join(self.server.host_worktrees_dir, safe)
|
||||
os.makedirs(os.path.join(path, ".git"), exist_ok=True)
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/worktree" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(body["status"], "ready")
|
||||
self.assertTrue(body["exists"])
|
||||
self._set_task(task["id"], worktree_host="other-host")
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/worktree" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(body["status"], "remote")
|
||||
self.assertIsNone(body["exists"])
|
||||
|
||||
def test_pr_checks_and_no_pr(self):
|
||||
task = self._create_task()
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/pr/checks" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(status, 409, body)
|
||||
self._set_task(task["id"], pr_number=42,
|
||||
pr_url="https://example.invalid/pr/42")
|
||||
# 制表符回落 + 无仓库时不查 check-runs API。
|
||||
self.server.pr_gh_runner = lambda cmd, cwd: SimpleNamespace(
|
||||
returncode=0, stdout="build\tpass\ntest\tfail\n",
|
||||
stderr="")
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/pr/checks" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertFalse(body["all_pass"])
|
||||
self.assertEqual(body["failed"], ["test"])
|
||||
self.assertIn("test", body["diagnostics"])
|
||||
self.assertFalse(body["check_runs"]["available"])
|
||||
# 有仓库时:JSON checks + check-runs 日志与行号注释。
|
||||
status, repo_proj = self._req(
|
||||
"POST", "/projects",
|
||||
{"name": "repo-proj",
|
||||
"repo_url": "https://github.com/openqj/demo.git"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 201, repo_proj)
|
||||
repo_task = self._create_task(repo_proj["id"])
|
||||
self._set_task(repo_task["id"], pr_number=43,
|
||||
pr_url="https://example.invalid/pr/43")
|
||||
|
||||
def _gh(cmd, cwd):
|
||||
if "--json" in cmd:
|
||||
return SimpleNamespace(returncode=0, stdout=json.dumps([
|
||||
{"name": "build", "bucket": "pass",
|
||||
"workflow": "CI",
|
||||
"link": "https://example.invalid/ci/1",
|
||||
"description": "ok"},
|
||||
{"name": "test", "bucket": "fail",
|
||||
"workflow": "CI",
|
||||
"link": "https://example.invalid/ci/2",
|
||||
"description": "1 failed"}]), stderr="")
|
||||
if "view" in cmd:
|
||||
return SimpleNamespace(returncode=0, stdout="abc123\n",
|
||||
stderr="")
|
||||
if any("commits" in c for c in cmd):
|
||||
return SimpleNamespace(returncode=0, stdout=json.dumps(
|
||||
{"check_runs": [{"id": 99, "name": "test",
|
||||
"conclusion": "failure"}]}),
|
||||
stderr="")
|
||||
if any("check-runs" in c for c in cmd):
|
||||
return SimpleNamespace(returncode=0, stdout=json.dumps({
|
||||
"id": 99, "conclusion": "failure",
|
||||
"output": {
|
||||
"title": "Test failed",
|
||||
"summary": "1 test failed",
|
||||
"text": "FAILED test_x: assert failed",
|
||||
"annotations": [{
|
||||
"path": "src/a.py", "start_line": 12,
|
||||
"annotation_level": "failure",
|
||||
"message": "assert failed"}]}}),
|
||||
stderr="")
|
||||
return SimpleNamespace(returncode=1, stdout="", stderr="boom")
|
||||
|
||||
self.server.pr_gh_runner = _gh
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/pr/checks" % repo_task["id"],
|
||||
None, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["checks"][1]["workflow"], "CI")
|
||||
self.assertIn("example.invalid/ci/2", body["checks"][1]["link"])
|
||||
self.assertTrue(body["check_runs"]["available"])
|
||||
run_detail = body["check_runs"]["runs"][0]
|
||||
self.assertIn("FAILED test_x", run_detail["log_text"])
|
||||
self.assertEqual(run_detail["annotations"][0]["path"], "src/a.py")
|
||||
self.assertEqual(run_detail["annotations"][0]["start_line"], 12)
|
||||
self._set_task(task["id"], worktree_host="other-host")
|
||||
status, body = self._req(
|
||||
"GET", "/tasks/%s/pr/checks" % task["id"], None, self.owner_key)
|
||||
self.assertEqual(status, 409, body)
|
||||
|
||||
def test_sleep_and_wait(self):
|
||||
task = self._create_task()
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/sleep" % task["id"],
|
||||
{"seconds": 0}, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["slept_seconds"], 0)
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/sleep" % task["id"],
|
||||
{"seconds": 61}, self.owner_key)
|
||||
self.assertEqual(status, 400, body)
|
||||
parent = self._create_task()
|
||||
child = self._create_task()
|
||||
self._set_task(child["id"], status="completed",
|
||||
parent_task_id=parent["id"])
|
||||
# 无游标:已终态的子任务立即作为首变返回,不等满超时。
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/wait" % parent["id"],
|
||||
{"timeout_seconds": 30}, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertTrue(body["all_terminal"])
|
||||
self.assertFalse(body["timed_out"])
|
||||
self.assertEqual(body["children"][0]["task_id"], child["id"])
|
||||
self.assertEqual(body["changed_task"]["task_id"], child["id"])
|
||||
self.assertEqual(body["changed_task"]["kind"], "terminal")
|
||||
cursor = body["cursor"]
|
||||
# 同一游标再等:已终态不再重复报,超时回进度 + 新游标。
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/wait" % parent["id"],
|
||||
{"timeout_seconds": 0, "after_cursor": cursor},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertTrue(body["timed_out"])
|
||||
self.assertIsNone(body["changed_task"])
|
||||
# 非法游标 400;超 8 个 400;运行中 + 超时 0 回超时。
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/wait" % parent["id"],
|
||||
{"timeout_seconds": 0, "after_cursor": "bogus!!"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 400, body)
|
||||
running = self._create_task()
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/wait" % parent["id"],
|
||||
{"task_ids": [running["id"]], "timeout_seconds": 0},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertTrue(body["timed_out"])
|
||||
self.assertFalse(body["all_terminal"])
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/wait" % parent["id"],
|
||||
{"task_ids": ["x"] * 9, "timeout_seconds": 0},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 400, body)
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/wait" % parent["id"],
|
||||
{"timeout_seconds": 121}, self.owner_key)
|
||||
self.assertEqual(status, 400, body)
|
||||
|
||||
def test_wait_cursor_approval_trigger(self):
|
||||
# 基线游标(运行中、无待审批)-> 新建 elicitation 审批
|
||||
# -> 再等同一游标,立即报 needs_input(不是等满超时)。
|
||||
parent = self._create_task()
|
||||
target = self._create_task()
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/wait" % parent["id"],
|
||||
{"task_ids": [target["id"]], "timeout_seconds": 0},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertTrue(body["timed_out"])
|
||||
cursor = body["cursor"]
|
||||
status, appr = self._req(
|
||||
"POST", "/tasks/%s/approvals" % target["id"],
|
||||
{"kind": "elicitation", "summary": "请确认是否继续"},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 201, appr)
|
||||
status, body = self._req(
|
||||
"POST", "/tasks/%s/wait" % parent["id"],
|
||||
{"task_ids": [target["id"]], "timeout_seconds": 30,
|
||||
"after_cursor": cursor},
|
||||
self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertFalse(body["timed_out"])
|
||||
self.assertEqual(body["changed_task"]["kind"], "needs_input")
|
||||
self.assertEqual(body["changed_task"]["task_id"], target["id"])
|
||||
|
||||
def test_automation_update_and_get(self):
|
||||
status, auto = self._req(
|
||||
"POST", "/projects/%s/automations" % self.project["id"],
|
||||
{"name": "daily", "prompt": "做每日检查"}, self.owner_key)
|
||||
self.assertEqual(status, 201, auto)
|
||||
status, body = self._req(
|
||||
"PATCH", "/projects/%s/automations/%s"
|
||||
% (self.project["id"], auto["id"]),
|
||||
{"interval_seconds": 7200}, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["interval_seconds"], 7200)
|
||||
status, body = self._req(
|
||||
"GET", "/projects/%s/automations/%s"
|
||||
% (self.project["id"], auto["id"]),
|
||||
None, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(body["runs"], [])
|
||||
status, body = self._req(
|
||||
"PATCH", "/projects/%s/automations/%s"
|
||||
% (self.project["id"], auto["id"]),
|
||||
{}, self.owner_key)
|
||||
self.assertEqual(status, 400, body)
|
||||
status, body = self._req(
|
||||
"PATCH", "/projects/%s/automations/%s"
|
||||
% (self.project2["id"], auto["id"]),
|
||||
{"interval_seconds": 3600}, self.owner_key)
|
||||
self.assertEqual(status, 404, body)
|
||||
status, body = self._req(
|
||||
"POST", "/projects/%s/automations/%s/cancel"
|
||||
% (self.project["id"], auto["id"]),
|
||||
{}, self.owner_key)
|
||||
self.assertEqual(status, 200, body)
|
||||
status, body = self._req(
|
||||
"PATCH", "/projects/%s/automations/%s"
|
||||
% (self.project["id"], auto["id"]),
|
||||
{"interval_seconds": 3600}, self.owner_key)
|
||||
self.assertEqual(status, 409, body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user