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:
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
@@ -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。
|
||||
@@ -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 并发 | 多端口多实例 |
|
||||
@@ -0,0 +1,7 @@
|
||||
"""local_rig — 单机轻量 Agent 团队(OpenRig 哲学 + 我们的 ACP 协议)。
|
||||
|
||||
YAML 定义团队 → 本地 daemon(SQLite + HTTP)→ tmux/CLI 跑 Agent。
|
||||
零外部服务依赖:不需要 Matrix / Tuwunel / tuttid。
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)}
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
pyyaml>=6.0
|
||||
Executable
+549
@@ -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
|
||||
@@ -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 三次 ⇒ 只应有一个任务")
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user