125 lines
4.7 KiB
Python
125 lines
4.7 KiB
Python
"""任务循环与持久化。运行记录是可审计 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()
|