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

@ -217,6 +217,15 @@ def _run_startup_hooks(app: FastAPI) -> None:
_log_info(f"[startup-prebuild] scheduler_started interval_s={_prewarm_interval_seconds()}")
except Exception as exc:
_log_warn(f"[startup-prebuild] scheduler_start_failed: {exc}")
try:
from svc.persistence.assistant_store import get_assistant_store
from runtime.scheduler.service import ensure_scheduler_started
store = get_assistant_store()
sid = ensure_scheduler_started(store=store)
_log_info(f"[scheduled-jobs] scheduler_started thread={sid}")
except Exception as exc:
_log_warn(f"[scheduled-jobs] scheduler_start_failed: {exc}")
_relocate_root_scan_artifacts()
startup_targets = _resolve_startup_workspace_dirs(cfg)
initialize_hooks_runtime(cfg=cfg, workspace_dir=startup_targets[0][1])
@ -302,6 +311,31 @@ def create_app() -> FastAPI:
changed = store.ack_channel_outbound_message(message_id=msg_id, ok=ok, error=err)
return {"ok": changed}
@app.get("/weixin/outbound/pending")
def weixin_outbound_pending(account_id: str = "", limit: int = 20) -> dict[str, Any]:
from svc.persistence.assistant_store import get_assistant_store
store = get_assistant_store()
lister = getattr(store, "list_pending_weixin_outbound_messages", None)
if not callable(lister):
return {"ok": True, "items": []}
items = lister(account_id=str(account_id or "").strip(), limit=limit)
return {"ok": True, "items": items}
@app.post("/weixin/outbound/ack")
def weixin_outbound_ack(payload: dict[str, Any]) -> dict[str, Any]:
from svc.persistence.assistant_store import get_assistant_store
body = payload if isinstance(payload, dict) else {}
msg_id = str(body.get("id") or "").strip()
if not msg_id:
return {"ok": False, "error": "missing id"}
ok = bool(body.get("ok", True))
err = str(body.get("error") or "").strip()
store = get_assistant_store()
changed = store.ack_channel_outbound_message(message_id=msg_id, ok=ok, error=err)
return {"ok": changed}
@app.post("/wecom/inbound")
async def wecom_inbound(payload: dict[str, Any]) -> dict[str, Any]:
payload = payload if isinstance(payload, dict) else {}