feat(scheduler): add scheduled jobs with channel-aware delivery

Introduce persisted cron/interval jobs, gateway scheduler loop, worker turns,
and admin CRUD/edit UI. Route proactive reminders via the originating chat
channel (WhatsApp vs WeChat), harden Weixin outbound with durable queue and
PG-compatible polling, and fix chat UI to show each scheduled reminder separately.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-26 15:56:10 +08:00
parent 5336f8835f
commit 47813f6a94
45 changed files with 4956 additions and 43 deletions

View file

@ -238,6 +238,7 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
tenant_id = str(payload.get("tenant_id") or "")
user_id = str(payload.get("user_id") or "")
viewer_username = str(payload.get("viewer_username") or "")
is_scheduled_turn = str(task.task_type or "") == "scheduled_turn"
model_profile_id = str(payload.get("model_profile_id") or "") or None
interaction_mode = normalize_interaction_mode(
str(payload.get("interaction_mode") or metadata.get("interaction_mode") or "")
@ -284,6 +285,10 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
system_prompt = ""
if not system_prompt:
system_prompt = str(getattr(executor, "system_prompt", "") or "")
if is_scheduled_turn:
from runtime.scheduler.turn_text import scheduled_turn_system_suffix
system_prompt = str(system_prompt or "") + scheduled_turn_system_suffix(lang=lang)
max_messages = int(store.get_setting("AIA_TURN_MAX_CONTEXT_MESSAGES") or 80)
max_tool_rounds = int(store.get_setting("AIA_TURN_MAX_TOOL_ROUNDS") or 100)
@ -299,18 +304,19 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
attachments=list(attachments or []),
metadata=dict(metadata or {}),
)
_maybe_rename_from_first_user_message(
store=store,
session_id=session_id,
user_text=user_text,
attachments=list(attachments or []),
)
_maybe_generate_title_on_third_round(store=store, msg=msg, model=getattr(executor, "model", None))
if not is_scheduled_turn:
_maybe_rename_from_first_user_message(
store=store,
session_id=session_id,
user_text=user_text,
attachments=list(attachments or []),
)
_maybe_generate_title_on_third_round(store=store, msg=msg, model=getattr(executor, "model", None))
run_out = run_agent_core(
store=store,
data=AgentCoreRunInput(
msg=msg,
persisted_user_text=str(user_text or ""),
persisted_user_text="" if is_scheduled_turn else str(user_text or ""),
lang=lang,
system_prompt=system_prompt,
model=executor.model,
@ -327,10 +333,12 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
oclaw_worker_id=worker_id,
skill_binding_role=skill_binding_role,
wire_policy_role=wire_policy_role,
persist_user_message=not is_scheduled_turn,
),
)
base_result = {
"run_id": str(run_out.run_id or ""),
"turn_uuid": str(run_out.outcome.turn_uuid or ""),
"reply_text": run_out.outcome.final_text,
"tool_trace_count": len(run_out.outcome.tool_traces),
"relay_pointer_count": int(payload.get("relay_pointer_count") or 0),
@ -344,6 +352,15 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
relay_envelope=relay_share_envelope,
)
)
if str(task.task_type or "") == "scheduled_turn":
from runtime.scheduler.worker_turn import finalize_scheduled_turn_success
finalize_scheduled_turn_success(
store=store,
task=task,
payload=payload,
base_result=base_result,
)
store.oclaw_task_finish(task_id=task.id, result=base_result)
if trace_id:
trace_payload = {
@ -369,6 +386,10 @@ def _worker_loop(*, store: Any, worker_id: str, poll_interval_s: float) -> None:
payload=trace_payload,
)
except Exception as exc:
if str(getattr(task, "task_type", "") or "") == "scheduled_turn":
from runtime.scheduler.worker_turn import finalize_scheduled_turn_failure
finalize_scheduled_turn_failure(store=store, payload=payload, error=str(exc))
store.oclaw_task_fail(task_id=task.id, error=str(exc), result={"ok": False})
try:
if trace_id: