Files
hermes-mac13 93758f3720 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
2026-10-06 13:36:14 +08:00

248 lines
9.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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