"""任务循环与持久化。运行记录是可审计 JSON,不依赖 UI 内存。""" from __future__ import annotations import json import os import threading import uuid from datetime import datetime, timezone from pathlib import Path from .paths import TASKS, ensure_workdirs from .planner import deterministic_plan, llm_plan from .tools import ToolError, execute from . import device, profiles _LOCK_GUARD = threading.Lock() _TASK_LOCKS: dict[str, threading.Lock] = {} _DEVICE_LOCKS: dict[str, threading.Lock] = {} def _lock(pool: dict[str, threading.Lock], key: str) -> threading.Lock: with _LOCK_GUARD: return pool.setdefault(key, threading.Lock()) def now() -> str: return datetime.now(timezone.utc).isoformat() def create(device_serial: str = "", *, allow_submit: bool = False, roster_path: str = "", success_action: str = "download", qr_image: str = "") -> dict: ensure_workdirs() if success_action not in {"download", "email"}: raise ValueError("success_action 只能是 download 或 email") task = {"id": uuid.uuid4().hex, "type": "invoice_h5", "status": "created", "device_serial": device_serial, "roster_path": roster_path, "qr_image": qr_image, "navigation": {"phase": "push_qr" if qr_image else "done"}, "allow_submit": allow_submit, "invoice": {"progress": [], "success_action": success_action}, "history": [], "created_at": now(), "updated_at": now()} save(task) return task def task_path(task_id: str) -> Path: return TASKS / f"{task_id}.json" def load(task_id: str) -> dict: return json.loads(task_path(task_id).read_text(encoding="utf-8")) def save(task: dict) -> None: ensure_workdirs() task["updated_at"] = now() destination = task_path(task["id"]) temporary = destination.with_suffix(".tmp") temporary.write_text(json.dumps(task, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") os.replace(temporary, destination) def _run(task: dict, settings: dict) -> dict: if not bool(settings.get("safety", {}).get("allow_submit", False)): task["allow_submit"] = False try: current = device.fingerprint(device.selected_device(task.get("device_serial", "")).serial) task["device_serial"] = current["serial"] profile_check = profiles.compatibility(profiles.load(current["serial"]), current) except Exception as exc: task["status"] = "blocked" task["block_reason"] = str(exc) save(task) return task if profile_check["status"] != "ready": task["status"] = "needs_sampling" task["block_reason"] = ";".join(profile_check["reasons"]) or "设备档案尚未验证" save(task) return task try: limit = int(settings.get("safety", {}).get("max_loop_steps", 30)) except (TypeError, ValueError): limit = 30 task["status"] = "running" for _ in range(max(1, min(limit, 100))): try: plan = deterministic_plan(task) if not plan: plan = llm_plan(task, settings) except Exception as exc: task["status"] = "failed" task["block_reason"] = f"规划失败:{exc}" save(task) return task if not plan: task["status"] = "completed" if task.get("invoice", {}).get("finished") else "waiting_for_review" break for step in plan: try: result = execute(step.tool, step.arguments, task) task["history"].append({"at": now(), "tool": step.tool, "arguments": step.arguments, "reason": step.reason, "result": result}) except Exception as exc: task["history"].append({"at": now(), "tool": step.tool, "arguments": step.arguments, "reason": step.reason, "error": str(exc)}) task["status"] = "failed" save(task) return task save(task) else: task["status"] = "step_limit_reached" save(task) return task def run(task: dict, settings: dict) -> dict: task_lock = _lock(_TASK_LOCKS, task["id"]) if not task_lock.acquire(blocking=False): task["status"] = "already_running"; task["block_reason"] = "该任务正在运行"; save(task); return task try: try: serial = device.selected_device(task.get("device_serial", "")).serial except Exception: return _run(task, settings) device_lock = _lock(_DEVICE_LOCKS, serial) if not device_lock.acquire(blocking=False): task["status"] = "device_busy"; task["block_reason"] = f"设备 {serial} 正在执行另一个任务"; save(task); return task try: return _run(task, settings) finally: device_lock.release() finally: task_lock.release()