runner.py 4.2 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
  1. """定时任务执行器。
  2. 一次执行 = 建一个独立会话 + 跑一轮 agent + 把结果落库并计费。
  3. 为什么用「独立会话」而不是 1.5 那种 ``scheduled-task-{id}-{run_id}`` 裸线程 ID:
  4. 本系统其他所有地方都强制 ``thread_id ≡ session_id``(那条不变式是防止
  5. 跨会话读写图状态的第一道闸)。定时任务若用另一个 ID 命名法,就成了唯一
  6. 的例外,将来谁改了都会踩。用独立会话既同样隔离,又让用户能在会话列表里
  7. 直接翻到定时任务的产出——1.5 的定时任务结果只能去 schedule_runs 表里看。
  8. """
  9. from __future__ import annotations
  10. import asyncio
  11. from typing import Any
  12. import config
  13. from billing import pricing
  14. from db import chat_store, db_conn, schedule_store
  15. from obs import get_logger
  16. logger = get_logger(__name__)
  17. # 定时任务的图递归上限:比交互式对话紧得多。
  18. # 理由是没人盯着它——跑飞了只会白烧额度,早点中止更划算。
  19. SCHEDULED_RECURSION_LIMIT = 30
  20. async def run_schedule(schedule_id: str, trigger: str = "cron") -> dict[str, Any]:
  21. """执行并持久化状态;准入失败由 HTTP 或调度回调处理。"""
  22. schedule = await asyncio.to_thread(schedule_store.get_schedule, schedule_id)
  23. if schedule is None:
  24. logger.warning("定时任务不存在 id=%s", schedule_id)
  25. return {"ok": False, "error": "schedule_not_found"}
  26. from agents import prompts
  27. from runtime import execution
  28. from runtime import concurrency
  29. import anyio
  30. user_name = schedule.get("user_name") or "default"
  31. # 同一个计划的 cron 和手动执行共用互斥,避免重复触发。
  32. with concurrency.session_guard(f"schedule:{schedule_id}"):
  33. from billing import guard
  34. await asyncio.to_thread(guard.precheck, user_name)
  35. session = await asyncio.to_thread(chat_store.create_session, user_name,
  36. schedule.get("mode") or "full", f"[定时] {schedule.get('title') or schedule_id[:8]}")
  37. thread_id = session["id"]
  38. admission = await execution.admit(user_name, thread_id)
  39. run_id = None
  40. stream = None
  41. try:
  42. run_id = await asyncio.to_thread(schedule_store.start_run, schedule_id, thread_id)
  43. prompt = schedule.get("prompt") or ""
  44. preferences = await asyncio.to_thread(chat_store.preferences_as_text, user_name)
  45. message = prompt + prompts.build_preference_instruction(preferences) + prompts.build_mode_instruction(schedule.get("mode") or "full")
  46. stream = await execution.build_execution(admission,
  47. session_id=thread_id, thread_id=thread_id, user_name=user_name,
  48. kind="scheduled", model=config.MODEL, original_user_message=prompt, message=message,
  49. config={"configurable": {"thread_id": thread_id}, "recursion_limit": SCHEDULED_RECURSION_LIMIT})
  50. result = await stream.run()
  51. await asyncio.to_thread(schedule_store.finish_run, run_id, result.status,
  52. result.error or None, result.output[:2000])
  53. return {"ok": result.status in {"success", "waiting_input"}, "status": result.status,
  54. "run_id": run_id, "session_id": thread_id, "output": result.output,
  55. "error": result.error, "input_tokens": result.usage["input_tokens"],
  56. "output_tokens": result.usage["output_tokens"]}
  57. except BaseException as exc:
  58. if run_id:
  59. with anyio.CancelScope(shield=True):
  60. await asyncio.to_thread(schedule_store.finish_run, run_id,
  61. "cancelled" if isinstance(exc, asyncio.CancelledError) else "failed", type(exc).__name__, None)
  62. raise
  63. finally:
  64. admission.release()
  65. def ensure_pricing_seeded() -> None:
  66. """确保单价表就绪,避免定时任务因为模型没定价而 fail-closed。"""
  67. try:
  68. pricing.seed_default_pricing()
  69. except Exception as exc: # pragma: no cover - 取决于数据库可用性
  70. logger.warning("单价表初始化失败:%s: %s", type(exc).__name__, exc)
  71. __all__ = ["SCHEDULED_RECURSION_LIMIT", "ensure_pricing_seeded", "run_schedule", "db_conn"]