"""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= 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"])