Files
codex-app-server/cloud/tests/test_thread_lifecycle.py
T
host cf08c4cf37 feat: 线程生命周期对齐 15 项(bindings/refs/快照/心跳绑定/移交落点)
对照 Codex app 第二批:GET /tasks/{id}/bindings(项目/
环境/主机/worktree/PR/附件/血缘总表)、/refs 外部引用
增删查、/worktree/archive 快照(branch/head/status/diff/
untracked 路径)与 restore(目录在才解归档、目录删了
409 附快照)、自动化 bind + heartbeat(走 thread/message
同款投递路,cron 绑定不唤醒)、移交 completed 后任务
worktree_host 跟随切换、pr/checks 响应补 head_sha、
thread/read 的 tool 条目补 server/status。

诚实边界见 docs/thread-lifecycle-align.md:已有 worktree
附加、自动化变化静默、建议器、服务端定时触发器未实现;
Memory 对位是待合的 PR #102,本批不重复实现。

三套件:cloud 948 OK(4 跳过)、host 91 OK、deploy 29 OK
2026-10-28 04:56:22 +08:00

299 lines
12 KiB
Python

"""线程生命周期补充单测:cloud/lifecycle 纯逻辑 + server 端点。
覆盖:bindings 聚合、外部引用增删查、心跳绑定与唤醒、
worktree 快照归档/恢复、移交完成更新 worktree_host、
PR checks 补 head_sha。不跑真实模型、不联网、不调真实
git(快照测试用空 .git 目录,git 命令失败时仍能建快照,
只 branch/head 为空——这是诚实降级分支本身)。"""
import http.client
import json
import os
import shutil
import sqlite3
import sys
import tempfile
import threading
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.server import create_server
from cloud.lifecycle import (add_ref, bind_automation,
create_snapshot, get_binding,
latest_snapshot, list_refs,
list_snapshots)
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.lifecycle import (add_ref, bind_automation,
create_snapshot, get_binding,
latest_snapshot, list_refs,
list_snapshots)
class LifecyclePureTestCase(unittest.TestCase):
def setUp(self):
self.conn = sqlite3.connect(":memory:")
self.conn.row_factory = sqlite3.Row
def tearDown(self):
self.conn.close()
def test_snapshot_truncates_and_lists(self):
snap = create_snapshot(
self.conn, "t1", "p1", "/tmp/wt", "main", "abc",
"M a.py", "diff " * 10000, ["untracked.txt"])
self.assertLessEqual(len(snap["diff_text"]), 20000)
self.assertEqual(snap["untracked"], ["untracked.txt"])
self.assertIsNone(snap["restored_at"])
self.assertEqual(latest_snapshot(self.conn, "t1")["id"],
snap["id"])
self.assertEqual(len(list_snapshots(self.conn, "t1")), 1)
def test_ref_validation(self):
ref = add_ref(self.conn, "t1", "p1", "doc",
"https://example.invalid/spec.md", "规格", "u1")
self.assertEqual(ref["kind"], "doc")
with self.assertRaises(ValueError):
add_ref(self.conn, "t1", "p1", "bogus", "x", None, "u1")
with self.assertRaises(ValueError):
add_ref(self.conn, "t1", "p1", "doc", " ", None, "u1")
self.assertEqual(len(list_refs(self.conn, "t1")), 1)
def test_binding_mode_and_overwrite(self):
b = bind_automation(self.conn, "a1", "p1", "t1", "heartbeat")
self.assertEqual(b["mode"], "heartbeat")
b = bind_automation(self.conn, "a1", "p1", "t2", "cron")
self.assertEqual((b["task_id"], b["mode"]), ("t2", "cron"))
self.assertEqual(get_binding(self.conn, "a1")["task_id"], "t2")
with self.assertRaises(ValueError):
bind_automation(self.conn, "a2", "p1", "t1", "weekly")
class LifecycleServerTestCase(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.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")
def tearDown(self):
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 _create_task(self, **extra):
body = {"project_id": 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 _worktree_dir(self, task_id):
# _thread_worktree_for_task 的算法:host_worktrees_dir +
# 安全化后的任务 id(本测试任务 id 只含 hex,无需转义)。
return os.path.join(self.server.host_worktrees_dir, task_id)
def test_bindings_aggregates(self):
task = self._create_task()
self._set_task(task["id"], pr_number=7,
pr_url="https://example.invalid/pr/7")
status, body = self._req(
"GET", "/tasks/%s/bindings" % task["id"], None,
self.reader_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["project"]["name"], "demo")
self.assertEqual(body["pull_request"]["number"], 7)
self.assertEqual(body["worktree"]["status"], "creating")
self.assertTrue(body["host"]["local"])
self.assertEqual(body["lineage"]["parent_task_id"], None)
self.assertEqual(body["attachments"]["files"], [])
status, _ = self._req(
"GET", "/tasks/%s/bindings" % task["id"], None,
self.outsider_key)
self.assertEqual(status, 403)
def test_refs_crud(self):
task = self._create_task()
status, ref = self._req(
"POST", "/tasks/%s/refs" % task["id"],
{"kind": "doc", "ref": "https://example.invalid/spec.md",
"note": "规格"}, self.owner_key)
self.assertEqual(status, 201, ref)
status, body = self._req(
"POST", "/tasks/%s/refs" % task["id"],
{"kind": "bogus", "ref": "x"}, self.owner_key)
self.assertEqual(status, 400, body)
status, body = self._req(
"GET", "/tasks/%s/refs" % task["id"], None, self.reader_key)
self.assertEqual(len(body["refs"]), 1)
status, body = self._req(
"DELETE", "/tasks/%s/refs/%s" % (task["id"], ref["id"]),
None, self.owner_key)
self.assertEqual(status, 200, body)
status, body = self._req(
"DELETE", "/tasks/%s/refs/%s" % (task["id"], ref["id"]),
None, self.owner_key)
self.assertEqual(status, 404, body)
def test_worktree_snapshot_and_restore(self):
task = self._create_task()
wt = self._worktree_dir(task["id"])
os.makedirs(os.path.join(wt, ".git"), exist_ok=True)
status, body = self._req(
"POST", "/tasks/%s/worktree/archive" % task["id"],
{}, self.owner_key)
self.assertEqual(status, 201, body)
self.assertEqual(body["untracked_count"], 0)
status, body = self._req(
"GET", "/tasks/%s/worktree/snapshots" % task["id"],
None, self.reader_key)
self.assertEqual(len(body["snapshots"]), 1)
status, body = self._req(
"POST", "/tasks/%s/worktree/restore" % task["id"],
{}, self.owner_key)
self.assertEqual(status, 200, body)
self.assertIsNotNone(body["snapshot"]["restored_at"])
# 目录删了之后再恢复:409 并附快照,不编假恢复。
shutil.rmtree(wt)
status, body = self._req(
"POST", "/tasks/%s/worktree/archive" % task["id"],
{}, self.owner_key)
self.assertEqual(status, 409, body)
# 先再拍一张是不可能的(目录已删),直接用旧快照恢复:
status, body = self._req(
"POST", "/tasks/%s/worktree/restore" % task["id"],
{}, self.owner_key)
self.assertEqual(status, 409, body)
self.assertIn("snapshot", body)
def test_handoff_completed_moves_host(self):
task = self._create_task()
status, h = self._req(
"POST", "/tasks/%s/handoff" % task["id"],
{"target_host": "host-b"}, self.owner_key)
self.assertEqual(status, 201, h)
for st in ("running", "completed"):
status, body = self._req(
"POST", "/tasks/%s/handoff/%s/report"
% (task["id"], h["id"]),
{"status": st}, self.owner_key)
self.assertEqual(status, 200, body)
status, task_after = self._req(
"GET", "/tasks/%s" % task["id"], None, self.owner_key)
self.assertEqual(task_after["worktree_host"], "host-b")
def test_automation_heartbeat_binding(self):
status, auto = self._req(
"POST", "/projects/%s/automations" % self.project["id"],
{"name": "watch", "prompt": "检查部署健康",
"interval_seconds": 3600}, self.owner_key)
self.assertEqual(status, 201, auto)
aid = auto["id"]
task = self._create_task()
self._set_task(task["id"], status="running")
# 未绑定时心跳 409。
status, body = self._req(
"POST", "/projects/%s/automations/%s/heartbeat"
% (self.project["id"], aid),
{"message": "查一下"}, self.owner_key)
self.assertEqual(status, 409, body)
status, body = self._req(
"POST", "/projects/%s/automations/%s/bind"
% (self.project["id"], aid),
{"task_id": task["id"], "mode": "heartbeat"},
self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["binding"]["task_id"], task["id"])
status, body = self._req(
"GET", "/projects/%s/automations/%s" % (self.project["id"],
aid),
None, self.reader_key)
self.assertEqual(body["heartbeat_binding"]["mode"], "heartbeat")
status, body = self._req(
"POST", "/projects/%s/automations/%s/heartbeat"
% (self.project["id"], aid),
{"message": "检查部署健康并汇报"}, self.owner_key)
self.assertEqual(status, 201, body)
self.assertEqual(body["delivery"], "queued")
# cron 绑定不唤醒线程。
status, body = self._req(
"POST", "/projects/%s/automations/%s/bind"
% (self.project["id"], aid),
{"task_id": task["id"], "mode": "cron"}, self.owner_key)
self.assertEqual(status, 200, body)
status, body = self._req(
"POST", "/projects/%s/automations/%s/heartbeat"
% (self.project["id"], aid),
{}, self.owner_key)
self.assertEqual(status, 409, body)
status, body = self._req(
"DELETE", "/projects/%s/automations/%s/bind"
% (self.project["id"], aid), None, self.owner_key)
self.assertEqual(status, 200, body)
status, body = self._req(
"GET", "/projects/%s/automations/%s/bind"
% (self.project["id"], aid), None, self.reader_key)
self.assertIsNone(body["binding"])
if __name__ == "__main__":
unittest.main()