Files
mini-openclaw-v2/backend/services/assistant_worker.py
T
qiaoqichaoandClaude Code 02289661a8 feat(v2): 纠偏第一批——执行链产出新版预审记录(真实运行验收)
覆盖 AC-46/47/48/49(BR-45/46/47、E-27)。审计确认的 P1 缺口"正常发起评审不产生
新版预审记录"由此关闭:视图由执行链出口产生,不再依赖测试或脚本注入。

后端:
- services/prescreen.py:新增 screening_from_run(),把一次真实运行的执行结果装配成
  预审视图;维度与阶段按**本次实际执行的组件与核查项**投影
  (dimensions_and_evidence:冻结方案声明过、且本次真的执行过的技能/规则才算维度;
  经验核查等方案外伪步骤不算;阶段只表达证据成熟度,不看结论好坏)。
- services/assistant_worker.py:运行出口(含失败路径)统一调用 save_prescreen_view(),
  运行结束即落库视图;装配失败单独记事件,不让运行一起丢。
- contracts/prescreen.py:视图带 evidence(界面"查看依据"的来源实体);
  新增运行级错误码 RUN_STEP_FAILED(E-27)。
- 维度状态用本次执行的步骤与核查项算(含跑通但无问题的项),
  只看待办 finding 会把通过的维度误判成未核查。

前端:面板新增"核查结果"区(每条可打开依据抽屉,显示来源、定位、原文片段与组件版本)
与降级记录展示;没有证据时明确写"按缺证处理",不编造。

测试:backend/test_prescreen_from_run.py 12 项,用真实 enqueue + DurableWorker.process_next
(临时库)断言视图由执行链产生、失败与模型不可用如实留痕、重跑新增记录。
含反例:执行前不得存在任何记录。

实际验收(浏览器渲染 + 真实运行):状态 partial、10 维(5 已核查/2 待核实/3 阻断)、
4 条核查结果、14 条证据、1 条 RUN_STEP_FAILED 未完成项;记录见
docs/v2/17-batch-delivery-records.md(含剩余缺口:界面点击未自动化、材料确认未在浏览器走完、
演示角色需 XINGBANG_DEMO_ROLE_LOGIN=1、模型未配置)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-30 12:34:45 +08:00

328 lines
20 KiB
Python

"""SQLite durable worker with leases, fencing, checkpointed steps and cancellation."""
import json
import os
import queue
import threading
import time
from datetime import datetime, timezone, timedelta
from copy import deepcopy
from services.assistant_store import get, put, listing, event, uid, now, digest, finish_time_event
from services import prescreen, prescreen_store
from services.assistant_data import read_cards
from services.skill_executor import execute_skill, execute_rules
from services.assistant_experiences import apply_experiences
from services import assistant_model
from contracts.project_assistant import SkillResult, ProjectSnapshot
LEASE_SECONDS = 30
MODEL_SLOTS = threading.BoundedSemaphore(2)
def authorized(identity, project_id):
configured = os.getenv("XINGBANG_AUTH_USERS", "")
if identity.get("preview"):
return not configured and os.getenv("XINGBANG_REQUIRE_AUTH") != "1"
if identity.get("demo"):
return os.getenv("XINGBANG_DEMO_ROLE_LOGIN") == "1" and identity.get("role") in ("manager", "reviewer", "admin") and project_id in identity.get("projects", [])
try:
account = next((u for u in json.loads(configured or "[]") if u.get("id") == identity["id"]), None)
except ValueError:
return False
return bool(account and account.get("role") in ("manager", "reviewer", "admin") and ("*" in account.get("projects", []) or project_id in account.get("projects", [])) and set(identity.get("teams", [])).issubset(account.get("teams", [])))
def reconcile(db, project_id, run_id, checks):
prior = {f["code"]: f for f in listing(db, "finding", project_id)}
changes = {"resolved": [], "reopened": [], "retained": [], "new": []}
for check in checks:
old = prior.get(check["code"])
if not old and check["result"] in ("pass", "not_applicable"):
continue
finding = deepcopy(old) if old else {"id": uid("finding"), "project_id": project_id, "first_seen_run_id": run_id, "history": [], "status": "待补件", "human_status": None}
previous_status = finding["status"]
basis_changed = bool(old and old.get("basis_ids") != check.get("basis_ids"))
status = previous_status
if check["result"] == "pass" and check.get("evidence_ids"):
status = "待复核" if basis_changed else "已解决"
elif check["result"] in ("issue", "unknown", "error"):
if previous_status == "已解决" or basis_changed:
status = "待复核"
elif previous_status != "不适用":
status = "待补件" if check["result"] == "unknown" else previous_status
finding.update(deepcopy(check), last_seen_run_id=run_id, status=status, machine_result=check["result"], stale=False)
finding["history"].append({"time": now(), "run_id": run_id, "result": check["result"], "from": previous_status, "to": status, "basis_ids": check.get("basis_ids", []), "evidence_ids": check.get("evidence_ids", []), "type": "machine"})
put(db, "finding", finding)
key = "new" if not old else "resolved" if status == "已解决" and previous_status != status else "reopened" if previous_status == "已解决" and status != previous_status else "retained"
changes[key].append(finding["id"])
# Unexecuted checks remain unresolved/stale, never disappear into an implied pass.
executed = {c["code"] for c in checks}
for code, old in prior.items():
if code not in executed:
old["stale"] = True
put(db, "finding", old)
changes["retained"].append(old["id"])
return listing(db, "finding", project_id), changes
class DurableWorker:
def __init__(self, service):
self.service = service
self.owner = uid("worker")
self.stopped = threading.Event()
self.thread = None
def start(self):
self.thread = threading.Thread(target=self.loop, name="xingbang-durable-worker", daemon=True)
self.thread.start()
return self
def stop(self):
self.stopped.set()
if self.thread:
self.thread.join(timeout=2)
def loop(self):
while not self.stopped.is_set():
try:
worked = self.process_next()
except Exception:
worked = False
if not worked:
self.stopped.wait(.5)
def claim(self):
with self.service.connect() as db:
db.execute("BEGIN IMMEDIATE")
row = db.execute("SELECT id FROM assistant_jobs WHERE state='queued' OR (state='running' AND lease_until<?) ORDER BY rowid LIMIT 1", (time.time(),)).fetchone()
if not row:
return None
run = get(db, "run", row[0])
attempts = db.execute("SELECT attempts FROM assistant_jobs WHERE id=?", (row[0],)).fetchone()[0]
if attempts >= 3:
run.update(status="failed", error="工作进程恢复次数超过上限,请创建新运行", ended_at=now())
put(db, "run", run)
db.execute("UPDATE assistant_jobs SET state='failed' WHERE id=?", (row[0],))
return None
db.execute("UPDATE assistant_jobs SET state='running',owner=?,lease_until=?,attempts=attempts+1 WHERE id=?", (self.owner, time.time() + LEASE_SECONDS, run["id"]))
run.update(status="running", started_at=run.get("started_at", now()), attempt=attempts + 1)
put(db, "run", run)
event(db, run["project_id"], "run.claimed", self.owner, {"attempt": attempts + 1}, run["id"])
return run
def valid(self, db, run_id):
row = db.execute("SELECT state,owner,lease_until FROM assistant_jobs WHERE id=?", (run_id,)).fetchone()
return bool(row and row[0] == "running" and row[1] == self.owner and row[2] > time.time() and not self.stopped.is_set())
def alive(self, run):
with self.service.connect() as db:
return self.valid(db, run["id"]) and authorized(run["identity"], run["project_id"])
def heartbeat(self, run, done):
while not done.wait(5) and not self.stopped.is_set():
with self.service.connect() as db:
db.execute("UPDATE assistant_jobs SET lease_until=? WHERE id=? AND owner=? AND state='running'", (time.time() + LEASE_SECONDS, run["id"], self.owner))
def save_prescreen_view(self, db, run):
"""运行出口:把本次执行结果装配成新版预审视图(纠偏第一批 / BR-45)。
装配失败不吞掉:运行结果已经落库,视图失败单独记事件便于定位,
而不是让整个运行一起丢。失败运行也走这里,保证"未完成"能被页面看到。
"""
try:
project = get(db, "project", run["project_id"])
view = prescreen.screening_from_run(
run, data_domain=str(project.get("data_domain") or "demo"))
prescreen_store.save(db, view)
event(db, run["project_id"], "prescreen_view.saved", self.owner,
{"run_id": run["id"], "view_id": view["id"], "status": view["status"],
"dimensions": len(view["dimension_results"]),
"missing_inputs": len(view["missing_inputs"]),
"degradations": len(view["degradations"])}, run["id"])
except Exception as exc:
event(db, run["project_id"], "prescreen_view.failed", self.owner,
{"run_id": run["id"], "error": f"{type(exc).__name__}: {exc}"[:400]},
run["id"])
def save_step(self, run, step):
if step["status"] != "running":
step = SkillResult.model_validate(step).model_dump()
with self.service.connect() as db:
db.execute("BEGIN IMMEDIATE")
if not self.valid(db, run["id"]) or not authorized(run["identity"], run["project_id"]):
raise InterruptedError("运行已取消或工作租约失效")
current = get(db, "run", run["id"])
current["steps"] = [s for s in current["steps"] if s["step_id"] != step["step_id"]] + [step]
run["steps"] = current["steps"]
put(db, "run", current)
event(db, run["project_id"], "step." + step["status"], self.owner, {"step_id": step["step_id"], "name": step["name"], "attempt": step["attempt"]}, run["id"])
def bounded_model(self, function, timeout, run):
if not MODEL_SLOTS.acquire(blocking=False):
raise TimeoutError("模型并发预算已占用")
result = queue.Queue(maxsize=1)
def call():
try:
result.put((True, function()))
except Exception as exc:
result.put((False, exc))
finally:
MODEL_SLOTS.release()
threading.Thread(target=call, daemon=True, name="xingbang-scoped-model").start()
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if not self.alive(run):
raise InterruptedError("运行取消或权限失效")
try:
ok, value = result.get(timeout=min(.5, max(.01, deadline - time.monotonic())))
if not ok:
raise value
return value
except queue.Empty:
continue
raise TimeoutError("模型步骤超过时间预算")
def process_next(self):
run = self.claim()
if not run:
return False
done = threading.Event()
heartbeat_thread = threading.Thread(target=self.heartbeat, args=(run, done), daemon=True)
heartbeat_thread.start()
try:
if not authorized(run["identity"], run["project_id"]):
raise PermissionError("项目权限已撤销")
self.execute(run)
except Exception as exc:
with self.service.connect() as db:
db.execute("BEGIN IMMEDIATE")
if self.valid(db, run["id"]):
current = get(db, "run", run["id"])
current.update(status="failed", ended_at=now(), error="运行未完成:" + type(exc).__name__)
put(db, "run", current)
db.execute("UPDATE assistant_jobs SET state='failed' WHERE id=? AND owner=?", (run["id"], self.owner))
finish_time_event(db, "run", run["id"])
event(db, run["project_id"], "run.failed", self.owner, {"error_type": type(exc).__name__}, run["id"])
# AC-48:失败运行同样要能被页面读到,并明确显示未完成原因
self.save_prescreen_view(db, current)
finally:
done.set()
heartbeat_thread.join(timeout=2)
return True
def execute(self, run):
components = run["components"]
with self.service.connect() as db:
original = get(db, "input_snapshot", run["input_snapshot_id"])
if run.get("snapshot_id"):
snapshot = get(db, "snapshot", run["snapshot_id"])
else:
snapshot = None
if snapshot is None:
data_project = dict(original, id=run["project_id"])
values, sources, datasets, errors = read_cards(data_project, components)
snapshot = dict(original, id=uid("snapshot"), values=values, sources=sources, datasets=datasets, data_errors=errors)
snapshot = ProjectSnapshot.model_validate(snapshot).model_dump()
snapshot["content_hash"] = digest(snapshot)
with self.service.connect() as db:
db.execute("BEGIN IMMEDIATE")
if not self.valid(db, run["id"]) or not authorized(run["identity"], run["project_id"]):
raise InterruptedError()
put(db, "snapshot", snapshot, immutable=True)
run["snapshot_id"] = snapshot["id"]
current = get(db, "run", run["id"])
current["snapshot_id"] = snapshot["id"]
# Freeze applicable experience versions once; recovery never reads a newer release.
current["experience_versions"] = [get(db, "experience_version", r["version_id"]) for r in listing(db, "experience_release")] if run.get("feature_flags", {}).get("experiences", True) else []
put(db, "run", current)
run.update(current)
values, sources, all_checks = deepcopy(snapshot["values"]), deepcopy(snapshot["sources"]), []
budget = components["plan"].get("budget", {"timeout_seconds": 120, "max_tool_calls": 12})
deadline = time.monotonic() + budget["timeout_seconds"]
experience_checks, applications, guidance = apply_experiences(run.get("experience_versions", []), snapshot, run["identity"], run["id"])
steps = [s for s in components["skills"] if s["engine"] != "model"] + [dict(r, engine="rules") for r in components["rules"]]
steps += [dict(id="experience.applications", name="已发布经验核查", version="1.0.0", engine="experience", content_hash=digest(guidance))]
steps += [s for s in components["skills"] if s["engine"] == "model"]
required_fields = {f for s in components["skills"] if s["engine"] == "required" for f in s.get("inputs", [])}
for skill in steps:
if not self.alive(run):
raise PermissionError("运行取消或项目权限失效")
key = skill["id"]
cached = next((s for s in run["steps"] if s["step_id"] == key and s["status"] in ("completed", "partial")), None)
if cached:
all_checks.extend(cached["findings"])
values.update(cached["outputs"])
sources.update(cached.get("output_sources", {}))
continue
step = {"run_id": run["id"], "step_id": key, "name": skill["name"], "skill": {"id": key, "version": skill["version"]},
"status": "running", "input_snapshot_id": snapshot["id"], "input_hash": digest({"values": values, "component": skill.get("content_hash")}),
"started_at": now(), "attempt": run["attempt"], "outputs": {}, "findings": [], "errors": [], "missing_inputs": [], "output_sources": {}}
self.save_step(run, step)
try:
if time.monotonic() >= deadline:
raise TimeoutError("运行时间预算已耗尽")
engine = skill["engine"]
if engine == "model":
payload, trace = self.bounded_model(lambda: assistant_model.invoke(skill, dict(snapshot, values=values, sources=sources), all_checks, components["rules"], run["requirement"], guidance,
run["model_config"], budget, cancelled=lambda: not self.alive(run) or time.monotonic() >= deadline, feedback=run.get("feedback_context")), max(.1, deadline - time.monotonic()), run)
step["outputs"] = {"requirement_response": payload["requirement_response"]}
if payload.get("experience_proposal"):
step["outputs"]["experience_proposal"] = payload["experience_proposal"]
step["findings"] = [dict(c, code=key + "." + c["code"], basis_ids=[key + "@" + skill["version"]], severity="attention") for c in payload["findings"]]
step["trace"] = trace
elif engine == "experience":
step["findings"] = experience_checks
elif engine == "rules":
step["findings"] = execute_rules(skill, values, sources)
else:
step["outputs"], step["findings"] = execute_skill(skill, values, sources)
if engine == "quality":
step["findings"] = [c for c in step["findings"] if not set(c["fields"]).issubset(required_fields)]
if engine == "ratio":
step["output_sources"] = {skill["output"]: sources.get(skill["numerator"], []) + sources.get(skill["denominator"], [])}
step["missing_inputs"] = [f for f in skill.get("inputs", []) if f != "*" and values.get(f) is None]
step["status"] = "partial" if any(c["result"] in ("unknown", "error") for c in step["findings"]) else "completed"
except Exception as exc:
step.update(status="failed", errors=[{"type": type(exc).__name__, "message": str(exc)[:400] if isinstance(exc, (ValueError, TimeoutError)) else "步骤执行失败,请检查内网模型或数据服务"}])
step["ended_at"] = now()
self.save_step(run, step)
values.update(step["outputs"])
sources.update(step["output_sources"])
all_checks.extend(step["findings"])
with self.service.connect() as db:
db.execute("BEGIN IMMEDIATE")
if not self.valid(db, run["id"]) or not authorized(run["identity"], run["project_id"]):
raise PermissionError("发布前权限或租约已失效")
project = get(db, "project", run["project_id"])
current = get(db, "run", run["id"])
current["superseded"] = project["data_revision"] != snapshot["data_revision"]
if run.get("feedback_context") and not current["superseded"]:
proposal = next((s["outputs"].get("experience_proposal") for s in current["steps"] if s["status"] in ("completed", "partial") and s["outputs"].get("experience_proposal")), None)
if proposal:
candidate = self.service.save_experience(run["project_id"], run["actor_id"], {**proposal, "feedback_id": run["feedback_context"]["id"], "scope": "project", "expires_at": (datetime.now(timezone.utc) + timedelta(days=90)).isoformat()}, db=db)
current["proposed_experience_id"] = candidate["id"]
if not current["superseded"]:
findings, changes = reconcile(db, run["project_id"], run["id"], all_checks)
else:
findings, changes = [], {"reason": "运行期间项目已更新,未覆盖当前问题状态"}
cited = {key for check in all_checks for key in check.get("evidence_ids", [])}
document_refs = [{"id": chunk["evidence_id"], "project_id": run["project_id"], "snapshot_id": snapshot["id"], "document_id": document["id"],
"source": document["name"], "locator": chunk["locator"], "field": "document.text", "value": chunk["text"][:500], "version": document["version"]}
for document in snapshot["documents"] for chunk in document["chunks"] if chunk.get("evidence_id") in cited]
current.update(status="partial" if snapshot.get("data_errors") or current["superseded"] or any(s["status"] != "completed" for s in current["steps"]) else "completed",
ended_at=now(), findings=findings, checks=all_checks, change_summary=changes, data_errors=snapshot.get("data_errors", []),
evidence=[dict(r, snapshot_id=snapshot["id"]) for refs in snapshot["sources"].values() for r in refs] + document_refs,
summary={"coverage": values.get("metrics.coverage"), "open": sum(f["status"] not in ("已解决", "不适用") for f in findings), "resolved": sum(f["status"] == "已解决" for f in findings)})
put(db, "run", current)
for application in applications:
put(db, "experience_application", application, immutable=True)
db.execute("UPDATE assistant_jobs SET state=? WHERE id=? AND owner=?", (current["status"], run["id"], self.owner))
finish_time_event(db, "run", run["id"])
self.service.mark_stale(db, run["project_id"], ["*"])
# A new result invalidates existing drafts, not the findings just reconciled.
for finding in findings:
put(db, "finding", finding)
event(db, run["project_id"], "run." + current["status"], self.owner, {"summary": current["summary"], "superseded": current["superseded"]}, run["id"])
# 纠偏第一批(BR-45):运行结束即产出新版预审视图,页面直接读它。
self.save_prescreen_view(db, current)