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
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
build/
.pytest_cache/
.ruff_cache/
+96
View File
@@ -0,0 +1,96 @@
# local-rig
单机轻量 Agent 团队 —— OpenRig 哲学 + ACP(Agent Coordination Protocol)。
从 `tutti-selfhost` 拆出(2026-10-06),独立仓库维护。
## 是什么
`local_rig` 是一个**单机多 Agent 调度器**:
- 一个 `rig.yaml` 定义成员(member)+ 运行环境(runtime)+ 协作边(edge)
- daemon 起 HTTP server(默认 `:27900`)
- 支持两种 runtime:**hermes-cli**(调 Hermes Agent)和 **terminal**(跑 shell 命令)
- 任务队列持久化(SQLite),支持幂等复验
- 自带 Web UI(`/ui`)
**不是** tutti-selfhost 的 bridge —— 它是更底层的「单机 Agent 团队」原语,bridge 可以调用它,但它不依赖 bridge。
## 快速开始
```bash
# 1. 装依赖
pip install pyyaml
# 2. 准备 rig 配置
cat > my-rig.yaml << 'EOF'
members:
- id: coder
runtime: hermes-cli
profile: default
- id: tester
runtime: terminal
command: "echo 'test passed'"
edges:
- from: coder
to: tester
EOF
# 3. 起 daemon
python3 -m local_rig.cli -f my-rig.yaml
# 4. 开 UI
open http://127.0.0.1:27900/ui
```
## CLI
```bash
python3 -m local_rig.cli -f <rig.yaml> # 起 daemon
python3 -m local_rig.cli -f <rig.yaml> --once # 跑一轮就退
```
## 测试
```bash
python3 -m unittest discover -s tests -v
```
## 文件结构
```
local_rig/
__init__.py # 包入口
cli.py # CLI 入口(python3 -m local_rig.cli)
daemon.py # HTTP daemon + 任务调度
queue.py # SQLite 持久化任务队列
rig_schema.py # rig.yaml 解析 + 校验
runtime_hermes.py # hermes-cli runtime(调 Hermes Agent)
runtime_terminal.py # terminal runtime(跑 shell 命令)
ui.html # Web UI(单文件,无构建)
CHANGELOG.md # 变更记录
tests/
test_local_rig.py # 单元测试(纯 local_rig,无外部依赖)
test_rig_real_agent.py # 真 agent 端到端测试
scripts/
15-criterion4-live.sh # 判据④活环境门(两档判定/幂等复验)
```
## 依赖
- Python 3.9+
- `pyyaml`(唯一外部依赖)
## 与 tutti-selfhost 的关系
| | local-rig | tutti-selfhost bridge |
|---|---|---|
| 定位 | 单机 Agent 团队原语 | 多 Agent 协作工作台的事件桥 |
| 依赖 | 纯 stdlib + pyyaml | 依赖 local_rig + Matrix + tuttid |
| 部署 | 任何有 Python 的机器 | 需要 tuttid + Matrix + hermes |
bridge 通过 `RigDispatcher` 调用 local_rig,但 local_rig 不反向依赖 bridge。
## 来源
从 `gitea.apescale.com/figmar/tutti-selfhost` 拆出(commit `3b23853`,2026-10-06)。
原 PR #43(feat(local_rig): 单机轻量 Agent 团队)已合入 tutti-selfhost main。
+159
View File
@@ -0,0 +1,159 @@
# local_rig 变更记录
> 分支:`feat/local-rig` → main
> PR:#43
> 基线:main `992ad7c`(rebase 后,2026-10-05)
> 最后更新:2026-10-05
## 与多智能体协作工作台的关系
local_rig 是工作台的**单机执行层**:
| 层 | 组件 | 职责 |
|---|---|---|
| **协作层**(Matrix 房间) | `22-agent-bus.sh` + bridge + tuttid | 人 + 多 Agent 在 #agent-hub-test 里 @ 派任务、收回执、翻历史 |
| **执行层**(本 PR) | `local_rig` daemon | 单台机器上跑多个 agent 实例(hermes CLI / terminal),任务入队 → on-demand spawn → 结果回报 |
对接点:
- 协作层里 @ 一个 agent 后,bridge 把任务经 tuttid 派到 `local_rig` daemon(`:27900`)
- `local_rig` 执行完把结果写回 tuttid → bridge → Matrix 房间(`com.apescale.tutti.result.v1`)
- 没有 local_rig 时,bridge 只能回执"accepted → running → succeeded"但没有真实产出(TY02 当前状态)
- local_rig 是**让 @ 之后有真 agent 执行并回报**的那一环(dsh 在 issue#2 里指出的断链)
## 回滚锚点
| 锚点 | 含义 |
|---|---|
| `main @ 8f547cc` | 合并前 main 的 HEAD(完全回滚 = `git reset --hard 8f547cc`) |
| `feat/local-rig @ 3f688bb` | 第一版(rig_schema/queue/daemon/cli/runtime + 测试) |
| `feat/local-rig @ f0ff9fe` | 第二版(on-demand dispatch + profile + dsh/workbuddy) |
| 下一个 commit(待提交) | 第三版(UI dashboard) |
**回滚操作**:
```bash
# 完全回滚(丢弃 local_rig)
git checkout main && git reset --hard 8f547cc
# 回到第一版(去掉 on-demand/profile 改动)
git checkout feat/local-rig && git reset --hard 3f688bb
# 回到第二版(去掉 UI)
git checkout feat/local-rig && git reset --hard f0ff9fe
```
## 文件清单
| 文件 | 行数 | 职责 | 新增/修改 |
|---|---|---|---|
| `local_rig/__init__.py` | 7 | 版本号 | 新增 |
| `local_rig/rig_schema.py` | 195 | YAML 解析 + 校验(pods/members/edges) | 新增 |
| `local_rig/queue.py` | 219 | SQLite WAL 持久化队列,状态机 | 新增 |
| `local_rig/daemon.py` | 326 | HTTP daemon + on-demand dispatch + `/ui` | 新增 |
| `local_rig/runtime_terminal.py` | 90 | tmux 裸终端运行时 | 新增 |
| `local_rig/runtime_hermes.py` | 157 | hermes CLI 运行时(profile + 自动检测 bin) | 新增 |
| `local_rig/cli.py` | 136 | `rig start/status/assign/tasks/stop` | 新增 |
| `local_rig/ui.html` | 183 | 单文件 HTML dashboard(vanilla JS) | 新增 |
| `demo/rig.yaml` | 50 | 示例团队(5 Agent 含 dsh/workbuddy) | 新增 |
| `tests/test_local_rig.py` | 401 | 29 个测试(schema/queue/daemon) | 新增 |
| `README.md` | +81 | 新增「单机轻量版」章节 | 修改 |
**总计**:11 个新文件 + 1 个修改,~1,830 行
## 变更时间线
### Commit 1: `3f688bb` — 单机轻量 Agent 团队
- `rig_schema.py`:YAML → `Rig` 对象(pods/members/edges,runtime 枚举校验)
- `queue.py`:SQLite WAL,`pending→running→done|failed|timeout`,`BEGIN IMMEDIATE` 原子领取
- `daemon.py`:`ThreadingHTTPServer`,API 见下
- `runtime_terminal.py`:tmux session 管理(start/stop/status)
- `runtime_hermes.py`:spawn `hermes chat`(预起常驻)
- `cli.py`:`rig start/status/assign/tasks/stop`
- `tests/test_local_rig.py`:29 例
- `demo/rig.yaml`:2 pod(dev + ops),3 member
### Commit 2: `f0ff9fe` — on-demand dispatch + profile
- **daemon.py**:
- `start_member()` hermes-cli 改 on-demand(不预起,任务到达时 spawn)
- `enqueue()` 触发 `_dispatch_hermes()` → 独立线程跑 `hermes -z "prompt"`
- `terminal` 失败不阻塞(tmux 未装 graceful degrade)
- `stop()` 加 `server_close()`(修端口释放)
- **runtime_hermes.py**:
- 自动检测 hermes bin(PATH → `~/Library/Application Support/.../desktop-bin/hermes`)
- `--profile` 支持(`HermesProcess.profile` 字段)
- `available()` 绝对路径走 `Path.exists()` 不走 `which`
- **demo/rig.yaml**:加 `dev.impl`(profile=dsh)+ `dev.qa`(profile=workbuddy)
### Commit 3(待提交):UI dashboard
- **ui.html**:单文件 HTML(暗色主题,vanilla JS,3s 轮询)
- Members 面板:状态灯 + runtime + profile
- Tasks 面板:状态 badge + result/error
- Assign 表单:选 member + 输入 prompt + 派发
- **daemon.py**:`GET /ui` 返回 `ui.html`
## API 一览
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/health` | `{"status":"ok","rig":"name"}` |
| GET | `/rig` | rig 定义(pods/members/edges) |
| GET | `/status` | 所有 member 状态 |
| GET | `/tasks` | 任务列表(`?status=&assignee=&limit=`) |
| GET | `/tasks/{id}` | 单个任务 |
| GET | `/ui` | HTML dashboard |
| POST | `/tasks` | 入队 `{"assignee","prompt","deadline_s"}` |
| POST | `/tasks/{id}/claim` | 领取 |
| POST | `/tasks/{id}/done` | 完成 `{"result"}` |
| POST | `/tasks/{id}/fail` | 失败 `{"error"}` |
| POST | `/start` | 启动所有 member |
| POST | `/start/{member}` | 启动单个 |
| POST | `/stop` | 停止所有 |
| POST | `/stop/{member}` | 停止单个 |
## CLI 用法
```bash
# 启动(前台,Ctrl+C 停)
python3 -m local_rig.cli -f demo/rig.yaml start --port 27900
# 状态
python3 -m local_rig.cli -f demo/rig.yaml status --port 27900
# 派任务
python3 -m local_rig.cli -f demo/rig.yaml assign --to dev.lead "修复登录 bug"
# 查任务
python3 -m local_rig.cli -f demo/rig.yaml tasks --status done
# 停止
python3 -m local_rig.cli -f demo/rig.yaml stop --port 27900
```
## 本机部署
```bash
# 1. 启动 daemon(后台)
cd <repo>
python3 -c "
import sys; sys.path.insert(0,'.')
from local_rig.daemon import serve
d = serve('demo/rig.yaml', port=27900)
import time
while True: time.sleep(60)
" &
# 2. 打开 UI
open http://127.0.0.1:27900/ui
# 3. CLI
python3 -m local_rig.cli -f demo/rig.yaml status
python3 -m local_rig.cli -f demo/rig.yaml assign --to dev.lead "reply OK"
```
## 已知限制
| 限制 | 影响 | 后续 |
|---|---|---|
| tmux 未装 | `terminal` 运行时不可用(graceful degrade) | `brew install tmux` |
| dsh/workbuddy 无独立 CLI | 通过 `hermes --profile` 代理 | 等 DSH CLI / WorkBuddy API |
| hermes-acp 未实现 | 需 tuttid 运行时 | 第二版接入 `:27800` WS |
| 单 daemon 单进程 | 不支持多 rig 并发 | 多端口多实例 |
+7
View File
@@ -0,0 +1,7 @@
"""local_rig — 单机轻量 Agent 团队(OpenRig 哲学 + 我们的 ACP 协议)。
YAML 定义团队 → 本地 daemon(SQLite + HTTP)→ tmux/CLI 跑 Agent。
零外部服务依赖:不需要 Matrix / Tuwunel / tuttid。
"""
__version__ = "0.1.0"
+136
View File
@@ -0,0 +1,136 @@
"""rig CLI — 一条命令管理单机 Agent 团队。
用法:
rig start -f rig.yaml [--port 27900]
rig status -f rig.yaml
rig assign -f rig.yaml --to dev.lead "修复登录 bug"
rig tasks -f rig.yaml [--status pending]
rig stop -f rig.yaml
"""
from __future__ import annotations
import argparse
import json
import sys
import time
import urllib.request
import ssl
from .rig_schema import parse_rig, RigSchemaError
from .daemon import RigDaemon
def _api(port: int, path: str, method: str = "GET", data: dict | None = None) -> dict:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
url = f"http://127.0.0.1:{port}{path}"
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, method=method,
headers={"Content-Type": "application/json"} if body else {})
try:
with urllib.request.urlopen(req, context=ctx, timeout=10) as resp:
return json.loads(resp.read())
except Exception as e:
print(f"❌ daemon 未响应 ({url}): {e}", file=sys.stderr)
print(" 先运行: rig start -f rig.yaml", file=sys.stderr)
sys.exit(1)
def cmd_start(args):
rig = parse_rig(args.file)
print(f"🚀 启动 rig '{rig.name}' ({len(rig.all_members())} members)")
d = RigDaemon(args.file, host="127.0.0.1", port=args.port)
d.start(background=False) # 前台运行
print(f" http://127.0.0.1:{args.port}/health")
print(f" members: {', '.join(rig.all_members())}")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\n⏹ 停止...")
d.stop()
def cmd_stop(args):
_api(args.port, "/stop", "POST")
print("⏹ rig 已停止")
def cmd_status(args):
health = _api(args.port, "/health")
print(f"📡 {health['rig']} — {health['status']}")
status = _api(args.port, "/status")
for mid, st in status.items():
icon = "🟢" if st == "running" else "⚪"
print(f" {icon} {mid:20s} {st}")
tasks = _api(args.port, "/tasks?limit=10")
if tasks:
print(f"\n📋 最近任务:")
for t in tasks[:10]:
print(f" [{t['status']:8s}] {t['id'][:20]} → {t['assignee']}: {t['prompt'][:50]}")
def cmd_assign(args):
result = _api(args.port, "/tasks", "POST", {
"assignee": args.to,
"prompt": args.prompt,
"deadline_s": args.deadline,
})
print(f"✅ 任务已入队: {result['id']} → {args.to}")
def cmd_tasks(args):
path = "/tasks"
params = []
if args.status:
params.append(f"status={args.status}")
if params:
path += "?" + "&".join(params)
tasks = _api(args.port, path)
if not tasks:
print("(无任务)")
return
for t in tasks:
print(f"[{t['status']:8s}] {t['id']}")
print(f" → {t['assignee']}")
print(f" {t['prompt']}")
if t.get("result"):
print(f" ✅ {t['result'][:80]}")
if t.get("error"):
print(f" ❌ {t['error'][:80]}")
print()
def main():
p = argparse.ArgumentParser(prog="rig", description="单机轻量 Agent 团队")
p.add_argument("-f", "--file", default="rig.yaml", help="rig.yaml 路径")
p.add_argument("--port", type=int, default=27900, help="daemon 端口")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("start", help="启动 daemon + 所有 member")
sub.add_parser("stop", help="停止 daemon + 所有 member")
sub.add_parser("status", help="查看 rig 状态")
sub.add_parser("tasks", help="查看任务")
pa = sub.add_parser("assign", help="派发任务")
pa.add_argument("--to", required=True, help="目标 member (pod.member)")
pa.add_argument("prompt", help="任务描述")
pa.add_argument("--deadline", type=float, default=600, help="超时秒数")
pa = sub.add_parser("tasks")
pa.add_argument("--status", help="过滤: pending/running/done/failed/timeout")
args = p.parse_args()
try:
parse_rig(args.file) # 预校验
except RigSchemaError as e:
print(f"❌ rig.yaml 错误: {e}", file=sys.stderr)
sys.exit(1)
{"start": cmd_start, "stop": cmd_stop, "status": cmd_status,
"assign": cmd_assign, "tasks": cmd_tasks}[args.cmd](args)
if __name__ == "__main__":
main()
+368
View File
@@ -0,0 +1,368 @@
"""本地 HTTP daemon(SQLite + 任务队列 + 运行时管理)。
API:
GET /health → {"status":"ok","rig":"name"}
GET /rig → rig 定义(pods/members/edges)
GET /status → 所有 member 状态
GET /tasks → 任务列表(?status=&assignee=)
POST /tasks → 入队 {"assignee":"dev.lead","prompt":"..."}
GET /tasks/{id} → 单个任务
POST /tasks/{id}/claim → 领取任务
POST /tasks/{id}/done → 完成 {"result":"..."}
POST /tasks/{id}/fail → 失败 {"error":"..."}
POST /start → 启动所有 member
POST /stop → 停止所有 member
POST /start/{member} → 启动单个 member
POST /stop/{member} → 停止单个 member
"""
from __future__ import annotations
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import urlparse, parse_qs
from .queue import TaskQueue
from .rig_schema import Rig, parse_rig
from .runtime_hermes import HermesCliRuntime
from .runtime_terminal import TerminalRuntime
class RigDaemon:
"""单机 rig daemon:管理 member 生命周期 + 任务队列。"""
def __init__(self, rig_path: str | Path, db_path: str | Path | None = None,
host: str = "127.0.0.1", port: int = 27900):
self.rig = parse_rig(rig_path)
self.db_path = db_path or Path(rig_path).parent / "rig.sqlite3"
self.queue = TaskQueue(self.db_path)
self.host = host
self.port = port
self.terminal_rt = TerminalRuntime(self.rig.name)
self.hermes_rt = HermesCliRuntime()
self.hermes_procs: dict[str, Any] = {} # member_id → HermesProcess
self._http: ThreadingHTTPServer | None = None
self._thread: threading.Thread | None = None
self._sweep_stop = threading.Event()
self._sweep_thread: threading.Thread | None = None
# ── 生命周期 ──
def start(self, background: bool = True):
"""启动 daemon HTTP 服务 + 超时扫描线程。"""
handler = _make_handler(self)
self._http = _ReusableHTTPServer((self.host, self.port), handler)
self._thread = threading.Thread(target=self._http.serve_forever, daemon=True)
self._thread.start()
# 超时扫描(每 30s)
self._sweep_stop.clear()
self._sweep_thread = threading.Thread(target=self._sweep_loop, daemon=True)
self._sweep_thread.start()
self.queue.set_meta("daemon_started", str(time.time()))
return self
def stop(self):
if self._http:
self._http.shutdown()
self._http.server_close() # 关闭底层 socket,释放端口
self._sweep_stop.set()
self.stop_all_members()
self.queue.set_meta("daemon_stopped", str(time.time()))
self.queue.close()
def _sweep_loop(self):
while not self._sweep_stop.is_set():
self.queue.sweep_timeouts()
self._sweep_stop.wait(30)
# ── member 管理 ──
def start_member(self, member_id: str) -> str:
"""启动单个 member。member_id = 'pod.member' 格式。"""
m = self.rig.all_members().get(member_id)
if not m:
return f"unknown member: {member_id}"
if m.runtime == "terminal":
try:
self.terminal_rt.start(m.id, m.command or "", m.cwd)
return f"started {member_id} (terminal)"
except Exception as e:
return f"terminal start failed for {member_id}: {e}"
elif m.runtime == "hermes-cli":
if not self.hermes_rt.available():
return f"hermes CLI 不可用"
# hermes-cli 用 on-demand spawn(hermes chat 需 TTY,不预起常驻)
# 任务到达时由 dispatch 触发 spawn
return f"ready {member_id} (hermes-cli, target={m.target}, profile={m.profile}, on-demand)"
elif m.runtime == "hermes-acp":
return f"hermes-acp 需要 tuttid(第一版未实现)"
return f"unknown runtime: {m.runtime}"
def stop_member(self, member_id: str) -> str:
m = self.rig.all_members().get(member_id)
if not m:
return f"unknown member: {member_id}"
if m.runtime == "terminal":
self.terminal_rt.stop(m.id)
elif m.runtime in ("hermes-cli", "hermes-acp"):
hp = self.hermes_procs.pop(member_id, None)
if hp:
self.hermes_rt.stop(hp)
return f"stopped {member_id}"
def start_all(self) -> list[str]:
results = []
for mid in self.rig.all_members():
results.append(self.start_member(mid))
return results
def stop_all_members(self):
for mid in list(self.hermes_procs):
self.stop_member(mid)
self.terminal_rt.stop_all()
def member_status(self, member_id: str) -> str:
m = self.rig.all_members().get(member_id)
if not m:
return "unknown"
if m.runtime == "terminal":
return self.terminal_rt.status(m.id)
hp = self.hermes_procs.get(member_id)
if hp:
return self.hermes_rt.status(hp)
return "stopped"
def all_status(self) -> dict[str, str]:
return {mid: self.member_status(mid) for mid in self.rig.all_members()}
# ── 任务 ──
def resolve_member(self, assignee: str) -> str | None:
"""把 `hermes` / `dev.hermes` 都解析成 rig 的规范键 `pod.member`。
【dsh@mac13 补,实测缺口】原来 `enqueue()` 直接 `all_members().get(assignee)`,
而该 dict 的 key 是 **`pod.member`** ⇒ 传裸 member id(demo/rig.yaml 与桥的 target 都是这种写法)
时查不到成员,on-demand 派发**静默不触发**:任务永远停在 pending,既不报错也不执行。
"""
members = self.rig.all_members()
if assignee in members:
return assignee
hits = [k for k in members if k.split(".", 1)[-1] == assignee]
if len(hits) == 1:
return hits[0]
if len(hits) > 1:
raise KeyError(f"assignee {assignee!r} 有歧义,请用完整键: {hits}")
return None
def enqueue(self, assignee: str, prompt: str, **kw) -> Any:
key = self.resolve_member(assignee)
if key is None:
raise KeyError(f"rig 里没有成员 {assignee!r}(可用: {sorted(self.rig.all_members())})")
task = self.queue.enqueue(key, prompt, **kw)
# 如果 assignee 是 hermes-cli,立即 on-demand spawn
m = self.rig.all_members().get(key)
if m and m.runtime == "hermes-cli":
self._dispatch_hermes(key, task)
return task
def _dispatch_hermes(self, member_id: str, task: Any):
"""On-demand spawn hermes 子进程处理单个任务。"""
import threading
m = self.rig.all_members()[member_id]
thread = threading.Thread(
target=self._run_hermes_task,
args=(member_id, m, task),
daemon=True,
)
thread.start()
def _run_hermes_task(self, member_id: str, m, task: Any):
"""在独立线程里跑 hermes 子进程(不阻塞 HTTP 线程)。"""
import subprocess as _sp
try:
claimed = self.queue.claim(task_id=task.id)
if not claimed:
return
# 构造命令:hermes [-z prompt] [--profile p]
# -z 是全局 flag(单条 prompt 非交互模式),不能跟在 chat 后面
cmd = [self.hermes_rt.hermes_bin]
if m.profile and m.profile != "default":
cmd += ["--profile", m.profile]
# 【dsh@mac13 实测缺口】原来这里拼的是 `[bin, "--agent", target, "-z", prompt]`,
# 但 hermes CLI **没有全局 --agent**(只有 profile 概念):实测直接报
# `hermes: error: argument command: invalid choice: 'hermes'` ⇒ 任务必然 fail。
# 现在按 CLI 真实形态构造:oneshot 用 `-z`,成员区分靠 `--profile`。
if getattr(m, "model", ""):
cmd += ["-m", m.model] # 实测必需:不传 -m 会被 API 拒(HTTP 400)
cmd += ["-z", task.prompt] # 单条 prompt 模式
r = _sp.run(
cmd,
capture_output=True,
text=True,
timeout=600,
cwd=m.cwd or ".",
)
output = (r.stdout or "").strip()
if not output and r.stderr:
output = f"[stderr] {r.stderr.strip()[:500]}"
self.queue.complete(task.id, result=output[:2000] or "(no output)")
except Exception as e:
self.queue.fail(task.id, error=str(e))
def claim(self, assignee: str | None = None, task_id: str | None = None):
return self.queue.claim(assignee, task_id)
class _ReusableHTTPServer(ThreadingHTTPServer):
allow_reuse_address = True
class _Handler(BaseHTTPRequestHandler):
daemon: RigDaemon = None # type: ignore
def log_message(self, format, *args): # noqa: A002
pass # 静默
def _json(self, code: int, data: Any):
body = json.dumps(data, ensure_ascii=False).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _body(self) -> dict:
length = int(self.headers.get("Content-Length", 0))
if not length:
return {}
return json.loads(self.rfile.read(length))
def do_GET(self):
d = self.daemon
path = urlparse(self.path).path.rstrip("/")
qs = parse_qs(urlparse(self.path).query)
if path == "/health":
self._json(200, {"status": "ok", "rig": d.rig.name})
elif path == "/rig":
from dataclasses import asdict
self._json(200, asdict(d.rig))
elif path == "/status":
self._json(200, d.all_status())
elif path == "/tasks":
tasks = d.queue.list(
status=qs.get("status", [None])[0],
assignee=qs.get("assignee", [None])[0],
limit=int(qs.get("limit", ["50"])[0]))
self._json(200, [
{"id": t.id, "assignee": t.assignee, "prompt": t.prompt[:100],
"status": t.status, "result": t.result, "error": t.error,
"created_at": t.created_at}
for t in tasks
])
elif path.startswith("/tasks/"):
tid = path.split("/")[-1]
t = d.queue.get(tid)
if t:
# 【dsh@mac13 补】把 idem_key/context 也回出来:接通件与人都要能**看见**幂等键落库了没有
self._json(200, {"id": t.id, "assignee": t.assignee,
"prompt": t.prompt, "status": t.status,
"result": t.result, "error": t.error,
"idem_key": getattr(t, "idem_key", None),
"context": getattr(t, "context", None)})
else:
self._json(404, {"error": "not found"})
elif path == "/ui":
ui_path = Path(__file__).parent / "ui.html"
if ui_path.exists():
body = ui_path.read_bytes()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
self._json(404, {"error": "ui.html not found"})
else:
self._json(404, {"error": "not found"})
def do_POST(self):
d = self.daemon
path = urlparse(self.path).path.rstrip("/")
body = self._body()
if path == "/tasks":
assignee = body.get("assignee", "")
prompt = body.get("prompt", "")
if not assignee or not prompt:
self._json(400, {"error": "assignee 和 prompt 必填"})
return
# 【dsh@mac13 补,承 #43 审查 G1/G2】原来只往下传 context/deadline_s ⇒
# ① 桥传来的 `task_id` 被丢弃(rig 自己生成 id),回执关联就断了;
# ② `idem_key` 根本没进 HTTP 层,幂等只在 queue 层可用、经 HTTP 用不上。
# 现在把两者透传下去(queue 已支持;缺省行为不变:不给就是 None → 自动生成 id、不去重)。
try:
resolved = d.resolve_member(assignee)
except KeyError as e:
self._json(400, {"error": str(e)})
return
if resolved is None:
self._json(400, {"error": f"rig 里没有成员 {assignee!r}",
"available": sorted(d.rig.all_members())})
return
assignee = resolved
task = d.enqueue(assignee, prompt,
context=body.get("context"),
deadline_s=body.get("deadline_s", 600),
task_id=body.get("task_id") or None,
idem_key=body.get("idem_key") or None)
self._json(201, {"id": task.id, "status": task.status})
elif path.startswith("/tasks/") and path.endswith("/claim"):
tid = path.split("/")[-2]
task = d.claim(task_id=tid)
if task:
self._json(200, {"id": task.id, "status": "running"})
else:
self._json(404, {"error": "no pending task"})
elif path.startswith("/tasks/") and path.endswith("/done"):
tid = path.split("/")[-2]
d.queue.complete(tid, result=body.get("result"))
self._json(200, {"id": tid, "status": "done"})
elif path.startswith("/tasks/") and path.endswith("/fail"):
tid = path.split("/")[-2]
d.queue.fail(tid, error=body.get("error", "unknown"))
self._json(200, {"id": tid, "status": "failed"})
elif path == "/start":
results = d.start_all()
self._json(200, results)
elif path.startswith("/start/"):
mid = path.split("/")[-1]
self._json(200, {"result": d.start_member(mid)})
elif path == "/stop":
d.stop_all_members()
self._json(200, {"result": "all stopped"})
elif path.startswith("/stop/"):
mid = path.split("/")[-1]
self._json(200, {"result": d.stop_member(mid)})
else:
self._json(404, {"error": "not found"})
def _make_handler(daemon: RigDaemon):
handler = type("BoundHandler", (_Handler,), {"daemon": daemon})
return handler
def serve(rig_path: str | Path, host: str = "127.0.0.1", port: int = 27900,
db_path: str | Path | None = None) -> RigDaemon:
"""创建并启动 daemon,返回 RigDaemon 实例。"""
d = RigDaemon(rig_path, db_path=db_path, host=host, port=port)
d.start(background=True)
return d
+247
View File
@@ -0,0 +1,247 @@
"""SQLite 持久化任务队列(重启不丢,借鉴 OpenRig durable queue)。
设计:
- 单文件 SQLite(WAL 模式)
- 任务状态机: pending → running → done | failed | timeout
- 幂等:同一 task_id 重复入队只保留一条
- 原子领取:BEGIN IMMEDIATE + UPDATE ... WHERE status='pending'
"""
from __future__ import annotations
import json
import sqlite3
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# 幂等窗口:契约 R-1 固化为 **24h**(同 idem_key 24h 内只执行一次)
IDEM_TTL_S = 24 * 3600
SCHEMA = """
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
assignee TEXT NOT NULL,
prompt TEXT NOT NULL,
context TEXT DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending',
result TEXT,
error TEXT,
created_at REAL NOT NULL,
claimed_at REAL,
finished_at REAL,
deadline_s REAL DEFAULT 600,
idem_key TEXT UNIQUE
);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee, status);
CREATE TABLE IF NOT EXISTS rig_meta (
key TEXT PRIMARY KEY,
value TEXT
);
"""
@dataclass
class Task:
id: str
assignee: str
prompt: str
context: dict[str, Any] = field(default_factory=dict)
status: str = "pending"
result: str | None = None
error: str | None = None
created_at: float = field(default_factory=time.time)
claimed_at: float | None = None
finished_at: float | None = None
deadline_s: float = 600.0
idem_key: str | None = None
def to_row(self) -> tuple:
return (
self.id, self.assignee, self.prompt, json.dumps(self.context, ensure_ascii=False),
self.status, self.result, self.error,
self.created_at, self.claimed_at, self.finished_at, self.deadline_s,
)
@classmethod
def from_row(cls, row: tuple) -> Task:
return cls(
id=row[0], assignee=row[1], prompt=row[2],
context=json.loads(row[3] or "{}"), status=row[4],
result=row[5], error=row[6], created_at=row[7],
claimed_at=row[8], finished_at=row[9], deadline_s=row[10],
idem_key=(row[11] if len(row) > 11 else None), # G1:SCHEMA 第 12 列;老库迁移后同样存在
)
def ensure_idem_column(conn) -> None:
"""G1:老库补列。SQLite `ADD COLUMN` 不能带 UNIQUE ⇒ 用**唯一索引**实现同等约束;
历史行 idem_key 为 NULL(NULL 不违反唯一索引),不影响既有数据。"""
cols = [r[1] for r in conn.execute("PRAGMA table_info(tasks)").fetchall()]
if "idem_key" not in cols:
conn.execute("ALTER TABLE tasks ADD COLUMN idem_key TEXT")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_idem ON tasks(idem_key)")
conn.commit()
class TaskQueue:
"""SQLite 持久化任务队列。线程安全(sqlite3 check_same_thread=False + 锁)。"""
def __init__(self, db_path: str | Path = ":memory:"):
self.db_path = str(db_path)
self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
self.conn.execute("PRAGMA journal_mode=WAL")
self.conn.execute("PRAGMA busy_timeout=5000")
self.conn.executescript(SCHEMA)
ensure_idem_column(self.conn) # G1:老库补列(新库已在 SCHEMA 里)
self.conn.commit()
def close(self):
self.conn.close()
# ── 入队 ──
def enqueue(self, assignee: str, prompt: str,
context: dict | None = None,
task_id: str | None = None,
deadline_s: float = 600.0,
idem_key: str | None = None) -> Task:
"""入队。**G1 幂等**:给了 `idem_key` 且 24h 内已有同键任务 ⇒ 直接返回那条,**不重复执行**
(对齐契约 R-1 / PRD A4「重发三次只执行一次」)。"""
if idem_key:
row = self.conn.execute(
"SELECT id FROM tasks WHERE idem_key=? AND created_at > ?",
(idem_key, time.time() - IDEM_TTL_S)).fetchone()
if row:
got = self.get(row[0])
if got is not None:
return got
tid = task_id or f"task-{uuid.uuid4().hex[:12]}"
task = Task(id=tid, assignee=assignee, prompt=prompt,
context=context or {}, deadline_s=deadline_s, idem_key=idem_key)
# 幂等:已存在则跳过
cur = self.conn.execute("SELECT id FROM tasks WHERE id=?", (tid,))
if cur.fetchone():
return self.get(tid) # type: ignore
self.conn.execute(
"INSERT INTO tasks (id,assignee,prompt,context,status,created_at,deadline_s,idem_key) "
"VALUES (?,?,?,?,?,?,?,?)",
(tid, assignee, prompt, json.dumps(context or {}, ensure_ascii=False),
"pending", task.created_at, deadline_s, idem_key),
)
self.conn.commit()
return task
# ── 领取(原子) ──
def claim(self, assignee: str | None = None, task_id: str | None = None) -> Task | None:
"""原子领取一个 pending 任务。assignee=None 时不限制 assignee。"""
self.conn.execute("BEGIN IMMEDIATE")
try:
if task_id:
cur = self.conn.execute(
"SELECT * FROM tasks WHERE id=? AND status='pending'", (task_id,))
else:
where = "status='pending'"
params: list[Any] = []
if assignee:
where += " AND assignee=?"
params.append(assignee)
cur = self.conn.execute(
f"SELECT * FROM tasks WHERE {where} ORDER BY created_at LIMIT 1", params)
row = cur.fetchone()
if not row:
self.conn.execute("COMMIT")
return None
task = Task.from_row(row)
self.conn.execute(
"UPDATE tasks SET status='running', claimed_at=? WHERE id=?",
(time.time(), task.id))
self.conn.execute("COMMIT")
task.status = "running"
task.claimed_at = time.time()
return task
except Exception:
self.conn.execute("ROLLBACK")
raise
# ── 完成 ──
def complete(self, task_id: str, result: str | None = None):
self.conn.execute(
"UPDATE tasks SET status='done', result=?, finished_at=? WHERE id=?",
(result, time.time(), task_id))
self.conn.commit()
def fail(self, task_id: str, error: str):
self.conn.execute(
"UPDATE tasks SET status='failed', error=?, finished_at=? WHERE id=?",
(error, time.time(), task_id))
self.conn.commit()
def timeout(self, task_id: str):
self.conn.execute(
"UPDATE tasks SET status='timeout', finished_at=? WHERE id=?",
(time.time(), task_id))
self.conn.commit()
# ── 查询 ──
def get(self, task_id: str) -> Task | None:
cur = self.conn.execute("SELECT * FROM tasks WHERE id=?", (task_id,))
row = cur.fetchone()
return Task.from_row(row) if row else None
def list(self, status: str | None = None, assignee: str | None = None,
limit: int = 50) -> list[Task]:
where, params = ["1=1"], []
if status:
where.append("status=?")
params.append(status)
if assignee:
where.append("assignee=?")
params.append(assignee)
cur = self.conn.execute(
f"SELECT * FROM tasks WHERE {' AND '.join(where)} ORDER BY created_at DESC LIMIT ?",
params + [limit])
return [Task.from_row(r) for r in cur.fetchall()]
def count(self, status: str | None = None) -> int:
if status:
cur = self.conn.execute("SELECT COUNT(*) FROM tasks WHERE status=?", (status,))
else:
cur = self.conn.execute("SELECT COUNT(*) FROM tasks")
return cur.fetchone()[0]
def pending_for(self, assignee: str) -> int:
cur = self.conn.execute(
"SELECT COUNT(*) FROM tasks WHERE assignee=? AND status='pending'", (assignee,))
return cur.fetchone()[0]
# ── 超时扫描 ──
def sweep_timeouts(self) -> list[str]:
"""把超时的 running 任务标为 timeout。返回被标记的 task_id 列表。"""
now = time.time()
cur = self.conn.execute(
"SELECT id FROM tasks WHERE status='running' AND claimed_at IS NOT NULL "
"AND (claimed_at + deadline_s) < ?", (now,))
ids = [r[0] for r in cur.fetchall()]
for tid in ids:
self.timeout(tid)
return ids
# ── meta ──
def set_meta(self, key: str, value: str):
self.conn.execute(
"INSERT OR REPLACE INTO rig_meta (key,value) VALUES (?,?)", (key, value))
self.conn.commit()
def get_meta(self, key: str) -> str | None:
cur = self.conn.execute("SELECT value FROM rig_meta WHERE key=?", (key,))
row = cur.fetchone()
return row[0] if row else None
+200
View File
@@ -0,0 +1,200 @@
"""rig.yaml 解析 + 校验(借鉴 OpenRig pods/members/edges schema)。
schema:
version: "0.1"
name: my-rig
pods:
- id: dev
label: Development
members:
- id: lead
runtime: hermes-cli | terminal | hermes-acp
target: hermes # hermes-* 运行时用
command: "python3 qa.py" # terminal 运行时用
edges:
- kind: delegates_to | can_observe | collaborates_with
from: lead
to: qa
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
VALID_RUNTIMES = {"terminal", "hermes-cli", "hermes-acp"}
VALID_EDGE_KINDS = {"delegates_to", "can_observe", "collaborates_with"}
_ID_RE = re.compile(r"^[a-z][a-z0-9_-]{0,31}$")
class RigSchemaError(ValueError):
"""rig.yaml 格式/语义错误。"""
@dataclass
class Member:
id: str
runtime: str
target: str | None = None # hermes-* 运行时
command: str | None = None # terminal 运行时
profile: str = "default"
cwd: str = "."
# 【dsh@mac13 补】真机实测:hermes oneshot 不带 `-m` 时服务端直接报
# `HTTP 400: The supported API model names are deepseek-flash, deepseek-v4-pro, but you passed .`
# ⇒ 模型必须显式传。默认值取本机 provider 支持的名字,可在 rig.yaml 覆盖。
model: str = "deepseek-flash"
def __post_init__(self):
if self.runtime not in VALID_RUNTIMES:
raise RigSchemaError(f"member '{self.id}': runtime '{self.runtime}' 不合法,可选 {sorted(VALID_RUNTIMES)}")
if self.runtime == "terminal" and not self.command:
raise RigSchemaError(f"member '{self.id}': terminal 运行时必须指定 command")
if self.runtime in ("hermes-cli", "hermes-acp") and not self.target:
raise RigSchemaError(f"member '{self.id}': {self.runtime} 运行时必须指定 target")
@dataclass
class Edge:
kind: str
from_id: str
to_id: str
def __post_init__(self):
if self.kind not in VALID_EDGE_KINDS:
raise RigSchemaError(f"edge kind '{self.kind}' 不合法,可选 {sorted(VALID_EDGE_KINDS)}")
@dataclass
class Pod:
id: str
label: str = ""
members: list[Member] = field(default_factory=list)
edges: list[Edge] = field(default_factory=list)
@dataclass
class Rig:
name: str
pods: list[Pod] = field(default_factory=list)
version: str = "0.1"
def all_members(self) -> dict[str, Member]:
"""扁平化所有 member,key = 'pod_id.member_id'。"""
out: dict[str, Member] = {}
for pod in self.pods:
for m in pod.members:
out[f"{pod.id}.{m.id}"] = m
return out
def resolve_edge(self, e: Edge) -> tuple[str, str]:
"""把 from/to 解析为 'pod.member' 全名。"""
return self._resolve(e.from_id), self._resolve(e.to_id)
def _resolve(self, ref: str) -> str:
if "." in ref:
pod_id, _, member_id = ref.partition(".")
for pod in self.pods:
if pod.id == pod_id:
for m in pod.members:
if m.id == member_id:
return f"{pod_id}.{member_id}"
raise RigSchemaError(f"edge 引用 '{ref}' 找不到对应 member")
# 无 pod 前缀:全局查找
for pod in self.pods:
for m in pod.members:
if m.id == ref:
return f"{pod.id}.{m.id}"
raise RigSchemaError(f"edge 引用 '{ref}' 找不到对应 member")
def _check_id(id_val: str, ctx: str):
if not _ID_RE.match(id_val or ""):
raise RigSchemaError(f"{ctx} id '{id_val}' 不合法(须小写字母开头,≤32 字符)")
def parse_rig(path: str | Path) -> Rig:
"""解析 rig.yaml 并校验。"""
p = Path(path)
if not p.exists():
raise RigSchemaError(f"rig 文件不存在: {p}")
data = yaml.safe_load(p.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise RigSchemaError("rig.yaml 顶层必须是 mapping")
name = data.get("name")
if not name or not isinstance(name, str):
raise RigSchemaError("rig.yaml 缺少 name(字符串)")
pods_raw = data.get("pods") or []
if not isinstance(pods_raw, list) or not pods_raw:
raise RigSchemaError("rig.yaml 缺少 pods(非空列表)")
pods: list[Pod] = []
pod_ids: set[str] = set()
for i, pr in enumerate(pods_raw):
if not isinstance(pr, dict):
raise RigSchemaError(f"pods[{i}] 必须是 mapping")
pid = pr.get("id")
_check_id(pid, f"pods[{i}]")
if pid in pod_ids:
raise RigSchemaError(f"重复 pod id: {pid}")
pod_ids.add(pid)
members: list[Member] = []
member_ids: set[str] = set()
for j, mr in enumerate(pr.get("members") or []):
if not isinstance(mr, dict):
raise RigSchemaError(f"pod '{pid}' members[{j}] 必须是 mapping")
mid = mr.get("id")
_check_id(mid, f"pod '{pid}' members[{j}]")
if mid in member_ids:
raise RigSchemaError(f"pod '{pid}' 重复 member id: {mid}")
member_ids.add(mid)
members.append(Member(
id=mid,
runtime=mr.get("runtime", "terminal"),
target=mr.get("target"),
command=mr.get("command"),
profile=mr.get("profile", "default"),
cwd=mr.get("cwd", "."),
model=mr.get("model", "deepseek-flash"), # 【dsh@mac13 补】可在 rig.yaml 覆盖
))
edges: list[Edge] = []
for k, er in enumerate(pr.get("edges") or []):
if not isinstance(er, dict):
raise RigSchemaError(f"pod '{pid}' edges[{k}] 必须是 mapping")
edges.append(Edge(
kind=er.get("kind", "delegates_to"),
from_id=er.get("from", ""),
to_id=er.get("to", ""),
))
pods.append(Pod(id=pid, label=pr.get("label", ""), members=members, edges=edges))
rig = Rig(name=name, pods=pods, version=str(data.get("version", "0.1")))
# 全局校验:edge 引用必须存在
all_ids = set(rig.all_members().keys())
for pod in pods:
for e in pod.edges:
try:
rig.resolve_edge(e)
except RigSchemaError:
raise
# 确保 from/to 在已知 member 里
for ref in (e.from_id, e.to_id):
resolved = rig._resolve(ref)
if resolved not in all_ids:
raise RigSchemaError(f"pod '{pod.id}' edge 引用 '{ref}' → '{resolved}' 不是已知 member")
return rig
def load_rig(path: str | Path) -> Rig:
"""parse_rig 的别名(语义更清晰)。"""
return parse_rig(path)
+158
View File
@@ -0,0 +1,158 @@
"""Hermes 运行时适配器(standalone 模式:spawn CLI 子进程,零外部依赖)。
两种模式:
- hermes-cli: spawn `hermes chat --agent <target>` 子进程,stdin/stdout 通信
- hermes-acp: WS 连 tuttid(可选增强,需 tuttid 在跑)
第一版只做 hermes-cli。
"""
from __future__ import annotations
import json
import os
import shutil
import signal
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class HermesProcess:
target: str
profile: str = "default"
proc: subprocess.Popen | None = None
started_at: float = 0.0
@property
def alive(self) -> bool:
return self.proc is not None and self.proc.poll() is None
class HermesCliRuntime:
"""spawn hermes CLI 子进程,stdin 写 prompt,stdout 收回复。"""
def __init__(self, hermes_bin: str | None = None):
if hermes_bin:
self.hermes_bin = hermes_bin
else:
# 自动检测:PATH → desktop-bin
found = shutil.which("hermes")
if found:
self.hermes_bin = found
else:
default = (Path.home() / "Library/Application Support"
/ "cn.org.hermesagent.desktop/runtime/desktop-bin/hermes")
self.hermes_bin = str(default) if default.exists() else "hermes"
def available(self) -> bool:
# 绝对路径直接查存在性,否则走 which
if Path(self.hermes_bin).is_absolute():
return Path(self.hermes_bin).exists()
return shutil.which(self.hermes_bin) is not None
def spawn(self, target: str, prompt: str | None = None,
cwd: str = ".", timeout: float = 300.0,
profile: str = "default") -> HermesProcess:
"""启动 hermes 子进程。prompt=None 时交互式等待。"""
cmd = [self.hermes_bin, "chat"]
if profile and profile != "default":
cmd += ["--profile", profile]
# 【dsh@mac13 实测】hermes CLI 无全局 --agent(只有 profile)⇒ 传它会被 argparse
# 当成子命令名而直接报错。成员的区分改由 --profile 承担;target 仅作为 rig 内的键。
if prompt:
cmd.append(prompt)
env = os.environ.copy()
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE if not prompt else subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=cwd,
env=env,
)
hp = HermesProcess(target=target, profile=profile, proc=proc, started_at=time.time())
return hp
def send_prompt(self, hp: HermesProcess, prompt: str) -> str:
"""向已启动的 hermes 进程发送 prompt,等回复。"""
if not hp.alive or not hp.proc:
raise RuntimeError(f"hermes[{hp.target}] 不在运行")
assert hp.proc.stdin and hp.proc.stdout
hp.proc.stdin.write(prompt + "\n")
hp.proc.stdin.flush()
# 简单阻塞读(生产应改 async)
line = hp.proc.stdout.readline()
return line.strip()
def stop(self, hp: HermesProcess):
if hp.alive and hp.proc:
try:
hp.proc.terminate()
hp.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
hp.proc.kill()
finally:
hp.proc = None
def status(self, hp: HermesProcess) -> str:
if hp.alive:
return "running"
return "exited" if hp.proc else "stopped"
class HermesAcpRuntime:
"""WS 连 tuttid(可选增强,需 tuttid 在跑 + websockets 库)。
复用 bridge/tuttid.py 的 WsTuttidSource 逻辑(复制少量代码保持自包含)。
"""
def __init__(self, base_url: str = "http://127.0.0.1:27800",
token: str = "", target: str = "hermes"):
self.base_url = base_url.rstrip("/")
self.token = token
self.target = target
def available(self) -> bool:
"""检查 tuttid 是否在跑。"""
import urllib.request
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
req = urllib.request.Request(
f"{self.base_url}/v1/health",
headers={"Authorization": f"Bearer {self.token}"})
with urllib.request.urlopen(req, context=ctx, timeout=3) as resp:
return resp.status == 200
except Exception:
return False
def send_task(self, prompt: str, context: dict | None = None) -> dict:
"""通过 ACP 协议发任务(简化版:HTTP POST)。"""
import urllib.request
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
payload = json.dumps({
"prompt": prompt,
"context": context or {},
"target": self.target,
}).encode()
req = urllib.request.Request(
f"{self.base_url}/v1/tasks",
data=payload,
headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
})
try:
with urllib.request.urlopen(req, context=ctx, timeout=30) as resp:
return json.loads(resp.read())
except Exception as e:
return {"error": str(e)}
+90
View File
@@ -0,0 +1,90 @@
"""tmux 裸终端运行时(零依赖,最轻量)。
每个 member 对应一个 tmux 会话(session = rig 名,window = member id)。
启动:tmux new-session -d -s <rig> -n <member> '<command>'
停止:tmux kill-session -t <rig>:<member>
"""
from __future__ import annotations
import shutil
import subprocess
from dataclasses import dataclass
class TmuxNotFoundError(RuntimeError):
pass
def _tmux(*args: str) -> str:
if not shutil.which("tmux"):
raise TmuxNotFoundError("tmux 未安装")
r = subprocess.run(["tmux", *args], capture_output=True, text=True, timeout=10)
if r.returncode != 0:
raise RuntimeError(f"tmux {' '.join(args)} 失败: {r.stderr.strip()}")
return r.stdout.strip()
@dataclass
class TmuxSession:
rig: str
member: str
command: str
@property
def target(self) -> str:
return f"{self.rig}:{self.member}"
def exists(self) -> bool:
try:
_tmux("has-session", "-t", self.target)
return True
except (RuntimeError, Exception):
return False
def start(self):
if self.exists():
return
_tmux("new-session", "-d", "-s", self.rig, "-n", self.member, self.command)
def stop(self):
try:
_tmux("kill-window", "-t", self.target)
except (RuntimeError, Exception):
pass
def capture(self, lines: int = 20) -> str:
"""抓最后 N 行输出。"""
if not self.exists():
return ""
try:
return _tmux("capture-pane", "-t", self.target, "-p", "-S", f"-{lines}")
except (RuntimeError, Exception):
return ""
class TerminalRuntime:
"""tmux 裸终端运行时管理器。"""
def __init__(self, rig_name: str):
self.rig_name = rig_name
def start(self, member_id: str, command: str, cwd: str = ".") -> TmuxSession:
sess = TmuxSession(rig=self.rig_name, member=member_id, command=command)
if cwd != ".":
command = f"cd {cwd} && {command}"
sess.command = command
sess.start()
return sess
def stop(self, member_id: str):
TmuxSession(rig=self.rig_name, member=member_id, command="").stop()
def stop_all(self):
try:
_tmux("kill-session", "-t", self.rig_name)
except (RuntimeError, Exception):
pass
def status(self, member_id: str) -> str:
sess = TmuxSession(rig=self.rig_name, member=member_id, command="")
return "running" if sess.exists() else "stopped"
+165
View File
@@ -0,0 +1,165 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Rig Dashboard</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0d1117;--card:#161b22;--border:#30363d;--text:#e6edf3;--dim:#8b949e;--green:#3fb950;--yellow:#d29922;--red:#f85149;--blue:#58a6ff;--purple:#bc8cff}
body{font:14px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);padding:20px}
h1{font-size:18px;margin-bottom:4px}
.sub{color:var(--dim);font-size:12px;margin-bottom:20px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-bottom:20px}
.card{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:14px}
.card h3{font-size:13px;color:var(--dim);margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px}
.member{display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border)}
.member:last-child{border:none}
.dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.dot.running{background:var(--green)}.dot.stopped{background:var(--dim)}.dot.ready{background:var(--blue)}
.mname{font-weight:600;font-size:13px}
.mmeta{color:var(--dim);font-size:11px;margin-left:auto}
.task{padding:8px 0;border-bottom:1px solid var(--border);font-size:13px}
.task:last-child{border:none}
.tid{color:var(--dim);font-size:11px;font-family:monospace}
.tprompt{margin:2px 0}
.tresult{color:var(--green);font-size:12px;max-height:60px;overflow:hidden}
.terror{color:var(--red);font-size:12px}
.badge{display:inline-block;padding:1px 6px;border-radius:10px;font-size:11px;font-weight:600}
.badge.pending{background:#3d1d00;color:var(--yellow)}
.badge.running{background:#0d1f2d;color:var(--blue)}
.badge.done{background:#0d2818;color:var(--green)}
.badge.failed{background:#2d0d0d;color:var(--red)}
.badge.timeout{background:#2d0d2d;color:var(--purple)}
form{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap}
select,input,button{background:var(--bg);border:1px solid var(--border);color:var(--text);padding:8px 12px;border-radius:6px;font-size:13px}
select:focus,input:focus{outline:none;border-color:var(--blue)}
button{cursor:pointer;background:var(--blue);color:#fff;border:none;font-weight:600}
button:hover{opacity:.85}
button.sec{background:var(--card);color:var(--text);border:1px solid var(--border)}
#log{margin-top:12px;font-family:monospace;font-size:11px;color:var(--dim);max-height:120px;overflow-y:auto;background:var(--card);border-radius:6px;padding:8px;display:none}
</style>
</head>
<body>
<h1>🔧 Rig Dashboard</h1>
<div class="sub" id="sub">connecting...</div>
<form id="assign-form" onsubmit="return assign(event)">
<select id="f-assignee"></select>
<input id="f-prompt" placeholder="任务描述..." style="flex:1;min-width:200px" required>
<button type="submit">派发</button>
</form>
<div class="grid">
<div class="card"><h3>Members</h3><div id="members"></div></div>
<div class="card"><h3>Tasks</h3><div id="tasks"><div style="color:var(--dim)">loading...</div></div></div>
</div>
<div id="log"></div>
<script>
const BASE = location.origin;
let members = {};
async function api(path, opts) {
const r = await fetch(BASE + path, opts);
return r.json();
}
function log(msg) {
const el = document.getElementById('log');
el.style.display = 'block';
el.innerHTML += `<div>[${new Date().toLocaleTimeString()}] ${msg}</div>`;
el.scrollTop = el.scrollHeight;
}
async function refresh() {
try {
const [rig, status, tasks] = await Promise.all([
api('/rig'), api('/status'), api('/tasks?limit=20')
]);
// sub
document.getElementById('sub').textContent =
`${rig.name} · ${new Date().toLocaleTimeString()}`;
// members
const all = {};
for (const pod of rig.pods || []) {
for (const m of pod.members || []) {
all[pod.id + '.' + m.id] = m;
}
}
members = all;
const el = document.getElementById('members');
el.innerHTML = '';
for (const [mid, m] of Object.entries(all)) {
const st = status[mid] || 'stopped';
const dot = st === 'running' ? 'running' : st === 'ready' ? 'ready' : 'stopped';
const meta = m.runtime + (m.profile && m.profile !== 'default' ? ' · ' + m.profile : '');
el.innerHTML += `<div class="member">
<div class="dot ${dot}"></div>
<span class="mname">${mid}</span>
<span class="mmeta">${meta}</span>
</div>`;
}
// assignee dropdown
const sel = document.getElementById('f-assignee');
if (!sel.options.length) {
for (const mid of Object.keys(all)) {
const o = document.createElement('option');
o.value = mid; o.textContent = mid;
sel.appendChild(o);
}
}
// tasks
const tel = document.getElementById('tasks');
if (!tasks.length) {
tel.innerHTML = '<div style="color:var(--dim)">无任务</div>';
} else {
tel.innerHTML = tasks.map(t => {
let extra = '';
if (t.result) extra = `<div class="tresult">${esc(t.result.slice(0,200))}</div>`;
if (t.error) extra = `<div class="terror">${esc(t.error.slice(0,200))}</div>`;
return `<div class="task">
<span class="badge ${t.status}">${t.status}</span>
<span class="tid">${t.id.slice(0,16)}...</span>
<div class="tprompt">${esc(t.prompt)}</div>
${extra}
</div>`;
}).join('');
}
} catch (e) {
log('refresh error: ' + e.message);
}
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
async function assign(e) {
e.preventDefault();
const assignee = document.getElementById('f-assignee').value;
const prompt = document.getElementById('f-prompt').value;
if (!assignee || !prompt) return false;
try {
const r = await api('/tasks', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({assignee, prompt})
});
log(`✅ ${r.id} → ${assignee}`);
document.getElementById('f-prompt').value = '';
refresh();
} catch (err) {
log('❌ ' + err.message);
}
return false;
}
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
pyyaml>=6.0
+549
View File
@@ -0,0 +1,549 @@
#!/usr/bin/env bash
# 15-criterion4-live.sh —— 契约 §6 **判据④** 的活环境验收门
#
# 判据④ 原文:≥2 台物理机的 Agent **在同一房间互见**,且各自被 @ 时命中。
#
# 为什么值得单独一个脚本:判据④ 是**阶段 1 的唯一硬阻塞**,而它此前只能肉眼看房间 -
# 谁在跑、谁被命中、有没有串台,全靠人记。本脚本把它变成"跑一次、看一张表、退出码说话"。
#
# 用法(凭据只走环境变量,不落盘、不打印):
# MATRIX_BASE_URL=https://imbot.apescale.com \
# MATRIX_ACCESS_TOKEN=<本节点 bot token> \
# BRIDGE_ROOM='!xxxx:matrix.113.249.102.nip.io' \
# TARGET_A='@tutti-hermes:matrix.113.249.102.nip.io' \
# TARGET_B='@tutti-pi:matrix.113.249.102.nip.io' \
# BRIDGE_A='@bridge3:...' BRIDGE_B='@bridge2:...' \
# NODE_NAME=mac13 \
# bash scripts/15-criterion4-live.sh
#
# bash scripts/15-criterion4-live.sh --selftest # 离线自测(含"门必须会红"的对照)
# bash scripts/15-criterion4-live.sh --pair a.log b.log # 两台机各跑一次后, 机器判定"两台物理机"
#
# 退出码:0 = 判据④ 通过 | 1 = 断言失败(门会红)| 2 = 前置缺失/取数不可信(拒绝判读)
# 边界:只向 BRIDGE_ROOM 白名单房间发消息(R-8);不发任何其它房间;不打印任何凭据值。
set -uo pipefail
BASE="${MATRIX_BASE_URL:-}"
TOKEN="${MATRIX_ACCESS_TOKEN:-}"
ROOM="${BRIDGE_ROOM:-}"
TA="${TARGET_A:-}"
TB="${TARGET_B:-}"
BA="${BRIDGE_A:-}"
BB="${BRIDGE_B:-}"
NODE="${NODE_NAME:-$(hostname -s 2>/dev/null || echo unknown)}"
WAIT="${P4_WAIT:-45}" # 等回执链的总超时(秒)
NEG_WAIT="${P4_NEG_WAIT:-8}" # 阴性对照观察窗(秒)
pass=0; fail=0; skip=0
ok() { printf ' \033[32m✅\033[0m %s\n' "$*"; pass=$((pass+1)); }
bad() { printf ' \033[31m❌\033[0m %s\n' "$*"; fail=$((fail+1)); }
skp() { printf ' \033[33m⏭\033[0m %s\n' "$*"; skip=$((skip+1)); }
sec() { printf '\n\033[1m── %s\033[0m\n' "$*"; }
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
FETCH_FAIL=0 # 取数失败 ⇒ 拒绝判读(exit 2), 不与"断言失败(exit 1)"混为一谈
enc() { python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$1"; }
api() { # api <METHOD> <path> [json-file] -> stdout; 返回 curl 退出码
local m="$1" p="$2" f="${3:-}"
if [ -n "$f" ]; then
curl -sS --max-time 20 -X "$m" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' --data-binary "@$f" "$BASE$p"
else
curl -sS --max-time 20 -X "$m" -H "Authorization: Bearer $TOKEN" "$BASE$p"
fi
}
need() { [ -n "${1:-}" ] || { echo "🔴 缺少环境变量 $2 —— 判读不可信,拒绝判读(exit 2)" >&2; return 2; }; }
preflight() {
sec "前置(只读)"
need "$BASE" MATRIX_BASE_URL; need "$TOKEN" MATRIX_ACCESS_TOKEN
need "$ROOM" BRIDGE_ROOM; need "$TA" TARGET_A; need "$TB" TARGET_B
local r
r="$(api GET /_matrix/client/v3/account/whoami)" || { bad "whoami 请求失败(curl)"; return 2; }
ME="$(printf '%s' "$r" | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(d.get('user_id','') if isinstance(d,dict) else '')")"
[ -n "$ME" ] && ok "桥身份可认证: $ME" || { bad "whoami 拿不到 user_id(令牌失效?) —— 判读不可信"; return 2; }
r="$(api GET /_matrix/client/v3/joined_rooms)"
printf '%s' "$r" | python3 -c "
import json,sys
d=json.load(sys.stdin)
sys.exit(0 if isinstance(d,dict) and sys.argv[1] in (d.get('joined_rooms') or []) else 1)" "$ROOM" \
&& ok "已加入白名单房间 $ROOM" || { bad "该 token 未加入 ${ROOM}(或 joined_rooms 取数失败)"; return 2; }
r="$(api GET "/_matrix/client/v3/rooms/$(enc "$ROOM")/joined_members")"
MISS="$(printf '%s' "$r" | python3 -c "
import json,sys
d=json.load(sys.stdin)
if not isinstance(d,dict) or 'joined' not in d:
print('APIERR'); sys.exit(0)
j=set(d['joined'])
print(','.join(t for t in sys.argv[1:3] if t not in j))" "$TA" "$TB")"
case "$MISS" in
APIERR) bad "joined_members 取数失败 —— 判读不可信"; return 2;;
"") ok "两个 target 都在房间里(R-7 前置): $(basename "${TA%%:*}") / $(basename "${TB%%:*}")";;
*) bad "房间缺 target: $MISS —— 判据④ 前置不满足(先做 T-1.10 步骤 1)"; return 2;;
esac
return 0
}
send() { # send <text> -> 打印 event_id
local text="$1" f="$TMP/send.json" txn="p4-$$-$RANDOM"
python3 -c "
import json,sys
json.dump({'msgtype':'m.text','body':sys.argv[1]}, open(sys.argv[2],'w',encoding='utf-8'), ensure_ascii=False)" \
"$text" "$f"
api PUT "/_matrix/client/v3/rooms/$(enc "$ROOM")/send/m.room.message/$(enc "$txn")" "$f" \
| python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('event_id','') if isinstance(d,dict) else '')"
}
receipts() { # receipts <根事件id> -> 每行 "<sender> <status>"; 取数失败输出 __FETCH_FAILED__ 哨兵
local root="$1" f="$TMP/msgs.json"
api GET "/_matrix/client/v3/rooms/$(enc "$ROOM")/messages?dir=b&limit=100" > "$f"
python3 - "$root" "$f" <<'PY'
import json, sys
root, path = sys.argv[1], sys.argv[2]
# 缺陷L(workbuddy-mac13 报): 取数失败**不许**与"确实 0 条"同形 —— 阴性对照的判据就是"0 条",
# 吞掉错误会让它 fail-open(403/401/网络故障都会判绿)。故用哨兵, 由消费处拒绝判读。
try:
d = json.load(open(path, encoding="utf-8"))
except Exception:
print("__FETCH_FAILED__"); sys.exit(0)
if not isinstance(d, dict) or "chunk" not in d:
print("__FETCH_FAILED__"); sys.exit(0)
rows = []
for ev in (d.get('chunk') or []):
c = ev.get('content') or {}
rel = c.get('m.relates_to') or {}
if rel.get('rel_type') != 'm.thread' or rel.get('event_id') != root:
continue
st = c.get('status') or c.get('tutti_status')
if st:
rows.append((ev.get('origin_server_ts', 0), ev.get('sender', '?'), st))
for _, sender, st in sorted(rows): # 按服务端时间戳正序, 不信服务端返回顺序
print(f"{sender} {st}")
PY
}
expect_chain() { # expect_chain <标签> <根事件id> <期望 sender(可空)> <禁止 sender(可空)>
local label="$1" root="$2" want="$3" forbid="$4" got=""
local deadline=$(( $(date +%s) + WAIT ))
while :; do
got="$(receipts "$root")"
if printf '%s' "$got" | grep -q '__FETCH_FAILED__'; then
bad "$label 取数失败(/messages) —— 判读不可信, 拒绝给结论"; FETCH_FAIL=1; return 2
fi
printf '%s' "$got" | grep -qE ' (succeeded|failed|timeout)$' && break
[ "$(date +%s)" -ge "$deadline" ] && break
sleep 2
done
local states senders
states="$(printf '%s' "$got" | awk 'NF{print $2}' | paste -sd' ' - 2>/dev/null)"
senders="$(printf '%s' "$got" | awk 'NF{print $1}' | sort -u | paste -sd' ' - 2>/dev/null)"
printf ' %s 回执: [%s] 来自: [%s]\n' "$label" "${states:-无}" "${senders:-无}"
# 1) 必须是 accepted -> running -> 终态, 且 running 不许缺(R-3)
printf '%s' "$states" | grep -q "accepted" || { bad "$label 缺 accepted"; return 1; }
printf '%s' "$states" | grep -q "running" || { bad "$label 缺 running(R-3: 禁止静默完成)"; return 1; }
printf '%s' "$states" | grep -qE "succeeded|failed|timeout" || { bad "$label 未达终态(超时 ${WAIT}s)"; return 1; }
python3 -c "
import sys
seq=sys.argv[1].split()
# 【dsh@mac13】R-1 与 R-3/R-4 的**表面冲突**:契约 R-1 明写"重复投递返回上一次的回执、attempt 递增",
# 于是合法序列可能是 `accepted accepted running succeeded`。老断言要求 accepted 只能在第 0 位,
# 会把这种**合法重放**误判成顺序错。改判为:首条必须是 accepted;accepted 不得晚于 running;
# running 至多一条;终态后不得再有回执。(真链路实测出现过 attempt 1→2 两条 accepted。)
first_run = next((i for i, s in enumerate(seq) if s == 'running'), None)
first_acc = next((i for i, s in enumerate(seq) if s == 'accepted'), None)
bad = []
if first_acc != 0: bad.append('首条不是 accepted')
if any(s == 'accepted' and first_run is not None and i > first_run for i, s in enumerate(seq)):
bad.append('accepted 出现在 running 之后')
if seq.count('running') > 1: bad.append('running 出现多次')
term=[i for i,s in enumerate(seq) if s in ('succeeded','failed','timeout')]
assert not bad, 'accepted/running 顺序不合 R-3/R-4: '+'; '.join(bad)+' | '+' '.join(seq)
assert not term or term[0]==len(seq)-1, '终态后仍有回执: '+sys.argv[1]" "$states" \
|| { bad "$label 回执顺序不合 R-3/R-4"; return 1; }
ok "$label 回执链完整(到达终态, R-3): $states"
# 【dsh@mac13】补第二档判定:上面只保证"出现了终态"(R-3 不许静默完成),
# 但 **A2 的真实语义是"结果回到房间"** ⇒ 失败/超时不该与成功同等计为通过。
# 实测依据:接真 agent 后出现过 `[accepted running failed]` 被旧门判 ✅。
if printf '%s' "$states" | grep -qE " (failed|timeout)$"; then
bad "$label 终态是失败/超时(A2 未达成): $states"
return 1
fi
ok "$label 终态为成功(A2 结果已回到房间)"
# 2) 归属: 只应由服务该 target 的桥回执(R-7 一 target 一账号, 不许串台)
if [ -n "$want" ]; then
[ "$senders" = "$want" ] && ok "$label 回执归属正确: $want" \
|| { bad "$label 期望来自 $want, 实际 [$senders]"; return 1; }
fi
if [ -n "$forbid" ]; then
case " $senders " in
*" $forbid "*) bad "$label 出现**另一节点**的桥($forbid)回执 ⇒ 串台(R-7 违反)"; return 1;;
*) ok "$label 未串台(无 $forbid 回执)";;
esac
fi
return 0
}
probe() { # probe <label> <target> <期望sender> <禁止sender>
local label="$1" target="$2" want="$3" forbid="$4" eid
# 【dsh@mac13】探针正文可配置(**默认行为不变**):实测 hermes CLI 对"短促+编号+方括号标签"
# 这类文本会长时间不退出(对照:自然语言 ~40-100s 正常返回),导致 A2 只能拿到 failed。
# 节点归属信息不丢 —— 两台机比对用的是下面的 P4-FINGERPRINT 行,不是消息尾巴。
eid="$(send "$target ${P4_LABEL_PREFIX:-判据④ 探针} $(date +%H%M%S)${P4_PROBE_SUFFIX- [$NODE]}")"
[ -n "$eid" ] || { bad "$label 发送失败(无 event_id)"; return 1; }
printf ' 根事件: %s\n' "$eid"
ROOTS="$ROOTS $label=$eid"
if expect_chain "$label" "$eid" "$want" "$forbid"; then
# 机器可读行: 供 --pair 做"两台物理机"的**自动**判定, 不再靠人比对两行指纹。
printf ' P4-HIT node=%s target=%s bridge=%s ok=1\n' "$NODE" "$target" "${want:-?}"
return 0
fi
printf ' P4-HIT node=%s target=%s bridge=%s ok=0\n' "$NODE" "$target" "${want:-?}"
return 1
}
negative_control() {
local eid got n
eid="$(send "判据④ 阴性对照:本条没有 @ 任何 target${P4_PROBE_SUFFIX- [$NODE]}")"
[ -n "$eid" ] || { bad "阴性对照发送失败"; return 1; }
sleep "$NEG_WAIT"
got="$(receipts "$eid")"
if printf '%s' "$got" | grep -q '__FETCH_FAILED__'; then
bad "阴性对照取数失败(/messages) ⇒ 不能拿\"0 条\"当通过(缺陷L)"; FETCH_FAIL=1; return 2
fi
n="$(printf '%s' "$got" | grep -c . )"
if [ "$n" -eq 0 ]; then ok "阴性对照: 无 @ 的消息在 ${NEG_WAIT}s 内 0 条回执(判据③ 活环境侧)";
else bad "阴性对照失败: 无 @ 却收到 $n 条回执 ⇒ 误触发"; printf '%s\n' "$got" | sed 's/^/ /'; return 1; fi
}
pair_check() { # pair_check <fileA> <fileB>: 两台机各跑一次的原始输出
local a="$1" b="$2"
for f in "$a" "$b"; do
[ -f "$f" ] || { echo "🔴 找不到文件: $f" >&2; exit 2; }
done
python3 - "$a" "$b" <<'PY'
import re, sys
HIT = re.compile(r"P4-HIT node=(\S+) target=(\S+) bridge=(\S+) ok=(\d)")
FP = re.compile(r"P4-FINGERPRINT node=(\S+) user=(\S+) room=(\S+)")
def parse(path):
hits, node, user, room = [], None, None, None
for ln in open(path, encoding="utf-8", errors="replace"):
m = HIT.search(ln)
if m:
hits.append(m.groups())
f = FP.search(ln)
if f:
node, user, room = f.group(1), f.group(2), f.group(3)
return hits, node, user, room
ha, na, ua, ra = parse(sys.argv[1])
hb, nb, ub, rb = parse(sys.argv[2])
fail, ok = [], []
if not ha or not hb:
print("🔴 至少一侧没有 P4-HIT 行(该侧未跑或未通过) ⇒ 判读不可信"); sys.exit(2)
if not (na and ua and ra and nb and ub and rb):
print("🔴 至少一侧缺指纹行(node/user/room) ⇒ 判读不可信"); sys.exit(2)
if na == nb:
fail.append(f"两侧 node 相同({na}) ⇒ **不是两台物理机**")
else:
ok.append(f"两个节点不同: {na} / {nb}")
if ua == ub:
fail.append(f"两侧自称的桥身份相同({ua}) ⇒ 无法证明是两个桥进程")
else:
ok.append(f"两个桥身份不同: {ua} / {ub}")
if ra != rb:
fail.append(f"两侧房间不同: {ra} vs {rb} ⇒ 不在同一房间, 判据④ 不成立")
else:
ok.append(f"同一房间: {ra}")
# 每轮: 所有探针必须 ok, 且每个 target 在**该轮内**只被一个桥应答(R-7)
maps, bad_probe = [], False
for tag, hits in (("A", ha), ("B", hb)):
if [h for h in hits if h[3] != "1"]:
fail.append(f"第 {tag} 轮存在未通过的探针: "
+ ",".join(f"{h[0]}:{h[1]}" for h in hits if h[3] != "1"))
bad_probe = True
per = {}
for _node, target, bridge, _ok in hits:
per.setdefault(target, set()).add(bridge)
for target, brs in sorted(per.items()):
if len(brs) > 1:
fail.append(f"第 {tag} 轮 target {target} 被多个桥应答 {sorted(brs)} ⇒ R-7 违反")
maps.append(per)
# 缺陷K(workbuddy-mac13 报): 原判定是**条件式**的 —— 两侧都没有某 target 时两支都不进,
# 静默跳过 ⇒ 零交叉也判通过(阶段门放水)。现在改为**无条件**:
sa, sb = set(maps[0]), set(maps[1])
if sa != sb:
fail.append(f"两侧 target 覆盖不一致: A={sorted(sa)} B={sorted(sb)} ⇒ "
f"零交叉/单侧覆盖都**没验过**'各自被 @ 时命中'")
elif len(sa) < 2:
fail.append(f"每侧只覆盖 {len(sa)} 个 target({sorted(sa)}) ⇒ 判据④ 要求两个 target 各自被 @ 命中")
else:
ok.append(f"两侧覆盖一致且 ≥2 个 target: {sorted(sa)}")
for tg in sorted(sa & sb):
a_br, b_br = sorted(maps[0][tg]), sorted(maps[1][tg])
if a_br != b_br:
fail.append(f"跨轮不一致: target {tg} 在 A 轮由 {a_br} 应答、B 轮由 {b_br} ⇒ 归属不稳定(R-7)")
else:
ok.append(f"跨轮一致: {tg} → {a_br[0]}")
bridges = {h[2] for h in (ha + hb)}
if len(bridges) < 2:
fail.append(f"两次运行只见 1 个桥身份({sorted(bridges)}) ⇒ 无法证明两个节点各自独立应答")
else:
ok.append(f"两个桥身份在场: {'/'.join(sorted(bridges))}")
# 绿勾只在**没有 fail** 时打(原写法 `not fail or all(...)` 会在 fail 非空时照打绿勾)
if not fail:
print(f" ✅ 每一轮内每个 target 唯一应答; 探针全部通过")
for x in ok:
print(f" ✅ {x}")
for x in fail:
print(f" ❌ {x}")
print()
if fail:
print(f"❌ 判据④(两台物理机)未通过: {len(fail)} 项"); sys.exit(1)
print("✅ 判据④(两台物理机)通过: 两节点 · 两桥身份 · 同房间 · 每 target 归属唯一且跨轮一致")
PY
}
run_live() {
local rc=0
# 每次进来重取环境: 这样 --selftest 里 export 的替身配置才生效(脚本顶部的初值只在直接调用时有用)。
BASE="${MATRIX_BASE_URL:-}"; TOKEN="${MATRIX_ACCESS_TOKEN:-}"; ROOM="${BRIDGE_ROOM:-}"
TA="${TARGET_A:-}"; TB="${TARGET_B:-}"; BA="${BRIDGE_A:-}"; BB="${BRIDGE_B:-}"
NODE="${NODE_NAME:-$(hostname -s 2>/dev/null || echo unknown)}"
WAIT="${P4_WAIT:-45}"; NEG_WAIT="${P4_NEG_WAIT:-8}"
ROOTS=""
preflight || return 2
sec "探针(真实发消息到白名单房间)"
probe "判据④-A @ ${TA%%:*}" "$TA" "$BA" "${BB:-}" || rc=1
probe "判据④-B @ ${TB%%:*}" "$TB" "$BB" "${BA:-}" || rc=1
sec "阴性对照"
negative_control || rc=1
idempotency_control "$TA" "${BA:-$TA}" || rc=1
sec "节点指纹(两台机各跑一次, 两行对比即为判据④ 的「两台物理机」证据)"
printf ' P4-FINGERPRINT node=%s user=%s room=%s roots=%s\n' "$NODE" "${ME:-?}" "$ROOM" "${ROOTS# }"
printf '\n\033[1m═══ 判据④: %d 通过 / %d 失败 / %d 跳过 ═══\033[0m\n' "$pass" "$fail" "$skip"
if [ "$FETCH_FAIL" = "1" ]; then
echo "🔴 判据④ **拒绝判读**: 有取数失败(见上) —— 不要把'取不到'当成'没发生'"; return 2
fi
[ "$rc" -eq 0 ] && { echo "✅ 判据④ 在本节点通过"; return 0; } || { echo "❌ 判据④ 未通过"; return 1; }
}
# ── 幂等复验(R-1/A4 的活环境形态)──────────────────────────────────
# ⚠️ 「重放」的正确定义(我上一版在这里想错了,留字免得后人照抄):
# 契约 F2 是 idem_key = sha256(room|sender|client_ts|prompt_digest) —— **含时间戳**;
# §6 判据 2 说的是「同一消息重放 3 次(同 idem_key)→ 只执行一次」。
# ⇒ 连发三条正文相同的消息**不是重放**:各自 origin_server_ts 不同 ⇒ 本就该各执行一次
# (实测 rig 里就是 3 个 task_id,全 done)。真正的重放是**同一条事件被重复投递**
# (Matrix 重投、或桥重启后 initial sync 把旧事件再带回一次)。
# 所以这里:发一条 → 等它到终态 → 用 REPLAY_CMD 让同一条事件被重新投递 →
# 要求房间里**仍只有一个 task_id**,且重投确实带来了新回执(否则不许算通过)。
idempotency_control() { # idempotency_control <target> <期望应答者>
local target="$1" want="$2"
[ -n "$target" ] || { skip "幂等复验: 未给 TARGET_A ⇒ 跳过"; return 0; }
if [ -z "${REPLAY_CMD:-}" ]; then
skip "幂等复验: 未提供 REPLAY_CMD(重投同一条事件的手段) ⇒ 跳过,不假装通过"
return 0
fi
sec "幂等复验(R-1/A4:同一条事件被重投)"
local body eid roots="" all n_recv n_tasks n_recv2 wait_left t0
wait_left="${IDEM_WAIT:-150}"
body="$target ${P4_LABEL_PREFIX:-判据④ 探针} replay$(date +%H%M%S)${P4_PROBE_SUFFIX- [$NODE]}"
eid="$(send "$body")"
[ -n "$eid" ] || { bad "幂等复验发送失败"; return 1; }
roots="$eid"
printf ' 原始事件: %s\n' "$eid"
t0=$(date +%s)
while :; do
sleep 5
all="$(probe_receipts "$roots")" || { bad "取数失败 ⇒ 拒绝判读(承缺陷L)"; FETCH_FAIL=1; return 2; }
[[ "$all" == __FETCH_FAILED__* ]] && { bad "取数失败 ⇒ 拒绝判读(承缺陷L)"; FETCH_FAIL=1; return 2; }
printf '%s\n' "$all" | grep -qE " (succeeded|failed|timeout)$" && break
[ $(( $(date +%s) - t0 )) -ge "$wait_left" ] && break
done
n_recv="$(printf '%s\n' "$all" | grep -c .)"
if [ "${n_recv:-0}" -eq 0 ]; then
bad "第一轮就没收到任何回执 ⇒ 无法判定(不许把'没测到'当'通过')"; return 1
fi
ok "第一轮有回执 ${n_recv} 条,进入重投"
say " 执行重投: $REPLAY_CMD"
sh -c "$REPLAY_CMD" >/dev/null 2>&1 || { bad "重投命令失败 ⇒ 无法判定"; return 1; }
t0=$(date +%s)
while :; do
sleep 5
all="$(probe_receipts "$roots")" || { bad "取数失败 ⇒ 拒绝判读"; FETCH_FAIL=1; return 2; }
[[ "$all" == __FETCH_FAILED__* ]] && { bad "取数失败 ⇒ 拒绝判读"; FETCH_FAIL=1; return 2; }
n_recv2="$(printf '%s\n' "$all" | grep -c .)"
[ "${n_recv2:-0}" -gt "${n_recv:-0}" ] && break
[ $(( $(date +%s) - t0 )) -ge "$wait_left" ] && break
done
n_recv2="$(printf '%s\n' "$all" | grep -c .)"
n_tasks="$(printf '%s\n' "$all" | awk 'NF>=2{print $1}' | sort -u | grep -c .)"
printf ' 重投后 ⇒ 回执 %s 条(原 %s),涉及 task_id %s 个\n' "${n_recv2:-0}" "${n_recv:-0}" "${n_tasks:-0}"
if [ "${n_recv2:-0}" -le "${n_recv:-0}" ]; then
bad "重投后没有任何新回执 ⇒ 这条断言其实没测到东西(不许当成通过)"; return 1
fi
if [ "${n_tasks:-0}" -eq 1 ]; then
ok "重投同一条事件仍只有**一个** task_id(R-1 幂等成立:不重复执行)"
else
bad "出现 ${n_tasks} 个 task_id ⇒ 幂等在真链路上失效"; printf '%s\n' "$all" | sed 's/^/ /'; return 1
fi
return 0
}
# 从 /messages 取指定 thread root 下的回执:每行 "<task_id> <status>";取数失败给哨兵
probe_receipts() { # probe_receipts "<root event ids...>"
local f="$TMP/pr.json"
api GET "/_matrix/client/v3/rooms/$(enc "$ROOM")/messages?dir=b&limit=100" > "$f" || { echo __FETCH_FAILED__; return 1; }
python3 -c '
import json, sys
path = sys.argv[1]; roots = set(sys.argv[2].split())
try:
d = json.load(open(path, encoding="utf-8"))
except Exception:
print("__FETCH_FAILED__"); sys.exit(0)
if not isinstance(d, dict) or "chunk" not in d:
print("__FETCH_FAILED__"); sys.exit(0)
rows = []
for ev in (d.get("chunk") or []):
c = ev.get("content") or {}
rel = c.get("m.relates_to") or {}
root = rel.get("event_id") if rel.get("rel_type") == "m.thread" else None
# 真回执把 task_id/status 放在 content **顶层**(事件类型才是 com.apescale.…result.v1);
# 离线替身里也出现过嵌套形态 ⇒ 两种都认,别只按一种写死(我就因此漏抓过一次)。
r = c.get("com.apescale.tutti.result.v1") or c
tid, st = r.get("task_id"), r.get("status")
if tid and st and root in roots:
rows.append((int(ev.get("origin_server_ts") or 0), tid, st))
for _, tid, st in sorted(rows):
print(tid, st)
' "$f" "$1"
}
selftest() {
local out port pid rc
out="$(mktemp)"
echo "── 0. 起 mock matrix 替身"
MOCK_TOKEN=mock-token python3 "$(dirname "$0")/../tests/mock_matrix.py" 0 >"$out" 2>&1 &
pid=$!
trap 'kill "${pid:-}" 2>/dev/null; rm -rf "${TMP:-}" "${out:-}"' EXIT
for _ in $(seq 1 50); do port="$(awk '/LISTENING/{print $2; exit}' "$out" 2>/dev/null)"; [ -n "${port:-}" ] && break; sleep 0.1; done
[ -z "${port:-}" ] && { echo "mock 未启动"; cat "$out"; exit 1; }
echo " ✅ 监听 127.0.0.1:$port"
export MATRIX_BASE_URL="http://127.0.0.1:$port" MATRIX_ACCESS_TOKEN=mock-token
export BRIDGE_ROOM='!p4:matrix.test' NODE_NAME=selftest P4_WAIT=6 P4_NEG_WAIT=1
export TARGET_A='@tutti-hermes:matrix.test' TARGET_B='@tutti-pi:matrix.test'
export BRIDGE_A='@bridge3:matrix.test' BRIDGE_B='@bridge2:matrix.test'
echo "── 1. 正例:应全绿(exit 0)"
run_live >/dev/null 2>&1; rc=$?
[ "$rc" -eq 0 ] && echo " ✅ 正例通过" || { echo " ❌ 正例失败 exit=$rc"; run_live | sed 's/^/ /'; exit 1; }
echo "── 2. 对照:缺 running(R-3 违例) 必须判红(exit 1)"
kill $pid 2>/dev/null; wait $pid 2>/dev/null
MOCK_TOKEN=mock-token MOCK_SKIP_RUNNING=1 python3 "$(dirname "$0")/../tests/mock_matrix.py" 0 >"$out" 2>&1 &
pid=$!
for _ in $(seq 1 50); do port="$(awk '/LISTENING/{print $2; exit}' "$out" 2>/dev/null)"; [ -n "${port:-}" ] && break; sleep 0.1; done
export MATRIX_BASE_URL="http://127.0.0.1:$port"
run_live >/dev/null 2>&1; rc=$?
[ "$rc" -eq 1 ] && echo " ✅ 缺 running 时门正确报红(exit 1)" || { echo " ❌ 缺 running 未报红 exit=$rc"; exit 1; }
echo "── 3. 对照:房间缺第二个 target 必须判前置不足(exit 2)"
kill $pid 2>/dev/null; wait $pid 2>/dev/null
MOCK_TOKEN=mock-token MOCK_NO_TARGET_B=1 python3 "$(dirname "$0")/../tests/mock_matrix.py" 0 >"$out" 2>&1 &
pid=$!
for _ in $(seq 1 50); do port="$(awk '/LISTENING/{print $2; exit}' "$out" 2>/dev/null)"; [ -n "${port:-}" ] && break; sleep 0.1; done
export MATRIX_BASE_URL="http://127.0.0.1:$port"
run_live >/dev/null 2>&1; rc=$?
[ "$rc" -eq 2 ] && echo " ✅ 缺 target 时拒绝判读(exit 2)" || { echo " ❌ 缺 target 未判前置不足 exit=$rc"; exit 1; }
echo "── 4. 对照:另一节点的桥抢答(串台, R-7 违反) 必须判红(exit 1)"
kill $pid 2>/dev/null; wait $pid 2>/dev/null
MOCK_TOKEN=mock-token MOCK_LEAK=1 python3 "$(dirname "$0")/../tests/mock_matrix.py" 0 >"$out" 2>&1 &
pid=$!
for _ in $(seq 1 50); do port="$(awk '/LISTENING/{print $2; exit}' "$out" 2>/dev/null)"; [ -n "${port:-}" ] && break; sleep 0.1; done
export MATRIX_BASE_URL="http://127.0.0.1:$port" MATRIX_ACCESS_TOKEN=mock-token
run_live >/dev/null 2>&1; rc=$?
[ "$rc" -eq 1 ] && echo " ✅ 串台时门正确报红(exit 1)" || { echo " ❌ 串台未报红 exit=$rc"; exit 1; }
echo "── 5. 对照:错 token 必须拒绝判读(exit 2)"
export MATRIX_ACCESS_TOKEN=wrong-token
run_live >/dev/null 2>&1; rc=$?
[ "$rc" -eq 2 ] && echo " ✅ 令牌失效时拒绝判读(exit 2)" || { echo " ❌ 错 token 未拒绝 exit=$rc"; exit 1; }
kill $pid 2>/dev/null
echo "── 6. --pair:两台机各跑一次的原始输出应被**机械**判定为通过"
kill $pid 2>/dev/null; wait $pid 2>/dev/null
MOCK_TOKEN=mock-token python3 "$(dirname "$0")/../tests/mock_matrix.py" 0 >"$out" 2>&1 &
pid=$!
for _ in $(seq 1 50); do port="$(awk '/LISTENING/{print $2; exit}' "$out" 2>/dev/null)"; [ -n "${port:-}" ] && break; sleep 0.1; done
export MATRIX_BASE_URL="http://127.0.0.1:$port" MATRIX_ACCESS_TOKEN=mock-token
run_live > "$TMP/pa.log" 2>&1
run_live > "$TMP/pb.log" 2>&1
sed -i.bak 's/node=selftest/node=mac13/' "$TMP/pa.log" # 只改"我在哪台机"
sed -i.bak 's/node=selftest/node=mac15/; s/user=@bridge3:matrix.test/user=@bridge2:matrix.test/' "$TMP/pb.log"
if pair_check "$TMP/pa.log" "$TMP/pb.log"; then
echo " ✅ 两节点/两桥/同房间/归属唯一且跨轮一致 ⇒ 机械判定通过"
else
echo " ❌ --pair 正例失败"; exit 1
fi
echo "── 7. 对照:两侧 node 相同(同一台机跑两次) 必须判红"
cp "$TMP/pa.log" "$TMP/pc.log"
if pair_check "$TMP/pa.log" "$TMP/pc.log" >/dev/null 2>&1; then
echo " ❌ 同一节点跑两次竟被判通过"; exit 1
else
echo " ✅ 同一节点被判红(确实不是两台物理机)"
fi
echo "── 8. 对照:跨轮归属不一致(同一 target 换桥应答) 必须判红 —— R-7"
sed 's/target=@tutti-hermes:matrix.test bridge=@bridge3:matrix.test/target=@tutti-hermes:matrix.test bridge=@bridge9:matrix.test/' \
"$TMP/pb.log" > "$TMP/pd.log"
if pair_check "$TMP/pa.log" "$TMP/pd.log" >/dev/null 2>&1; then
echo " ❌ 跨轮归属不一致竟被判通过"; exit 1
else
echo " ✅ 归属不稳定被判红(R-7)"
fi
echo "── 9. 对照:两侧 target 零交叉(各只测一个) 必须判红 —— 缺陷K"
grep -v "tutti-pi" "$TMP/pa.log" > "$TMP/pe.log" # A 侧只留 hermes
grep -v "tutti-hermes" "$TMP/pb.log" > "$TMP/pf.log" # B 侧只留 pi
if pair_check "$TMP/pe.log" "$TMP/pf.log" >/dev/null 2>&1; then
echo " ❌ 零交叉竟被判通过(阶段门放水)"; exit 1
else
echo " ✅ 零交叉被判红(缺陷K 已修)"
fi
echo "── 10. 对照:/messages 取数 403 ⇒ 阴性对照必须报取数失败 —— 缺陷L"
kill $pid 2>/dev/null; wait $pid 2>/dev/null
MOCK_TOKEN=mock-token MOCK_MESSAGES_403=1 python3 "$(dirname "$0")/../tests/mock_matrix.py" 0 >"$out" 2>&1 &
pid=$!
for _ in $(seq 1 50); do port="$(awk '/LISTENING/{print $2; exit}' "$out" 2>/dev/null)"; [ -n "${port:-}" ] && break; sleep 0.1; done
export MATRIX_BASE_URL="http://127.0.0.1:$port" MATRIX_ACCESS_TOKEN=mock-token
run_live >/dev/null 2>&1; rc=$?
[ "$rc" -eq 2 ] && echo " ✅ 取数失败时拒绝判读(exit 2), 不再 fail-open" \
|| { echo " ❌ 取数失败未拒绝判读 exit=$rc"; exit 1; }
echo
echo "═══ 15-criterion4-live selftest: 全部通过(正例 2 + 对照 8)═══"
}
case "${1:-}" in
--selftest) selftest;;
--pair) pair_check "${2:-}" "${3:-}";;
""|--live) run_live;;
--help|-h) sed -n '2,28p' "$0";;
*) echo "未知参数: $1(用 --selftest 或 --help)" >&2; exit 2;;
esac
+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()