302 lines
18 KiB
Python
302 lines
18 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.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_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"])
|
|
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"])
|