Files
wehub-resource-sync fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

435 lines
16 KiB
Python

"""Body of ``execute_scheduled_run`` — runs a single agent execution.
Not a DURABLE_TASK: agent runs have side effects (messages, CRM writes)
and blind auto-retry would double them. Failures after agent.gen starts
are terminal and recorded; only the pre-start load is retry-safe.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from celery.exceptions import SoftTimeLimitExceeded
from sqlalchemy import text as sql_text
from application.agents.headless_runner import run_agent_headless
from application.core.settings import settings
from application.events.publisher import publish_user_event
from application.storage.db.base_repository import row_to_dict
from application.storage.db.engine import get_engine
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
from application.storage.db.repositories.schedule_runs import (
ScheduleRunsRepository,
)
from application.storage.db.repositories.schedules import SchedulesRepository
from application.storage.db.repositories.token_usage import TokenUsageRepository
logger = logging.getLogger(__name__)
# Cap output verbatim in the run log; beyond the cap we keep the head and stamp output_truncated.
_OUTPUT_CAP_CHARS = 24_000
def _agent_config_for_schedule(schedule: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Resolve the agent row (agent-bound) or build an ephemeral classic config.
For agentless schedules (``agent_id IS NULL``), the worker constructs an
in-memory agent shape carrying just enough fields for ``run_agent_headless``:
classic agent type, system-default retriever/chunks/prompt, no source, and
the optional ``model_id`` override. The runtime toolset is rebuilt by
``ToolExecutor`` at fire time (current ``user_tools`` + non-disabled,
non-headless-excluded defaults), so a snapshot here would be dead code.
"""
if schedule.get("agent_id"):
engine = get_engine()
with engine.connect() as conn:
row = conn.execute(
sql_text("SELECT * FROM agents WHERE id = CAST(:id AS uuid)"),
{"id": str(schedule["agent_id"])},
).fetchone()
return row_to_dict(row) if row is not None else None
return _ephemeral_agent_for_agentless(schedule)
def _ephemeral_agent_for_agentless(
schedule: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Build an agent-shaped config for a schedule with no parent agent."""
# ``agent_config["tools"]`` is intentionally omitted: ``run_agent_headless``
# never reads it. The runtime toolset is rebuilt by
# ``ToolExecutor._get_user_tools(owner)`` at fire time — same dereference
# the agent-bound path uses, so a tool added/disabled after creation is
# reflected. Headless mode there filters chat-only tools (``scheduler``).
user_id = schedule.get("user_id")
if not user_id:
return None
return {
"id": None,
"user_id": user_id,
"agent_type": "classic",
"retriever": "classic",
"chunks": 2,
"prompt_id": "default",
"source_id": None,
"default_model_id": schedule.get("model_id") or "",
}
def _load_chat_history(schedule: Dict[str, Any]) -> list:
"""Originating conversation history (one-time only; recurring has none)."""
origin = schedule.get("origin_conversation_id")
if not origin or schedule.get("trigger_type") != "once":
return []
user_id = schedule.get("user_id")
if not user_id:
return []
try:
engine = get_engine()
with engine.connect() as conn:
conv = ConversationsRepository(conn).get_any(str(origin), user_id)
if conv is None:
return []
messages = ConversationsRepository(conn).get_messages(str(conv["id"]))
except Exception:
logger.exception("scheduler: failed loading chat history")
return []
history: list = []
for msg in messages:
if msg.get("prompt") and msg.get("response"):
history.append({
"prompt": msg["prompt"],
"response": msg["response"],
})
return history
def _publish_run_event(
event_type: str, run: Dict[str, Any], schedule: Dict[str, Any], **extra: Any
) -> None:
"""Best-effort SSE publish for a scheduler run state transition."""
user_id = run.get("user_id") or schedule.get("user_id")
if not user_id:
return
agent_id_raw = schedule.get("agent_id")
payload = {
"run_id": str(run["id"]),
"schedule_id": str(schedule["id"]),
"agent_id": str(agent_id_raw) if agent_id_raw else None,
"trigger_type": schedule.get("trigger_type"),
"status": run.get("status"),
**extra,
}
try:
publish_user_event(
user_id,
event_type,
payload,
scope={"kind": "schedule", "id": str(schedule["id"])},
)
except Exception:
logger.exception(
"scheduler: SSE publish failed event=%s run=%s",
event_type, run.get("id"),
)
def _publish_message_appended(
user_id: str,
conversation_id: str,
message: Dict[str, Any],
schedule_id: str,
run_id: str,
) -> None:
"""SSE message-appended event for a one-time run's chat turn."""
try:
publish_user_event(
user_id,
"schedule.message.appended",
{
"conversation_id": str(conversation_id),
"message_id": str(message["id"]),
"schedule_id": str(schedule_id),
"run_id": str(run_id),
"position": int(message.get("position", 0)),
},
scope={"kind": "conversation", "id": str(conversation_id)},
)
except Exception:
logger.exception(
"scheduler: message.appended publish failed run=%s", run_id,
)
def _append_one_time_turn(
schedule: Dict[str, Any],
run: Dict[str, Any],
outcome: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Insert an assistant turn in the originating conversation (once only)."""
origin = schedule.get("origin_conversation_id")
if not origin:
return None
engine = get_engine()
user_id = schedule.get("user_id")
metadata = {
"scheduled": True,
"schedule_id": str(schedule["id"]),
"run_id": str(run["id"]),
"scheduled_run_at": (
run.get("scheduled_for")
if isinstance(run.get("scheduled_for"), str)
else None
),
}
with engine.begin() as conn:
conv = ConversationsRepository(conn).get_any(str(origin), user_id)
if conv is None:
return None
message = ConversationsRepository(conn).append_message(
str(conv["id"]),
{
"prompt": schedule.get("instruction") or "",
"response": outcome.get("answer") or "",
"thought": outcome.get("thought") or "",
"sources": outcome.get("sources") or [],
"tool_calls": outcome.get("tool_calls") or [],
"model_id": outcome.get("model_id"),
"metadata": metadata,
},
)
return message
def execute_scheduled_run_body(run_id: str, celery_task_id: Optional[str]) -> Dict[str, Any]:
"""Execute one scheduled run by id; returns a result dict for tracing."""
if not settings.POSTGRES_URI:
return {"status": "skipped", "reason": "POSTGRES_URI not set"}
engine = get_engine()
with engine.connect() as conn:
run = ScheduleRunsRepository(conn).get_internal(run_id)
if run is None:
return {"status": "skipped", "reason": "run not found"}
schedule = SchedulesRepository(conn).get_internal(str(run["schedule_id"]))
if schedule is None:
return {"status": "skipped", "reason": "schedule not found"}
# Refuse non-runnable terminal states; manual run-now bypasses.
if run.get("status") != "pending":
return {"status": "skipped", "reason": f"run status={run.get('status')}"}
if schedule.get("status") in {"cancelled", "completed"} and run.get(
"trigger_source"
) != "manual":
with engine.begin() as conn:
ScheduleRunsRepository(conn).update(
run_id,
{
"status": "skipped",
"finished_at": datetime.now(timezone.utc),
"error_type": "internal",
"error": "schedule no longer active",
},
)
return {"status": "skipped", "reason": "schedule terminal"}
agent_config = _agent_config_for_schedule(schedule)
if agent_config is None:
with engine.begin() as conn:
updated = ScheduleRunsRepository(conn).update(
run_id,
{
"status": "failed",
"finished_at": datetime.now(timezone.utc),
"error_type": "internal",
"error": "agent missing",
},
)
SchedulesRepository(conn).bump_failure_count(str(schedule["id"]))
_publish_run_event("schedule.run.failed", updated or run, schedule,
error="agent missing")
return {"status": "failed", "reason": "agent missing"}
with engine.begin() as conn:
if not ScheduleRunsRepository(conn).mark_running(run_id, celery_task_id):
return {"status": "skipped", "reason": "lost race to mark_running"}
started = datetime.now(timezone.utc)
instruction = schedule.get("instruction") or ""
allowlist = schedule.get("tool_allowlist") or []
chat_history = _load_chat_history(schedule)
outcome: Dict[str, Any]
error_type: Optional[str] = None
error_text: Optional[str] = None
timed_out = False
try:
outcome = run_agent_headless(
agent_config,
instruction,
tool_allowlist=allowlist,
model_id_override=schedule.get("model_id"),
endpoint="schedule",
conversation_id=schedule.get("origin_conversation_id"),
chat_history=chat_history,
)
except SoftTimeLimitExceeded:
timed_out = True
outcome = {"answer": "", "tool_calls": [], "sources": [], "thought": ""}
error_type = "timeout"
error_text = "run exceeded soft time limit"
except Exception as exc:
outcome = {"answer": "", "tool_calls": [], "sources": [], "thought": ""}
error_type = "agent_error"
error_text = str(exc)
logger.exception("scheduler: agent run failed run=%s", run_id)
finished = datetime.now(timezone.utc)
# Headless denial with no usable output → tool_not_allowed.
if (
error_type is None
and (outcome.get("denied") or [])
and not (outcome.get("answer") or "").strip()
):
error_type = "tool_not_allowed"
error_text = "headless allowlist blocked required tool"
prompt_tokens = int(outcome.get("prompt_tokens", 0) or 0)
generated_tokens = int(outcome.get("generated_tokens", 0) or 0)
used_tokens = prompt_tokens + generated_tokens
if (
schedule.get("token_budget") is not None
and int(schedule["token_budget"]) > 0
and used_tokens > int(schedule["token_budget"])
):
error_type = "budget_exceeded"
error_text = (
f"used {used_tokens} tokens exceeds budget "
f"{schedule['token_budget']}"
)
answer = outcome.get("answer") or ""
truncated = False
if len(answer) > _OUTPUT_CAP_CHARS:
answer = answer[:_OUTPUT_CAP_CHARS]
truncated = True
new_status = (
"timeout" if timed_out else ("failed" if error_type else "success")
)
with engine.begin() as conn:
update_fields: Dict[str, Any] = {
"status": new_status,
"started_at": started,
"finished_at": finished,
"output": answer or None,
"output_truncated": truncated,
"prompt_tokens": prompt_tokens,
"generated_tokens": generated_tokens,
}
if error_type:
update_fields["error_type"] = error_type
update_fields["error"] = error_text
updated_run = ScheduleRunsRepository(conn).update(run_id, update_fields)
if used_tokens > 0:
agent_id_raw = schedule.get("agent_id")
try:
TokenUsageRepository(conn).insert(
user_id=schedule.get("user_id"),
api_key=None,
prompt_tokens=prompt_tokens,
generated_tokens=generated_tokens,
timestamp=finished,
agent_id=str(agent_id_raw) if agent_id_raw else None,
source="schedule",
request_id=str(run_id),
model_id=outcome.get("model_id"),
)
except Exception:
logger.exception(
"scheduler: token_usage insert failed run=%s", run_id,
)
schedules_repo = SchedulesRepository(conn)
autopaused = False
if new_status == "success":
schedules_repo.reset_failure_count(str(schedule["id"]))
elif new_status in ("failed", "timeout"):
count = schedules_repo.bump_failure_count(str(schedule["id"]))
if (
settings.SCHEDULE_AUTOPAUSE_FAILURES > 0
and count >= settings.SCHEDULE_AUTOPAUSE_FAILURES
and schedule.get("trigger_type") == "recurring"
):
autopaused = schedules_repo.autopause(str(schedule["id"]))
# Once: terminal-flip on cron-fired runs only; manual runs on a
# still-active once-schedule leave the future cadence intact.
if (
schedule.get("trigger_type") == "once"
and run.get("trigger_source") != "manual"
and schedule.get("status") == "active"
):
schedules_repo.update_internal(
str(schedule["id"]),
{"status": "completed", "next_run_at": None},
)
appended: Optional[Dict[str, Any]] = None
if (
schedule.get("trigger_type") == "once"
and new_status == "success"
and schedule.get("origin_conversation_id")
):
try:
appended = _append_one_time_turn(schedule, updated_run or run, outcome)
except Exception:
logger.exception(
"scheduler: append turn failed run=%s", run_id,
)
if appended is not None:
with engine.begin() as conn:
ScheduleRunsRepository(conn).update(
run_id,
{
"conversation_id": str(appended["conversation_id"]),
"message_id": str(appended["id"]),
},
)
_publish_message_appended(
schedule.get("user_id"),
str(appended["conversation_id"]),
appended,
str(schedule["id"]),
run_id,
)
if new_status == "success":
_publish_run_event("schedule.run.completed", updated_run or run, schedule)
else:
_publish_run_event(
"schedule.run.failed",
updated_run or run,
schedule,
error_type=error_type,
error=error_text,
)
if autopaused:
_publish_run_event(
"schedule.autopaused",
updated_run or run,
schedule,
consecutive_failure_count=settings.SCHEDULE_AUTOPAUSE_FAILURES,
)
return {
"status": new_status,
"run_id": run_id,
"error_type": error_type,
}