- local_rig/ 完整包(7 源文件 + ui.html + CHANGELOG) - tests/test_local_rig.py + test_rig_real_agent.py(纯 local_rig 测试) - scripts/15-criterion4-live.sh(判据④活环境门) - README.md(定位/快速开始/与 tutti-selfhost 的关系) - requirements.txt(唯一外部依赖 pyyaml) - .gitignore 来源: figmar/tutti-selfhost @ 3b23853 (2026-10-06) 原 PR #43 已合入 tutti-selfhost main
454 lines
14 KiB
Python
454 lines
14 KiB
Python
"""local_rig 测试:schema 解析 + 队列持久化 + daemon API。"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import socket
|
|
import sqlite3
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
import urllib.request
|
|
import ssl
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
# ── schema 测试 ──
|
|
|
|
import sys
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from local_rig.rig_schema import parse_rig, Rig, RigSchemaError, Member, Pod, Edge
|
|
|
|
|
|
def _write_rig(tmp: str, content: str) -> str:
|
|
p = os.path.join(tmp, "rig.yaml")
|
|
with open(p, "w") as f:
|
|
f.write(content)
|
|
return p
|
|
|
|
|
|
MINIMAL_RIG = """
|
|
version: "0.1"
|
|
name: test-rig
|
|
pods:
|
|
- id: dev
|
|
members:
|
|
- id: lead
|
|
runtime: terminal
|
|
command: "echo hi"
|
|
"""
|
|
|
|
FULL_RIG = """
|
|
version: "0.1"
|
|
name: full-rig
|
|
pods:
|
|
- id: dev
|
|
label: Development
|
|
members:
|
|
- id: lead
|
|
runtime: hermes-cli
|
|
target: hermes
|
|
- id: qa
|
|
runtime: terminal
|
|
command: "python3 qa.py"
|
|
cwd: ./qa
|
|
edges:
|
|
- kind: delegates_to
|
|
from: lead
|
|
to: qa
|
|
- id: rev
|
|
members:
|
|
- id: r1
|
|
runtime: terminal
|
|
command: "echo review"
|
|
edges:
|
|
- kind: can_observe
|
|
from: r1
|
|
to: dev.lead
|
|
"""
|
|
|
|
|
|
class TestRigSchema(unittest.TestCase):
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.mkdtemp(prefix="hermes-rig-test-")
|
|
|
|
def tearDown(self):
|
|
import shutil
|
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
|
|
def test_minimal_rig(self):
|
|
p = _write_rig(self.tmp, MINIMAL_RIG)
|
|
rig = parse_rig(p)
|
|
self.assertEqual(rig.name, "test-rig")
|
|
self.assertEqual(len(rig.pods), 1)
|
|
self.assertEqual(rig.pods[0].id, "dev")
|
|
members = rig.all_members()
|
|
self.assertIn("dev.lead", members)
|
|
self.assertEqual(members["dev.lead"].runtime, "terminal")
|
|
|
|
def test_full_rig(self):
|
|
p = _write_rig(self.tmp, FULL_RIG)
|
|
rig = parse_rig(p)
|
|
members = rig.all_members()
|
|
self.assertEqual(len(members), 3)
|
|
self.assertEqual(members["dev.lead"].runtime, "hermes-cli")
|
|
self.assertEqual(members["dev.lead"].target, "hermes")
|
|
self.assertEqual(members["dev.qa"].cwd, "./qa")
|
|
# edges
|
|
self.assertEqual(len(rig.pods[0].edges), 1)
|
|
e = rig.pods[0].edges[0]
|
|
self.assertEqual(e.kind, "delegates_to")
|
|
self.assertEqual(rig.resolve_edge(e), ("dev.lead", "dev.qa"))
|
|
|
|
def test_missing_name(self):
|
|
p = _write_rig(self.tmp, "pods: []\n")
|
|
with self.assertRaises(RigSchemaError):
|
|
parse_rig(p)
|
|
|
|
def test_empty_pods(self):
|
|
p = _write_rig(self.tmp, 'name: x\npods: []\n')
|
|
with self.assertRaises(RigSchemaError):
|
|
parse_rig(p)
|
|
|
|
def test_bad_runtime(self):
|
|
content = MINIMAL_RIG.replace("terminal", "warp-drive")
|
|
p = _write_rig(self.tmp, content)
|
|
with self.assertRaises(RigSchemaError):
|
|
parse_rig(p)
|
|
|
|
def test_terminal_requires_command(self):
|
|
content = """
|
|
name: t
|
|
pods:
|
|
- id: dev
|
|
members:
|
|
- id: x
|
|
runtime: terminal
|
|
"""
|
|
p = _write_rig(self.tmp, content)
|
|
with self.assertRaises(RigSchemaError):
|
|
parse_rig(p)
|
|
|
|
def test_hermes_requires_target(self):
|
|
content = """
|
|
name: t
|
|
pods:
|
|
- id: dev
|
|
members:
|
|
- id: x
|
|
runtime: hermes-cli
|
|
"""
|
|
p = _write_rig(self.tmp, content)
|
|
with self.assertRaises(RigSchemaError):
|
|
parse_rig(p)
|
|
|
|
def test_duplicate_pod_id(self):
|
|
content = """
|
|
name: t
|
|
pods:
|
|
- id: dev
|
|
members:
|
|
- id: a
|
|
runtime: terminal
|
|
command: "echo"
|
|
- id: dev
|
|
members:
|
|
- id: b
|
|
runtime: terminal
|
|
command: "echo"
|
|
"""
|
|
p = _write_rig(self.tmp, content)
|
|
with self.assertRaises(RigSchemaError):
|
|
parse_rig(p)
|
|
|
|
def test_bad_edge_ref(self):
|
|
content = """
|
|
name: t
|
|
pods:
|
|
- id: dev
|
|
members:
|
|
- id: a
|
|
runtime: terminal
|
|
command: "echo"
|
|
edges:
|
|
- kind: delegates_to
|
|
from: a
|
|
to: ghost
|
|
"""
|
|
p = _write_rig(self.tmp, content)
|
|
with self.assertRaises(RigSchemaError):
|
|
parse_rig(p)
|
|
|
|
def test_bad_id_chars(self):
|
|
content = """
|
|
name: t
|
|
pods:
|
|
- id: Dev
|
|
members:
|
|
- id: a
|
|
runtime: terminal
|
|
command: "echo"
|
|
"""
|
|
p = _write_rig(self.tmp, content)
|
|
with self.assertRaises(RigSchemaError):
|
|
parse_rig(p)
|
|
|
|
def test_cross_pod_edge(self):
|
|
p = _write_rig(self.tmp, FULL_RIG)
|
|
rig = parse_rig(p)
|
|
e = rig.pods[1].edges[0] # rev.r1 → dev.lead
|
|
self.assertEqual(rig.resolve_edge(e), ("rev.r1", "dev.lead"))
|
|
|
|
|
|
# ── 队列测试 ──
|
|
|
|
from local_rig.queue import TaskQueue
|
|
|
|
|
|
class TestTaskQueue(unittest.TestCase):
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.mkdtemp(prefix="hermes-rig-queue-")
|
|
self.db = os.path.join(self.tmp, "test.sqlite3")
|
|
self.q = TaskQueue(self.db)
|
|
|
|
def tearDown(self):
|
|
self.q.close()
|
|
import shutil
|
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
|
|
def test_enqueue_and_get(self):
|
|
t = self.q.enqueue("dev.lead", "修 bug")
|
|
self.assertEqual(t.status, "pending")
|
|
got = self.q.get(t.id)
|
|
self.assertIsNotNone(got)
|
|
self.assertEqual(got.prompt, "修 bug")
|
|
self.assertEqual(got.assignee, "dev.lead")
|
|
|
|
def test_idempotent_enqueue(self):
|
|
t1 = self.q.enqueue("dev.lead", "修 bug", task_id="fixed-id")
|
|
t2 = self.q.enqueue("dev.lead", "不同的 prompt", task_id="fixed-id")
|
|
self.assertEqual(t1.id, t2.id)
|
|
self.assertEqual(t2.prompt, "修 bug") # 不覆盖
|
|
self.assertEqual(self.q.count(), 1)
|
|
|
|
def test_claim_atomic(self):
|
|
self.q.enqueue("dev.lead", "task1")
|
|
t = self.q.claim(assignee="dev.lead")
|
|
self.assertIsNotNone(t)
|
|
self.assertEqual(t.status, "running")
|
|
# 再领 → None
|
|
self.assertIsNone(self.q.claim(assignee="dev.lead"))
|
|
|
|
def test_claim_wrong_assignee(self):
|
|
self.q.enqueue("dev.lead", "task1")
|
|
self.assertIsNone(self.q.claim(assignee="rev.r1"))
|
|
|
|
def test_complete(self):
|
|
t = self.q.enqueue("dev.lead", "task1")
|
|
self.q.claim(assignee="dev.lead")
|
|
self.q.complete(t.id, result="done!")
|
|
got = self.q.get(t.id)
|
|
self.assertEqual(got.status, "done")
|
|
self.assertEqual(got.result, "done!")
|
|
|
|
def test_fail(self):
|
|
t = self.q.enqueue("dev.lead", "task1")
|
|
self.q.claim(assignee="dev.lead")
|
|
self.q.fail(t.id, error="boom")
|
|
got = self.q.get(t.id)
|
|
self.assertEqual(got.status, "failed")
|
|
self.assertEqual(got.error, "boom")
|
|
|
|
def test_timeout_sweep(self):
|
|
t = self.q.enqueue("dev.lead", "task1", deadline_s=0.01)
|
|
self.q.claim(assignee="dev.lead")
|
|
time.sleep(0.05)
|
|
timed_out = self.q.sweep_timeouts()
|
|
self.assertIn(t.id, timed_out)
|
|
got = self.q.get(t.id)
|
|
self.assertEqual(got.status, "timeout")
|
|
|
|
def test_persistence_across_restart(self):
|
|
t = self.q.enqueue("dev.lead", "持久化测试")
|
|
self.q.close()
|
|
# 重开
|
|
q2 = TaskQueue(self.db)
|
|
got = q2.get(t.id)
|
|
self.assertIsNotNone(got)
|
|
self.assertEqual(got.prompt, "持久化测试")
|
|
self.assertEqual(got.status, "pending")
|
|
q2.close()
|
|
|
|
def test_list_filter(self):
|
|
self.q.enqueue("dev.lead", "a")
|
|
self.q.enqueue("dev.qa", "b")
|
|
self.q.enqueue("dev.lead", "c")
|
|
self.assertEqual(len(self.q.list(assignee="dev.lead")), 2)
|
|
self.assertEqual(len(self.q.list(status="pending")), 3)
|
|
|
|
def test_meta(self):
|
|
self.q.set_meta("version", "0.1")
|
|
self.assertEqual(self.q.get_meta("version"), "0.1")
|
|
self.assertIsNone(self.q.get_meta("nonexistent"))
|
|
|
|
|
|
# ── daemon API 测试 ──
|
|
|
|
from local_rig.daemon import RigDaemon
|
|
|
|
|
|
class TestRigDaemon(unittest.TestCase):
|
|
|
|
def setUp(self):
|
|
self.tmp = tempfile.mkdtemp(prefix="hermes-rig-daemon-")
|
|
self.rig_path = _write_rig(self.tmp, MINIMAL_RIG)
|
|
self.db_path = os.path.join(self.tmp, "rig.sqlite3")
|
|
# 动态端口(避免冲突)
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
s.bind(("127.0.0.1", 0))
|
|
self.port = s.getsockname()[1]
|
|
s.close()
|
|
self.d = RigDaemon(self.rig_path, db_path=self.db_path, port=self.port)
|
|
self.d.start(background=True)
|
|
self.ctx = ssl.create_default_context()
|
|
self.ctx.check_hostname = False
|
|
self.ctx.verify_mode = ssl.CERT_NONE
|
|
time.sleep(0.2) # 等 daemon 就绪
|
|
|
|
def tearDown(self):
|
|
self.d.stop()
|
|
import shutil
|
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
|
|
def _get(self, path):
|
|
req = urllib.request.Request(f"http://127.0.0.1:{self.port}{path}")
|
|
with urllib.request.urlopen(req, context=self.ctx, timeout=5) as resp:
|
|
return json.loads(resp.read())
|
|
|
|
def _post(self, path, data=None):
|
|
body = json.dumps(data or {}).encode()
|
|
req = urllib.request.Request(
|
|
f"http://127.0.0.1:{self.port}{path}", data=body, method="POST",
|
|
headers={"Content-Type": "application/json"})
|
|
with urllib.request.urlopen(req, context=self.ctx, timeout=5) as resp:
|
|
return json.loads(resp.read())
|
|
|
|
def test_health(self):
|
|
r = self._get("/health")
|
|
self.assertEqual(r["status"], "ok")
|
|
self.assertEqual(r["rig"], "test-rig")
|
|
|
|
def test_rig_definition(self):
|
|
r = self._get("/rig")
|
|
self.assertEqual(r["name"], "test-rig")
|
|
self.assertIn("pods", r)
|
|
|
|
def test_status(self):
|
|
r = self._get("/status")
|
|
self.assertIn("dev.lead", r)
|
|
|
|
def test_enqueue_and_list(self):
|
|
r = self._post("/tasks", {"assignee": "dev.lead", "prompt": "测试任务"})
|
|
self.assertEqual(r["status"], "pending")
|
|
tid = r["id"]
|
|
tasks = self._get("/tasks")
|
|
self.assertEqual(len(tasks), 1)
|
|
self.assertEqual(tasks[0]["prompt"], "测试任务")
|
|
|
|
def test_task_lifecycle(self):
|
|
r = self._post("/tasks", {"assignee": "dev.lead", "prompt": "lifecycle"})
|
|
tid = r["id"]
|
|
# claim
|
|
self._post(f"/tasks/{tid}/claim")
|
|
t = self._get(f"/tasks/{tid}")
|
|
self.assertEqual(t["status"], "running")
|
|
# done
|
|
self._post(f"/tasks/{tid}/done", {"result": "完成"})
|
|
t = self._get(f"/tasks/{tid}")
|
|
self.assertEqual(t["status"], "done")
|
|
self.assertEqual(t["result"], "完成")
|
|
|
|
def test_task_fail(self):
|
|
r = self._post("/tasks", {"assignee": "dev.lead", "prompt": "will-fail"})
|
|
tid = r["id"]
|
|
self._post(f"/tasks/{tid}/claim")
|
|
self._post(f"/tasks/{tid}/fail", {"error": "崩溃了"})
|
|
t = self._get(f"/tasks/{tid}")
|
|
self.assertEqual(t["status"], "failed")
|
|
|
|
def test_missing_fields(self):
|
|
import urllib.error
|
|
try:
|
|
self._post("/tasks", {"assignee": "dev.lead"}) # 缺 prompt
|
|
self.fail("应该 400")
|
|
except urllib.error.HTTPError as e:
|
|
self.assertEqual(e.code, 400)
|
|
|
|
def test_404_unknown_task(self):
|
|
import urllib.error
|
|
try:
|
|
self._get("/tasks/nonexistent")
|
|
self.fail("应该 404")
|
|
except urllib.error.HTTPError as e:
|
|
self.assertEqual(e.code, 404)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
|
|
|
|
class TestMemberResolution(unittest.TestCase):
|
|
"""实测缺口回归:assignee 传裸 member id 时曾**静默不派发**(任务永远 pending)。"""
|
|
|
|
def _daemon(self, runtime="terminal"):
|
|
from local_rig.daemon import RigDaemon
|
|
from pathlib import Path
|
|
cfg = Path(tempfile.mkdtemp()) / "rig.yaml"
|
|
cfg.write_text(
|
|
"version: \"0.1\"\nname: t\npods:\n - id: dev\n label: D\n members:\n"
|
|
" - id: hermes\n runtime: %s\n target: hermes\n"
|
|
" command: \"echo hi\"\n edges: []\n" % runtime, encoding="utf-8")
|
|
return RigDaemon(cfg, db_path=str(Path(tempfile.mkdtemp()) / "q.db"))
|
|
|
|
def test_bare_member_id_resolves_to_pod_member(self):
|
|
d = self._daemon()
|
|
self.assertEqual(d.resolve_member("hermes"), "dev.hermes")
|
|
self.assertEqual(d.resolve_member("dev.hermes"), "dev.hermes")
|
|
|
|
def test_unknown_member_raises_instead_of_silent_pending(self):
|
|
d = self._daemon()
|
|
with self.assertRaises(KeyError):
|
|
d.enqueue("nope", "x")
|
|
|
|
def test_hermes_cli_dispatch_actually_fires(self):
|
|
"""端到端最小闭环:入队 → 真子进程 → done(曾经停在 pending 且无任何报错)。"""
|
|
import os, stat, time
|
|
d = self._daemon(runtime="hermes-cli")
|
|
fake = os.path.join(tempfile.mkdtemp(), "fake-agent")
|
|
with open(fake, "w") as f:
|
|
f.write('#!/bin/sh\necho "AGENT-OK: $*"\n')
|
|
os.chmod(fake, stat.S_IRWXU)
|
|
d.hermes_rt.hermes_bin = fake
|
|
task = d.enqueue("hermes", "说一句你好", task_id="gap-1", idem_key="idem-gap")
|
|
row = None
|
|
for _ in range(20):
|
|
time.sleep(0.5)
|
|
row = d.queue.get(task.id)
|
|
if row and row.status in ("done", "failed"):
|
|
break
|
|
self.assertIsNotNone(row)
|
|
self.assertEqual(row.status, "done", "裸 member id 的任务应被真正执行,而不是停在 pending")
|
|
self.assertIn("AGENT-OK", row.result or "")
|
|
|
|
def test_idempotency_survives_real_dispatch_path(self):
|
|
import os, stat
|
|
d = self._daemon(runtime="hermes-cli")
|
|
fake = os.path.join(tempfile.mkdtemp(), "fa"); open(fake, "w").write('#!/bin/sh\necho ok\n')
|
|
os.chmod(fake, stat.S_IRWXU); d.hermes_rt.hermes_bin = fake
|
|
ids = {d.enqueue("hermes", "同一件事", idem_key="same-key").id for _ in range(3)}
|
|
self.assertEqual(len(ids), 1, "同 idem_key 三次 ⇒ 只应有一个任务")
|