runner.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  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. import logging
  12. import time
  13. from typing import Any
  14. import config
  15. from billing import pricing, guard
  16. from runtime import concurrency
  17. from db import chat_store, db_conn, schedule_store
  18. from obs import get_logger, get_trace_id, bind_trace_id, unbind_trace_id, log_context
  19. logger = get_logger(__name__)
  20. # 定时任务的图递归上限:比交互式对话紧得多。
  21. # 理由是没人盯着它——跑飞了只会白烧额度,早点中止更划算。
  22. SCHEDULED_RECURSION_LIMIT = 30
  23. async def run_schedule(schedule_id: str, trigger: str = "cron") -> dict[str, Any]:
  24. token = bind_trace_id(get_trace_id())
  25. started = time.perf_counter()
  26. try:
  27. with log_context(schedule_id=schedule_id, trigger=trigger):
  28. try:
  29. result = await _run_schedule(schedule_id, trigger)
  30. except asyncio.CancelledError:
  31. logger.warning("定时执行已取消", extra={"kv": {"event": "schedule.cancelled"}})
  32. raise
  33. except (guard.QuotaExceededError, concurrency.TooManyTasksError, concurrency.SessionBusyError) as exc:
  34. logger.warning("定时执行未获准", extra={"kv": {"event": "schedule.rejected", "error_type": type(exc).__name__}})
  35. raise
  36. except Exception:
  37. logger.exception("定时执行失败", extra={"kv": {"event": "schedule.failed"}})
  38. raise
  39. logger.log(logging.INFO if result.get("ok") else logging.WARNING, "定时执行结束", extra={"kv": {
  40. "event": "schedule.completed", "status": result.get("status", result.get("error")),
  41. "schedule_run_id": result.get("run_id"), "session_id": result.get("session_id"),
  42. "duration_ms": round((time.perf_counter() - started) * 1000, 1),
  43. }})
  44. return result
  45. finally:
  46. unbind_trace_id(token)
  47. async def _run_schedule(schedule_id: str, trigger: str) -> dict[str, Any]:
  48. """执行并持久化状态;准入失败由 HTTP 或调度回调处理。"""
  49. schedule = await asyncio.to_thread(schedule_store.get_schedule, schedule_id)
  50. if schedule is None:
  51. logger.warning("定时任务不存在 id=%s", schedule_id)
  52. return {"ok": False, "error": "schedule_not_found"}
  53. from agents import prompts
  54. from runtime import execution
  55. from runtime import concurrency
  56. import anyio
  57. user_name = schedule.get("user_name") or "default"
  58. # 同一个计划的 cron 和手动执行共用互斥,避免重复触发。
  59. with concurrency.session_guard(f"schedule:{schedule_id}"):
  60. from billing import guard
  61. await asyncio.to_thread(guard.precheck, user_name)
  62. session = await asyncio.to_thread(chat_store.create_session, user_name,
  63. schedule.get("mode") or "full", f"[定时] {schedule.get('title') or schedule_id[:8]}")
  64. thread_id = session["id"]
  65. admission = await execution.admit(user_name, thread_id)
  66. run_id = None
  67. stream = None
  68. try:
  69. run_id = await asyncio.to_thread(schedule_store.start_run, schedule_id, thread_id)
  70. prompt = schedule.get("prompt") or ""
  71. preferences = await asyncio.to_thread(chat_store.preferences_as_text, user_name)
  72. message = prompt + prompts.build_preference_instruction(preferences) + prompts.build_mode_instruction(schedule.get("mode") or "full")
  73. stream = await execution.build_execution(admission,
  74. session_id=thread_id, thread_id=thread_id, user_name=user_name,
  75. kind="scheduled", model=config.MODEL, original_user_message=prompt, message=message,
  76. config={"configurable": {"thread_id": thread_id}, "recursion_limit": SCHEDULED_RECURSION_LIMIT})
  77. result = await stream.run()
  78. await asyncio.to_thread(schedule_store.finish_run, run_id, result.status,
  79. result.error or None, result.output[:2000])
  80. return {"ok": result.status in {"success", "waiting_input"}, "status": result.status,
  81. "run_id": run_id, "session_id": thread_id, "output": result.output,
  82. "error": result.error, "input_tokens": result.usage["input_tokens"],
  83. "output_tokens": result.usage["output_tokens"]}
  84. except BaseException as exc:
  85. if run_id:
  86. with anyio.CancelScope(shield=True):
  87. await asyncio.to_thread(schedule_store.finish_run, run_id,
  88. "cancelled" if isinstance(exc, asyncio.CancelledError) else "failed", type(exc).__name__, None)
  89. raise
  90. finally:
  91. admission.release()
  92. def ensure_pricing_seeded() -> None:
  93. """确保单价表就绪,避免定时任务因为模型没定价而 fail-closed。"""
  94. try:
  95. pricing.seed_default_pricing()
  96. except Exception as exc: # pragma: no cover - 取决于数据库可用性
  97. logger.warning("单价表初始化失败:%s: %s", type(exc).__name__, exc)
  98. __all__ = ["SCHEDULED_RECURSION_LIMIT", "ensure_pricing_seeded", "run_schedule", "db_conn"]