feat: 从 tutti-selfhost 拆出 local_rig 独立仓

- 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
This commit is contained in:
2026-10-06 13:36:14 +08:00
commit 93758f3720
15 changed files with 2736 additions and 0 deletions
+453
View File
@@ -0,0 +1,453 @@
"""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 三次 ⇒ 只应有一个任务")
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""**真 hermes CLI** 的端到端验证(默认跳过;`RIG_REAL=1` 才跑)。
为什么单独一个开关:它会**真调用本机 agent 与模型 API**(有网络/配额成本、耗时数十秒),
所以不进常规 CI。2026-10-05 实测已通过:入队 → 真子进程 → done → result="好的"。
同时钉住两个实测缺口(都曾让链路静默失效):
① `--agent` 不存在于 hermes CLI ⇒ 拼进去会被 argparse 当子命令名报错;
② 不传 `-m/--model` ⇒ 服务端 `HTTP 400: supported API model names are …, but you passed .`
"""
import os
import shutil
import sys
import tempfile
import time
import unittest
from pathlib import Path
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.dirname(_HERE))
from local_rig.daemon import RigDaemon # noqa: E402
from local_rig.rig_schema import Member # noqa: E402
HERMES_BIN = os.path.expanduser(
"~/Library/Application Support/cn.org.hermesagent.desktop/runtime/desktop-bin/hermes")
YAML = ('version: "0.1"\nname: real\npods:\n - id: dev\n label: D\n members:\n'
' - id: hermes\n runtime: hermes-cli\n target: hermes\n edges: []\n')
def _daemon():
cfg = Path(tempfile.mkdtemp()) / "rig.yaml"
cfg.write_text(YAML, encoding="utf-8")
return RigDaemon(cfg, db_path=str(Path(tempfile.mkdtemp()) / "q.db"))
class TestModelFlagIsPlumbed(unittest.TestCase):
"""静态可验的部分(不需要真跑):model 字段存在、命令里带 -m、不再带 --agent。"""
def test_member_has_model_default(self):
self.assertEqual(Member(id="x", runtime="hermes-cli", target="t").model, "deepseek-flash")
def test_yaml_can_override_model(self):
from local_rig.rig_schema import parse_rig
cfg = Path(tempfile.mkdtemp()) / "r.yaml"
cfg.write_text(YAML.replace("target: hermes\n", "target: hermes\n model: deepseek-v4-pro\n"),
encoding="utf-8")
rig = parse_rig(cfg)
self.assertEqual(rig.all_members()["dev.hermes"].model, "deepseek-v4-pro")
def test_no_bogus_agent_flag_in_sources(self):
for f in ("local_rig/daemon.py", "local_rig/runtime_hermes.py"):
src = Path(_HERE).parent.joinpath(f).read_text(encoding="utf-8")
code = "\n".join(l for l in src.splitlines() if not l.strip().startswith("#"))
self.assertNotIn('"--agent"', code, f"{f}: 仍把 --agent 传给 hermes CLI(它没有这个全局参数)")
@unittest.skipUnless(os.environ.get("RIG_REAL") == "1", "设 RIG_REAL=1 才真调 agent")
@unittest.skipUnless(shutil.which("hermes") or Path(HERMES_BIN).exists(), "本机没有 hermes CLI")
class TestRealAgentEndToEnd(unittest.TestCase):
def test_real_agent_answers_and_result_flows_to_bridge_event(self):
d = _daemon()
if not shutil.which("hermes"):
d.hermes_rt.hermes_bin = HERMES_BIN
d.enqueue("hermes", "只回两个字:好的", task_id="real-e2e", idem_key="idem-real-e2e")
row = None
for _ in range(60):
time.sleep(2)
row = d.queue.get("real-e2e")
if row and row.status in ("done", "failed"):
break
self.assertIsNotNone(row)
self.assertEqual(row.status, "done", f"真执行未达终态: {row.error}")
self.assertTrue((row.result or "").strip(), "真 agent 应有非空产出")
self.assertNotIn("HTTP 400", row.result or "", "不该再把模型名传错")
from bridge.rig import RigResultSource
import json
class _Local:
def __call__(self, req, timeout=None):
tk = d.queue.get(req.full_url.rsplit("/", 1)[-1])
class R:
def __enter__(self): return self
def __exit__(self, *a): return False
def read(self):
return json.dumps({"id": tk.id, "status": tk.status,
"result": tk.result, "error": tk.error}).encode()
return R()
s = RigResultSource(); s._open = _Local()
s.watch({"task_id": "real-e2e", "rig_task_id": "real-e2e"})
evs = s.poll()
self.assertEqual([e.contract_status for e in evs], ["succeeded"], "真产出应经接通件变成 succeeded 事件")
self.assertTrue(evs[0].summary.strip(), "事件里要带上 agent 的真实文本,房间才有内容可看")
if __name__ == "__main__":
unittest.main()