diff --git a/assistant_migrations/versions/002_scheduled_jobs.py b/assistant_migrations/versions/002_scheduled_jobs.py
new file mode 100644
index 00000000..11826456
--- /dev/null
+++ b/assistant_migrations/versions/002_scheduled_jobs.py
@@ -0,0 +1,80 @@
+"""Add scheduled_job tables for PostgreSQL deployments."""
+
+from __future__ import annotations
+
+from alembic import op
+from sqlalchemy import text
+
+revision = "002_scheduled_jobs"
+down_revision = "001_assistant_pg_initial"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ if bind.dialect.name != "postgresql":
+ return
+ op.execute(
+ text(
+ """
+ CREATE TABLE IF NOT EXISTS scheduled_job (
+ id TEXT PRIMARY KEY,
+ tenant_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'active',
+ schedule_kind TEXT NOT NULL,
+ schedule_expr TEXT NOT NULL,
+ timezone TEXT NOT NULL DEFAULT 'Asia/Shanghai',
+ prompt_text TEXT NOT NULL,
+ interaction_mode TEXT NOT NULL DEFAULT 'expert',
+ specialist TEXT NOT NULL DEFAULT 'generalist',
+ lang TEXT NOT NULL DEFAULT 'zh',
+ delivery_json TEXT NOT NULL DEFAULT '{}',
+ source_session_id TEXT,
+ created_by_user_id TEXT NOT NULL DEFAULT '',
+ source TEXT NOT NULL DEFAULT 'admin',
+ next_run_at TEXT,
+ last_run_at TEXT,
+ last_run_status TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )
+ """
+ )
+ )
+ op.execute(text("CREATE INDEX IF NOT EXISTS idx_scheduled_job_due ON scheduled_job(status, next_run_at)"))
+ op.execute(
+ text(
+ """
+ CREATE TABLE IF NOT EXISTS scheduled_job_run (
+ id TEXT PRIMARY KEY,
+ job_id TEXT NOT NULL REFERENCES scheduled_job(id) ON DELETE CASCADE,
+ tenant_id TEXT NOT NULL,
+ status TEXT NOT NULL,
+ scheduled_at TEXT NOT NULL,
+ started_at TEXT,
+ finished_at TEXT,
+ session_id TEXT,
+ oclaw_task_id TEXT,
+ run_id TEXT,
+ reply_text TEXT NOT NULL DEFAULT '',
+ delivery_status_json TEXT NOT NULL DEFAULT '{}',
+ error TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL
+ )
+ """
+ )
+ )
+ op.execute(
+ text("CREATE INDEX IF NOT EXISTS idx_scheduled_job_run_job ON scheduled_job_run(job_id, created_at DESC)")
+ )
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ if bind.dialect.name != "postgresql":
+ return
+ op.execute(text("DROP TABLE IF EXISTS scheduled_job_run"))
+ op.execute(text("DROP TABLE IF EXISTS scheduled_job"))
diff --git a/interfaces/admin/routes.py b/interfaces/admin/routes.py
index 80cdc25b..9f8a0753 100644
--- a/interfaces/admin/routes.py
+++ b/interfaces/admin/routes.py
@@ -1986,6 +1986,194 @@ def build_admin_router() -> APIRouter:
},
}
+ @router.get("/admin/api/scheduled-jobs/meta/targets")
+ def api_scheduled_jobs_meta_targets(
+ tenant_id: str = Query(default=""),
+ authorization: str | None = Header(default=None),
+ ) -> dict[str, Any]:
+ from runtime.scheduler.session_resolver import resolve_weixin_binding
+
+ store = get_assistant_store()
+ ctx = _resolve_auth(store, authorization)
+ _require_permission(ctx, "admin:read")
+ tid = str(tenant_id or ctx.get("tenant_id") or "default").strip()
+ _require_tenant_scope(ctx, tid)
+ aid = str(os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
+ known = store.list_whatsapp_known_groups(tenant_id=tid, account_id=aid)
+ weixin = resolve_weixin_binding(store, tenant_id=tid)
+ return {"ok": True, "whatsapp_groups": known, "weixin_binding": weixin, "whatsapp_account_id": aid}
+
+ @router.get("/admin/api/scheduled-jobs")
+ def api_scheduled_jobs_list(
+ status: str | None = Query(default=None),
+ limit: int = Query(default=100),
+ offset: int = Query(default=0),
+ authorization: str | None = Header(default=None),
+ ) -> dict[str, Any]:
+ store = get_assistant_store()
+ ctx = _resolve_auth(store, authorization)
+ _require_permission(ctx, "admin:read")
+ tenant_id = str(ctx.get("tenant_id") or "")
+ rows = store.scheduled_job_list(
+ tenant_id=tenant_id,
+ status=str(status or "").strip() or None,
+ limit=max(1, min(int(limit or 100), 300)),
+ offset=max(0, int(offset or 0)),
+ )
+ return {"ok": True, "items": [store.scheduled_job_to_dict(r) for r in rows]}
+
+ @router.get("/admin/api/scheduled-jobs/{job_id}")
+ def api_scheduled_jobs_get(
+ job_id: str,
+ authorization: str | None = Header(default=None),
+ ) -> dict[str, Any]:
+ store = get_assistant_store()
+ ctx = _resolve_auth(store, authorization)
+ _require_permission(ctx, "admin:read")
+ tenant_id = str(ctx.get("tenant_id") or "")
+ row = store.scheduled_job_get(job_id=str(job_id), tenant_id=tenant_id)
+ return {"ok": True, "job": None if not row else store.scheduled_job_to_dict(row)}
+
+ @router.post("/admin/api/scheduled-jobs")
+ def api_scheduled_jobs_create(
+ payload: dict[str, Any],
+ authorization: str | None = Header(default=None),
+ ) -> dict[str, Any]:
+ from runtime.scheduler.cron_service import build_default_delivery
+ from runtime.scheduler.expressions import normalize_schedule_kind
+
+ store = get_assistant_store()
+ ctx = _resolve_auth(store, authorization)
+ _require_permission(ctx, "admin:runtime:write")
+ tenant_id = str(ctx.get("tenant_id") or "")
+ name = str(payload.get("name") or "").strip()
+ prompt_text = str(payload.get("prompt_text") or "").strip()
+ schedule_kind = normalize_schedule_kind(payload.get("schedule_kind") or "cron")
+ schedule_expr = str(payload.get("schedule_expr") or "").strip()
+ if not name or not prompt_text or not schedule_expr:
+ return {"ok": False, "error": "name, prompt_text, schedule_expr are required"}
+ delivery = payload.get("delivery") if isinstance(payload.get("delivery"), dict) else None
+ if delivery is None:
+ wa_chat = str((payload.get("whatsapp") or {}).get("chat_id") if isinstance(payload.get("whatsapp"), dict) else payload.get("whatsapp_chat_id") or "")
+ delivery = build_default_delivery(store=store, tenant_id=tenant_id, whatsapp_chat_id=wa_chat)
+ row = store.scheduled_job_create(
+ tenant_id=tenant_id,
+ name=name,
+ prompt_text=prompt_text,
+ schedule_kind=schedule_kind,
+ schedule_expr=schedule_expr,
+ timezone_name=str(payload.get("timezone") or "Asia/Shanghai"),
+ description=str(payload.get("description") or ""),
+ interaction_mode=normalize_interaction_mode(payload.get("interaction_mode") or "expert"),
+ specialist=normalize_requested_specialist(payload.get("specialist") or "generalist"),
+ lang=str(payload.get("lang") or "zh"),
+ delivery=delivery,
+ source_session_id=str(payload.get("source_session_id") or "").strip() or None,
+ created_by_user_id=str(ctx.get("user_id") or ""),
+ source="admin",
+ )
+ store.add_admin_audit_log(
+ actor_tenant_id=tenant_id,
+ actor_user_id=str(ctx.get("user_id") or ""),
+ action="scheduled_job_create",
+ target_type="scheduled_job",
+ target_id=row.id,
+ status="ok",
+ )
+ return {"ok": True, "job": store.scheduled_job_to_dict(row)}
+
+ @router.patch("/admin/api/scheduled-jobs/{job_id}")
+ def api_scheduled_jobs_patch(
+ job_id: str,
+ payload: dict[str, Any],
+ authorization: str | None = Header(default=None),
+ ) -> dict[str, Any]:
+ store = get_assistant_store()
+ ctx = _resolve_auth(store, authorization)
+ _require_permission(ctx, "admin:runtime:write")
+ tenant_id = str(ctx.get("tenant_id") or "")
+ row = store.scheduled_job_update(tenant_id=tenant_id, job_id=str(job_id), patch=dict(payload or {}))
+ if not row:
+ return {"ok": False, "error": "job_not_found"}
+ store.add_admin_audit_log(
+ actor_tenant_id=tenant_id,
+ actor_user_id=str(ctx.get("user_id") or ""),
+ action="scheduled_job_update",
+ target_type="scheduled_job",
+ target_id=str(job_id),
+ status="ok",
+ )
+ return {"ok": True, "job": store.scheduled_job_to_dict(row)}
+
+ @router.post("/admin/api/scheduled-jobs/{job_id}/pause")
+ def api_scheduled_jobs_pause(
+ job_id: str,
+ authorization: str | None = Header(default=None),
+ ) -> dict[str, Any]:
+ store = get_assistant_store()
+ ctx = _resolve_auth(store, authorization)
+ _require_permission(ctx, "admin:runtime:write")
+ tenant_id = str(ctx.get("tenant_id") or "")
+ ok = store.scheduled_job_set_status(tenant_id=tenant_id, job_id=str(job_id), status="paused")
+ return {"ok": bool(ok)}
+
+ @router.post("/admin/api/scheduled-jobs/{job_id}/resume")
+ def api_scheduled_jobs_resume(
+ job_id: str,
+ authorization: str | None = Header(default=None),
+ ) -> dict[str, Any]:
+ store = get_assistant_store()
+ ctx = _resolve_auth(store, authorization)
+ _require_permission(ctx, "admin:runtime:write")
+ tenant_id = str(ctx.get("tenant_id") or "")
+ ok = store.scheduled_job_set_status(tenant_id=tenant_id, job_id=str(job_id), status="active")
+ return {"ok": bool(ok)}
+
+ @router.delete("/admin/api/scheduled-jobs/{job_id}")
+ def api_scheduled_jobs_delete(
+ job_id: str,
+ authorization: str | None = Header(default=None),
+ ) -> dict[str, Any]:
+ store = get_assistant_store()
+ ctx = _resolve_auth(store, authorization)
+ _require_permission(ctx, "admin:runtime:write")
+ tenant_id = str(ctx.get("tenant_id") or "")
+ ok = store.scheduled_job_delete(tenant_id=tenant_id, job_id=str(job_id))
+ return {"ok": bool(ok)}
+
+ @router.post("/admin/api/scheduled-jobs/{job_id}/run-now")
+ def api_scheduled_jobs_run_now(
+ job_id: str,
+ authorization: str | None = Header(default=None),
+ ) -> dict[str, Any]:
+ from runtime.scheduler.service import run_scheduled_job_now
+
+ store = get_assistant_store()
+ ctx = _resolve_auth(store, authorization)
+ _require_permission(ctx, "admin:runtime:write")
+ tenant_id = str(ctx.get("tenant_id") or "")
+ out = run_scheduled_job_now(store, tenant_id=tenant_id, job_id=str(job_id))
+ return out
+
+ @router.get("/admin/api/scheduled-jobs/{job_id}/runs")
+ def api_scheduled_jobs_runs(
+ job_id: str,
+ limit: int = Query(default=50),
+ offset: int = Query(default=0),
+ authorization: str | None = Header(default=None),
+ ) -> dict[str, Any]:
+ store = get_assistant_store()
+ ctx = _resolve_auth(store, authorization)
+ _require_permission(ctx, "admin:read")
+ tenant_id = str(ctx.get("tenant_id") or "")
+ rows = store.scheduled_job_run_list(
+ job_id=str(job_id),
+ tenant_id=tenant_id,
+ limit=max(1, min(int(limit or 50), 200)),
+ offset=max(0, int(offset or 0)),
+ )
+ return {"ok": True, "items": [store.scheduled_job_run_to_dict(r) for r in rows]}
+
@router.get("/admin/api/replay/turn")
def api_replay_turn(
session_id: str,
diff --git a/interfaces/admin/static/app.js b/interfaces/admin/static/app.js
index d68694ec..2c925119 100644
--- a/interfaces/admin/static/app.js
+++ b/interfaces/admin/static/app.js
@@ -5,6 +5,7 @@ const I18N = {
"nav.models": "模型管理",
"nav.apiGrants": "API 使用授权",
"nav.stack": "运行时",
+ "nav.scheduledJobs": "定时任务",
"nav.users": "用户管理",
"nav.workspacePaths": "工作区路径",
"nav.memory": "记忆",
@@ -18,6 +19,41 @@ const I18N = {
"notice.noLogin": "v2 已启用登录鉴权:请仅在内网访问",
"action.refresh": "刷新",
"title.stack": "运行时",
+ "title.scheduledJobs": "定时任务",
+ "scheduledJobs.all": "全部",
+ "scheduledJobs.statusActive": "运行中",
+ "scheduledJobs.statusPaused": "已暂停",
+ "scheduledJobs.loading": "Loading…",
+ "scheduledJobs.count": "{count} 个任务",
+ "scheduledJobs.colName": "名称",
+ "scheduledJobs.colSchedule": "计划",
+ "scheduledJobs.colStatus": "状态",
+ "scheduledJobs.colNextRun": "下次运行",
+ "scheduledJobs.colLastRun": "上次运行",
+ "scheduledJobs.colSpecialist": "专家",
+ "scheduledJobs.colDelivery": "投递",
+ "scheduledJobs.colActions": "操作",
+ "scheduledJobs.menuTitle": "任务操作",
+ "scheduledJobs.viewRuns": "查看运行记录",
+ "scheduledJobs.pause": "暂停",
+ "scheduledJobs.resume": "恢复",
+ "scheduledJobs.runNow": "立即运行",
+ "scheduledJobs.delete": "删除",
+ "scheduledJobs.deleteConfirm": "确定删除该定时任务?",
+ "scheduledJobs.triggered": "已触发",
+ "scheduledJobs.createTitle": "新建任务",
+ "scheduledJobs.create": "创建",
+ "scheduledJobs.edit": "编辑",
+ "scheduledJobs.editTitle": "编辑任务",
+ "scheduledJobs.updated": "已保存",
+ "scheduledJobs.cancel": "取消",
+ "scheduledJobs.runHistory": "运行记录",
+ "scheduledJobs.runHistoryHint": "最近运行(自动刷新)",
+ "scheduledJobs.noRuns": "暂无运行记录",
+ "scheduledJobs.weixinFixed": "微信固定投递:{id}",
+ "scheduledJobs.weixinMissing": "未找到微信绑定(需管理员渠道身份)",
+ "scheduledJobs.deliveryWeixin": "微信",
+ "scheduledJobs.deliveryWhatsapp": "WhatsApp",
"title.users": "用户管理",
"title.workspacePaths": "工作区路径(按用户)",
"title.memory": "记忆",
@@ -447,6 +483,7 @@ const I18N = {
"nav.models": "Models",
"nav.apiGrants": "API access",
"nav.stack": "Runtime",
+ "nav.scheduledJobs": "Scheduled Jobs",
"nav.users": "Users",
"nav.workspacePaths": "Workspace paths",
"nav.memory": "Memory",
@@ -460,6 +497,41 @@ const I18N = {
"notice.noLogin": "v2 login enabled: internal network only",
"action.refresh": "Refresh",
"title.stack": "Runtime",
+ "title.scheduledJobs": "Scheduled Jobs",
+ "scheduledJobs.all": "All",
+ "scheduledJobs.statusActive": "active",
+ "scheduledJobs.statusPaused": "paused",
+ "scheduledJobs.loading": "Loading…",
+ "scheduledJobs.count": "{count} job(s)",
+ "scheduledJobs.colName": "name",
+ "scheduledJobs.colSchedule": "schedule",
+ "scheduledJobs.colStatus": "status",
+ "scheduledJobs.colNextRun": "next_run",
+ "scheduledJobs.colLastRun": "last_run",
+ "scheduledJobs.colSpecialist": "specialist",
+ "scheduledJobs.colDelivery": "delivery",
+ "scheduledJobs.colActions": "actions",
+ "scheduledJobs.menuTitle": "Job actions",
+ "scheduledJobs.viewRuns": "View runs",
+ "scheduledJobs.pause": "Pause",
+ "scheduledJobs.resume": "Resume",
+ "scheduledJobs.runNow": "Run now",
+ "scheduledJobs.delete": "Delete",
+ "scheduledJobs.deleteConfirm": "Delete this scheduled job?",
+ "scheduledJobs.triggered": "Triggered.",
+ "scheduledJobs.createTitle": "Create job",
+ "scheduledJobs.create": "Create",
+ "scheduledJobs.edit": "Edit",
+ "scheduledJobs.editTitle": "Edit job",
+ "scheduledJobs.updated": "Saved.",
+ "scheduledJobs.cancel": "Cancel",
+ "scheduledJobs.runHistory": "Run history",
+ "scheduledJobs.runHistoryHint": "Recent runs (auto refresh)",
+ "scheduledJobs.noRuns": "No runs yet",
+ "scheduledJobs.weixinFixed": "Weixin fixed target: {id}",
+ "scheduledJobs.weixinMissing": "Weixin binding not found (administrator channel identity required)",
+ "scheduledJobs.deliveryWeixin": "WeChat",
+ "scheduledJobs.deliveryWhatsapp": "WhatsApp",
"title.users": "Users",
"title.workspacePaths": "Workspace paths (per user)",
"title.memory": "Memory",
@@ -9221,6 +9293,366 @@ async function renderSkills() {
]);
}
+function formatScheduledJobDelivery(job) {
+ const d = (job && job.delivery && typeof job.delivery === "object") ? job.delivery : {};
+ const parts = [];
+ if (d.weixin && d.weixin.enabled) parts.push(t("scheduledJobs.deliveryWeixin"));
+ if (d.whatsapp && d.whatsapp.enabled) parts.push(t("scheduledJobs.deliveryWhatsapp"));
+ return parts.length ? parts.join(" + ") : "—";
+}
+
+function buildScheduledJobDeliveryPayload(job, waChatId) {
+ const existing = (job && job.delivery && typeof job.delivery === "object") ? job.delivery : {};
+ const wa = existing.whatsapp && typeof existing.whatsapp === "object" ? { ...existing.whatsapp } : {};
+ const wx =
+ existing.weixin && typeof existing.weixin === "object"
+ ? { ...existing.weixin }
+ : { enabled: true, fixed: true };
+ const chat = String(waChatId || "").trim();
+ wa.enabled = Boolean(chat);
+ wa.chat_id = chat;
+ wa.target_type = chat.includes("@g.us") ? "group" : "direct";
+ if (wx.enabled === undefined) wx.enabled = true;
+ return { whatsapp: wa, weixin: wx };
+}
+
+async function renderScheduledJobs() {
+ let scheduledJobMenuEl = null;
+ let editingJob = null;
+ const canWrite = hasPermission("admin:runtime:write");
+ const closeScheduledJobMenu = () => {
+ if (scheduledJobMenuEl && scheduledJobMenuEl.parentNode) {
+ scheduledJobMenuEl.remove();
+ }
+ scheduledJobMenuEl = null;
+ };
+
+ const status = el("select", {}, [
+ el("option", { value: "", text: t("scheduledJobs.all") }),
+ el("option", { value: "active", text: t("scheduledJobs.statusActive") }),
+ el("option", { value: "paused", text: t("scheduledJobs.statusPaused") }),
+ ]);
+ const tbody = el("tbody", {});
+ const runsBox = el("pre", { class: "code", style: "max-height:220px;overflow:auto;white-space:pre-wrap;" });
+ const msg = el("div", { class: "muted", text: "" });
+
+ const editModal = el("div", { class: "session-monitor-modal", style: "display:none;" });
+ const editTitle = el("div", { class: "card__title", text: t("scheduledJobs.editTitle") });
+ const editNameInput = el("input", { class: "input", placeholder: "name" });
+ const editKindInput = el("select", {}, [
+ el("option", { value: "cron", text: "cron" }),
+ el("option", { value: "once", text: "once" }),
+ el("option", { value: "interval", text: "interval" }),
+ ]);
+ const editExprInput = el("input", { class: "input", placeholder: "schedule_expr (cron / ISO / seconds)" });
+ const editPromptInput = el("textarea", { class: "input", rows: "4", placeholder: "prompt_text" });
+ const editSpecialistInput = el("input", { class: "input", placeholder: "specialist" });
+ const editWaChatInput = el("input", { class: "input", placeholder: "whatsapp chat_id (optional)" });
+ const editWeixinInfo = el("div", { class: "muted", text: "" });
+ const closeEditModal = () => {
+ editingJob = null;
+ editModal.style.display = "none";
+ };
+ const openEditModal = (job) => {
+ if (!job || !job.id) return;
+ editingJob = job;
+ editTitle.textContent = `${t("scheduledJobs.editTitle")}: ${String(job.name || job.id)}`;
+ editNameInput.value = String(job.name || "");
+ editKindInput.value = String(job.schedule_kind || "cron");
+ editExprInput.value = String(job.schedule_expr || "");
+ editPromptInput.value = String(job.prompt_text || "");
+ editSpecialistInput.value = String(job.specialist || "generalist");
+ const wa = job.delivery && job.delivery.whatsapp ? job.delivery.whatsapp : {};
+ editWaChatInput.value = String(wa.chat_id || "");
+ const wx = job.delivery && job.delivery.weixin ? job.delivery.weixin : {};
+ editWeixinInfo.textContent = wx.external_user_id
+ ? tf("scheduledJobs.weixinFixed", { id: String(wx.external_user_id) })
+ : weixinInfo.textContent || t("scheduledJobs.weixinMissing");
+ editModal.style.display = "flex";
+ setTimeout(() => editNameInput.focus(), 0);
+ };
+ editModal.addEventListener("click", (e) => {
+ if (e.target === editModal) closeEditModal();
+ });
+ const editSaveBtn = el("button", {
+ class: "btn btn--primary",
+ text: t("action.save"),
+ onclick: async () => {
+ if (!editingJob || !editingJob.id) return;
+ const name = String(editNameInput.value || "").trim();
+ const promptText = String(editPromptInput.value || "").trim();
+ const scheduleExpr = String(editExprInput.value || "").trim();
+ if (!name || !promptText || !scheduleExpr) {
+ msg.textContent = "name, prompt_text, schedule_expr are required";
+ return;
+ }
+ try {
+ await apiRequest("PATCH", `/admin/api/scheduled-jobs/${encodeURIComponent(String(editingJob.id))}`, {
+ name,
+ schedule_kind: editKindInput.value,
+ schedule_expr: scheduleExpr,
+ prompt_text: promptText,
+ specialist: String(editSpecialistInput.value || "generalist").trim() || "generalist",
+ delivery: buildScheduledJobDeliveryPayload(editingJob, editWaChatInput.value),
+ });
+ closeEditModal();
+ msg.textContent = t("scheduledJobs.updated");
+ await loadJobs();
+ } catch (e) {
+ msg.textContent = String(e && e.message ? e.message : e);
+ }
+ },
+ });
+ const editModalCard = el("div", { class: "card session-monitor-modal__card", style: "width:min(640px,96vw);" }, [
+ editTitle,
+ editWeixinInfo,
+ editNameInput,
+ el("div", { class: "row", style: "gap:8px;" }, [editKindInput, editExprInput]),
+ editPromptInput,
+ editSpecialistInput,
+ editWaChatInput,
+ el("div", { class: "row", style: "gap:8px;justify-content:flex-end;margin-top:10px;" }, [
+ el("button", { class: "btn", text: t("scheduledJobs.cancel"), onclick: closeEditModal }),
+ editSaveBtn,
+ ]),
+ ]);
+ editModal.appendChild(editModalCard);
+
+ async function loadLatestRuns(items) {
+ const jobs = Array.isArray(items) ? items : [];
+ if (!jobs.length) {
+ runsBox.textContent = t("scheduledJobs.noRuns");
+ return;
+ }
+ const withLast = jobs.filter((j) => String(j.last_run_at || "").trim());
+ const target = withLast.length ? withLast[0] : jobs[0];
+ if (!target || !target.id) {
+ runsBox.textContent = t("scheduledJobs.noRuns");
+ return;
+ }
+ const runs = await apiGet(`/admin/api/scheduled-jobs/${encodeURIComponent(String(target.id))}/runs?limit=10`);
+ const runItems = runs.items || [];
+ runsBox.textContent = runItems.length
+ ? JSON.stringify(runItems, null, 2)
+ : t("scheduledJobs.noRuns");
+ }
+
+ async function loadJobs() {
+ closeScheduledJobMenu();
+ msg.textContent = t("scheduledJobs.loading");
+ const st = String(status.value || "").trim();
+ const q = st ? `?status=${encodeURIComponent(st)}` : "";
+ const resp = await apiGet(`/admin/api/scheduled-jobs${q}`);
+ tbody.replaceChildren();
+ for (const job of resp.items || []) {
+ const jobId = String(job.id || "");
+ const btnMore = el("button", {
+ class: "chat-sess-more",
+ text: "⋯",
+ title: t("scheduledJobs.menuTitle"),
+ onclick: (ev) => {
+ ev.stopPropagation();
+ closeScheduledJobMenu();
+ const menu = el("div", { class: "chat-sess-menu-pop", style: "position:fixed;z-index:250;" }, [
+ el("button", {
+ class: "chat-sess-menu-item",
+ text: t("scheduledJobs.viewRuns"),
+ onclick: async () => {
+ closeScheduledJobMenu();
+ const runs = await apiGet(`/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/runs`);
+ runsBox.textContent = JSON.stringify(runs.items || [], null, 2);
+ },
+ }),
+ ...(canWrite
+ ? [
+ el("button", {
+ class: "chat-sess-menu-item",
+ text: t("scheduledJobs.edit"),
+ onclick: () => {
+ closeScheduledJobMenu();
+ openEditModal(job);
+ },
+ }),
+ ]
+ : []),
+ el("button", {
+ class: "chat-sess-menu-item",
+ text: job.status === "active" ? t("scheduledJobs.pause") : t("scheduledJobs.resume"),
+ onclick: async () => {
+ closeScheduledJobMenu();
+ const path =
+ job.status === "active"
+ ? `/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/pause`
+ : `/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/resume`;
+ await apiPost(path, {});
+ await loadJobs();
+ },
+ }),
+ el("button", {
+ class: "chat-sess-menu-item",
+ text: t("scheduledJobs.runNow"),
+ onclick: async () => {
+ closeScheduledJobMenu();
+ await apiPost(`/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}/run-now`, {});
+ msg.textContent = t("scheduledJobs.triggered");
+ },
+ }),
+ ...(canWrite
+ ? [
+ el("button", {
+ class: "chat-sess-menu-item",
+ text: t("scheduledJobs.delete"),
+ onclick: async () => {
+ closeScheduledJobMenu();
+ if (!window.confirm(t("scheduledJobs.deleteConfirm"))) return;
+ await fetch(resolveAdminApiUrl(`/admin/api/scheduled-jobs/${encodeURIComponent(jobId)}`), {
+ method: "DELETE",
+ headers: { authorization: `Bearer ${getStoredAuthToken()}`, accept: "application/json" },
+ });
+ await loadJobs();
+ },
+ }),
+ ]
+ : []),
+ ]);
+ const rect = ev.currentTarget.getBoundingClientRect();
+ document.body.appendChild(menu);
+ const mrect = menu.getBoundingClientRect();
+ const pad = 8;
+ let left = rect.left - 120;
+ let top = rect.bottom + 4;
+ if (top + mrect.height > window.innerHeight - pad) {
+ top = rect.top - 4 - mrect.height;
+ }
+ left = Math.max(pad, Math.min(left, window.innerWidth - pad - mrect.width));
+ top = Math.max(pad, Math.min(top, window.innerHeight - pad - mrect.height));
+ menu.style.left = `${left}px`;
+ menu.style.top = `${top}px`;
+ scheduledJobMenuEl = menu;
+ const close = (e) => {
+ if (!menu.contains(e.target)) {
+ closeScheduledJobMenu();
+ document.removeEventListener("click", close);
+ }
+ };
+ setTimeout(() => document.addEventListener("click", close), 0);
+ },
+ });
+ const lastRun = job.last_run_at
+ ? `${formatSystemLocalDateTime(String(job.last_run_at))}${job.last_run_status ? ` (${job.last_run_status})` : ""}`
+ : "—";
+ const tr = el("tr", {}, [
+ tdCell(job.name || "", 24),
+ tdCell(`${job.schedule_kind}:${job.schedule_expr}`, 28),
+ tdCell(job.status || "", 10),
+ tdCell(job.next_run_at || "—", 20),
+ tdCell(lastRun, 24),
+ tdCell(job.specialist || "", 14),
+ tdCell(formatScheduledJobDelivery(job), 12),
+ el("td", { class: "table__cell--actions" }, [
+ el("div", { class: "table__cell-actions" }, [btnMore]),
+ ]),
+ ]);
+ tbody.appendChild(tr);
+ }
+ msg.textContent = tf("scheduledJobs.count", { count: String((resp.items || []).length) });
+ await loadLatestRuns(resp.items || []);
+ }
+
+ const nameInput = el("input", { class: "input", placeholder: "name" });
+ const kindInput = el("select", {}, [
+ el("option", { value: "cron", text: "cron" }),
+ el("option", { value: "once", text: "once" }),
+ el("option", { value: "interval", text: "interval" }),
+ ]);
+ const exprInput = el("input", { class: "input", placeholder: "schedule_expr (cron / ISO / seconds)" });
+ const promptInput = el("textarea", { class: "input", rows: "3", placeholder: "prompt_text" });
+ const specialistInput = el("input", { class: "input", placeholder: "specialist", value: "generalist" });
+ const waChatInput = el("input", { class: "input", placeholder: "whatsapp chat_id (optional)" });
+ const weixinInfo = el("div", { class: "muted", text: "" });
+
+ async function loadMeta() {
+ const meta = await apiGet("/admin/api/scheduled-jobs/meta/targets");
+ const wx = meta.weixin_binding || {};
+ weixinInfo.textContent = wx.external_user_id
+ ? tf("scheduledJobs.weixinFixed", { id: String(wx.external_user_id) })
+ : t("scheduledJobs.weixinMissing");
+ }
+
+ await loadMeta();
+ await loadJobs();
+
+ const createCardChildren = [
+ el("div", { class: "card__title", text: t("scheduledJobs.createTitle") }),
+ weixinInfo,
+ nameInput,
+ el("div", { class: "row", style: "gap:8px;" }, [kindInput, exprInput]),
+ promptInput,
+ specialistInput,
+ waChatInput,
+ el("button", {
+ class: "btn btn--primary",
+ text: t("scheduledJobs.create"),
+ onclick: async () => {
+ await apiPost("/admin/api/scheduled-jobs", {
+ name: nameInput.value,
+ schedule_kind: kindInput.value,
+ schedule_expr: exprInput.value,
+ prompt_text: promptInput.value,
+ specialist: specialistInput.value,
+ whatsapp_chat_id: waChatInput.value,
+ delivery: buildScheduledJobDeliveryPayload(null, waChatInput.value),
+ });
+ await loadJobs();
+ },
+ }),
+ ];
+
+ return el("div", {}, [
+ editModal,
+ el("div", { class: "card" }, [
+ el("div", { class: "card__title", text: t("title.scheduledJobs") }),
+ el("div", { class: "row", style: "gap:8px;align-items:center;" }, [
+ status,
+ el("button", { class: "btn", text: t("action.refresh"), onclick: () => loadJobs() }),
+ msg,
+ ]),
+ el("div", { class: "table-wrap" }, [
+ el("table", { class: "table table--compact" }, [
+ el("colgroup", {}, [
+ el("col", { style: "width:16%" }),
+ el("col", { style: "width:18%" }),
+ el("col", { style: "width:8%" }),
+ el("col", { style: "width:14%" }),
+ el("col", { style: "width:14%" }),
+ el("col", { style: "width:10%" }),
+ el("col", { style: "width:10%" }),
+ el("col", { style: "width:10%" }),
+ ]),
+ el("thead", {}, [
+ el("tr", {}, [
+ el("th", { text: t("scheduledJobs.colName") }),
+ el("th", { text: t("scheduledJobs.colSchedule") }),
+ el("th", { text: t("scheduledJobs.colStatus") }),
+ el("th", { text: t("scheduledJobs.colNextRun") }),
+ el("th", { text: t("scheduledJobs.colLastRun") }),
+ el("th", { text: t("scheduledJobs.colSpecialist") }),
+ el("th", { text: t("scheduledJobs.colDelivery") }),
+ el("th", { text: t("scheduledJobs.colActions") }),
+ ]),
+ ]),
+ tbody,
+ ]),
+ ]),
+ ]),
+ ...(canWrite ? [el("div", { class: "card" }, createCardChildren)] : []),
+ el("div", { class: "card" }, [
+ el("div", { class: "card__title", text: `${t("scheduledJobs.runHistory")} · ${t("scheduledJobs.runHistoryHint")}` }),
+ runsBox,
+ ]),
+ ]);
+}
+
async function router() {
const route = getRoute();
const page = route.page;
@@ -9243,6 +9675,7 @@ async function router() {
document.querySelectorAll(".nav__item").forEach((a) => {
const p = String(a.dataset.page || "");
if (p === "stack" && !hasPermission("admin:runtime:write")) a.style.display = "none";
+ else if (p === "scheduled-jobs" && !hasPermission("admin:read")) a.style.display = "none";
else if (p === "users" && !hasPermission("admin:user:read")) a.style.display = "none";
else if (p === "session-monitor" && !isAdministratorUsername()) a.style.display = "none";
else if (p === "admin-audit" && !hasPermission("admin:user:write")) a.style.display = "none";
@@ -9271,6 +9704,9 @@ async function router() {
);
view = await renderStack();
}
+ else if (page === "scheduled-jobs") {
+ view = hasPermission("admin:read") ? await renderScheduledJobs() : forbiddenCard();
+ }
else if (page === "users") {
view = hasPermission("admin:user:read") ? await renderUserManagement() : forbiddenCard();
} else if (page === "memory") view = await renderMemory();
diff --git a/interfaces/admin/static/chat.html b/interfaces/admin/static/chat.html
index e682796c..f28f22a9 100644
--- a/interfaces/admin/static/chat.html
+++ b/interfaces/admin/static/chat.html
@@ -574,7 +574,7 @@
diff --git a/interfaces/admin/static/chat.js b/interfaces/admin/static/chat.js
index a308035c..1ddf522f 100644
--- a/interfaces/admin/static/chat.js
+++ b/interfaces/admin/static/chat.js
@@ -408,6 +408,58 @@ function _normalizeEventType(v) {
return String(v || "").trim().toLowerCase();
}
+function _isAssistantBodyEventType(eventType) {
+ const et = _normalizeEventType(eventType);
+ return !et || et === "assistant_text" || et === "assistant" || et === "scheduled_reminder";
+}
+
+function _parseEventPayload(raw) {
+ let ep = raw;
+ if (typeof ep === "string" && String(ep).trim()) {
+ try {
+ ep = JSON.parse(ep);
+ } catch (_) {
+ ep = null;
+ }
+ }
+ return ep && typeof ep === "object" && !Array.isArray(ep) ? ep : null;
+}
+
+function _isScheduledProactiveMessage(m) {
+ const eventType = _normalizeEventType(m && m.event_type);
+ if (eventType === "scheduled_reminder") return true;
+ const ep = _parseEventPayload(m && m.event_payload);
+ if (ep && ep.scheduled_proactive) return true;
+ const rc = String((ep && ep.reasoning_content) || "").trim();
+ if (!rc) return false;
+ if (!/定时主动提醒|定时任务模式|scheduled reminder|proactive reminder/i.test(rc)) return false;
+ return eventType === "assistant_text" || eventType === "assistant" || !eventType;
+}
+
+function _messageTurnUuid(m) {
+ return String((m && m.turn_uuid) || "").trim();
+}
+
+function _pushScheduledAssistantRow(rows, m, content, eventType) {
+ const body = String(content || "").trim();
+ const attsParsed = parseAttachments(m && m.attachments);
+ if (!body && !attsParsed.length) return;
+ const piece = {
+ kind: "assistant_text",
+ text: content,
+ assistantEventType: eventType || "assistant_text",
+ };
+ if (attsParsed.length) piece.attachments = attsParsed;
+ rows.push({
+ role: "assistant",
+ content: body,
+ timestamp: (m && m.timestamp) != null ? m.timestamp : "",
+ attachments: m && m.attachments ? m.attachments : null,
+ _items: [piece],
+ _message_ids: m && m.id != null ? [m.id] : [],
+ });
+}
+
function _collapsedBlockNode(title, text) {
const raw = String(text || "");
const clipped = raw.length > REASONING_BLOCK_MAX_CHARS ? raw.slice(0, REASONING_BLOCK_MAX_CHARS) : raw;
@@ -610,6 +662,15 @@ function _buildRenderRows(msgs) {
continue;
}
if (role === "assistant" || role === "tool" || role === "function") {
+ const turnUuid = _messageTurnUuid(m);
+ if (agg && turnUuid && agg._turn_uuid && turnUuid !== agg._turn_uuid) {
+ flush();
+ }
+ if (role === "assistant" && _isScheduledProactiveMessage(m)) {
+ flush();
+ _pushScheduledAssistantRow(rows, m, content, eventType);
+ continue;
+ }
if (!agg) {
agg = {
role: "assistant",
@@ -618,20 +679,16 @@ function _buildRenderRows(msgs) {
attachments: null,
_items: [],
_message_ids: [],
+ _turn_uuid: turnUuid,
};
+ } else if (turnUuid && !agg._turn_uuid) {
+ agg._turn_uuid = turnUuid;
}
if (m && m.id != null) agg._message_ids.push(m.id);
if (role === "assistant") {
// thinking_mode_enabled: reasoning lives in event_payload.reasoning_content (no separate reasoning rows).
- let ep = m && m.event_payload;
- if (typeof ep === "string" && String(ep).trim()) {
- try {
- ep = JSON.parse(ep);
- } catch (_) {
- ep = null;
- }
- }
- if (ep && typeof ep === "object" && !Array.isArray(ep)) {
+ const ep = _parseEventPayload(m && m.event_payload);
+ if (ep) {
const rc = String(ep.reasoning_content || "").trim();
if (rc) {
agg._items.push({ kind: "reasoning", text: rc });
@@ -649,7 +706,7 @@ function _buildRenderRows(msgs) {
if (attsParsed.length) piece.attachments = attsParsed;
agg._items.push(piece);
}
- } else if (eventType === "assistant_text" || eventType === "assistant" || !eventType) {
+ } else if (_isAssistantBodyEventType(eventType)) {
const hasText = !!String(content || "").trim();
const attsParsed = parseAttachments(m && m.attachments);
if (hasText || attsParsed.length) {
@@ -4308,7 +4365,7 @@ async function renderChatUi() {
).trim();
const hasBodyText = out.some((row) => {
const et = _normalizeEventType(row.event_type);
- return et === "assistant_text" || et === "tool_call";
+ return _isAssistantBodyEventType(et) || et === "tool_call";
});
if (textFallback && !hasBodyText) {
out.push({ ...base, content: textFallback, event_type: fallbackEventType });
@@ -4327,7 +4384,7 @@ async function renderChatUi() {
if (out.length) {
const _isBodyRow = (row) => {
const et = _normalizeEventType(row && row.event_type);
- return et === "assistant_text" || et === "tool_call";
+ return _isAssistantBodyEventType(et) || et === "tool_call";
};
let attachIdx = out.length - 1;
for (let i = out.length - 1; i >= 0; i -= 1) {
@@ -4408,7 +4465,7 @@ async function renderChatUi() {
// Recovery should only accept visible assistant body, not intermediate
// reasoning/tool-call events; otherwise we may terminate on a partial line.
const et = String((m && m.event_type) || "").trim().toLowerCase();
- if (et && et !== "assistant_text" && et !== "assistant") continue;
+ if (et && !_isAssistantBodyEventType(et)) continue;
if (!String((m && m.content) || "").trim()) continue;
{
last = m;
diff --git a/interfaces/admin/static/index.html b/interfaces/admin/static/index.html
index 32621ceb..6ca0b2d1 100644
--- a/interfaces/admin/static/index.html
+++ b/interfaces/admin/static/index.html
@@ -65,6 +65,7 @@
系统与运行
Runtime
+
定时任务
会话监控
工作区路径
附件
diff --git a/interfaces/gateway/context_builder.py b/interfaces/gateway/context_builder.py
index 5df33b16..f80c9d51 100644
--- a/interfaces/gateway/context_builder.py
+++ b/interfaces/gateway/context_builder.py
@@ -5,6 +5,7 @@ from typing import Any
from runtime.skill_installer import install_skill_from_registry_archive
from runtime.tools.skills.clawhub_client import get_skill_detail as clawhub_get_skill_detail
from runtime.tools.skills.clawhub_client import search_skills as clawhub_search_skills
+from runtime.scheduler.cron_service import CronService
def build_common_gateway_context(*, store: Any) -> dict[str, Any]:
@@ -80,6 +81,7 @@ def build_common_gateway_context(*, store: Any) -> dict[str, Any]:
return {
"store": store,
+ "cron": CronService(store=store),
"search_clawhub_skills": _search_clawhub_skills,
"fetch_clawhub_skill_detail": _fetch_clawhub_skill_detail,
"install_skill_from_clawhub": _install_skill_from_clawhub,
diff --git a/interfaces/gateway/dispatcher.py b/interfaces/gateway/dispatcher.py
index 39cc848c..60c64ad6 100644
--- a/interfaces/gateway/dispatcher.py
+++ b/interfaces/gateway/dispatcher.py
@@ -4,6 +4,7 @@ from interfaces.gateway.server_methods.agent import agent_handlers
from interfaces.gateway.server_methods.chat import chat_handlers
from interfaces.gateway.server_methods.commands import commands_handlers
from interfaces.gateway.server_methods.connect import connect_handlers
+from interfaces.gateway.server_methods.cron import cron_handlers
from interfaces.gateway.server_methods.sessions import sessions_handlers
from interfaces.gateway.server_methods.skills import skills_handlers
from interfaces.gateway.server_methods.shared_types import GatewayRequestHandlers
@@ -19,6 +20,7 @@ def build_gateway_method_handlers() -> GatewayRequestHandlers:
sessions_handlers,
skills_handlers,
agent_handlers,
+ cron_handlers,
):
for method, handler in (group or {}).items():
merged[str(method)] = handler
diff --git a/interfaces/gateway/server_methods/cron.py b/interfaces/gateway/server_methods/cron.py
index 9fe59ad2..fa5c2536 100644
--- a/interfaces/gateway/server_methods/cron.py
+++ b/interfaces/gateway/server_methods/cron.py
@@ -70,6 +70,8 @@ def _cron_list_handler(opts: dict[str, Any]) -> None:
"enabled": params.get("enabled"),
"sortBy": params.get("sortBy"),
"sortDir": params.get("sortDir"),
+ "tenantId": params.get("tenantId"),
+ "tenant_id": params.get("tenant_id"),
}
),
)
diff --git a/interfaces/http/fastapi_app.py b/interfaces/http/fastapi_app.py
index a99e8510..c83fd870 100644
--- a/interfaces/http/fastapi_app.py
+++ b/interfaces/http/fastapi_app.py
@@ -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 {}
diff --git a/interfaces/http/weixin_ilink_api.py b/interfaces/http/weixin_ilink_api.py
index 30ae8240..7b037637 100644
--- a/interfaces/http/weixin_ilink_api.py
+++ b/interfaces/http/weixin_ilink_api.py
@@ -262,12 +262,23 @@ class _IlinkBridge:
if eid <= cursor:
continue
if str(event.get("token") or "") != token:
- continue
+ ev_tok = str(event.get("token") or "")
+ if ev_tok and ev_tok not in ("default", token):
+ continue
payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
if channel and str(payload.get("channel") or "") != channel:
- continue
- if account_id and str(payload.get("account_id") or "") != account_id:
- continue
+ alt = {"wechat", "weixin"}
+ if not (
+ channel in alt
+ and str(payload.get("channel") or "") in alt
+ ):
+ continue
+ req_acct = str(account_id or "").strip()
+ pay_acct = str(payload.get("account_id") or "").strip()
+ if req_acct and pay_acct and pay_acct != req_acct:
+ legacy = {"", "weixin-default"}
+ if pay_acct not in legacy and req_acct not in legacy:
+ continue
next_cursor = max(next_cursor, eid)
item = dict(payload)
item["id"] = str(eid)
@@ -319,6 +330,23 @@ async def _process_inbound_and_enqueue(
return ""
+ ctx_token = _extract_context_token(payload)
+ try:
+ from runtime.scheduler.channel_delivery import persist_channel_context_token
+ from svc.persistence.assistant_store import get_assistant_store
+
+ store = get_assistant_store()
+ persist_channel_context_token(
+ store,
+ tenant_id=str(payload.get("tenant_id") or ""),
+ channel=channel,
+ account_id=account_id,
+ external_chat_id=str(chat_id or user_id or ""),
+ context_token=ctx_token,
+ )
+ except Exception:
+ pass
+
try:
out = await asyncio.wait_for(asyncio.to_thread(_process_inbound_payload_usecase, payload), timeout=60.0)
except asyncio.TimeoutError:
@@ -534,5 +562,32 @@ async def whatsapp_native_reply(
}
-__all__ = ["router"]
+def enqueue_weixin_outbound_reply(
+ *,
+ channel: str,
+ account_id: str,
+ chat_id: str,
+ text: str,
+ context_token: str = "",
+ token: str = "",
+) -> str:
+ """Enqueue a proactive Weixin/WeChat outbound message for the sidecar poll loop."""
+ tok = str(
+ token or os.getenv("AIA_WEIXIN_ILINK_TOKEN") or os.getenv("AIA_ILINK_TOKEN") or "default"
+ ).strip()
+ ch = str(channel or "wechat").strip().lower()
+ if ch == "weixin":
+ ch = "wechat"
+ _BRIDGE.enqueue_reply(
+ token=tok,
+ channel=ch,
+ account_id=str(account_id or "").strip(),
+ chat_id=str(chat_id or "").strip(),
+ text=str(text or ""),
+ context_token=str(context_token or "").strip(),
+ )
+ return str(_BRIDGE._seq)
+
+
+__all__ = ["router", "enqueue_weixin_outbound_reply"]
diff --git a/requirements.txt b/requirements.txt
index 24cb7787..db3a758f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -21,3 +21,4 @@ python-dotenv>=1.0.0
sqlalchemy>=2.0.0
psycopg[binary]>=3.1.0
alembic>=1.13.0
+croniter>=2.0.0
diff --git a/runtime/agent_core_attempt.py b/runtime/agent_core_attempt.py
index 06dc1e80..2788923a 100644
--- a/runtime/agent_core_attempt.py
+++ b/runtime/agent_core_attempt.py
@@ -161,8 +161,18 @@ def run_attempt(*, store: Any, data: AttemptRunnerInput) -> AttemptRunnerOutput:
on_tool_ui=data.on_tool_ui,
should_stop=data.should_stop,
workspace_owner_session_id=_workspace_owner_session_id_from_msg(data.msg),
- path_policy_tenant_id=str(data.msg.metadata.get("tenant_id") or "") if isinstance(data.msg.metadata, dict) else None,
- path_policy_user_id=str(data.msg.metadata.get("user_id") or "") if isinstance(data.msg.metadata, dict) else None,
+ path_policy_tenant_id=str(data.msg.tenant_id or "")
+ or (
+ str(data.msg.metadata.get("tenant_id") or "")
+ if isinstance(data.msg.metadata, dict)
+ else ""
+ ),
+ path_policy_user_id=str(data.msg.user_id or "")
+ or (
+ str(data.msg.metadata.get("user_id") or "")
+ if isinstance(data.msg.metadata, dict)
+ else ""
+ ),
workspace_dir=data.workspace_dir,
memory_context=data.memory_context,
persist_user_message=bool(data.persist_user_message),
diff --git a/runtime/agent_core_run.py b/runtime/agent_core_run.py
index 9b1958d5..16c77aaa 100644
--- a/runtime/agent_core_run.py
+++ b/runtime/agent_core_run.py
@@ -48,6 +48,7 @@ class AgentCoreRunInput:
skill_binding_role: str | None = None
wire_policy_role: str | None = None
persisted_user_text: str | None = None
+ persist_user_message: bool = True
@dataclass(frozen=True)
@@ -204,7 +205,7 @@ def run_agent_core(*, store: Any, data: AgentCoreRunInput) -> AgentCoreRunOutput
max_tool_rounds=data.max_tool_rounds,
max_tool_workers=data.max_tool_workers,
memory_context=mem_ctx,
- persist_user_message=(idx == 1),
+ persist_user_message=(idx == 1 and bool(data.persist_user_message)),
on_token=data.on_token,
on_progress=data.on_progress,
on_tool_ui=data.on_tool_ui,
diff --git a/runtime/application/gateway/inbound_service.py b/runtime/application/gateway/inbound_service.py
index 927a5f8e..cde774df 100644
--- a/runtime/application/gateway/inbound_service.py
+++ b/runtime/application/gateway/inbound_service.py
@@ -103,7 +103,7 @@ def _menu_text() -> str:
)
-def _handle_productivity_commands(*, text: str, tenant_id: str, user_id: str) -> str | None:
+def _handle_productivity_commands(*, text: str, tenant_id: str, user_id: str, session_id: str = "") -> str | None:
t = (text or "").strip()
if not t:
return None
@@ -175,9 +175,88 @@ def _handle_productivity_commands(*, text: str, tenant_id: str, user_id: str) ->
lines = [f"- {str(h.get('source') or '')}: {str(h.get('snippet') or '')}" for h in hits[:5] if isinstance(h, dict)]
return "知识检索结果:\n" + "\n".join(lines)
+ if t in ("查定时任务", "定时任务", "schedules", "/schedule list"):
+ rows = store.scheduled_job_list(tenant_id=tenant_id, status=None, limit=10)
+ if not rows:
+ return "当前没有定时任务。"
+ lines = [
+ f"- {r.name} | {r.schedule_kind}:{r.schedule_expr} | {r.status} | id={r.id[:8]}"
+ for r in rows
+ ]
+ return "定时任务:\n" + "\n".join(lines)
+
+ if t.startswith("暂停定时任务 ") or t.startswith("暂停定时 "):
+ prefix = "暂停定时任务 " if t.startswith("暂停定时任务 ") else "暂停定时 "
+ jid = t[len(prefix) :].strip()
+ if not jid:
+ return "请提供任务 id 前缀。示例:暂停定时任务 1234abcd"
+ rows = store.scheduled_job_list(tenant_id=tenant_id, status=None, limit=200)
+ full = next((r.id for r in rows if str(r.id).startswith(jid)), jid)
+ ok = store.scheduled_job_set_status(tenant_id=tenant_id, job_id=full, status="paused")
+ return "已暂停。" if ok else "未找到该定时任务。"
+
+ if t.startswith("删除定时任务 ") or t.startswith("删除定时 "):
+ prefix = "删除定时任务 " if t.startswith("删除定时任务 ") else "删除定时 "
+ jid = t[len(prefix) :].strip()
+ if not jid:
+ return "请提供任务 id 前缀。示例:删除定时任务 1234abcd"
+ rows = store.scheduled_job_list(tenant_id=tenant_id, status=None, limit=200)
+ full = next((r.id for r in rows if str(r.id).startswith(jid)), jid)
+ ok = store.scheduled_job_delete(tenant_id=tenant_id, job_id=full)
+ return "已删除。" if ok else "未找到该定时任务。"
+
+ if t.startswith("记定时 ") or t.startswith("创建定时 "):
+ body = t.split(" ", 1)[1].strip() if " " in t else ""
+ parts = body.split(None, 1)
+ if len(parts) < 2:
+ return "格式:记定时 <时间> <提醒内容>。示例:记定时 5分钟 提醒我休息"
+ when_raw, prompt_text = parts[0].strip(), parts[1].strip()
+ seconds = _parse_schedule_duration_seconds(when_raw)
+ if seconds <= 0:
+ return "无法识别时间。示例:5分钟、1小时、300秒"
+ from runtime.scheduler.cron_service import build_delivery_for_session
+
+ delivery = build_delivery_for_session(
+ store,
+ tenant_id=tenant_id,
+ session_id=str(session_id or "").strip(),
+ )
+ row = store.scheduled_job_create(
+ tenant_id=tenant_id,
+ name=prompt_text[:40] or "定时提醒",
+ prompt_text=prompt_text,
+ schedule_kind="interval",
+ schedule_expr=str(seconds),
+ delivery=delivery,
+ source_session_id=str(session_id or "").strip() or None,
+ created_by_user_id=user_id,
+ source="chat",
+ )
+ return f"已创建定时任务:{row.id[:8]} | {prompt_text} | 每 {seconds} 秒"
+
return None
+def _parse_schedule_duration_seconds(raw: str) -> int:
+ import re
+
+ text = str(raw or "").strip().lower()
+ if not text:
+ return 0
+ if text.isdigit():
+ return max(1, int(text))
+ m = re.match(r"^(\d+(?:\.\d+)?)\s*(秒|s|sec|secs|second|seconds|分钟|分|min|mins|小时|时|h|hr|hrs|hour|hours)$", text)
+ if not m:
+ return 0
+ val = float(m.group(1))
+ unit = m.group(2)
+ if unit in {"秒", "s", "sec", "secs", "second", "seconds"}:
+ return max(1, int(val))
+ if unit in {"分钟", "分", "min", "mins"}:
+ return max(1, int(val * 60))
+ return max(1, int(val * 3600))
+
+
def _role_can_write(role: str, text: str) -> bool:
low = (text or "").strip().lower()
if not low:
@@ -810,6 +889,24 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
)
channel_session_id = str(session_id)
store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
+ if str(inbound.channel or "").strip().lower() in {"wechat", "weixin"}:
+ from runtime.scheduler.channel_delivery import (
+ extract_context_token_from_inbound_metadata,
+ persist_channel_context_token,
+ )
+
+ ctx_tok = extract_context_token_from_inbound_metadata(
+ inbound.metadata if isinstance(inbound.metadata, dict) else None
+ )
+ if ctx_tok:
+ persist_channel_context_token(
+ store,
+ tenant_id=tenant_id,
+ channel=str(inbound.channel or "weixin"),
+ account_id=account_id,
+ external_chat_id=str(inbound.external_chat_id or inbound.external_user_id or ""),
+ context_token=ctx_tok,
+ )
scope = "group" if inbound.is_group else "direct"
pe = PolicyEngine()
blob = (inbound.text or "").lower()
@@ -842,6 +939,7 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
text=inbound.text,
tenant_id=tenant_id,
user_id=user_id,
+ session_id=str(session_id),
)
if cmd_reply is not None:
reply = cmd_reply
diff --git a/runtime/chat/tool_runtime.py b/runtime/chat/tool_runtime.py
index bde44747..da8fee2e 100644
--- a/runtime/chat/tool_runtime.py
+++ b/runtime/chat/tool_runtime.py
@@ -493,7 +493,21 @@ class ToolExecutor:
msg = f"Unregistered tool: {tc.name}" if ctx.lang.startswith("en") else f"未注册的工具: {tc.name}"
return {"ok": False, "error_code": "tool_not_registered", "error": msg}, int((time.perf_counter() - t0) * 1000)
- ok, v_err = validate_tool_arguments(tool.parameters, tc.arguments)
+ from runtime.tools.context_inject import enrich_tool_arguments
+ from runtime.tools.tool_validation import filter_arguments_to_schema
+
+ raw_args = tc.arguments if isinstance(tc.arguments, dict) else {}
+ tool_args = enrich_tool_arguments(
+ store=ctx.store,
+ session_id=ctx.session_id,
+ tool_name=str(tc.name or ""),
+ arguments=raw_args,
+ path_policy_tenant_id=ctx.path_policy_tenant_id,
+ path_policy_user_id=ctx.path_policy_user_id,
+ )
+ tool_args = filter_arguments_to_schema(tool.parameters, tool_args)
+
+ ok, v_err = validate_tool_arguments(tool.parameters, tool_args)
if not ok:
msg = f"Invalid arguments: {v_err}" if ctx.lang.startswith("en") else f"参数不合法: {v_err}"
return {"ok": False, "error_code": "tool_invalid_arguments", "error": msg}, int((time.perf_counter() - t0) * 1000)
@@ -530,7 +544,7 @@ class ToolExecutor:
netx_lang_token = NETX_TOOL_LANG.set(str(ctx.lang or "zh"))
try:
- return tool.handler(tc.arguments)
+ return tool.handler(tool_args)
finally:
if netx_lang_token is not None:
from runtime.tools.experts.network_ops.netx_tools import NETX_TOOL_LANG
diff --git a/runtime/direct_loop.py b/runtime/direct_loop.py
index 6c23e46b..e9df7bec 100644
--- a/runtime/direct_loop.py
+++ b/runtime/direct_loop.py
@@ -690,6 +690,14 @@ def _build_model_context(
)
except Exception:
pass
+ if str(user_text or "").strip() and str(active_turn_uuid or "").strip():
+ has_turn_user = any(
+ str(getattr(m, "role", "") or "") == "user"
+ and str(getattr(m, "turn_uuid", "") or "") == str(active_turn_uuid)
+ for m in (rows or [])
+ )
+ if not has_turn_user:
+ llm_messages.append({"role": "user", "content": str(user_text).strip()})
return llm_messages
diff --git a/runtime/operations/weixin_bridge/official_runner.ts b/runtime/operations/weixin_bridge/official_runner.ts
index aadd95a8..e03710c1 100644
--- a/runtime/operations/weixin_bridge/official_runner.ts
+++ b/runtime/operations/weixin_bridge/official_runner.ts
@@ -192,6 +192,154 @@ function resolveHeaders(token: string): Record
{
};
}
+async function postLocalJson(token: string, route: string, body: Json, timeoutMs = 8000): Promise {
+ const url = `${LOCAL_BASE_URL.replace(/\/+$/, "")}/${route.replace(/^\/+/, "")}`;
+ const res = await fetch(url, {
+ method: "POST",
+ headers: resolveHeaders(token),
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ const text = await res.text();
+ if (!res.ok) {
+ throw new HttpStatusError(res.status, `${route} ${res.status}: ${text.slice(0, 300)}`);
+ }
+ return text ? (JSON.parse(text) as Json) : {};
+}
+
+async function getLocalJson(token: string, route: string, timeoutMs = 8000): Promise {
+ const url = `${LOCAL_BASE_URL.replace(/\/+$/, "")}/${route.replace(/^\/+/, "")}`;
+ const res = await fetch(url, {
+ method: "GET",
+ headers: resolveHeaders(token),
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ const text = await res.text();
+ if (!res.ok) {
+ throw new HttpStatusError(res.status, `${route} ${res.status}: ${text.slice(0, 300)}`);
+ }
+ return text ? (JSON.parse(text) as Json) : {};
+}
+
+async function flushWeixinDbOutbound(args: {
+ modules: OfficialModules;
+ token: string;
+ accountId: string;
+ cloudBaseUrl: string;
+ userContextTokens: TokenMap;
+}): Promise {
+ const q = encodeURIComponent(args.accountId);
+ const out = await getLocalJson(args.token, `weixin/outbound/pending?account_id=${q}&limit=20`, 8000);
+ const items = Array.isArray(out.items) ? (out.items as Json[]) : [];
+ for (const item of items) {
+ const toUser = String(item.chat_id || "").trim();
+ const text = String(item.text || "").trim();
+ const msgId = String(item.id || "").trim();
+ if (!toUser || !text || !msgId) continue;
+ const contextToken = String(
+ (item.context_token as string) ||
+ args.modules.getContextToken(args.accountId, toUser) ||
+ args.userContextTokens[toUser] ||
+ "",
+ ).trim();
+ if (!contextToken) {
+ log(`db proactive reply missing context_token; keep pending. id=${msgId} to=${toUser}`);
+ continue;
+ }
+ try {
+ await args.modules.sendMessageWeixin({
+ to: toUser,
+ text,
+ opts: {
+ baseUrl: args.cloudBaseUrl,
+ token: args.token,
+ contextToken,
+ },
+ });
+ await postLocalJson(args.token, "weixin/outbound/ack", { id: msgId, ok: true }, 8000);
+ log(`db proactive reply sent: id=${msgId} to=${toUser} textLen=${text.length}`);
+ } catch (err) {
+ try {
+ await postLocalJson(
+ args.token,
+ "weixin/outbound/ack",
+ { id: msgId, ok: false, error: String(err) },
+ 8000,
+ );
+ } catch (_) {
+ // ignore ack failure
+ }
+ log(`db proactive reply failed: id=${msgId} to=${toUser} err=${String(err)}`);
+ }
+ }
+}
+
+async function flushLocalProactiveReplies(args: {
+ modules: OfficialModules;
+ token: string;
+ accountId: string;
+ cloudBaseUrl: string;
+ localCursor: string;
+ userContextTokens: TokenMap;
+}): Promise {
+ let cursor = args.localCursor;
+ for (let i = 0; i < 3; i += 1) {
+ const out = await postLocalJson(
+ args.token,
+ "ilink/bot/getupdates",
+ {
+ channel: "wechat",
+ account_id: args.accountId,
+ get_updates_buf: cursor,
+ longpolling_timeout_ms: 1000,
+ limit: 20,
+ },
+ 8000,
+ );
+ const msgs = Array.isArray(out.msgs) ? (out.msgs as Json[]) : [];
+ const next = String(out.get_updates_buf || cursor || "").trim();
+ const batchCursor = cursor;
+ const nextCursor = next || cursor;
+ if (!msgs.length) {
+ break;
+ }
+ let allSucceeded = true;
+ for (const r of msgs) {
+ const toUser = String(r.chat_id || "").trim();
+ const text = String(r.text || "").trim();
+ if (!toUser || !text) continue;
+ const contextToken = String(
+ (r.context_token as string) ||
+ args.modules.getContextToken(args.accountId, toUser) ||
+ args.userContextTokens[toUser] ||
+ "",
+ ).trim();
+ if (!contextToken) {
+ allSucceeded = false;
+ log(`proactive reply missing context_token; keep cursor. to=${toUser} textLen=${text.length}`);
+ continue;
+ }
+ try {
+ await args.modules.sendMessageWeixin({
+ to: toUser,
+ text,
+ opts: {
+ baseUrl: args.cloudBaseUrl,
+ token: args.token,
+ contextToken,
+ },
+ });
+ log(`proactive reply sent: to=${toUser} textLen=${text.length}`);
+ } catch (err) {
+ log(`proactive reply failed: to=${toUser} err=${String(err)}`);
+ allSucceeded = false;
+ }
+ }
+ cursor = allSucceeded ? nextCursor : batchCursor;
+ }
+ return cursor;
+}
+
async function postNativeReply(token: string, body: Json): Promise {
const url = `${LOCAL_BASE_URL.replace(/\/+$/, "")}/weixin/native/reply`;
const timeoutMs = Number(process.env.OCLAW_WEIXIN_NATIVE_REPLY_TIMEOUT_MS || "100000") || 100000;
@@ -490,6 +638,7 @@ async function main(): Promise {
ensureDir(STATE_DIR);
const state = (readJsonFile(STATE_FILE) || {}) as Json;
let cloudCursor = String(state.cloud_cursor || "").trim();
+ let localCursor = String(state.local_cursor || "").trim();
const userContextTokens: TokenMap =
state.user_context_tokens && typeof state.user_context_tokens === "object"
? (state.user_context_tokens as TokenMap)
@@ -500,6 +649,25 @@ async function main(): Promise {
log(`official runner started account=${accountId} cloud=${cloudBaseUrl} local=${LOCAL_BASE_URL}`);
while (true) {
try {
+ try {
+ await flushWeixinDbOutbound({
+ modules,
+ token,
+ accountId,
+ cloudBaseUrl,
+ userContextTokens,
+ });
+ } catch (err) {
+ log(`db proactive flush error: ${String(err)}`);
+ }
+ localCursor = await flushLocalProactiveReplies({
+ modules,
+ token,
+ accountId,
+ cloudBaseUrl,
+ localCursor,
+ userContextTokens,
+ });
const out = await modules.getUpdates({
baseUrl: cloudBaseUrl,
token,
@@ -530,6 +698,7 @@ async function main(): Promise {
}
writeJsonFile(STATE_FILE, {
cloud_cursor: cloudCursor,
+ local_cursor: localCursor,
user_context_tokens: userContextTokens,
updated_at: new Date().toISOString(),
});
diff --git a/runtime/scheduler/__init__.py b/runtime/scheduler/__init__.py
new file mode 100644
index 00000000..4d21ee85
--- /dev/null
+++ b/runtime/scheduler/__init__.py
@@ -0,0 +1,3 @@
+from __future__ import annotations
+
+__all__ = []
diff --git a/runtime/scheduler/channel_delivery.py b/runtime/scheduler/channel_delivery.py
new file mode 100644
index 00000000..81d7f36b
--- /dev/null
+++ b/runtime/scheduler/channel_delivery.py
@@ -0,0 +1,252 @@
+from __future__ import annotations
+
+import json
+import os
+from typing import Any
+
+from runtime.orchestration.group_ingest import is_nonsend_channel_reply_text, should_send_channel_reply_text
+from runtime.scheduler.session_resolver import parse_delivery_json
+
+
+def _encode_weixin_outbound_source(*, context_token: str) -> str:
+ return json.dumps(
+ {"kind": "scheduled_job", "context_token": str(context_token or "").strip()},
+ ensure_ascii=False,
+ )
+
+
+def _decode_weixin_outbound_source(raw: str) -> dict[str, Any]:
+ text = str(raw or "").strip()
+ if not text:
+ return {}
+ try:
+ data = json.loads(text)
+ return data if isinstance(data, dict) else {}
+ except Exception:
+ return {}
+
+
+def enqueue_weixin_reply(
+ *,
+ channel: str,
+ account_id: str,
+ chat_id: str,
+ text: str,
+ context_token: str = "",
+ store: Any = None,
+ tenant_id: str = "",
+) -> dict[str, Any]:
+ from runtime.scheduler.weixin_delivery import normalize_weixin_channel
+
+ ctx_tok = str(context_token or "").strip()
+ if not ctx_tok:
+ return {
+ "ok": False,
+ "channel": normalize_weixin_channel(channel),
+ "error": "context_token_missing",
+ "hint": "Send any message to the bot on WeChat first, then retry the scheduled job.",
+ }
+ durable_id = ""
+ enqueuer = getattr(store, "enqueue_channel_outbound_message", None)
+ if store is not None and callable(enqueuer):
+ try:
+ durable_id = str(
+ enqueuer(
+ channel=normalize_weixin_channel(channel),
+ chat_id=str(chat_id or "").strip(),
+ text=str(text or ""),
+ tenant_id=str(tenant_id or ""),
+ account_id=str(account_id or "").strip(),
+ source=_encode_weixin_outbound_source(context_token=ctx_tok),
+ )
+ or ""
+ ).strip()
+ except Exception:
+ durable_id = ""
+ try:
+ from interfaces.http.weixin_ilink_api import enqueue_weixin_outbound_reply
+ except Exception as exc:
+ if durable_id:
+ return {
+ "ok": True,
+ "channel": normalize_weixin_channel(channel),
+ "message_id": durable_id,
+ "queued": True,
+ "durable": True,
+ "context_token_present": True,
+ "bridge_error": f"{type(exc).__name__}: {exc}",
+ }
+ return {"ok": False, "channel": channel, "error": f"{type(exc).__name__}: {exc}"}
+ try:
+ bridge_seq = enqueue_weixin_outbound_reply(
+ channel=normalize_weixin_channel(channel),
+ account_id=str(account_id or "").strip(),
+ chat_id=str(chat_id or "").strip(),
+ text=text,
+ context_token=ctx_tok,
+ )
+ return {
+ "ok": True,
+ "channel": normalize_weixin_channel(channel),
+ "message_id": durable_id or bridge_seq,
+ "bridge_seq": bridge_seq,
+ "queued": True,
+ "durable": bool(durable_id),
+ "context_token_present": True,
+ "account_id": str(account_id or "").strip(),
+ "chat_id": str(chat_id or "").strip(),
+ }
+ except Exception as exc:
+ if durable_id:
+ return {
+ "ok": True,
+ "channel": normalize_weixin_channel(channel),
+ "message_id": durable_id,
+ "queued": True,
+ "durable": True,
+ "context_token_present": True,
+ "bridge_error": f"{type(exc).__name__}: {exc}",
+ }
+ return {"ok": False, "channel": channel, "error": f"{type(exc).__name__}: {exc}"}
+
+
+def persist_channel_context_token(
+ store: Any,
+ *,
+ tenant_id: str,
+ channel: str,
+ account_id: str,
+ external_chat_id: str,
+ context_token: str,
+) -> None:
+ from runtime.scheduler.weixin_delivery import normalize_weixin_channel
+
+ setter = getattr(store, "set_channel_context_token", None)
+ if not callable(setter):
+ return
+ tok = str(context_token or "").strip()
+ chat_id = str(external_chat_id or "").strip()
+ if not tok or not chat_id:
+ return
+ tid = str(tenant_id or "")
+ acct = str(account_id or "").strip()
+ channels = []
+ for ch in (channel, "wechat", "weixin"):
+ c = normalize_weixin_channel(str(ch or "")) if str(ch or "").lower() in {"wechat", "weixin"} else str(ch or "").strip().lower()
+ if c and c not in channels:
+ channels.append(c)
+ account_ids = [acct] if acct else [""]
+ if acct:
+ account_ids.append("")
+ for ch in channels:
+ for aid in account_ids:
+ setter(
+ tenant_id=tid,
+ channel=ch,
+ account_id=aid,
+ external_chat_id=chat_id,
+ context_token=tok,
+ )
+
+
+def extract_context_token_from_inbound_metadata(metadata: dict[str, Any] | None) -> str:
+ meta = metadata if isinstance(metadata, dict) else {}
+ raw = meta.get("raw") if isinstance(meta.get("raw"), dict) else {}
+ msg = raw.get("msg") if isinstance(raw.get("msg"), dict) else {}
+ for candidate in (
+ msg.get("context_token"),
+ (raw.get("metadata") or {}).get("context_token") if isinstance(raw.get("metadata"), dict) else None,
+ raw.get("context_token"),
+ meta.get("context_token"),
+ ):
+ tok = str(candidate or "").strip()
+ if tok:
+ return tok
+ return ""
+
+
+def deliver_scheduled_reply(
+ store: Any,
+ *,
+ tenant_id: str,
+ reply_text: str,
+ delivery_json: str,
+ resolved_channel: str = "",
+ resolved_chat_id: str = "",
+ resolved_account_id: str = "",
+ session_id: str = "",
+) -> dict[str, Any]:
+ text = str(reply_text or "").strip()
+ if not text or is_nonsend_channel_reply_text(text):
+ return {"ok": False, "skipped": True, "reason": "empty_or_silent_reply"}
+
+ ch_lower = str(resolved_channel or "").strip().lower()
+ if ch_lower in {"wechat", "weixin"}:
+ from runtime.application.gateway.inbound_service import _user_facing_wechat_reply
+
+ text = _user_facing_wechat_reply(reply=text)
+
+ delivery = parse_delivery_json(delivery_json)
+ results: dict[str, Any] = {}
+
+ wa = delivery.get("whatsapp") if isinstance(delivery.get("whatsapp"), dict) else {}
+ wa_enabled = bool(wa.get("enabled")) and str(wa.get("target_type") or "none") != "none"
+ chat_id = str(wa.get("chat_id") or resolved_chat_id or "").strip()
+ account_id = str(
+ wa.get("account_id") or resolved_account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default"
+ ).strip()
+ if wa_enabled and chat_id and should_send_channel_reply_text(text):
+ msg_id = store.enqueue_channel_outbound_message(
+ channel="whatsapp",
+ chat_id=chat_id,
+ text=text,
+ tenant_id=tenant_id,
+ account_id=account_id,
+ source="scheduled_job",
+ )
+ results["whatsapp"] = {"ok": True, "message_id": msg_id, "chat_id": chat_id}
+
+ wx = delivery.get("weixin") if isinstance(delivery.get("weixin"), dict) else {}
+ wx_enabled = bool(wx.get("enabled", True))
+ if wx_enabled and should_send_channel_reply_text(text):
+ from runtime.scheduler.weixin_delivery import resolve_weixin_delivery_target
+
+ target = resolve_weixin_delivery_target(
+ store,
+ tenant_id=tenant_id,
+ session_id=session_id,
+ delivery=delivery,
+ resolved_channel=resolved_channel,
+ resolved_chat_id=resolved_chat_id,
+ resolved_account_id=resolved_account_id,
+ )
+ wx_chat = str(target.get("chat_id") or "").strip()
+ wx_account = str(target.get("account_id") or "").strip()
+ wx_channel = str(target.get("channel") or "wechat")
+ context_token = str(target.get("context_token") or "").strip()
+ if wx_chat:
+ results["weixin"] = enqueue_weixin_reply(
+ channel=wx_channel,
+ account_id=wx_account,
+ chat_id=wx_chat,
+ text=text,
+ context_token=context_token,
+ store=store,
+ tenant_id=tenant_id,
+ )
+ else:
+ results["weixin"] = {"ok": False, "error": "weixin_chat_missing"}
+
+ if not results:
+ return {"ok": True, "skipped": True, "reason": "no_delivery_targets"}
+
+ ok = all(bool((v or {}).get("ok")) for v in results.values() if isinstance(v, dict))
+ return {"ok": ok, "channels": results}
+
+
+__all__ = [
+ "deliver_scheduled_reply",
+ "enqueue_weixin_reply",
+ "extract_context_token_from_inbound_metadata",
+ "persist_channel_context_token",
+]
diff --git a/runtime/scheduler/cron_service.py b/runtime/scheduler/cron_service.py
new file mode 100644
index 00000000..b7083501
--- /dev/null
+++ b/runtime/scheduler/cron_service.py
@@ -0,0 +1,201 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from runtime.scheduler.expressions import compute_next_run_at, normalize_schedule_kind
+from runtime.scheduler.service import run_scheduled_job_now
+from runtime.scheduler.session_resolver import parse_delivery_json, resolve_weixin_binding
+
+
+class CronService:
+ def __init__(self, *, store: Any) -> None:
+ self.store = store
+
+ def status(self) -> dict[str, Any]:
+ return {"running": True}
+
+ def wake(self, params: dict[str, Any]) -> dict[str, Any]:
+ return {"ok": True, **dict(params or {})}
+
+ def listPage(self, params: dict[str, Any]) -> dict[str, Any]:
+ tenant_id = str((params or {}).get("tenantId") or (params or {}).get("tenant_id") or "default").strip()
+ status = str((params or {}).get("enabled") or (params or {}).get("status") or "").strip() or None
+ if status in {"true", "1"}:
+ status = "active"
+ elif status in {"false", "0"}:
+ status = "paused"
+ limit = int((params or {}).get("limit") or 50)
+ offset = int((params or {}).get("offset") or 0)
+ rows = self.store.scheduled_job_list(
+ tenant_id=tenant_id,
+ status=status,
+ limit=limit,
+ offset=offset,
+ )
+ items = [self._job_to_gateway_item(self.store.scheduled_job_to_dict(r)) for r in rows]
+ return {"items": items, "total": len(items)}
+
+ def add(self, params: dict[str, Any]) -> dict[str, Any]:
+ p = dict(params or {})
+ tenant_id = str(p.get("tenantId") or p.get("tenant_id") or "default").strip()
+ name = str(p.get("name") or p.get("schedule") or "cron job").strip()
+ schedule = str(p.get("schedule") or p.get("schedule_expr") or "").strip()
+ schedule_kind = normalize_schedule_kind(p.get("schedule_kind") or p.get("scheduleKind") or "cron")
+ prompt = str(p.get("prompt") or p.get("prompt_text") or p.get("text") or name).strip()
+ job = self.store.scheduled_job_create(
+ tenant_id=tenant_id,
+ name=name,
+ prompt_text=prompt,
+ schedule_kind=schedule_kind,
+ schedule_expr=schedule,
+ timezone_name=str(p.get("timezone") or "Asia/Shanghai"),
+ description=str(p.get("description") or ""),
+ interaction_mode=str(p.get("interaction_mode") or "expert"),
+ specialist=str(p.get("specialist") or "generalist"),
+ lang=str(p.get("lang") or "zh"),
+ delivery=p.get("delivery") if isinstance(p.get("delivery"), dict) else {},
+ source="gateway",
+ )
+ return self._job_to_gateway_item(self.store.scheduled_job_to_dict(job))
+
+ def update(self, job_id: str, patch: dict[str, Any]) -> dict[str, Any]:
+ p = dict(patch or {})
+ tenant_id = str(p.pop("tenantId", None) or p.pop("tenant_id", None) or "default").strip()
+ mapped: dict[str, Any] = {}
+ for src, dst in (
+ ("name", "name"),
+ ("schedule", "schedule_expr"),
+ ("schedule_kind", "schedule_kind"),
+ ("prompt", "prompt_text"),
+ ("prompt_text", "prompt_text"),
+ ("timezone", "timezone"),
+ ("interaction_mode", "interaction_mode"),
+ ("specialist", "specialist"),
+ ("lang", "lang"),
+ ("delivery", "delivery"),
+ ("enabled", "status"),
+ ):
+ if src in p:
+ mapped[dst] = p[src]
+ if "enabled" in mapped:
+ mapped["status"] = "active" if bool(mapped.pop("enabled")) else "paused"
+ job = self.store.scheduled_job_update(tenant_id=tenant_id, job_id=str(job_id), patch=mapped)
+ if not job:
+ return {"id": job_id, "ok": False}
+ return self._job_to_gateway_item(self.store.scheduled_job_to_dict(job))
+
+ def remove(self, job_id: str) -> dict[str, Any]:
+ rows = self.store.scheduled_job_list(tenant_id="default", limit=500)
+ for row in rows:
+ if str(row.id) == str(job_id):
+ self.store.scheduled_job_delete(tenant_id=row.tenant_id, job_id=str(job_id))
+ return {"removed": True, "id": job_id}
+ self.store.scheduled_job_delete(tenant_id="default", job_id=str(job_id))
+ return {"removed": True, "id": job_id}
+
+ def enqueueRun(self, job_id: str, mode: str = "force") -> dict[str, Any]:
+ rows = self.store.scheduled_job_list(tenant_id="default", limit=500)
+ tenant_id = "default"
+ for row in rows:
+ if str(row.id) == str(job_id):
+ tenant_id = str(row.tenant_id)
+ break
+ out = run_scheduled_job_now(self.store, tenant_id=tenant_id, job_id=str(job_id))
+ return {"ok": bool(out.get("ok")), "ran": bool(out.get("ok")), "jobId": job_id, "mode": mode, **out}
+
+ def listRuns(self, params: dict[str, Any]) -> dict[str, Any]:
+ p = dict(params or {})
+ job_id = str(p.get("jobId") or p.get("id") or "").strip()
+ tenant_id = str(p.get("tenantId") or p.get("tenant_id") or "default").strip()
+ limit = int(p.get("limit") or 50)
+ if not job_id:
+ return {"items": [], "total": 0}
+ rows = self.store.scheduled_job_run_list(job_id=job_id, tenant_id=tenant_id, limit=limit)
+ items = [self.store.scheduled_job_run_to_dict(r) for r in rows]
+ return {"items": items, "total": len(items), "jobId": job_id}
+
+ def _job_to_gateway_item(self, row: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "id": row.get("id"),
+ "name": row.get("name"),
+ "schedule": row.get("schedule_expr"),
+ "schedule_kind": row.get("schedule_kind"),
+ "enabled": str(row.get("status") or "") == "active",
+ "prompt": row.get("prompt_text"),
+ "timezone": row.get("timezone"),
+ "nextRunAt": row.get("next_run_at"),
+ "lastRunAt": row.get("last_run_at"),
+ "specialist": row.get("specialist"),
+ "interaction_mode": row.get("interaction_mode"),
+ "delivery": row.get("delivery"),
+ }
+
+
+def build_default_delivery(*, store: Any, tenant_id: str, whatsapp_chat_id: str = "") -> dict[str, Any]:
+ import os
+
+ delivery: dict[str, Any] = {
+ "whatsapp": {
+ "enabled": bool(str(whatsapp_chat_id or "").strip()),
+ "target_type": "group" if str(whatsapp_chat_id or "").endswith("@g.us") else "direct",
+ "chat_id": str(whatsapp_chat_id or ""),
+ "account_id": str(os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default"),
+ },
+ "weixin": {"enabled": True, "fixed": True},
+ }
+ if not delivery["whatsapp"]["enabled"]:
+ delivery["whatsapp"]["target_type"] = "none"
+ binding = resolve_weixin_binding(store, tenant_id=tenant_id)
+ if binding:
+ ext = str(binding.get("external_user_id") or "")
+ delivery["weixin"]["external_user_id"] = ext
+ delivery["weixin"]["external_chat_id"] = str(binding.get("external_chat_id") or ext)
+ delivery["weixin"]["account_id"] = str(binding.get("account_id") or "weixin-default")
+ return delivery
+
+
+def build_delivery_for_session(
+ store: Any,
+ *,
+ tenant_id: str,
+ session_id: str = "",
+ whatsapp_chat_id: str = "",
+) -> dict[str, Any]:
+ """Pick delivery targets from the chat session that created the job (WhatsApp vs WeChat)."""
+ import os
+
+ tid = str(tenant_id or "").strip()
+ sid = str(session_id or "").strip()
+ explicit_wa = str(whatsapp_chat_id or "").strip()
+ lookup = getattr(store, "lookup_channel_session_by_session_id", None)
+ if sid and callable(lookup):
+ ctx = lookup(tenant_id=tid, session_id=sid)
+ if isinstance(ctx, dict):
+ ch = str(ctx.get("channel") or "").strip().lower()
+ chat_id = str(ctx.get("external_chat_id") or "").strip()
+ acct = str(ctx.get("account_id") or "").strip()
+ if ch == "whatsapp" and chat_id:
+ return {
+ "whatsapp": {
+ "enabled": True,
+ "target_type": "group" if chat_id.endswith("@g.us") else "direct",
+ "chat_id": chat_id,
+ "account_id": acct or str(os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default"),
+ },
+ "weixin": {"enabled": False, "fixed": False},
+ }
+ if ch in {"weixin", "wechat"}:
+ delivery = build_default_delivery(store=store, tenant_id=tid, whatsapp_chat_id="")
+ wa = delivery.get("whatsapp") if isinstance(delivery.get("whatsapp"), dict) else {}
+ delivery["whatsapp"] = {
+ **wa,
+ "enabled": False,
+ "target_type": "none",
+ "chat_id": "",
+ }
+ return delivery
+ return build_default_delivery(store=store, tenant_id=tid, whatsapp_chat_id=explicit_wa)
+
+
+__all__ = ["CronService", "build_default_delivery", "build_delivery_for_session", "compute_next_run_at"]
diff --git a/runtime/scheduler/expressions.py b/runtime/scheduler/expressions.py
new file mode 100644
index 00000000..5bd81e82
--- /dev/null
+++ b/runtime/scheduler/expressions.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+from typing import Any
+from zoneinfo import ZoneInfo
+
+try:
+ from croniter import croniter
+except ImportError: # pragma: no cover - guarded in requirements
+ croniter = None # type: ignore[assignment,misc]
+
+
+def normalize_schedule_kind(raw: Any) -> str:
+ kind = str(raw or "").strip().lower()
+ if kind in {"cron", "once", "interval"}:
+ return kind
+ return "cron"
+
+
+def _parse_iso_dt(value: str) -> datetime:
+ text = str(value or "").strip()
+ if not text:
+ raise ValueError("empty datetime")
+ if text.endswith("Z"):
+ text = text[:-1] + "+00:00"
+ dt = datetime.fromisoformat(text)
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ return dt.astimezone(timezone.utc)
+
+
+def compute_next_run_at(
+ *,
+ schedule_kind: str,
+ schedule_expr: str,
+ timezone_name: str = "Asia/Shanghai",
+ from_dt: datetime | None = None,
+) -> str | None:
+ kind = normalize_schedule_kind(schedule_kind)
+ expr = str(schedule_expr or "").strip()
+ if not expr:
+ return None
+ base = from_dt or datetime.now(timezone.utc)
+ if base.tzinfo is None:
+ base = base.replace(tzinfo=timezone.utc)
+ else:
+ base = base.astimezone(timezone.utc)
+
+ if kind == "once":
+ target = _parse_iso_dt(expr)
+ if target <= base:
+ return None
+ return target.isoformat()
+
+ if kind == "interval":
+ try:
+ seconds = max(1, int(expr))
+ except ValueError as exc:
+ raise ValueError(f"invalid interval seconds: {expr}") from exc
+ nxt = base + timedelta(seconds=seconds)
+ return nxt.isoformat()
+
+ if croniter is None:
+ raise RuntimeError("croniter is required for cron schedules")
+ try:
+ tz = ZoneInfo(str(timezone_name or "Asia/Shanghai"))
+ except Exception:
+ tz = ZoneInfo("Asia/Shanghai")
+ local_base = base.astimezone(tz)
+ itr = croniter(expr, local_base)
+ nxt_local = itr.get_next(datetime)
+ if nxt_local.tzinfo is None:
+ nxt_local = nxt_local.replace(tzinfo=tz)
+ return nxt_local.astimezone(timezone.utc).isoformat()
+
+
+__all__ = ["compute_next_run_at", "normalize_schedule_kind"]
diff --git a/runtime/scheduler/service.py b/runtime/scheduler/service.py
new file mode 100644
index 00000000..31fe3c14
--- /dev/null
+++ b/runtime/scheduler/service.py
@@ -0,0 +1,200 @@
+from __future__ import annotations
+
+import json
+import threading
+import time
+import uuid
+from datetime import datetime, timezone
+from typing import Any
+
+from runtime.scheduler.session_resolver import resolve_scheduled_session, resolve_scheduled_viewer_username
+from runtime.scheduler.turn_text import build_scheduled_turn_instruction
+from runtime.worker import ensure_worker_started
+
+_LOCK = threading.Lock()
+_THREAD: threading.Thread | None = None
+_RUNNING = False
+
+
+def _tick_interval_seconds() -> float:
+ import os
+
+ raw = str(os.getenv("AIA_SCHEDULER_TICK_SECONDS") or "30").strip()
+ try:
+ return max(5.0, min(float(raw), 3600.0))
+ except Exception:
+ return 30.0
+
+
+def enqueue_scheduled_job_run(
+ store: Any,
+ *,
+ job: Any,
+ mode: str = "scheduled",
+) -> dict[str, Any]:
+ tenant_id = str(getattr(job, "tenant_id", "") or "")
+ job_id = str(getattr(job, "id") or "")
+ run = store.scheduled_job_run_create(
+ job_id=job_id,
+ tenant_id=tenant_id,
+ scheduled_at=str(getattr(job, "next_run_at", "") or datetime.now(timezone.utc).isoformat()),
+ status="queued",
+ )
+ try:
+ resolved = resolve_scheduled_session(
+ store,
+ job=job,
+ created_by_user_id=str(getattr(job, "created_by_user_id", "") or ""),
+ )
+ except Exception as exc:
+ store.scheduled_job_run_update(
+ run_id=run.id,
+ tenant_id=tenant_id,
+ patch={
+ "status": "failed",
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ "error": str(exc),
+ },
+ )
+ store.scheduled_job_mark_run(
+ job_id=job_id,
+ tenant_id=tenant_id,
+ last_run_status="failed",
+ pause_after=False,
+ )
+ return {"ok": False, "error": str(exc), "run_id": run.id}
+
+ delivery: dict[str, Any] = {}
+ try:
+ raw = json.loads(str(getattr(job, "delivery_json", "") or "{}"))
+ if isinstance(raw, dict):
+ delivery = raw
+ except Exception:
+ delivery = {}
+
+ trace_id = uuid.uuid4().hex
+ agent_run_id = uuid.uuid4().hex
+ prompt_text = str(getattr(job, "prompt_text", "") or "").strip()
+ lang = str(getattr(job, "lang", "") or "zh")
+ user_text = build_scheduled_turn_instruction(prompt_text=prompt_text, mode=mode, lang=lang)
+ viewer_username = resolve_scheduled_viewer_username(
+ store,
+ tenant_id=tenant_id,
+ user_id=resolved.user_id,
+ channel=resolved.channel,
+ )
+ payload = {
+ "trace_id": trace_id,
+ "run_id": agent_run_id,
+ "session_id": resolved.session_id,
+ "tenant_id": tenant_id,
+ "user_id": resolved.user_id,
+ "viewer_username": viewer_username,
+ "role": "member",
+ "channel": resolved.channel if resolved.channel != "admin_chat" else "admin_chat",
+ "lang": lang,
+ "text": user_text,
+ "prompt_text": prompt_text,
+ "attachments": [],
+ "metadata": {
+ "scheduled_job_id": job_id,
+ "scheduled_run_id": run.id,
+ "interaction_mode": str(getattr(job, "interaction_mode", "") or "expert"),
+ "selected_specialist": str(getattr(job, "specialist", "") or "generalist"),
+ "scheduled_mode": mode,
+ "scheduled_proactive": True,
+ },
+ "interaction_mode": str(getattr(job, "interaction_mode", "") or "expert"),
+ "requested_specialist": str(getattr(job, "specialist", "") or "generalist"),
+ "selected_specialist": str(getattr(job, "specialist", "") or "generalist"),
+ "job_id": job_id,
+ "run_id_scheduled": run.id,
+ "delivery": delivery,
+ "resolved_channel": resolved.channel,
+ "resolved_chat_id": resolved.external_chat_id,
+ "resolved_account_id": resolved.account_id,
+ }
+ worker_id = ensure_worker_started(store=store)
+ task = store.oclaw_task_create(
+ tenant_id=tenant_id,
+ session_id=resolved.session_id,
+ task_type="scheduled_turn",
+ payload=payload,
+ )
+ store.scheduled_job_run_update(
+ run_id=run.id,
+ tenant_id=tenant_id,
+ patch={
+ "status": "running",
+ "started_at": datetime.now(timezone.utc).isoformat(),
+ "session_id": resolved.session_id,
+ "oclaw_task_id": task.id,
+ "run_id": agent_run_id,
+ },
+ )
+ store.scheduled_job_reserve_next_run(job_id=job_id, tenant_id=tenant_id)
+ pause_after = str(getattr(job, "schedule_kind", "") or "") == "once"
+ return {
+ "ok": True,
+ "run_id": run.id,
+ "task_id": task.id,
+ "worker_id": worker_id,
+ "pause_after": pause_after,
+ }
+
+
+def scheduler_tick(store: Any) -> dict[str, Any]:
+ due = store.scheduled_job_list_due(limit=20)
+ triggered = 0
+ errors: list[str] = []
+ for job in due:
+ try:
+ out = enqueue_scheduled_job_run(store, job=job, mode="scheduled")
+ if out.get("ok"):
+ triggered += 1
+ else:
+ errors.append(str(out.get("error") or "enqueue_failed"))
+ except Exception as exc:
+ errors.append(f"{getattr(job, 'id', '')}: {type(exc).__name__}: {exc}")
+ return {"ok": True, "due": len(due), "triggered": triggered, "errors": errors}
+
+
+def run_scheduled_job_now(store: Any, *, tenant_id: str, job_id: str) -> dict[str, Any]:
+ job = store.scheduled_job_get(job_id=job_id, tenant_id=tenant_id)
+ if not job:
+ return {"ok": False, "error": "job_not_found"}
+ if str(job.status or "") != "active":
+ return {"ok": False, "error": "job_not_active"}
+ return enqueue_scheduled_job_run(store, job=job, mode="manual")
+
+
+def _scheduler_loop(*, store: Any) -> None:
+ global _RUNNING
+ interval = _tick_interval_seconds()
+ while _RUNNING:
+ try:
+ scheduler_tick(store)
+ except Exception:
+ pass
+ time.sleep(interval)
+
+
+def ensure_scheduler_started(*, store: Any) -> str:
+ global _THREAD, _RUNNING
+ with _LOCK:
+ if _THREAD and _THREAD.is_alive():
+ return _THREAD.name
+ _RUNNING = True
+ tid = f"oclaw-scheduler-{uuid.uuid4().hex[:8]}"
+ t = threading.Thread(target=_scheduler_loop, kwargs={"store": store}, name=tid, daemon=True)
+ t.start()
+ _THREAD = t
+ return tid
+
+
+__all__ = [
+ "ensure_scheduler_started",
+ "enqueue_scheduled_job_run",
+ "run_scheduled_job_now",
+ "scheduler_tick",
+]
diff --git a/runtime/scheduler/session_resolver.py b/runtime/scheduler/session_resolver.py
new file mode 100644
index 00000000..f327e687
--- /dev/null
+++ b/runtime/scheduler/session_resolver.py
@@ -0,0 +1,262 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from dataclasses import dataclass
+from typing import Any
+
+from runtime.orchestration.group_ingest import session_user_key
+
+
+@dataclass(frozen=True)
+class ResolvedSession:
+ session_id: str
+ tenant_id: str
+ user_id: str
+ channel: str
+ account_id: str
+ external_chat_id: str
+ external_user_id: str
+ is_group: bool
+
+
+def _ensure_administrator_owner(store: Any, *, tenant_id: str) -> dict[str, Any] | None:
+ user = store.get_user_by_username(tenant_id=tenant_id, username="administrator")
+ if not user:
+ try:
+ from svc.config.passwords import load_expected_password
+ except Exception:
+ load_expected_password = None # type: ignore
+ pwd = load_expected_password(store) if callable(load_expected_password) else None
+ if not pwd:
+ return None
+ user = store.create_user_account(
+ tenant_id=tenant_id,
+ username="administrator",
+ password_hash=hashlib.sha256(pwd.encode("utf-8")).hexdigest(),
+ display_name="Administrator",
+ role="owner",
+ is_active=True,
+ )
+ user_id = str((user or {}).get("id") or "")
+ if not user_id:
+ return None
+ return {
+ "tenant_id": tenant_id,
+ "user_id": user_id,
+ "display_name": (user or {}).get("display_name") or "Administrator",
+ "role": str((user or {}).get("role") or "owner"),
+ }
+
+
+def resolve_weixin_binding(store: Any, *, tenant_id: str) -> dict[str, Any] | None:
+ owner = _ensure_administrator_owner(store, tenant_id=tenant_id)
+ if not owner:
+ return None
+ user_id = str(owner.get("user_id") or "")
+ rows = store.list_channel_identities_v2(
+ tenant_id=tenant_id,
+ channel="weixin",
+ user_id=user_id,
+ limit=20,
+ )
+ if not rows:
+ for ch in ("wechat", "weixin"):
+ rows = store.list_channel_identities_v2(
+ tenant_id=tenant_id,
+ channel=ch,
+ user_id=user_id,
+ limit=20,
+ )
+ if rows:
+ break
+ if not rows:
+ return None
+ row = rows[0]
+ account_id = str(row.get("account_id") or "weixin-default").strip() or "weixin-default"
+ external_user_id = str(row.get("external_user_id") or "").strip()
+ if not external_user_id:
+ return None
+ return {
+ "tenant_id": tenant_id,
+ "user_id": user_id,
+ "channel": str(row.get("channel") or "weixin"),
+ "account_id": account_id,
+ "external_user_id": external_user_id,
+ "external_chat_id": external_user_id,
+ "is_group": False,
+ }
+
+
+def parse_delivery_json(raw: str) -> dict[str, Any]:
+ try:
+ data = json.loads(raw or "{}")
+ return data if isinstance(data, dict) else {}
+ except Exception:
+ return {}
+
+
+def resolve_scheduled_viewer_username(
+ store: Any,
+ *,
+ tenant_id: str,
+ user_id: str,
+ channel: str,
+) -> str:
+ """Channel proactive jobs use the administrator model pool (same as gateway inbound)."""
+ ch = str(channel or "").strip().lower()
+ if ch in {"weixin", "wechat", "whatsapp"}:
+ return "administrator"
+ uid = str(user_id or "").strip()
+ if uid:
+ user = store.get_user_by_id(tenant_id=str(tenant_id or ""), user_id=uid)
+ if isinstance(user, dict):
+ uname = str(user.get("username") or "").strip()
+ if uname:
+ return uname
+ return "administrator"
+
+
+def resolve_scheduled_session(
+ store: Any,
+ *,
+ job: Any,
+ created_by_user_id: str = "",
+) -> ResolvedSession:
+ tenant_id = str(getattr(job, "tenant_id", "") or "")
+ delivery = parse_delivery_json(str(getattr(job, "delivery_json", "") or "{}"))
+ source_session_id = str(getattr(job, "source_session_id", "") or "").strip()
+ job_name = str(getattr(job, "name", "") or "Scheduled task")
+
+ if source_session_id:
+ sess = store.get_session_in_tenant(session_id=source_session_id, tenant_id=tenant_id)
+ if sess:
+ owner = store.get_ui_session_owner(session_id=source_session_id) or {}
+ user_id = str(owner.get("user_id") or created_by_user_id or "").strip()
+ if not user_id:
+ admin = _ensure_administrator_owner(store, tenant_id=tenant_id)
+ user_id = str((admin or {}).get("user_id") or "")
+ channel_ctx = None
+ lookup = getattr(store, "lookup_channel_session_by_session_id", None)
+ if callable(lookup):
+ channel_ctx = lookup(tenant_id=tenant_id, session_id=source_session_id)
+ if isinstance(channel_ctx, dict) and str(channel_ctx.get("channel") or "").strip():
+ ch = str(channel_ctx.get("channel") or "").strip().lower()
+ account_id = str(channel_ctx.get("account_id") or "").strip()
+ external_chat_id = str(channel_ctx.get("external_chat_id") or "").strip()
+ external_user_id = str(channel_ctx.get("external_user_id") or "").strip()
+ is_group = ch == "whatsapp" and external_chat_id.endswith("@g.us")
+ return ResolvedSession(
+ session_id=source_session_id,
+ tenant_id=tenant_id,
+ user_id=user_id,
+ channel=ch,
+ account_id=account_id or ("weixin-default" if ch in {"weixin", "wechat"} else ""),
+ external_chat_id=external_chat_id,
+ external_user_id=external_user_id,
+ is_group=is_group,
+ )
+ return ResolvedSession(
+ session_id=source_session_id,
+ tenant_id=tenant_id,
+ user_id=user_id,
+ channel="admin_chat",
+ account_id="",
+ external_chat_id="",
+ external_user_id="",
+ is_group=False,
+ )
+
+ wa = delivery.get("whatsapp") if isinstance(delivery.get("whatsapp"), dict) else {}
+ wx = delivery.get("weixin") if isinstance(delivery.get("weixin"), dict) else {}
+ wa_enabled = bool(wa.get("enabled")) and str(wa.get("target_type") or "none") != "none"
+ wx_enabled = bool(wx.get("enabled", True))
+
+ if wa_enabled and str(wa.get("chat_id") or "").strip():
+ chat_id = str(wa.get("chat_id") or "").strip()
+ account_id = str(wa.get("account_id") or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
+ target_type = str(wa.get("target_type") or "direct").strip().lower()
+ is_group = target_type == "group" or chat_id.endswith("@g.us")
+ external_user_id = session_user_key(is_group=is_group, external_user_id=chat_id.split("@", 1)[0])
+ admin = _ensure_administrator_owner(store, tenant_id=tenant_id)
+ user_id = str(created_by_user_id or (admin or {}).get("user_id") or "")
+ session_id = store.get_or_create_channel_session_v2(
+ tenant_id=tenant_id,
+ channel="whatsapp",
+ account_id=account_id,
+ external_chat_id=chat_id,
+ external_user_id=external_user_id,
+ session_title=f"Scheduled · {job_name}",
+ )
+ if user_id:
+ store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
+ return ResolvedSession(
+ session_id=session_id,
+ tenant_id=tenant_id,
+ user_id=user_id,
+ channel="whatsapp",
+ account_id=account_id,
+ external_chat_id=chat_id,
+ external_user_id=external_user_id,
+ is_group=is_group,
+ )
+
+ if wx_enabled:
+ binding = resolve_weixin_binding(store, tenant_id=tenant_id)
+ if not binding:
+ raise RuntimeError("weixin_binding_missing")
+ channel = str(binding.get("channel") or "weixin")
+ account_id = str(binding.get("account_id") or "weixin-default")
+ external_user_id = str(binding.get("external_user_id") or "")
+ external_chat_id = str(binding.get("external_chat_id") or external_user_id)
+ user_id = str(binding.get("user_id") or created_by_user_id or "")
+ session_id = store.get_or_create_channel_session_v2(
+ tenant_id=tenant_id,
+ channel=channel,
+ account_id=account_id,
+ external_chat_id=external_chat_id,
+ external_user_id=external_user_id,
+ session_title=f"Scheduled · {job_name}",
+ )
+ if user_id:
+ store.ensure_ui_session_owner(session_id=session_id, tenant_id=tenant_id, user_id=user_id)
+ return ResolvedSession(
+ session_id=session_id,
+ tenant_id=tenant_id,
+ user_id=user_id,
+ channel=channel,
+ account_id=account_id,
+ external_chat_id=external_chat_id,
+ external_user_id=external_user_id,
+ is_group=False,
+ )
+
+ admin = _ensure_administrator_owner(store, tenant_id=tenant_id)
+ user_id = str(created_by_user_id or (admin or {}).get("user_id") or "")
+ if not user_id:
+ raise RuntimeError("scheduled_session_owner_missing")
+ sess = store.create_session_for_user(
+ title=f"Scheduled · {job_name}",
+ tenant_id=tenant_id,
+ user_id=user_id,
+ )
+ return ResolvedSession(
+ session_id=str(sess.id),
+ tenant_id=tenant_id,
+ user_id=user_id,
+ channel="admin_chat",
+ account_id="",
+ external_chat_id="",
+ external_user_id="",
+ is_group=False,
+ )
+
+
+__all__ = [
+ "ResolvedSession",
+ "resolve_scheduled_session",
+ "resolve_scheduled_viewer_username",
+ "resolve_weixin_binding",
+ "parse_delivery_json",
+]
diff --git a/runtime/scheduler/turn_text.py b/runtime/scheduler/turn_text.py
new file mode 100644
index 00000000..5d6daac8
--- /dev/null
+++ b/runtime/scheduler/turn_text.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+
+def format_scheduled_user_reminder(prompt_text: str) -> str:
+ body = str(prompt_text or "").strip()
+ if not body:
+ return ""
+ if body.startswith("⏰"):
+ return body
+ return f"⏰ 提醒:{body}"
+
+
+def build_scheduled_turn_instruction(*, prompt_text: str, mode: str, lang: str) -> str:
+ """Internal LLM instruction for proactive scheduled reminders (not user-facing)."""
+ intent = str(prompt_text or "").strip()
+ _ = str(mode or "scheduled").strip()
+ is_en = str(lang or "").lower().startswith("en")
+ if is_en:
+ return (
+ "[Scheduled proactive reminder — internal instruction, not a user message]\n"
+ f"Reminder intent: {intent}\n"
+ "Write a short, friendly proactive reminder TO the user (second person). "
+ "Do not say you received a reminder or that you will remind someone; speak directly to the user."
+ )
+ return (
+ "【定时主动提醒·内部指令,不是用户发言】\n"
+ f"提醒意图:{intent}\n"
+ "请生成一条简短、自然、第二人称的主动提醒消息直接对用户说。"
+ "不要写「收到提醒」「好的我来提醒用户」等元对话;不要假装用户刚说了话。"
+ )
+
+
+def scheduled_turn_system_suffix(*, lang: str) -> str:
+ is_en = str(lang or "").lower().startswith("en")
+ if is_en:
+ return (
+ "\n\n[Scheduled job mode] You are sending a proactive reminder to the user. "
+ "Reply with the reminder text only; do not role-play as the user."
+ )
+ return (
+ "\n\n【定时任务模式】你正在主动向用户发送提醒。"
+ "只输出提醒正文,不要扮演用户,不要写「收到/好的」等对话式应答。"
+ )
+
+
+__all__ = [
+ "build_scheduled_turn_instruction",
+ "format_scheduled_user_reminder",
+ "scheduled_turn_system_suffix",
+]
diff --git a/runtime/scheduler/weixin_delivery.py b/runtime/scheduler/weixin_delivery.py
new file mode 100644
index 00000000..ac11d57d
--- /dev/null
+++ b/runtime/scheduler/weixin_delivery.py
@@ -0,0 +1,90 @@
+from __future__ import annotations
+
+from typing import Any
+
+from runtime.scheduler.session_resolver import resolve_weixin_binding
+
+
+def normalize_weixin_channel(channel: str) -> str:
+ ch = str(channel or "wechat").strip().lower()
+ return "wechat" if ch in {"wechat", "weixin"} else ch
+
+
+def resolve_weixin_delivery_target(
+ store: Any,
+ *,
+ tenant_id: str,
+ session_id: str,
+ delivery: dict[str, Any],
+ resolved_channel: str,
+ resolved_chat_id: str,
+ resolved_account_id: str,
+) -> dict[str, Any]:
+ wx = delivery.get("weixin") if isinstance(delivery.get("weixin"), dict) else {}
+ channel = normalize_weixin_channel(
+ str(resolved_channel or wx.get("channel") or "wechat")
+ )
+ chat_id = str(
+ resolved_chat_id or wx.get("external_chat_id") or wx.get("external_user_id") or ""
+ ).strip()
+ account_id = str(
+ resolved_account_id or wx.get("account_id") or ""
+ ).strip()
+
+ sid = str(session_id or "").strip()
+ lookup_sess = getattr(store, "lookup_channel_session_by_session_id", None)
+ if sid and callable(lookup_sess):
+ ctx = lookup_sess(tenant_id=str(tenant_id or ""), session_id=sid)
+ if isinstance(ctx, dict):
+ if not chat_id:
+ chat_id = str(ctx.get("external_chat_id") or ctx.get("external_user_id") or "").strip()
+ if not account_id or account_id == "weixin-default":
+ account_id = str(ctx.get("account_id") or account_id or "").strip()
+ if channel in {"wechat", "weixin"}:
+ channel = normalize_weixin_channel(str(ctx.get("channel") or channel))
+
+ lookup_chat = getattr(store, "lookup_channel_session_by_chat_v2", None)
+ if chat_id and callable(lookup_chat) and (not account_id or account_id == "weixin-default"):
+ ctx = lookup_chat(
+ tenant_id=str(tenant_id or ""),
+ channel=channel,
+ external_chat_id=chat_id,
+ )
+ if isinstance(ctx, dict):
+ acct = str(ctx.get("account_id") or "").strip()
+ if acct:
+ account_id = acct
+
+ if not chat_id:
+ binding = resolve_weixin_binding(store, tenant_id=str(tenant_id or ""))
+ if isinstance(binding, dict):
+ chat_id = str(
+ binding.get("external_chat_id") or binding.get("external_user_id") or ""
+ ).strip()
+ if not account_id or account_id == "weixin-default":
+ account_id = str(binding.get("account_id") or account_id or "").strip()
+ if channel in {"wechat", "weixin"}:
+ channel = normalize_weixin_channel(str(binding.get("channel") or channel))
+
+ context_token = str(wx.get("context_token") or "").strip()
+ getter = getattr(store, "get_channel_context_token_fuzzy", None)
+ if callable(getter) and chat_id and not context_token:
+ context_token = str(
+ getter(
+ tenant_id=str(tenant_id or ""),
+ channel=channel,
+ account_id=account_id,
+ external_chat_id=chat_id,
+ )
+ or ""
+ ).strip()
+
+ return {
+ "channel": channel,
+ "chat_id": chat_id,
+ "account_id": account_id,
+ "context_token": context_token,
+ }
+
+
+__all__ = ["normalize_weixin_channel", "resolve_weixin_delivery_target"]
diff --git a/runtime/scheduler/worker_turn.py b/runtime/scheduler/worker_turn.py
new file mode 100644
index 00000000..779355db
--- /dev/null
+++ b/runtime/scheduler/worker_turn.py
@@ -0,0 +1,184 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime, timezone
+from typing import Any
+
+
+from runtime.orchestration.group_ingest import is_nonsend_channel_reply_text
+from runtime.scheduler.turn_text import format_scheduled_user_reminder
+
+
+def resolve_scheduled_outbound_text(*, payload: dict[str, Any], reply_text: str) -> str:
+ text = str(reply_text or "").strip()
+ if text and not is_nonsend_channel_reply_text(text):
+ return text
+ prompt = str(payload.get("prompt_text") or "").strip()
+ if prompt:
+ return format_scheduled_user_reminder(prompt)
+ return ""
+
+
+def _persist_scheduled_assistant_reply(
+ store: Any,
+ *,
+ session_id: str,
+ turn_uuid: str,
+ reply_text: str,
+) -> None:
+ sid = str(session_id or "").strip()
+ body = str(reply_text or "").strip()
+ if not sid or not body:
+ return
+ tu = str(turn_uuid or "").strip()
+ payload = {"scheduled_proactive": True}
+ try:
+ rows = store.get_messages(session_id=sid, limit=80)
+ except Exception:
+ rows = []
+ if tu:
+ for m in rows or []:
+ if str(getattr(m, "role", "") or "").lower() != "assistant":
+ continue
+ if str(getattr(m, "turn_uuid", "") or "").strip() != tu:
+ continue
+ existing = str(getattr(m, "content", "") or "").strip()
+ mid = int(getattr(m, "id", 0) or 0)
+ updater = getattr(store, "update_message_content", None)
+ if existing == body:
+ if mid > 0 and callable(updater):
+ merged = dict(payload)
+ raw_ep = getattr(m, "event_payload", None)
+ if isinstance(raw_ep, dict):
+ merged = {**raw_ep, **merged}
+ elif isinstance(raw_ep, str) and raw_ep.strip():
+ try:
+ parsed = json.loads(raw_ep)
+ if isinstance(parsed, dict):
+ merged = {**parsed, **merged}
+ except Exception:
+ pass
+ updater(
+ session_id=sid,
+ message_id=mid,
+ content=body,
+ event_payload=merged,
+ )
+ return
+ if not existing:
+ if mid > 0 and callable(updater):
+ updater(
+ session_id=sid,
+ message_id=mid,
+ content=body,
+ event_payload=payload,
+ )
+ return
+ break
+ for m in rows or []:
+ if str(getattr(m, "role", "") or "").lower() != "assistant":
+ continue
+ if str(getattr(m, "content", "") or "").strip() == body:
+ return
+ try:
+ store.add_message(
+ session_id=sid,
+ role="assistant",
+ content=body,
+ turn_uuid=tu or None,
+ event_type="assistant_text",
+ event_payload=payload,
+ )
+ except Exception:
+ pass
+
+
+def finalize_scheduled_turn_success(
+ store: Any,
+ *,
+ task: Any,
+ payload: dict[str, Any],
+ base_result: dict[str, Any],
+) -> None:
+ from runtime.scheduler.channel_delivery import deliver_scheduled_reply
+
+ tenant_id = str(payload.get("tenant_id") or "")
+ job_id = str(payload.get("job_id") or "")
+ scheduled_run_id = str(payload.get("run_id_scheduled") or "")
+ reply_text = resolve_scheduled_outbound_text(payload=payload, reply_text=str(base_result.get("reply_text") or ""))
+ delivery = payload.get("delivery") if isinstance(payload.get("delivery"), dict) else {}
+ delivery_json = json.dumps(delivery, ensure_ascii=False)
+
+ job = store.scheduled_job_get(job_id=job_id, tenant_id=tenant_id) if job_id else None
+ if job:
+ delivery_json = str(getattr(job, "delivery_json", "") or delivery_json)
+
+ _persist_scheduled_assistant_reply(
+ store,
+ session_id=str(payload.get("session_id") or ""),
+ turn_uuid=str(base_result.get("turn_uuid") or payload.get("run_id") or ""),
+ reply_text=reply_text,
+ )
+ delivery_status = deliver_scheduled_reply(
+ store,
+ tenant_id=tenant_id,
+ reply_text=reply_text,
+ delivery_json=delivery_json,
+ resolved_channel=str(payload.get("resolved_channel") or ""),
+ resolved_chat_id=str(payload.get("resolved_chat_id") or ""),
+ resolved_account_id=str(payload.get("resolved_account_id") or ""),
+ session_id=str(payload.get("session_id") or ""),
+ )
+ if scheduled_run_id:
+ store.scheduled_job_run_update(
+ run_id=scheduled_run_id,
+ tenant_id=tenant_id,
+ patch={
+ "status": "success" if delivery_status.get("ok") else "failed",
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ "reply_text": reply_text,
+ "delivery_status": delivery_status,
+ "session_id": str(payload.get("session_id") or ""),
+ },
+ )
+ if job_id and job:
+ pause_after = str(getattr(job, "schedule_kind", "") or "") == "once"
+ store.scheduled_job_mark_run(
+ job_id=job_id,
+ tenant_id=tenant_id,
+ last_run_status="success" if delivery_status.get("ok") else "failed",
+ pause_after=pause_after,
+ )
+
+
+def finalize_scheduled_turn_failure(
+ store: Any,
+ *,
+ payload: dict[str, Any],
+ error: str,
+) -> None:
+ tenant_id = str(payload.get("tenant_id") or "")
+ job_id = str(payload.get("job_id") or "")
+ scheduled_run_id = str(payload.get("run_id_scheduled") or "")
+ if scheduled_run_id:
+ store.scheduled_job_run_update(
+ run_id=scheduled_run_id,
+ tenant_id=tenant_id,
+ patch={
+ "status": "failed",
+ "finished_at": datetime.now(timezone.utc).isoformat(),
+ "error": str(error or "")[:500],
+ },
+ )
+ if job_id:
+ job = store.scheduled_job_get(job_id=job_id, tenant_id=tenant_id)
+ pause_after = bool(job and str(getattr(job, "schedule_kind", "") or "") == "once")
+ store.scheduled_job_mark_run(
+ job_id=job_id,
+ tenant_id=tenant_id,
+ last_run_status="failed",
+ pause_after=pause_after,
+ )
+
+
+__all__ = ["finalize_scheduled_turn_failure", "finalize_scheduled_turn_success"]
diff --git a/runtime/tools/context_inject.py b/runtime/tools/context_inject.py
new file mode 100644
index 00000000..edcc7931
--- /dev/null
+++ b/runtime/tools/context_inject.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+from typing import Any
+
+_CONTEXT_SCOPED_TOOL_PREFIXES = ("schedule_", "todo_")
+_CONTEXT_SCOPED_TOOL_NAMES = frozenset(
+ {
+ "kb_add",
+ "kb_search",
+ }
+)
+
+
+def _session_owner(store: Any, session_id: str) -> tuple[str, str]:
+ sid = str(session_id or "").strip()
+ if not sid:
+ return "", ""
+ try:
+ owner = store.get_ui_session_owner(session_id=sid) or {}
+ except Exception:
+ owner = {}
+ if isinstance(owner, dict):
+ tid = str(owner.get("tenant_id") or "").strip()
+ uid = str(owner.get("user_id") or "").strip()
+ if tid and uid:
+ return tid, uid
+ return "", ""
+
+
+def enrich_tool_arguments(
+ *,
+ store: Any,
+ session_id: str,
+ tool_name: str,
+ arguments: dict[str, Any] | None,
+ path_policy_tenant_id: str | None = None,
+ path_policy_user_id: str | None = None,
+) -> dict[str, Any]:
+ """Inject tenant/user/session context for productivity tools (WeChat, WhatsApp, etc.)."""
+ merged = dict(arguments or {})
+ name = str(tool_name or "").strip()
+ if not name:
+ return merged
+ scoped = name in _CONTEXT_SCOPED_TOOL_NAMES or any(
+ name.startswith(p) for p in _CONTEXT_SCOPED_TOOL_PREFIXES
+ )
+ if not scoped:
+ return merged
+
+ tenant_id = str(path_policy_tenant_id or merged.get("tenant_id") or "").strip()
+ user_id = str(path_policy_user_id or merged.get("owner_user_id") or merged.get("user_id") or "").strip()
+ if not tenant_id or not user_id:
+ o_tid, o_uid = _session_owner(store, session_id)
+ tenant_id = tenant_id or o_tid
+ user_id = user_id or o_uid
+
+ if tenant_id:
+ merged["tenant_id"] = tenant_id
+ if user_id:
+ if name in _CONTEXT_SCOPED_TOOL_NAMES:
+ merged["user_id"] = user_id
+ else:
+ merged["owner_user_id"] = user_id
+ sid = str(session_id or merged.get("session_id") or "").strip()
+ if sid:
+ merged["session_id"] = sid
+ return merged
+
+
+__all__ = ["enrich_tool_arguments"]
diff --git a/runtime/tools/experts/productivity/schedule_tools.py b/runtime/tools/experts/productivity/schedule_tools.py
new file mode 100644
index 00000000..b13f420a
--- /dev/null
+++ b/runtime/tools/experts/productivity/schedule_tools.py
@@ -0,0 +1,334 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from runtime.scheduler.cron_service import build_delivery_for_session
+from runtime.scheduler.expressions import normalize_schedule_kind
+from runtime.scheduler.service import run_scheduled_job_now
+from runtime.types import normalize_interaction_mode, normalize_requested_specialist
+from svc.persistence.assistant_store import get_assistant_store
+from runtime.tools.base import ToolSpec
+from runtime.tools.context_inject import enrich_tool_arguments
+
+
+def _require(s: str, name: str) -> str:
+ v = (s or "").strip()
+ if not v:
+ raise ValueError(f"{name} is required")
+ return v
+
+
+def _parse_delivery_arg(raw: Any) -> dict[str, Any] | None:
+ if raw is None:
+ return None
+ if isinstance(raw, dict):
+ return raw
+ if isinstance(raw, str) and raw.strip():
+ try:
+ data = json.loads(raw)
+ return data if isinstance(data, dict) else None
+ except Exception:
+ return None
+ return None
+
+
+def schedule_create_tool() -> ToolSpec:
+ def handler(args: dict[str, Any]) -> dict[str, Any]:
+ try:
+ store = get_assistant_store()
+ args = enrich_tool_arguments(
+ store=store,
+ session_id=str(args.get("session_id") or ""),
+ tool_name="schedule_create",
+ arguments=args,
+ )
+ tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
+ owner_user_id = _require(
+ str(args.get("owner_user_id") or args.get("user_id") or ""),
+ "owner_user_id",
+ )
+ name = _require(str(args.get("name") or ""), "name")
+ prompt_text = _require(str(args.get("prompt_text") or ""), "prompt_text")
+ schedule_kind = normalize_schedule_kind(str(args.get("schedule_kind") or "cron"))
+ schedule_expr = _require(str(args.get("schedule_expr") or ""), "schedule_expr")
+ delivery = _parse_delivery_arg(args.get("delivery"))
+ if delivery is None:
+ delivery = build_delivery_for_session(
+ store,
+ tenant_id=tenant_id,
+ session_id=str(args.get("session_id") or ""),
+ whatsapp_chat_id=str(args.get("whatsapp_chat_id") or ""),
+ )
+ interaction_mode = normalize_interaction_mode(
+ str(args.get("interaction_mode") or "expert")
+ )
+ specialist = normalize_requested_specialist(
+ str(args.get("specialist") or args.get("selected_specialist") or "generalist")
+ )
+ row = store.scheduled_job_create(
+ tenant_id=tenant_id,
+ name=name,
+ prompt_text=prompt_text,
+ schedule_kind=schedule_kind,
+ schedule_expr=schedule_expr,
+ timezone_name=str(args.get("timezone") or "Asia/Shanghai"),
+ description=str(args.get("description") or ""),
+ interaction_mode=interaction_mode,
+ specialist=specialist,
+ lang=str(args.get("lang") or "zh"),
+ delivery=delivery,
+ source_session_id=str(args.get("session_id") or "").strip() or None,
+ created_by_user_id=owner_user_id,
+ source="chat",
+ )
+ return {"ok": True, "job": store.scheduled_job_to_dict(row)}
+ except Exception as e:
+ return {"ok": False, "error": f"{type(e).__name__}: {e}"}
+
+ return ToolSpec(
+ name="schedule_create",
+ description="Create a scheduled job (cron, once, or interval). Delivery follows the current chat channel (WhatsApp vs WeChat) unless delivery is set explicitly.",
+ parameters={
+ "type": "object",
+ "properties": {
+ "tenant_id": {"type": "string", "description": "Auto-filled from session; do not guess."},
+ "owner_user_id": {"type": "string", "description": "Auto-filled from session."},
+ "session_id": {"type": "string", "description": "Auto-filled from session."},
+ "name": {"type": "string"},
+ "prompt_text": {"type": "string"},
+ "schedule_kind": {"type": "string", "enum": ["cron", "once", "interval"]},
+ "schedule_expr": {"type": "string"},
+ "timezone": {"type": "string", "default": "Asia/Shanghai"},
+ "interaction_mode": {"type": "string"},
+ "specialist": {"type": "string"},
+ "selected_specialist": {"type": "string"},
+ "lang": {"type": "string"},
+ "whatsapp_chat_id": {"type": "string"},
+ "delivery": {"type": "object"},
+ "description": {"type": "string"},
+ },
+ "required": ["name", "prompt_text", "schedule_kind", "schedule_expr"],
+ "additionalProperties": False,
+ },
+ handler=handler,
+ tags=frozenset({"productivity", "write", "schedule"}),
+ )
+
+
+def schedule_list_tool() -> ToolSpec:
+ def handler(args: dict[str, Any]) -> dict[str, Any]:
+ try:
+ store = get_assistant_store()
+ args = enrich_tool_arguments(
+ store=store,
+ session_id=str(args.get("session_id") or ""),
+ tool_name="schedule_list",
+ arguments=args,
+ )
+ tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
+ status = str(args.get("status") or "").strip() or None
+ limit = int(args.get("limit") or 50)
+ rows = store.scheduled_job_list(tenant_id=tenant_id, status=status, limit=limit)
+ return {"ok": True, "items": [store.scheduled_job_to_dict(r) for r in rows]}
+ except Exception as e:
+ return {"ok": False, "error": f"{type(e).__name__}: {e}"}
+
+ return ToolSpec(
+ name="schedule_list",
+ description="List scheduled jobs for a tenant.",
+ parameters={
+ "type": "object",
+ "properties": {
+ "tenant_id": {"type": "string"},
+ "status": {"type": "string"},
+ "limit": {"type": "integer", "default": 50},
+ },
+ "required": [],
+ "additionalProperties": False,
+ },
+ handler=handler,
+ tags=frozenset({"productivity", "schedule"}),
+ )
+
+
+def _scoped_args(store: Any, tool_name: str, args: dict[str, Any]) -> dict[str, Any]:
+ return enrich_tool_arguments(
+ store=store,
+ session_id=str(args.get("session_id") or ""),
+ tool_name=tool_name,
+ arguments=args,
+ )
+
+
+def schedule_update_tool() -> ToolSpec:
+ def handler(args: dict[str, Any]) -> dict[str, Any]:
+ try:
+ store = get_assistant_store()
+ args = _scoped_args(store, "schedule_update", args)
+ tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
+ job_id = _require(str(args.get("job_id") or ""), "job_id")
+ patch: dict[str, Any] = {}
+ for key in (
+ "name",
+ "description",
+ "prompt_text",
+ "schedule_kind",
+ "schedule_expr",
+ "timezone",
+ "interaction_mode",
+ "specialist",
+ "lang",
+ ):
+ if key in args and args.get(key) is not None:
+ patch[key] = args.get(key)
+ delivery = _parse_delivery_arg(args.get("delivery"))
+ if delivery is not None:
+ patch["delivery"] = delivery
+ row = store.scheduled_job_update(tenant_id=tenant_id, job_id=job_id, patch=patch)
+ if not row:
+ return {"ok": False, "error": "job_not_found"}
+ return {"ok": True, "job": store.scheduled_job_to_dict(row)}
+ except Exception as e:
+ return {"ok": False, "error": f"{type(e).__name__}: {e}"}
+
+ return ToolSpec(
+ name="schedule_update",
+ description="Update a scheduled job.",
+ parameters={
+ "type": "object",
+ "properties": {
+ "tenant_id": {"type": "string"},
+ "job_id": {"type": "string"},
+ "name": {"type": "string"},
+ "prompt_text": {"type": "string"},
+ "schedule_kind": {"type": "string"},
+ "schedule_expr": {"type": "string"},
+ "timezone": {"type": "string"},
+ "interaction_mode": {"type": "string"},
+ "specialist": {"type": "string"},
+ "lang": {"type": "string"},
+ "delivery": {"type": "object"},
+ "description": {"type": "string"},
+ },
+ "required": ["job_id"],
+ "additionalProperties": False,
+ },
+ handler=handler,
+ tags=frozenset({"productivity", "write", "schedule"}),
+ )
+
+
+def schedule_pause_tool() -> ToolSpec:
+ def handler(args: dict[str, Any]) -> dict[str, Any]:
+ try:
+ store = get_assistant_store()
+ args = _scoped_args(store, "schedule_pause", args)
+ tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
+ job_id = _require(str(args.get("job_id") or ""), "job_id")
+ ok = store.scheduled_job_set_status(tenant_id=tenant_id, job_id=job_id, status="paused")
+ return {"ok": bool(ok), "job_id": job_id, "status": "paused"}
+ except Exception as e:
+ return {"ok": False, "error": f"{type(e).__name__}: {e}"}
+
+ return ToolSpec(
+ name="schedule_pause",
+ description="Pause a scheduled job.",
+ parameters={
+ "type": "object",
+ "properties": {"tenant_id": {"type": "string"}, "job_id": {"type": "string"}},
+ "required": ["job_id"],
+ "additionalProperties": False,
+ },
+ handler=handler,
+ tags=frozenset({"productivity", "write", "schedule"}),
+ )
+
+
+def schedule_resume_tool() -> ToolSpec:
+ def handler(args: dict[str, Any]) -> dict[str, Any]:
+ try:
+ store = get_assistant_store()
+ args = _scoped_args(store, "schedule_resume", args)
+ tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
+ job_id = _require(str(args.get("job_id") or ""), "job_id")
+ ok = store.scheduled_job_set_status(tenant_id=tenant_id, job_id=job_id, status="active")
+ return {"ok": bool(ok), "job_id": job_id, "status": "active"}
+ except Exception as e:
+ return {"ok": False, "error": f"{type(e).__name__}: {e}"}
+
+ return ToolSpec(
+ name="schedule_resume",
+ description="Resume a paused scheduled job.",
+ parameters={
+ "type": "object",
+ "properties": {"tenant_id": {"type": "string"}, "job_id": {"type": "string"}},
+ "required": ["job_id"],
+ "additionalProperties": False,
+ },
+ handler=handler,
+ tags=frozenset({"productivity", "write", "schedule"}),
+ )
+
+
+def schedule_delete_tool() -> ToolSpec:
+ def handler(args: dict[str, Any]) -> dict[str, Any]:
+ try:
+ store = get_assistant_store()
+ args = _scoped_args(store, "schedule_delete", args)
+ tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
+ job_id = _require(str(args.get("job_id") or ""), "job_id")
+ ok = store.scheduled_job_delete(tenant_id=tenant_id, job_id=job_id)
+ return {"ok": bool(ok), "job_id": job_id}
+ except Exception as e:
+ return {"ok": False, "error": f"{type(e).__name__}: {e}"}
+
+ return ToolSpec(
+ name="schedule_delete",
+ description="Delete (soft) a scheduled job.",
+ parameters={
+ "type": "object",
+ "properties": {"tenant_id": {"type": "string"}, "job_id": {"type": "string"}},
+ "required": ["job_id"],
+ "additionalProperties": False,
+ },
+ handler=handler,
+ tags=frozenset({"productivity", "write", "schedule"}),
+ )
+
+
+def schedule_run_now_tool() -> ToolSpec:
+ def handler(args: dict[str, Any]) -> dict[str, Any]:
+ try:
+ store = get_assistant_store()
+ args = _scoped_args(store, "schedule_run_now", args)
+ tenant_id = _require(str(args.get("tenant_id") or ""), "tenant_id")
+ job_id = _require(str(args.get("job_id") or ""), "job_id")
+ out = run_scheduled_job_now(store, tenant_id=tenant_id, job_id=job_id)
+ return out
+ except Exception as e:
+ return {"ok": False, "error": f"{type(e).__name__}: {e}"}
+
+ return ToolSpec(
+ name="schedule_run_now",
+ description="Trigger a scheduled job immediately.",
+ parameters={
+ "type": "object",
+ "properties": {"tenant_id": {"type": "string"}, "job_id": {"type": "string"}},
+ "required": ["job_id"],
+ "additionalProperties": False,
+ },
+ handler=handler,
+ tags=frozenset({"productivity", "write", "schedule"}),
+ )
+
+
+__all__ = [
+ "schedule_create_tool",
+ "schedule_list_tool",
+ "schedule_update_tool",
+ "schedule_pause_tool",
+ "schedule_resume_tool",
+ "schedule_delete_tool",
+ "schedule_run_now_tool",
+]
diff --git a/runtime/tools/tool_validation.py b/runtime/tools/tool_validation.py
index 338afeb4..be258ae9 100644
--- a/runtime/tools/tool_validation.py
+++ b/runtime/tools/tool_validation.py
@@ -10,6 +10,17 @@ except Exception: # pragma: no cover
validators = None
+def filter_arguments_to_schema(parameters: dict[str, Any], arguments: dict[str, Any]) -> dict[str, Any]:
+ """Drop keys not declared in tool schema when additionalProperties is false."""
+ if not isinstance(arguments, dict):
+ return {}
+ schema = parameters or {}
+ props = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
+ if schema.get("additionalProperties") is False and props:
+ return {k: v for k, v in arguments.items() if k in props}
+ return dict(arguments)
+
+
def validate_tool_arguments(parameters: dict[str, Any], arguments: dict[str, Any]) -> tuple[bool, str | None]:
"""校验模型给出的 arguments 是否符合工具的 JSON Schema(OpenAI function parameters)。"""
if not isinstance(arguments, dict):
@@ -33,5 +44,5 @@ def validate_tool_arguments(parameters: dict[str, Any], arguments: dict[str, Any
return True, None
-__all__ = ["validate_tool_arguments"]
+__all__ = ["filter_arguments_to_schema", "validate_tool_arguments"]
diff --git a/runtime/worker.py b/runtime/worker.py
index 9614cfae..5771e211 100644
--- a/runtime/worker.py
+++ b/runtime/worker.py
@@ -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:
diff --git a/svc/persistence/assistant_store_protocol.py b/svc/persistence/assistant_store_protocol.py
index 854ddc5a..1dbadc03 100644
--- a/svc/persistence/assistant_store_protocol.py
+++ b/svc/persistence/assistant_store_protocol.py
@@ -170,6 +170,20 @@ class AssistantStoreProtocol(Protocol):
def todo_create(self, *, tenant_id: 'str', owner_user_id: 'str', title: 'str', due_at: 'str | None' = None, assignee_user_id: 'str | None' = None) -> 'dict[str, Any]': ...
def todo_list(self, *, tenant_id: 'str', assignee_user_id: 'str | None' = None, status: 'str | None' = 'open', limit: 'int' = 50) -> 'list[dict[str, Any]]': ...
def todo_set_status(self, *, tenant_id: 'str', todo_id: 'str', status: 'str') -> 'bool': ...
+ def scheduled_job_create(self, *, tenant_id: 'str', name: 'str', prompt_text: 'str', schedule_kind: 'str', schedule_expr: 'str', timezone_name: 'str' = 'Asia/Shanghai', description: 'str' = '', interaction_mode: 'str' = 'expert', specialist: 'str' = 'generalist', lang: 'str' = 'zh', delivery: 'dict[str, Any] | None' = None, source_session_id: 'str | None' = None, created_by_user_id: 'str' = '', source: 'str' = 'admin', status: 'str' = 'active') -> 'Any': ...
+ def scheduled_job_get(self, *, job_id: 'str', tenant_id: 'str | None' = None) -> 'Any | None': ...
+ def scheduled_job_list(self, *, tenant_id: 'str', status: 'str | None' = None, limit: 'int' = 100, offset: 'int' = 0) -> 'list[Any]': ...
+ def scheduled_job_update(self, *, tenant_id: 'str', job_id: 'str', patch: 'dict[str, Any]') -> 'Any | None': ...
+ def scheduled_job_set_status(self, *, tenant_id: 'str', job_id: 'str', status: 'str') -> 'bool': ...
+ def scheduled_job_delete(self, *, tenant_id: 'str', job_id: 'str') -> 'bool': ...
+ def scheduled_job_list_due(self, *, limit: 'int' = 20, now_iso: 'str | None' = None) -> 'list[Any]': ...
+ def scheduled_job_mark_run(self, *, job_id: 'str', tenant_id: 'str', last_run_status: 'str', pause_after: 'bool' = False) -> 'None': ...
+ def scheduled_job_run_create(self, *, job_id: 'str', tenant_id: 'str', scheduled_at: 'str | None' = None, status: 'str' = 'queued') -> 'Any': ...
+ def scheduled_job_run_get(self, *, run_id: 'str', tenant_id: 'str | None' = None) -> 'Any | None': ...
+ def scheduled_job_run_list(self, *, job_id: 'str', tenant_id: 'str', limit: 'int' = 50, offset: 'int' = 0) -> 'list[Any]': ...
+ def scheduled_job_run_update(self, *, run_id: 'str', tenant_id: 'str', patch: 'dict[str, Any]') -> 'Any | None': ...
+ def scheduled_job_to_dict(self, job: 'Any') -> 'dict[str, Any]': ...
+ def scheduled_job_run_to_dict(self, run: 'Any') -> 'dict[str, Any]': ...
def touch_auth_session(self, *, session_token_hash: 'str') -> 'None': ...
def trim_messages(self, session_id: 'str', keep_last: 'int') -> 'None': ...
def update_llm_profile(self, profile_id: 'str', name: 'str', mode: 'str', model: 'str | None', base_url: 'str | None', *, thinking_mode_enabled: 'bool | None' = None, reasoning_effort: 'str | None' = None) -> 'None': ...
diff --git a/svc/persistence/scheduled_job_store.py b/svc/persistence/scheduled_job_store.py
new file mode 100644
index 00000000..3c36a2be
--- /dev/null
+++ b/svc/persistence/scheduled_job_store.py
@@ -0,0 +1,634 @@
+from __future__ import annotations
+
+import json
+import uuid
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Any
+
+from runtime.scheduler.expressions import compute_next_run_at, normalize_schedule_kind
+
+SCHEDULED_JOB_DDL = """
+CREATE TABLE IF NOT EXISTS scheduled_job (
+ id TEXT PRIMARY KEY,
+ tenant_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'active',
+ schedule_kind TEXT NOT NULL,
+ schedule_expr TEXT NOT NULL,
+ timezone TEXT NOT NULL DEFAULT 'Asia/Shanghai',
+ prompt_text TEXT NOT NULL,
+ interaction_mode TEXT NOT NULL DEFAULT 'expert',
+ specialist TEXT NOT NULL DEFAULT 'generalist',
+ lang TEXT NOT NULL DEFAULT 'zh',
+ delivery_json TEXT NOT NULL DEFAULT '{}',
+ source_session_id TEXT,
+ created_by_user_id TEXT NOT NULL DEFAULT '',
+ source TEXT NOT NULL DEFAULT 'admin',
+ next_run_at TEXT,
+ last_run_at TEXT,
+ last_run_status TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+"""
+
+SCHEDULED_JOB_INDEX_DDL = """
+CREATE INDEX IF NOT EXISTS idx_scheduled_job_due
+ON scheduled_job(status, next_run_at);
+"""
+
+SCHEDULED_JOB_RUN_DDL = """
+CREATE TABLE IF NOT EXISTS scheduled_job_run (
+ id TEXT PRIMARY KEY,
+ job_id TEXT NOT NULL,
+ tenant_id TEXT NOT NULL,
+ status TEXT NOT NULL,
+ scheduled_at TEXT NOT NULL,
+ started_at TEXT,
+ finished_at TEXT,
+ session_id TEXT,
+ oclaw_task_id TEXT,
+ run_id TEXT,
+ reply_text TEXT NOT NULL DEFAULT '',
+ delivery_status_json TEXT NOT NULL DEFAULT '{}',
+ error TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ FOREIGN KEY(job_id) REFERENCES scheduled_job(id) ON DELETE CASCADE
+);
+"""
+
+SCHEDULED_JOB_RUN_INDEX_DDL = """
+CREATE INDEX IF NOT EXISTS idx_scheduled_job_run_job
+ON scheduled_job_run(job_id, created_at DESC);
+"""
+
+
+def utc_now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+@dataclass(frozen=True)
+class ScheduledJob:
+ id: str
+ tenant_id: str
+ name: str
+ description: str
+ status: str
+ schedule_kind: str
+ schedule_expr: str
+ timezone: str
+ prompt_text: str
+ interaction_mode: str
+ specialist: str
+ lang: str
+ delivery_json: str
+ source_session_id: str | None
+ created_by_user_id: str
+ source: str
+ next_run_at: str | None
+ last_run_at: str | None
+ last_run_status: str
+ created_at: str
+ updated_at: str
+
+
+@dataclass(frozen=True)
+class ScheduledJobRun:
+ id: str
+ job_id: str
+ tenant_id: str
+ status: str
+ scheduled_at: str
+ started_at: str | None
+ finished_at: str | None
+ session_id: str | None
+ oclaw_task_id: str | None
+ run_id: str | None
+ reply_text: str
+ delivery_status_json: str
+ error: str
+ created_at: str
+
+
+def _row_to_job(row: Any) -> ScheduledJob:
+ return ScheduledJob(
+ id=str(row["id"] or ""),
+ tenant_id=str(row["tenant_id"] or ""),
+ name=str(row["name"] or ""),
+ description=str(row["description"] or ""),
+ status=str(row["status"] or ""),
+ schedule_kind=str(row["schedule_kind"] or ""),
+ schedule_expr=str(row["schedule_expr"] or ""),
+ timezone=str(row["timezone"] or "Asia/Shanghai"),
+ prompt_text=str(row["prompt_text"] or ""),
+ interaction_mode=str(row["interaction_mode"] or "expert"),
+ specialist=str(row["specialist"] or "generalist"),
+ lang=str(row["lang"] or "zh"),
+ delivery_json=str(row["delivery_json"] or "{}"),
+ source_session_id=str(row["source_session_id"] or "") or None,
+ created_by_user_id=str(row["created_by_user_id"] or ""),
+ source=str(row["source"] or ""),
+ next_run_at=str(row["next_run_at"] or "") or None,
+ last_run_at=str(row["last_run_at"] or "") or None,
+ last_run_status=str(row["last_run_status"] or ""),
+ created_at=str(row["created_at"] or ""),
+ updated_at=str(row["updated_at"] or ""),
+ )
+
+
+def _row_to_run(row: Any) -> ScheduledJobRun:
+ return ScheduledJobRun(
+ id=str(row["id"] or ""),
+ job_id=str(row["job_id"] or ""),
+ tenant_id=str(row["tenant_id"] or ""),
+ status=str(row["status"] or ""),
+ scheduled_at=str(row["scheduled_at"] or ""),
+ started_at=str(row["started_at"] or "") or None,
+ finished_at=str(row["finished_at"] or "") or None,
+ session_id=str(row["session_id"] or "") or None,
+ oclaw_task_id=str(row["oclaw_task_id"] or "") or None,
+ run_id=str(row["run_id"] or "") or None,
+ reply_text=str(row["reply_text"] or ""),
+ delivery_status_json=str(row["delivery_status_json"] or "{}"),
+ error=str(row["error"] or ""),
+ created_at=str(row["created_at"] or ""),
+ )
+
+
+_JOB_SELECT = """
+SELECT id, tenant_id, name, description, status, schedule_kind, schedule_expr, timezone,
+ prompt_text, interaction_mode, specialist, lang, delivery_json, source_session_id,
+ created_by_user_id, source, next_run_at, last_run_at, last_run_status, created_at, updated_at
+FROM scheduled_job
+"""
+
+
+class ScheduledJobStoreMixin:
+ def ensure_scheduled_job_tables(self, conn: Any) -> None:
+ conn.execute(SCHEDULED_JOB_DDL)
+ conn.execute(SCHEDULED_JOB_INDEX_DDL)
+ conn.execute(SCHEDULED_JOB_RUN_DDL)
+ conn.execute(SCHEDULED_JOB_RUN_INDEX_DDL)
+
+ def scheduled_job_create(
+ self,
+ *,
+ tenant_id: str,
+ name: str,
+ prompt_text: str,
+ schedule_kind: str,
+ schedule_expr: str,
+ timezone_name: str = "Asia/Shanghai",
+ description: str = "",
+ interaction_mode: str = "expert",
+ specialist: str = "generalist",
+ lang: str = "zh",
+ delivery: dict[str, Any] | None = None,
+ source_session_id: str | None = None,
+ created_by_user_id: str = "",
+ source: str = "admin",
+ status: str = "active",
+ ) -> ScheduledJob:
+ jid = str(uuid.uuid4())
+ ts = utc_now_iso()
+ kind = normalize_schedule_kind(schedule_kind)
+ tz = str(timezone_name or "Asia/Shanghai").strip() or "Asia/Shanghai"
+ next_run = compute_next_run_at(
+ schedule_kind=kind,
+ schedule_expr=str(schedule_expr or "").strip(),
+ timezone_name=tz,
+ from_dt=None,
+ )
+ delivery_json = json.dumps(delivery or {}, ensure_ascii=False)
+ with self._connect() as conn: # type: ignore[attr-defined]
+ conn.execute(
+ """
+ INSERT INTO scheduled_job
+ (id, tenant_id, name, description, status, schedule_kind, schedule_expr, timezone,
+ prompt_text, interaction_mode, specialist, lang, delivery_json, source_session_id,
+ created_by_user_id, source, next_run_at, last_run_at, last_run_status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, '', ?, ?)
+ """,
+ (
+ jid,
+ str(tenant_id),
+ str(name or "").strip() or "Scheduled task",
+ str(description or ""),
+ str(status or "active"),
+ kind,
+ str(schedule_expr or "").strip(),
+ tz,
+ str(prompt_text or "").strip(),
+ str(interaction_mode or "expert"),
+ str(specialist or "generalist"),
+ str(lang or "zh"),
+ delivery_json,
+ str(source_session_id) if source_session_id else None,
+ str(created_by_user_id or ""),
+ str(source or "admin"),
+ next_run,
+ ts,
+ ts,
+ ),
+ )
+ got = self.scheduled_job_get(job_id=jid, tenant_id=tenant_id)
+ if not got:
+ raise RuntimeError("failed to create scheduled job")
+ return got
+
+ def scheduled_job_get(self, *, job_id: str, tenant_id: str | None = None) -> ScheduledJob | None:
+ with self._connect() as conn: # type: ignore[attr-defined]
+ if tenant_id:
+ row = conn.execute(
+ f"{_JOB_SELECT} WHERE id = ? AND tenant_id = ?",
+ (str(job_id), str(tenant_id)),
+ ).fetchone()
+ else:
+ row = conn.execute(f"{_JOB_SELECT} WHERE id = ?", (str(job_id),)).fetchone()
+ if not row:
+ return None
+ return _row_to_job(row)
+
+ def scheduled_job_list(
+ self,
+ *,
+ tenant_id: str,
+ status: str | None = None,
+ limit: int = 100,
+ offset: int = 0,
+ ) -> list[ScheduledJob]:
+ lim = max(1, min(int(limit), 500))
+ off = max(0, int(offset))
+ where = ["tenant_id = ?", "status != 'deleted'"]
+ params: list[Any] = [str(tenant_id)]
+ if status:
+ where.append("status = ?")
+ params.append(str(status))
+ wsql = " AND ".join(where)
+ with self._connect() as conn: # type: ignore[attr-defined]
+ rows = conn.execute(
+ f"{_JOB_SELECT} WHERE {wsql} ORDER BY updated_at DESC LIMIT ? OFFSET ?",
+ (*params, lim, off),
+ ).fetchall()
+ return [_row_to_job(r) for r in rows]
+
+ def scheduled_job_update(
+ self,
+ *,
+ tenant_id: str,
+ job_id: str,
+ patch: dict[str, Any],
+ ) -> ScheduledJob | None:
+ cur = self.scheduled_job_get(job_id=job_id, tenant_id=tenant_id)
+ if not cur:
+ return None
+ p = dict(patch or {})
+ name = str(p.get("name") if "name" in p else cur.name)
+ description = str(p.get("description") if "description" in p else cur.description)
+ prompt_text = str(p.get("prompt_text") if "prompt_text" in p else cur.prompt_text)
+ interaction_mode = str(p.get("interaction_mode") if "interaction_mode" in p else cur.interaction_mode)
+ specialist = str(p.get("specialist") if "specialist" in p else cur.specialist)
+ lang = str(p.get("lang") if "lang" in p else cur.lang)
+ schedule_kind = normalize_schedule_kind(
+ str(p.get("schedule_kind") if "schedule_kind" in p else cur.schedule_kind)
+ )
+ schedule_expr = str(p.get("schedule_expr") if "schedule_expr" in p else cur.schedule_expr)
+ tz = str(p.get("timezone") if "timezone" in p else cur.timezone)
+ delivery_json = cur.delivery_json
+ if "delivery" in p and isinstance(p.get("delivery"), dict):
+ delivery_json = json.dumps(p["delivery"], ensure_ascii=False)
+ elif "delivery_json" in p:
+ delivery_json = str(p.get("delivery_json") or "{}")
+ source_session_id = cur.source_session_id
+ if "source_session_id" in p:
+ raw_sid = str(p.get("source_session_id") or "").strip()
+ source_session_id = raw_sid or None
+ status = str(p.get("status") if "status" in p else cur.status)
+ recompute = any(k in p for k in ("schedule_kind", "schedule_expr", "timezone", "status"))
+ next_run_at = cur.next_run_at
+ if recompute and status == "active":
+ next_run_at = compute_next_run_at(
+ schedule_kind=schedule_kind,
+ schedule_expr=schedule_expr,
+ timezone_name=tz,
+ from_dt=None,
+ )
+ elif status != "active":
+ next_run_at = None
+ ts = utc_now_iso()
+ with self._connect() as conn: # type: ignore[attr-defined]
+ conn.execute(
+ """
+ UPDATE scheduled_job SET
+ name = ?, description = ?, prompt_text = ?, interaction_mode = ?, specialist = ?,
+ lang = ?, schedule_kind = ?, schedule_expr = ?, timezone = ?, delivery_json = ?,
+ source_session_id = ?, status = ?, next_run_at = ?, updated_at = ?
+ WHERE id = ? AND tenant_id = ?
+ """,
+ (
+ name,
+ description,
+ prompt_text,
+ interaction_mode,
+ specialist,
+ lang,
+ schedule_kind,
+ schedule_expr,
+ tz,
+ delivery_json,
+ source_session_id,
+ status,
+ next_run_at,
+ ts,
+ str(job_id),
+ str(tenant_id),
+ ),
+ )
+ return self.scheduled_job_get(job_id=job_id, tenant_id=tenant_id)
+
+ def scheduled_job_set_status(self, *, tenant_id: str, job_id: str, status: str) -> bool:
+ cur = self.scheduled_job_get(job_id=job_id, tenant_id=tenant_id)
+ if not cur:
+ return False
+ ts = utc_now_iso()
+ next_run_at = cur.next_run_at
+ if status == "active":
+ next_run_at = compute_next_run_at(
+ schedule_kind=cur.schedule_kind,
+ schedule_expr=cur.schedule_expr,
+ timezone_name=cur.timezone,
+ from_dt=None,
+ )
+ else:
+ next_run_at = None
+ with self._connect() as conn: # type: ignore[attr-defined]
+ cur2 = conn.execute(
+ """
+ UPDATE scheduled_job SET status = ?, next_run_at = ?, updated_at = ?
+ WHERE id = ? AND tenant_id = ?
+ """,
+ (str(status), next_run_at, ts, str(job_id), str(tenant_id)),
+ )
+ return bool(cur2.rowcount)
+
+ def scheduled_job_delete(self, *, tenant_id: str, job_id: str) -> bool:
+ return self.scheduled_job_set_status(tenant_id=tenant_id, job_id=job_id, status="deleted")
+
+ def scheduled_job_list_due(self, *, limit: int = 20, now_iso: str | None = None) -> list[ScheduledJob]:
+ lim = max(1, min(int(limit), 100))
+ now = str(now_iso or utc_now_iso())
+ with self._connect() as conn: # type: ignore[attr-defined]
+ rows = conn.execute(
+ f"""
+ {_JOB_SELECT}
+ WHERE status = 'active' AND next_run_at IS NOT NULL AND next_run_at <= ?
+ ORDER BY next_run_at ASC
+ LIMIT ?
+ """,
+ (now, lim),
+ ).fetchall()
+ return [_row_to_job(r) for r in rows]
+
+ def scheduled_job_mark_run(
+ self,
+ *,
+ job_id: str,
+ tenant_id: str,
+ last_run_status: str,
+ pause_after: bool = False,
+ ) -> None:
+ cur = self.scheduled_job_get(job_id=job_id, tenant_id=tenant_id)
+ if not cur:
+ return
+ ts = utc_now_iso()
+ status = "paused" if pause_after else cur.status
+ with self._connect() as conn: # type: ignore[attr-defined]
+ conn.execute(
+ """
+ UPDATE scheduled_job SET
+ last_run_at = ?, last_run_status = ?, status = ?, updated_at = ?
+ WHERE id = ? AND tenant_id = ?
+ """,
+ (ts, str(last_run_status), status, ts, str(job_id), str(tenant_id)),
+ )
+
+ def scheduled_job_reserve_next_run(self, *, job_id: str, tenant_id: str) -> None:
+ """Advance next_run_at when a run is enqueued to avoid duplicate scheduler ticks."""
+ cur = self.scheduled_job_get(job_id=job_id, tenant_id=tenant_id)
+ if not cur:
+ return
+ ts = utc_now_iso()
+ if str(cur.schedule_kind or "") == "once":
+ with self._connect() as conn: # type: ignore[attr-defined]
+ conn.execute(
+ """
+ UPDATE scheduled_job SET next_run_at = NULL, status = 'paused', updated_at = ?
+ WHERE id = ? AND tenant_id = ?
+ """,
+ (ts, str(job_id), str(tenant_id)),
+ )
+ return
+ next_run_at = compute_next_run_at(
+ schedule_kind=cur.schedule_kind,
+ schedule_expr=cur.schedule_expr,
+ timezone_name=cur.timezone,
+ from_dt=datetime.now(timezone.utc),
+ )
+ with self._connect() as conn: # type: ignore[attr-defined]
+ conn.execute(
+ """
+ UPDATE scheduled_job SET next_run_at = ?, updated_at = ?
+ WHERE id = ? AND tenant_id = ?
+ """,
+ (next_run_at, ts, str(job_id), str(tenant_id)),
+ )
+
+ def scheduled_job_run_create(
+ self,
+ *,
+ job_id: str,
+ tenant_id: str,
+ scheduled_at: str | None = None,
+ status: str = "queued",
+ ) -> ScheduledJobRun:
+ rid = str(uuid.uuid4())
+ ts = utc_now_iso()
+ sched = str(scheduled_at or ts)
+ with self._connect() as conn: # type: ignore[attr-defined]
+ conn.execute(
+ """
+ INSERT INTO scheduled_job_run
+ (id, job_id, tenant_id, status, scheduled_at, started_at, finished_at,
+ session_id, oclaw_task_id, run_id, reply_text, delivery_status_json, error, created_at)
+ VALUES (?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, '', '{}', '', ?)
+ """,
+ (rid, str(job_id), str(tenant_id), str(status), sched, ts),
+ )
+ got = self.scheduled_job_run_get(run_id=rid, tenant_id=tenant_id)
+ if not got:
+ raise RuntimeError("failed to create scheduled job run")
+ return got
+
+ def scheduled_job_run_get(self, *, run_id: str, tenant_id: str | None = None) -> ScheduledJobRun | None:
+ with self._connect() as conn: # type: ignore[attr-defined]
+ if tenant_id:
+ row = conn.execute(
+ """
+ SELECT id, job_id, tenant_id, status, scheduled_at, started_at, finished_at,
+ session_id, oclaw_task_id, run_id, reply_text, delivery_status_json, error, created_at
+ FROM scheduled_job_run WHERE id = ? AND tenant_id = ?
+ """,
+ (str(run_id), str(tenant_id)),
+ ).fetchone()
+ else:
+ row = conn.execute(
+ """
+ SELECT id, job_id, tenant_id, status, scheduled_at, started_at, finished_at,
+ session_id, oclaw_task_id, run_id, reply_text, delivery_status_json, error, created_at
+ FROM scheduled_job_run WHERE id = ?
+ """,
+ (str(run_id),),
+ ).fetchone()
+ if not row:
+ return None
+ return _row_to_run(row)
+
+ def scheduled_job_run_list(
+ self,
+ *,
+ job_id: str,
+ tenant_id: str,
+ limit: int = 50,
+ offset: int = 0,
+ ) -> list[ScheduledJobRun]:
+ lim = max(1, min(int(limit), 200))
+ off = max(0, int(offset))
+ with self._connect() as conn: # type: ignore[attr-defined]
+ rows = conn.execute(
+ """
+ SELECT id, job_id, tenant_id, status, scheduled_at, started_at, finished_at,
+ session_id, oclaw_task_id, run_id, reply_text, delivery_status_json, error, created_at
+ FROM scheduled_job_run
+ WHERE job_id = ? AND tenant_id = ?
+ ORDER BY created_at DESC
+ LIMIT ? OFFSET ?
+ """,
+ (str(job_id), str(tenant_id), lim, off),
+ ).fetchall()
+ return [_row_to_run(r) for r in rows]
+
+ def scheduled_job_run_update(
+ self,
+ *,
+ run_id: str,
+ tenant_id: str,
+ patch: dict[str, Any],
+ ) -> ScheduledJobRun | None:
+ cur = self.scheduled_job_run_get(run_id=run_id, tenant_id=tenant_id)
+ if not cur:
+ return None
+ p = dict(patch or {})
+ status = str(p.get("status") if "status" in p else cur.status)
+ started_at = p.get("started_at") if "started_at" in p else cur.started_at
+ finished_at = p.get("finished_at") if "finished_at" in p else cur.finished_at
+ session_id = p.get("session_id") if "session_id" in p else cur.session_id
+ oclaw_task_id = p.get("oclaw_task_id") if "oclaw_task_id" in p else cur.oclaw_task_id
+ agent_run_id = p.get("run_id") if "run_id" in p else cur.run_id
+ reply_text = str(p.get("reply_text") if "reply_text" in p else cur.reply_text)
+ delivery_status_json = cur.delivery_status_json
+ if "delivery_status" in p and isinstance(p.get("delivery_status"), dict):
+ delivery_status_json = json.dumps(p["delivery_status"], ensure_ascii=False)
+ elif "delivery_status_json" in p:
+ delivery_status_json = str(p.get("delivery_status_json") or "{}")
+ error = str(p.get("error") if "error" in p else cur.error)
+ with self._connect() as conn: # type: ignore[attr-defined]
+ conn.execute(
+ """
+ UPDATE scheduled_job_run SET
+ status = ?, started_at = ?, finished_at = ?, session_id = ?,
+ oclaw_task_id = ?, run_id = ?, reply_text = ?, delivery_status_json = ?, error = ?
+ WHERE id = ? AND tenant_id = ?
+ """,
+ (
+ status,
+ started_at,
+ finished_at,
+ session_id,
+ oclaw_task_id,
+ agent_run_id,
+ reply_text,
+ delivery_status_json,
+ error,
+ str(run_id),
+ str(tenant_id),
+ ),
+ )
+ return self.scheduled_job_run_get(run_id=run_id, tenant_id=tenant_id)
+
+ def scheduled_job_to_dict(self, job: ScheduledJob) -> dict[str, Any]:
+ delivery: dict[str, Any] = {}
+ try:
+ raw = json.loads(job.delivery_json or "{}")
+ if isinstance(raw, dict):
+ delivery = raw
+ except Exception:
+ delivery = {}
+ return {
+ "id": job.id,
+ "tenant_id": job.tenant_id,
+ "name": job.name,
+ "description": job.description,
+ "status": job.status,
+ "schedule_kind": job.schedule_kind,
+ "schedule_expr": job.schedule_expr,
+ "timezone": job.timezone,
+ "prompt_text": job.prompt_text,
+ "interaction_mode": job.interaction_mode,
+ "specialist": job.specialist,
+ "lang": job.lang,
+ "delivery": delivery,
+ "source_session_id": job.source_session_id,
+ "created_by_user_id": job.created_by_user_id,
+ "source": job.source,
+ "next_run_at": job.next_run_at,
+ "last_run_at": job.last_run_at,
+ "last_run_status": job.last_run_status,
+ "created_at": job.created_at,
+ "updated_at": job.updated_at,
+ }
+
+ def scheduled_job_run_to_dict(self, run: ScheduledJobRun) -> dict[str, Any]:
+ delivery_status: dict[str, Any] = {}
+ try:
+ raw = json.loads(run.delivery_status_json or "{}")
+ if isinstance(raw, dict):
+ delivery_status = raw
+ except Exception:
+ delivery_status = {}
+ return {
+ "id": run.id,
+ "job_id": run.job_id,
+ "tenant_id": run.tenant_id,
+ "status": run.status,
+ "scheduled_at": run.scheduled_at,
+ "started_at": run.started_at,
+ "finished_at": run.finished_at,
+ "session_id": run.session_id,
+ "oclaw_task_id": run.oclaw_task_id,
+ "run_id": run.run_id,
+ "reply_text": run.reply_text,
+ "delivery_status": delivery_status,
+ "error": run.error,
+ "created_at": run.created_at,
+ }
+
+
+__all__ = [
+ "ScheduledJob",
+ "ScheduledJobRun",
+ "ScheduledJobStoreMixin",
+ "SCHEDULED_JOB_DDL",
+ "SCHEDULED_JOB_RUN_DDL",
+]
diff --git a/svc/persistence/sqlite_store.py b/svc/persistence/sqlite_store.py
index 8e8f8556..2043d9d3 100644
--- a/svc/persistence/sqlite_store.py
+++ b/svc/persistence/sqlite_store.py
@@ -20,6 +20,7 @@ from sqlalchemy.exc import IntegrityError
from svc.persistence.pg_adapter import PgConnShim, connect_postgres
from svc.persistence.pg_compat import scrub_nul_bytes_from_jsonable, scrub_nul_bytes_from_text
+from svc.persistence.scheduled_job_store import ScheduledJobStoreMixin
if sys.platform == "win32":
import ctypes
@@ -297,7 +298,7 @@ def _decode_secret(secret_text: str) -> str:
raise _CryptoError("未知的密钥编码格式")
-class SqliteStore:
+class SqliteStore(ScheduledJobStoreMixin):
@staticmethod
def _cap_json_for_log(obj: Any, *, max_chars: int, keep_keys: tuple[str, ...] = ("ok", "error_code", "error")) -> Any:
cap = max(2000, int(max_chars or 0))
@@ -677,6 +678,7 @@ class SqliteStore:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_channel_outbound_pending ON channel_outbound_message(channel, account_id, status, created_at)"
)
+ self.ensure_scheduled_job_tables(conn)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS todo_item (
@@ -1266,6 +1268,7 @@ class SqliteStore:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_channel_outbound_pending ON channel_outbound_message(channel, account_id, status, created_at)"
)
+ self.ensure_scheduled_job_tables(conn)
self._seed_builtin_llm_profiles(conn)
self._seed_default_permissions(conn)
conn.execute(
@@ -4713,6 +4716,186 @@ class SqliteStore:
for r in rows
]
+ @staticmethod
+ def _channel_context_token_key(
+ *,
+ tenant_id: str,
+ channel: str,
+ account_id: str,
+ external_chat_id: str,
+ ) -> str:
+ return (
+ f"channel_ctx_token:{str(tenant_id or '').strip()}:"
+ f"{str(channel or '').strip().lower()}:"
+ f"{str(account_id or '').strip()}:"
+ f"{str(external_chat_id or '').strip()}"
+ )
+
+ def set_channel_context_token(
+ self,
+ *,
+ tenant_id: str,
+ channel: str,
+ account_id: str,
+ external_chat_id: str,
+ context_token: str,
+ ) -> None:
+ tok = str(context_token or "").strip()
+ chat_id = str(external_chat_id or "").strip()
+ if not tok or not chat_id:
+ return
+ self.set_setting(
+ self._channel_context_token_key(
+ tenant_id=tenant_id,
+ channel=channel,
+ account_id=account_id,
+ external_chat_id=chat_id,
+ ),
+ tok,
+ )
+
+ def get_channel_context_token(
+ self,
+ *,
+ tenant_id: str,
+ channel: str,
+ account_id: str,
+ external_chat_id: str,
+ ) -> str:
+ chat_id = str(external_chat_id or "").strip()
+ if not chat_id:
+ return ""
+ return str(
+ self.get_setting(
+ self._channel_context_token_key(
+ tenant_id=tenant_id,
+ channel=channel,
+ account_id=account_id,
+ external_chat_id=chat_id,
+ )
+ )
+ or ""
+ ).strip()
+
+ def get_channel_context_token_fuzzy(
+ self,
+ *,
+ tenant_id: str,
+ channel: str,
+ account_id: str,
+ external_chat_id: str,
+ ) -> str:
+ chat_id = str(external_chat_id or "").strip()
+ if not chat_id:
+ return ""
+ channels = []
+ for ch in (channel, "wechat", "weixin"):
+ c = str(ch or "").strip().lower()
+ if c and c not in channels:
+ channels.append(c)
+ account_ids: list[str] = []
+ for aid in (account_id, ""):
+ a = str(aid or "").strip()
+ if a not in account_ids:
+ account_ids.append(a)
+ for ch in channels:
+ for aid in account_ids:
+ tok = self.get_channel_context_token(
+ tenant_id=tenant_id,
+ channel=ch,
+ account_id=aid,
+ external_chat_id=chat_id,
+ )
+ if tok:
+ return tok
+ return ""
+
+ def lookup_channel_session_by_chat_v2(
+ self,
+ *,
+ tenant_id: str,
+ channel: str,
+ external_chat_id: str,
+ ) -> dict[str, Any] | None:
+ chat_id = str(external_chat_id or "").strip()
+ tid = str(tenant_id or "").strip()
+ if not chat_id or not tid:
+ return None
+ channels = []
+ for ch in (channel, "wechat", "weixin"):
+ c = str(ch or "").strip().lower()
+ if c and c not in channels:
+ channels.append(c)
+ with self._connect() as conn:
+ for ch in channels:
+ row = conn.execute(
+ """
+ SELECT tenant_id, channel, account_id, external_chat_id, external_user_id, session_id
+ FROM channel_session_v2
+ WHERE tenant_id = ? AND channel = ? AND external_chat_id = ?
+ ORDER BY created_at DESC
+ LIMIT 1
+ """,
+ (tid, ch, chat_id),
+ ).fetchone()
+ if row:
+ return {
+ "tenant_id": str(row["tenant_id"] or ""),
+ "channel": str(row["channel"] or ""),
+ "account_id": str(row["account_id"] or ""),
+ "external_chat_id": str(row["external_chat_id"] or ""),
+ "external_user_id": str(row["external_user_id"] or ""),
+ "session_id": str(row["session_id"] or ""),
+ }
+ return None
+
+ def lookup_channel_session_by_session_id(
+ self,
+ *,
+ tenant_id: str,
+ session_id: str,
+ ) -> dict[str, Any] | None:
+ sid = str(session_id or "").strip()
+ tid = str(tenant_id or "").strip()
+ if not sid or not tid:
+ return None
+ with self._connect() as conn:
+ row = conn.execute(
+ """
+ SELECT tenant_id, channel, account_id, external_chat_id, external_user_id
+ FROM channel_session_v2
+ WHERE tenant_id = ? AND session_id = ?
+ LIMIT 1
+ """,
+ (tid, sid),
+ ).fetchone()
+ if row:
+ return {
+ "tenant_id": str(row["tenant_id"] or ""),
+ "channel": str(row["channel"] or ""),
+ "account_id": str(row["account_id"] or ""),
+ "external_chat_id": str(row["external_chat_id"] or ""),
+ "external_user_id": str(row["external_user_id"] or ""),
+ }
+ row = conn.execute(
+ """
+ SELECT tenant_id, channel, external_chat_id, external_user_id
+ FROM channel_session
+ WHERE tenant_id = ? AND session_id = ?
+ LIMIT 1
+ """,
+ (tid, sid),
+ ).fetchone()
+ if not row:
+ return None
+ return {
+ "tenant_id": str(row["tenant_id"] or ""),
+ "channel": str(row["channel"] or ""),
+ "account_id": "",
+ "external_chat_id": str(row["external_chat_id"] or ""),
+ "external_user_id": str(row["external_user_id"] or ""),
+ }
+
def get_or_create_channel_session(
self,
*,
@@ -5446,18 +5629,68 @@ class SqliteStore:
).fetchall()
return [
{
- "id": str(r[0] or ""),
- "tenant_id": str(r[1] or ""),
- "channel": str(r[2] or ""),
- "account_id": str(r[3] or ""),
- "chat_id": str(r[4] or ""),
- "text": str(r[5] or ""),
- "source": str(r[6] or ""),
- "created_at": str(r[7] or ""),
+ "id": str(r["id"] or ""),
+ "tenant_id": str(r["tenant_id"] or ""),
+ "channel": str(r["channel"] or ""),
+ "account_id": str(r["account_id"] or ""),
+ "chat_id": str(r["chat_id"] or ""),
+ "text": str(r["text"] or ""),
+ "source": str(r["source"] or ""),
+ "created_at": str(r["created_at"] or ""),
}
for r in rows
]
+ def list_pending_weixin_outbound_messages(
+ self,
+ *,
+ account_id: str,
+ limit: int = 20,
+ ) -> list[dict[str, Any]]:
+ lim = max(1, min(int(limit), 100))
+ aid = str(account_id or "").strip()
+ with self._connect() as conn:
+ rows = conn.execute(
+ """
+ SELECT id, tenant_id, channel, account_id, chat_id, text, source, created_at
+ FROM channel_outbound_message
+ WHERE channel IN ('wechat', 'weixin') AND status = 'pending'
+ AND (
+ account_id = ?
+ OR account_id = '' OR account_id = 'weixin-default'
+ OR ? = '' OR ? = 'weixin-default'
+ )
+ ORDER BY created_at ASC
+ LIMIT ?
+ """,
+ (aid, aid, aid, lim),
+ ).fetchall()
+ out: list[dict[str, Any]] = []
+ for r in rows:
+ source = str(r["source"] or "")
+ meta = {}
+ if source.startswith("{"):
+ try:
+ parsed = json.loads(source)
+ if isinstance(parsed, dict):
+ meta = parsed
+ except Exception:
+ meta = {}
+ out.append(
+ {
+ "id": str(r["id"] or ""),
+ "tenant_id": str(r["tenant_id"] or ""),
+ "channel": str(r["channel"] or ""),
+ "account_id": str(r["account_id"] or ""),
+ "chat_id": str(r["chat_id"] or ""),
+ "text": str(r["text"] or ""),
+ "source": source,
+ "context_token": str(meta.get("context_token") or ""),
+ "created_at": str(r["created_at"] or ""),
+ }
+ )
+ return out
+
def ack_channel_outbound_message(
self,
*,
diff --git a/tests/test_admin_scheduled_jobs_api.py b/tests/test_admin_scheduled_jobs_api.py
new file mode 100644
index 00000000..6754028f
--- /dev/null
+++ b/tests/test_admin_scheduled_jobs_api.py
@@ -0,0 +1,114 @@
+from __future__ import annotations
+
+import hashlib
+import os
+import tempfile
+import unittest
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+from fastapi.testclient import TestClient
+
+from interfaces.http.fastapi_app import create_app
+from svc.persistence.assistant_store import reset_assistant_store_singleton
+from svc.persistence.sqlite_store import SqliteStore
+
+
+class AdminScheduledJobsApiTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
+ self.db = Path(self._tmp.name) / "sched_admin.sqlite"
+ os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
+ os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
+ os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
+ reset_assistant_store_singleton()
+ self.store = SqliteStore(str(self.db))
+ t1 = self.store.create_tenant("TeamA")
+ self.tenant_id = str(t1["id"])
+ admin = self.store.create_user_account(
+ tenant_id=self.tenant_id,
+ username="administrator",
+ display_name="Admin",
+ role="owner",
+ password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
+ is_active=True,
+ )
+ self.user_id = str(admin["id"])
+ self.client = TestClient(create_app())
+ self.client.post("/admin/api/auth/bootstrap", json={})
+
+ def tearDown(self) -> None:
+ reset_assistant_store_singleton()
+ self._tmp.cleanup()
+
+ def _login(self) -> str:
+ resp = self.client.post(
+ "/admin/api/auth/login",
+ json={
+ "tenant_id": self.tenant_id,
+ "username": "administrator",
+ "password": "test-admin-pass",
+ "purpose": "console",
+ },
+ )
+ data = resp.json()
+ self.assertTrue(data.get("ok"), data)
+ return str(data.get("token") or "")
+
+ def test_scheduled_jobs_crud(self) -> None:
+ token = self._login()
+ headers = {"authorization": f"Bearer {token}"}
+ once_at = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
+ create = self.client.post(
+ "/admin/api/scheduled-jobs",
+ headers=headers,
+ json={
+ "name": "Test job",
+ "prompt_text": "Say hello",
+ "schedule_kind": "once",
+ "schedule_expr": once_at,
+ "specialist": "generalist",
+ },
+ )
+ self.assertEqual(create.status_code, 200, create.text)
+ body = create.json()
+ self.assertTrue(body.get("ok"), body)
+ job_id = str((body.get("job") or {}).get("id") or "")
+ self.assertTrue(job_id)
+
+ listed = self.client.get("/admin/api/scheduled-jobs", headers=headers)
+ self.assertTrue(listed.json().get("ok"))
+ self.assertGreaterEqual(len(listed.json().get("items") or []), 1)
+
+ pause = self.client.post(f"/admin/api/scheduled-jobs/{job_id}/pause", headers=headers, json={})
+ self.assertTrue(pause.json().get("ok"))
+
+ resume = self.client.post(f"/admin/api/scheduled-jobs/{job_id}/resume", headers=headers, json={})
+ self.assertTrue(resume.json().get("ok"))
+
+ runs = self.client.get(f"/admin/api/scheduled-jobs/{job_id}/runs", headers=headers)
+ self.assertTrue(runs.json().get("ok"))
+
+ patch = self.client.patch(
+ f"/admin/api/scheduled-jobs/{job_id}",
+ headers=headers,
+ json={
+ "name": "Updated job",
+ "schedule_kind": "interval",
+ "schedule_expr": "7200",
+ "prompt_text": "Stand up hourly",
+ "specialist": "generalist",
+ },
+ )
+ self.assertEqual(patch.status_code, 200, patch.text)
+ patched = patch.json()
+ self.assertTrue(patched.get("ok"), patched)
+ job = patched.get("job") or {}
+ self.assertEqual(str(job.get("name") or ""), "Updated job")
+ self.assertEqual(str(job.get("schedule_kind") or ""), "interval")
+ self.assertEqual(str(job.get("schedule_expr") or ""), "7200")
+ self.assertEqual(str(job.get("prompt_text") or ""), "Stand up hourly")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_gateway_cron_service.py b/tests/test_gateway_cron_service.py
new file mode 100644
index 00000000..0086a9da
--- /dev/null
+++ b/tests/test_gateway_cron_service.py
@@ -0,0 +1,49 @@
+from __future__ import annotations
+
+import os
+import tempfile
+import unittest
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+from interfaces.gateway.http_adapter import dispatch_gateway_http_method
+from svc.persistence.assistant_store import reset_assistant_store_singleton
+from svc.persistence.sqlite_store import SqliteStore
+
+
+class GatewayCronServiceTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
+ self.db = Path(self._tmp.name) / "cron.sqlite"
+ os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
+ os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
+ reset_assistant_store_singleton()
+ self.store = SqliteStore(str(self.db))
+ t = self.store.create_tenant("default")
+ self.tenant_id = str(t["id"])
+
+ def tearDown(self) -> None:
+ reset_assistant_store_singleton()
+ self._tmp.cleanup()
+
+ def test_cron_add_and_list(self) -> None:
+ once_at = (datetime.now(timezone.utc) + timedelta(hours=2)).isoformat()
+ add = dispatch_gateway_http_method(
+ "cron.add",
+ {
+ "name": "gw job",
+ "schedule": once_at,
+ "schedule_kind": "once",
+ "tenant_id": self.tenant_id,
+ "prompt": "hello",
+ },
+ )
+ self.assertTrue(add.get("ok"), add)
+ lst = dispatch_gateway_http_method("cron.list", {"tenant_id": self.tenant_id})
+ self.assertTrue(lst.get("ok"), lst)
+ payload = lst.get("payload") or {}
+ self.assertGreaterEqual(len(payload.get("items") or []), 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_schedule_duration_parse.py b/tests/test_schedule_duration_parse.py
new file mode 100644
index 00000000..df3685ca
--- /dev/null
+++ b/tests/test_schedule_duration_parse.py
@@ -0,0 +1,20 @@
+from __future__ import annotations
+
+import unittest
+
+from runtime.application.gateway.inbound_service import _parse_schedule_duration_seconds
+
+
+class ScheduleDurationParseTests(unittest.TestCase):
+ def test_minutes(self) -> None:
+ self.assertEqual(_parse_schedule_duration_seconds("5分钟"), 300)
+
+ def test_hours(self) -> None:
+ self.assertEqual(_parse_schedule_duration_seconds("1小时"), 3600)
+
+ def test_seconds(self) -> None:
+ self.assertEqual(_parse_schedule_duration_seconds("120秒"), 120)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_schedule_tools.py b/tests/test_schedule_tools.py
new file mode 100644
index 00000000..df7c3082
--- /dev/null
+++ b/tests/test_schedule_tools.py
@@ -0,0 +1,59 @@
+from __future__ import annotations
+
+import os
+import tempfile
+import unittest
+from pathlib import Path
+
+from runtime.tools.experts.productivity.schedule_tools import schedule_create_tool, schedule_list_tool
+from svc.persistence.assistant_store import reset_assistant_store_singleton
+from svc.persistence.sqlite_store import SqliteStore
+
+
+class ScheduleToolsTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
+ self.db = Path(self._tmp.name) / "tools.sqlite"
+ os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
+ os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
+ reset_assistant_store_singleton()
+ self.store = SqliteStore(str(self.db))
+ t = self.store.create_tenant("Team")
+ self.tenant_id = str(t["id"])
+ user = self.store.create_user_account(
+ tenant_id=self.tenant_id,
+ username="administrator",
+ display_name="Admin",
+ role="owner",
+ password_hash="x",
+ is_active=True,
+ )
+ self.user_id = str(user["id"])
+
+ def tearDown(self) -> None:
+ reset_assistant_store_singleton()
+ self._tmp.cleanup()
+
+ def test_schedule_create_and_list(self) -> None:
+ create = schedule_create_tool()
+ out = create.handler(
+ {
+ "tenant_id": self.tenant_id,
+ "owner_user_id": self.user_id,
+ "name": "Reminder",
+ "prompt_text": "Check inbox",
+ "schedule_kind": "interval",
+ "schedule_expr": "900",
+ "specialist": "ops",
+ "interaction_mode": "expert",
+ }
+ )
+ self.assertTrue(out.get("ok"), out)
+ lst = schedule_list_tool()
+ listed = lst.handler({"tenant_id": self.tenant_id})
+ self.assertTrue(listed.get("ok"))
+ self.assertEqual(len(listed.get("items") or []), 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_scheduler_ephemeral_context.py b/tests/test_scheduler_ephemeral_context.py
new file mode 100644
index 00000000..9ba71ef1
--- /dev/null
+++ b/tests/test_scheduler_ephemeral_context.py
@@ -0,0 +1,133 @@
+from __future__ import annotations
+
+import os
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import MagicMock
+
+from runtime.scheduler.worker_turn import resolve_scheduled_outbound_text
+from svc.persistence.sqlite_store import SqliteStore
+
+
+class EphemeralUserContextTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
+ self.db = Path(self._tmp.name) / "ctx.sqlite"
+ os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
+ self.store = SqliteStore(str(self.db))
+ sess = self.store.create_session("test")
+ self.session_id = str(sess.id)
+
+ def tearDown(self) -> None:
+ self._tmp.cleanup()
+
+ def test_build_model_context_injects_ephemeral_user_text(self) -> None:
+ from runtime.direct_loop import _build_model_context
+ from svc.llm.chat_models import RuleBasedChatModel
+
+ msgs = _build_model_context(
+ store=self.store,
+ session_id=self.session_id,
+ max_messages=20,
+ system_prompt="sys",
+ model=RuleBasedChatModel(),
+ lang="zh",
+ memory_context=None,
+ trace_id=None,
+ parent_span_id=None,
+ user_text="【定时主动提醒】站起来",
+ active_turn_uuid="turn-sched-1",
+ )
+ roles = [str(m.get("role") or "") for m in msgs]
+ self.assertIn("user", roles)
+ self.assertEqual(str(msgs[-1].get("content") or ""), "【定时主动提醒】站起来")
+ rows = self.store.get_messages(session_id=self.session_id, limit=10)
+ self.assertEqual(len(rows), 0)
+
+
+class ScheduledOutboundFallbackTests(unittest.TestCase):
+ def test_uses_prompt_when_model_reply_empty(self) -> None:
+ text = resolve_scheduled_outbound_text(
+ payload={"prompt_text": "站起来活动一下"},
+ reply_text="",
+ )
+ self.assertIn("站起来活动一下", text)
+ self.assertTrue(text.startswith("⏰"))
+
+ def test_keeps_model_reply_when_present(self) -> None:
+ text = resolve_scheduled_outbound_text(
+ payload={"prompt_text": "站起来活动一下"},
+ reply_text="该起来啦!",
+ )
+ self.assertEqual(text, "该起来啦!")
+
+
+class ScheduledAssistantPersistTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
+ self.db = Path(self._tmp.name) / "persist.sqlite"
+ os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
+ self.store = SqliteStore(str(self.db))
+ sess = self.store.create_session("sched")
+ self.session_id = str(sess.id)
+
+ def tearDown(self) -> None:
+ self._tmp.cleanup()
+
+ def test_finalize_persists_assistant_when_missing(self) -> None:
+ from runtime.scheduler.worker_turn import finalize_scheduled_turn_success
+
+ task = MagicMock()
+ task.task_type = "scheduled_turn"
+ payload = {
+ "tenant_id": "t1",
+ "job_id": "",
+ "run_id_scheduled": "",
+ "session_id": self.session_id,
+ "prompt_text": "休息",
+ "delivery": {"weixin": {"enabled": False}},
+ "resolved_channel": "admin_chat",
+ }
+ with unittest.mock.patch(
+ "runtime.scheduler.channel_delivery.deliver_scheduled_reply",
+ return_value={"ok": True, "skipped": True},
+ ):
+ finalize_scheduled_turn_success(
+ store=self.store,
+ task=task,
+ payload={
+ **payload,
+ "session_id": self.session_id,
+ },
+ base_result={"reply_text": "", "turn_uuid": "tu-1"},
+ )
+ rows = self.store.get_messages(session_id=self.session_id, limit=10)
+ self.assertEqual(len(rows), 1)
+ self.assertEqual(str(rows[0].role), "assistant")
+ self.assertEqual(str(getattr(rows[0], "event_type", "") or ""), "assistant_text")
+ self.assertIn("休息", str(rows[0].content or ""))
+
+ def test_updates_empty_assistant_for_same_turn(self) -> None:
+ from runtime.scheduler.worker_turn import _persist_scheduled_assistant_reply
+
+ self.store.add_message(
+ session_id=self.session_id,
+ role="assistant",
+ content="",
+ turn_uuid="tu-empty",
+ event_type="assistant_text",
+ )
+ _persist_scheduled_assistant_reply(
+ self.store,
+ session_id=self.session_id,
+ turn_uuid="tu-empty",
+ reply_text="⏰ 提醒:站起来",
+ )
+ rows = self.store.get_messages(session_id=self.session_id, limit=10)
+ self.assertEqual(len(rows), 1)
+ self.assertIn("站起来", str(rows[0].content or ""))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_scheduler_expressions.py b/tests/test_scheduler_expressions.py
new file mode 100644
index 00000000..75569174
--- /dev/null
+++ b/tests/test_scheduler_expressions.py
@@ -0,0 +1,130 @@
+from __future__ import annotations
+
+import os
+import tempfile
+import unittest
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+from runtime.scheduler.expressions import compute_next_run_at, normalize_schedule_kind
+from svc.persistence.sqlite_store import SqliteStore
+
+
+class SchedulerExpressionsTests(unittest.TestCase):
+ def test_normalize_schedule_kind(self) -> None:
+ self.assertEqual(normalize_schedule_kind("CRON"), "cron")
+ self.assertEqual(normalize_schedule_kind("bad"), "cron")
+
+ def test_interval_next_run(self) -> None:
+ base = datetime(2026, 6, 26, 10, 0, 0, tzinfo=timezone.utc)
+ nxt = compute_next_run_at(
+ schedule_kind="interval",
+ schedule_expr="120",
+ timezone_name="Asia/Shanghai",
+ from_dt=base,
+ )
+ self.assertEqual(nxt, (base + timedelta(seconds=120)).isoformat())
+
+ def test_once_future(self) -> None:
+ future = datetime.now(timezone.utc) + timedelta(hours=2)
+ nxt = compute_next_run_at(
+ schedule_kind="once",
+ schedule_expr=future.isoformat(),
+ timezone_name="Asia/Shanghai",
+ )
+ self.assertIsNotNone(nxt)
+
+
+class ScheduledJobStoreTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
+ self.db = Path(self._tmp.name) / "sched.sqlite"
+ os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
+ self.store = SqliteStore(str(self.db))
+ t = self.store.create_tenant("Team")
+ self.tenant_id = str(t["id"])
+ user = self.store.create_user_account(
+ tenant_id=self.tenant_id,
+ username="administrator",
+ display_name="Admin",
+ role="owner",
+ password_hash="x",
+ is_active=True,
+ )
+ self.user_id = str(user["id"])
+
+ def tearDown(self) -> None:
+ self._tmp.cleanup()
+
+ def test_row_to_job_dict_row(self) -> None:
+ from svc.persistence.scheduled_job_store import _row_to_job
+
+ row = {
+ "id": "j1",
+ "tenant_id": self.tenant_id,
+ "name": "Test",
+ "description": "",
+ "status": "active",
+ "schedule_kind": "interval",
+ "schedule_expr": "300",
+ "timezone": "Asia/Shanghai",
+ "prompt_text": "ping",
+ "interaction_mode": "expert",
+ "specialist": "generalist",
+ "lang": "zh",
+ "delivery_json": "{}",
+ "source_session_id": None,
+ "created_by_user_id": self.user_id,
+ "source": "chat",
+ "next_run_at": "2026-06-26T10:00:00+00:00",
+ "last_run_at": None,
+ "last_run_status": "",
+ "created_at": "2026-06-26T09:00:00+00:00",
+ "updated_at": "2026-06-26T09:00:00+00:00",
+ }
+ job = _row_to_job(row)
+ self.assertEqual(job.id, "j1")
+ self.assertEqual(job.name, "Test")
+ job = self.store.scheduled_job_create(
+ tenant_id=self.tenant_id,
+ name="Daily report",
+ prompt_text="Summarize alarms",
+ schedule_kind="interval",
+ schedule_expr="3600",
+ created_by_user_id=self.user_id,
+ )
+ self.assertEqual(job.status, "active")
+ self.assertTrue(job.next_run_at)
+ rows = self.store.scheduled_job_list(tenant_id=self.tenant_id)
+ self.assertEqual(len(rows), 1)
+ ok = self.store.scheduled_job_set_status(
+ tenant_id=self.tenant_id,
+ job_id=job.id,
+ status="paused",
+ )
+ self.assertTrue(ok)
+ due = self.store.scheduled_job_list_due(limit=10)
+ self.assertEqual(due, [])
+
+ def test_run_record(self) -> None:
+ job = self.store.scheduled_job_create(
+ tenant_id=self.tenant_id,
+ name="Once",
+ prompt_text="Ping",
+ schedule_kind="once",
+ schedule_expr=(datetime.now(timezone.utc) + timedelta(minutes=5)).isoformat(),
+ created_by_user_id=self.user_id,
+ )
+ run = self.store.scheduled_job_run_create(job_id=job.id, tenant_id=self.tenant_id)
+ self.assertEqual(run.status, "queued")
+ updated = self.store.scheduled_job_run_update(
+ run_id=run.id,
+ tenant_id=self.tenant_id,
+ patch={"status": "success", "reply_text": "ok"},
+ )
+ self.assertIsNotNone(updated)
+ self.assertEqual(str(updated.reply_text), "ok")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_scheduler_proactive_delivery.py b/tests/test_scheduler_proactive_delivery.py
new file mode 100644
index 00000000..1318580b
--- /dev/null
+++ b/tests/test_scheduler_proactive_delivery.py
@@ -0,0 +1,180 @@
+from __future__ import annotations
+
+import os
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+from runtime.scheduler.channel_delivery import deliver_scheduled_reply
+from runtime.scheduler.cron_service import build_delivery_for_session
+from runtime.scheduler.session_resolver import resolve_scheduled_session
+from runtime.scheduler.turn_text import build_scheduled_turn_instruction
+from svc.persistence.sqlite_store import SqliteStore
+
+
+class ScheduledTurnTextTests(unittest.TestCase):
+ def test_instruction_is_internal_not_user_facing(self) -> None:
+ text = build_scheduled_turn_instruction(
+ prompt_text="站起来活动一下",
+ mode="scheduled",
+ lang="zh",
+ )
+ self.assertIn("提醒意图", text)
+ self.assertNotIn("[scheduled:", text)
+
+
+class ScheduledSessionResolverTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
+ self.db = Path(self._tmp.name) / "sched.sqlite"
+ os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
+ self.store = SqliteStore(str(self.db))
+ t = self.store.create_tenant("Team")
+ self.tenant_id = str(t["id"])
+ admin = self.store.create_user_account(
+ tenant_id=self.tenant_id,
+ username="administrator",
+ display_name="Admin",
+ role="owner",
+ password_hash="x",
+ is_active=True,
+ )
+ self.admin_id = str(admin["id"])
+ sess = self.store.create_session("WeChat session")
+ self.session_id = str(sess.id)
+ self.store.ensure_ui_session_owner(
+ session_id=self.session_id,
+ tenant_id=self.tenant_id,
+ user_id=self.admin_id,
+ )
+ with self.store._connect() as conn:
+ conn.execute(
+ """
+ INSERT INTO channel_session_v2
+ (tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
+ """,
+ (self.tenant_id, "weixin", "weixin-default", "wx-user-123", "wx-user-123", self.session_id),
+ )
+
+ def tearDown(self) -> None:
+ self._tmp.cleanup()
+
+ def test_source_session_preserves_weixin_delivery_target(self) -> None:
+ job = MagicMock()
+ job.tenant_id = self.tenant_id
+ job.delivery_json = '{"weixin":{"enabled":true}}'
+ job.source_session_id = self.session_id
+ job.name = "rest"
+ resolved = resolve_scheduled_session(store=self.store, job=job, created_by_user_id=self.admin_id)
+ self.assertEqual(resolved.channel, "weixin")
+ self.assertEqual(resolved.external_chat_id, "wx-user-123")
+
+
+class ScheduledDeliveryChannelTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
+ self.db = Path(self._tmp.name) / "sched.sqlite"
+ os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
+ self.store = SqliteStore(str(self.db))
+ t = self.store.create_tenant("Team")
+ self.tenant_id = str(t["id"])
+
+ def tearDown(self) -> None:
+ self._tmp.cleanup()
+
+ def test_whatsapp_session_disables_weixin_delivery(self) -> None:
+ sess = self.store.create_session("WA session")
+ session_id = str(sess.id)
+ with self.store._connect() as conn:
+ conn.execute(
+ """
+ INSERT INTO channel_session_v2
+ (tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
+ """,
+ (self.tenant_id, "whatsapp", "wa-default", "15551234567@s.whatsapp.net", "15551234567", session_id),
+ )
+ delivery = build_delivery_for_session(
+ self.store,
+ tenant_id=self.tenant_id,
+ session_id=session_id,
+ )
+ self.assertTrue(delivery["whatsapp"]["enabled"])
+ self.assertEqual(delivery["whatsapp"]["chat_id"], "15551234567@s.whatsapp.net")
+ self.assertFalse(delivery["weixin"]["enabled"])
+
+ def test_weixin_session_disables_whatsapp_delivery(self) -> None:
+ sess = self.store.create_session("WX session")
+ session_id = str(sess.id)
+ with self.store._connect() as conn:
+ conn.execute(
+ """
+ INSERT INTO channel_session_v2
+ (tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
+ """,
+ (self.tenant_id, "weixin", "weixin-default", "wx-user-9", "wx-user-9", session_id),
+ )
+ delivery = build_delivery_for_session(
+ self.store,
+ tenant_id=self.tenant_id,
+ session_id=session_id,
+ )
+ self.assertTrue(delivery["weixin"]["enabled"])
+ self.assertFalse(delivery["whatsapp"]["enabled"])
+
+
+class ScheduledDeliveryTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
+ self.db = Path(self._tmp.name) / "sched.sqlite"
+ os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
+ self.store = SqliteStore(str(self.db))
+ t = self.store.create_tenant("Team")
+ self.tenant_id = str(t["id"])
+
+ def tearDown(self) -> None:
+ self._tmp.cleanup()
+
+ @patch("runtime.scheduler.channel_delivery.enqueue_weixin_reply")
+ def test_deliver_uses_stored_context_token(self, mock_enqueue: MagicMock) -> None:
+ mock_enqueue.return_value = {"ok": True, "channel": "wechat", "queued": True}
+ self.store.set_channel_context_token(
+ tenant_id=self.tenant_id,
+ channel="wechat",
+ account_id="real-acct",
+ external_chat_id="wx-user-123",
+ context_token="ctx-abc",
+ )
+ sess = self.store.create_session("wx-deliver")
+ session_id = str(sess.id)
+ with self.store._connect() as conn:
+ conn.execute(
+ """
+ INSERT INTO channel_session_v2
+ (tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
+ """,
+ (self.tenant_id, "wechat", "real-acct", "wx-user-123", "wx-user-123", session_id),
+ )
+ out = deliver_scheduled_reply(
+ self.store,
+ tenant_id=self.tenant_id,
+ reply_text="该起来活动啦",
+ delivery_json='{"weixin":{"enabled":true}}',
+ resolved_channel="wechat",
+ resolved_chat_id="wx-user-123",
+ resolved_account_id="weixin-default",
+ session_id=session_id,
+ )
+ self.assertTrue(out.get("ok"))
+ mock_enqueue.assert_called_once()
+ kwargs = mock_enqueue.call_args.kwargs
+ self.assertEqual(kwargs.get("context_token"), "ctx-abc")
+ self.assertEqual(kwargs.get("chat_id"), "wx-user-123")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_scheduler_viewer_username.py b/tests/test_scheduler_viewer_username.py
new file mode 100644
index 00000000..d46488c2
--- /dev/null
+++ b/tests/test_scheduler_viewer_username.py
@@ -0,0 +1,116 @@
+from __future__ import annotations
+
+import os
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import MagicMock
+
+from runtime.scheduler.session_resolver import resolve_scheduled_viewer_username
+from runtime.scheduler.service import enqueue_scheduled_job_run
+from svc.persistence.sqlite_store import SqliteStore
+
+
+class ResolveScheduledViewerUsernameTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
+ self.db = Path(self._tmp.name) / "sched.sqlite"
+ os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
+ self.store = SqliteStore(str(self.db))
+ t = self.store.create_tenant("Team")
+ self.tenant_id = str(t["id"])
+ self.admin = self.store.create_user_account(
+ tenant_id=self.tenant_id,
+ username="administrator",
+ display_name="Admin",
+ role="owner",
+ password_hash="x",
+ is_active=True,
+ )
+ self.member = self.store.create_user_account(
+ tenant_id=self.tenant_id,
+ username="alice",
+ display_name="Alice",
+ role="member",
+ password_hash="x",
+ is_active=True,
+ )
+
+ def tearDown(self) -> None:
+ self._tmp.cleanup()
+
+ def test_weixin_uses_administrator_pool(self) -> None:
+ uname = resolve_scheduled_viewer_username(
+ self.store,
+ tenant_id=self.tenant_id,
+ user_id=str(self.member["id"]),
+ channel="weixin",
+ )
+ self.assertEqual(uname, "administrator")
+
+ def test_admin_chat_uses_job_owner_username(self) -> None:
+ uname = resolve_scheduled_viewer_username(
+ self.store,
+ tenant_id=self.tenant_id,
+ user_id=str(self.member["id"]),
+ channel="admin_chat",
+ )
+ self.assertEqual(uname, "alice")
+
+ def test_enqueue_payload_includes_viewer_username_for_weixin(self) -> None:
+ job = MagicMock()
+ job.tenant_id = self.tenant_id
+ job.id = "job-weixin"
+ job.next_run_at = "2026-06-26T10:00:00+00:00"
+ job.delivery_json = '{"weixin":{"enabled":true}}'
+ job.prompt_text = "stretch"
+ job.lang = "zh"
+ job.interaction_mode = "expert"
+ job.specialist = "generalist"
+ job.created_by_user_id = str(self.member["id"])
+ job.schedule_kind = "interval"
+
+ run = MagicMock()
+ run.id = "run-1"
+ self.store.scheduled_job_run_create = MagicMock(return_value=run) # type: ignore[method-assign]
+ self.store.scheduled_job_run_update = MagicMock() # type: ignore[method-assign]
+ self.store.scheduled_job_reserve_next_run = MagicMock() # type: ignore[method-assign]
+ captured: dict[str, object] = {}
+
+ def _capture_create(**kwargs: object) -> MagicMock:
+ captured.update(kwargs)
+ return MagicMock(id="task-1")
+
+ self.store.oclaw_task_create = _capture_create # type: ignore[method-assign]
+
+ from runtime.scheduler import service as sched_service
+ from runtime.scheduler.session_resolver import ResolvedSession
+
+ resolved = ResolvedSession(
+ session_id="sess-1",
+ tenant_id=self.tenant_id,
+ user_id=str(self.admin["id"]),
+ channel="weixin",
+ account_id="weixin-default",
+ external_chat_id="wx-user-1",
+ external_user_id="wx-user-1",
+ is_group=False,
+ )
+ original_resolve = sched_service.resolve_scheduled_session
+ original_worker = sched_service.ensure_worker_started
+ try:
+ sched_service.resolve_scheduled_session = MagicMock(return_value=resolved)
+ sched_service.ensure_worker_started = MagicMock(return_value="worker-1")
+ enqueue_scheduled_job_run(self.store, job=job, mode="scheduled")
+ finally:
+ sched_service.resolve_scheduled_session = original_resolve
+ sched_service.ensure_worker_started = original_worker
+
+ payload = captured.get("payload")
+ self.assertIsInstance(payload, dict)
+ assert isinstance(payload, dict)
+ self.assertEqual(payload.get("viewer_username"), "administrator")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_tool_context_inject.py b/tests/test_tool_context_inject.py
new file mode 100644
index 00000000..474ae5b0
--- /dev/null
+++ b/tests/test_tool_context_inject.py
@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+import os
+import tempfile
+import unittest
+from pathlib import Path
+
+from runtime.tools.context_inject import enrich_tool_arguments
+from runtime.tools.experts.productivity.schedule_tools import schedule_create_tool
+from svc.persistence.assistant_store import reset_assistant_store_singleton
+from svc.persistence.sqlite_store import SqliteStore
+
+
+class ToolContextInjectTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
+ self.db = Path(self._tmp.name) / "ctx.sqlite"
+ os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
+ os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
+ reset_assistant_store_singleton()
+ self.store = SqliteStore(str(self.db))
+ t = self.store.create_tenant("Team")
+ self.tenant_id = str(t["id"])
+ user = self.store.create_user_account(
+ tenant_id=self.tenant_id,
+ username="administrator",
+ display_name="Admin",
+ role="owner",
+ password_hash="x",
+ is_active=True,
+ )
+ self.user_id = str(user["id"])
+ sess = self.store.create_session_for_user(
+ title="wx",
+ tenant_id=self.tenant_id,
+ user_id=self.user_id,
+ )
+ self.session_id = str(sess.id)
+ self.store.ensure_ui_session_owner(
+ session_id=self.session_id,
+ tenant_id=self.tenant_id,
+ user_id=self.user_id,
+ )
+
+ def tearDown(self) -> None:
+ reset_assistant_store_singleton()
+ self._tmp.cleanup()
+
+ def test_enrich_from_session_owner(self) -> None:
+ out = enrich_tool_arguments(
+ store=self.store,
+ session_id=self.session_id,
+ tool_name="schedule_create",
+ arguments={"name": "x"},
+ )
+ self.assertEqual(out.get("tenant_id"), self.tenant_id)
+ self.assertEqual(out.get("owner_user_id"), self.user_id)
+ self.assertEqual(out.get("session_id"), self.session_id)
+
+ def test_schedule_create_strips_llm_user_id(self) -> None:
+ tool = schedule_create_tool()
+ res = tool.handler(
+ {
+ "session_id": self.session_id,
+ "user_id": "wx_fake_external_id",
+ "name": "Rest",
+ "prompt_text": "提醒休息",
+ "schedule_kind": "interval",
+ "schedule_expr": "300",
+ }
+ )
+ self.assertTrue(res.get("ok"), res)
+
+ def test_tool_runtime_filters_user_id_for_schedule_schema(self) -> None:
+ from runtime.tools.tool_validation import filter_arguments_to_schema
+
+ tool = schedule_create_tool()
+ filtered = filter_arguments_to_schema(
+ tool.parameters,
+ {
+ "user_id": "bad",
+ "name": "x",
+ "prompt_text": "y",
+ "schedule_kind": "interval",
+ "schedule_expr": "60",
+ },
+ )
+ self.assertNotIn("user_id", filtered)
+ self.assertIn("name", filtered)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_tool_runtime_schedule_validation.py b/tests/test_tool_runtime_schedule_validation.py
new file mode 100644
index 00000000..541a5b20
--- /dev/null
+++ b/tests/test_tool_runtime_schedule_validation.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+import os
+import tempfile
+import unittest
+from pathlib import Path
+
+from runtime.chat.tool_runtime import ToolExecutionContext, ToolExecutor
+from runtime.tools.experts.productivity.schedule_tools import schedule_create_tool
+from runtime.tools.base import ToolRegistry
+from svc.llm.transports.base import LLMToolCall
+from svc.persistence.assistant_store import reset_assistant_store_singleton
+from svc.persistence.sqlite_store import SqliteStore
+
+
+class ToolRuntimeScheduleValidationTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
+ self.db = Path(self._tmp.name) / "rt.sqlite"
+ os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
+ os.environ["AIA_ASSISTANT_DB_BACKEND"] = "sqlite"
+ reset_assistant_store_singleton()
+ self.store = SqliteStore(str(self.db))
+ t = self.store.create_tenant("Team")
+ self.tenant_id = str(t["id"])
+ user = self.store.create_user_account(
+ tenant_id=self.tenant_id,
+ username="administrator",
+ display_name="Admin",
+ role="owner",
+ password_hash="x",
+ is_active=True,
+ )
+ self.user_id = str(user["id"])
+ sess = self.store.create_session_for_user(
+ title="wx",
+ tenant_id=self.tenant_id,
+ user_id=self.user_id,
+ )
+ self.session_id = str(sess.id)
+ self.store.ensure_ui_session_owner(
+ session_id=self.session_id,
+ tenant_id=self.tenant_id,
+ user_id=self.user_id,
+ )
+
+ def tearDown(self) -> None:
+ reset_assistant_store_singleton()
+ self._tmp.cleanup()
+
+ def test_execute_schedule_create_with_hallucinated_user_id(self) -> None:
+ reg = ToolRegistry([schedule_create_tool()])
+ ex = ToolExecutor()
+ ctx = ToolExecutionContext(
+ store=self.store,
+ tools=reg,
+ session_id=self.session_id,
+ path_policy_tenant_id=self.tenant_id,
+ path_policy_user_id=self.user_id,
+ )
+ tc = LLMToolCall(
+ id="call1",
+ name="schedule_create",
+ arguments={
+ "user_id": "wechat_external_123",
+ "name": "Break",
+ "prompt_text": "Stand up",
+ "schedule_kind": "interval",
+ "schedule_expr": "300",
+ },
+ )
+ result, _dur = ex._execute_tool(ctx, tc)
+ self.assertTrue(result.get("ok"), result)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_weixin_scheduled_delivery.py b/tests/test_weixin_scheduled_delivery.py
new file mode 100644
index 00000000..12bc846c
--- /dev/null
+++ b/tests/test_weixin_scheduled_delivery.py
@@ -0,0 +1,148 @@
+from __future__ import annotations
+
+import os
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+from runtime.scheduler.channel_delivery import enqueue_weixin_reply
+from runtime.scheduler.weixin_delivery import resolve_weixin_delivery_target
+from svc.persistence.sqlite_store import SqliteStore
+
+
+class WeixinBridgePollTests(unittest.TestCase):
+ def test_poll_accepts_wechat_channel_alias(self) -> None:
+ from interfaces.http.weixin_ilink_api import _BRIDGE
+
+ _BRIDGE._events.clear()
+ _BRIDGE._seq = 0
+ _BRIDGE.enqueue_reply(
+ token="",
+ channel="weixin",
+ account_id="acct-1",
+ chat_id="user-1",
+ text="hello",
+ context_token="ctx-1",
+ )
+ msgs, _ = _BRIDGE.poll(token="sidecar-token", cursor=0, channel="wechat", account_id="acct-1")
+ self.assertEqual(len(msgs), 1)
+ self.assertEqual(str(msgs[0].get("text") or ""), "hello")
+
+ def test_poll_accepts_legacy_default_account_id(self) -> None:
+ from interfaces.http.weixin_ilink_api import _BRIDGE
+
+ _BRIDGE._events.clear()
+ _BRIDGE._seq = 0
+ _BRIDGE.enqueue_reply(
+ token="",
+ channel="wechat",
+ account_id="weixin-default",
+ chat_id="user-1",
+ text="hello",
+ context_token="ctx-1",
+ )
+ msgs, _ = _BRIDGE.poll(token="sidecar-token", cursor=0, channel="wechat", account_id="real-acct")
+ self.assertEqual(len(msgs), 1)
+
+
+class WeixinDeliveryTargetTests(unittest.TestCase):
+ def setUp(self) -> None:
+ self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
+ self.db = Path(self._tmp.name) / "wx.sqlite"
+ os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
+ self.store = SqliteStore(str(self.db))
+ t = self.store.create_tenant("Team")
+ self.tenant_id = str(t["id"])
+ sess = self.store.create_session("wx")
+ self.session_id = str(sess.id)
+ with self.store._connect() as conn:
+ conn.execute(
+ """
+ INSERT INTO channel_session_v2
+ (tenant_id, channel, account_id, external_chat_id, external_user_id, session_id, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
+ """,
+ (self.tenant_id, "wechat", "real-acct-9", "wx-user-9", "wx-user-9", self.session_id),
+ )
+ self.store.set_channel_context_token(
+ tenant_id=self.tenant_id,
+ channel="wechat",
+ account_id="real-acct-9",
+ external_chat_id="wx-user-9",
+ context_token="ctx-xyz",
+ )
+
+ def tearDown(self) -> None:
+ self._tmp.cleanup()
+
+ def test_resolve_account_and_token_from_session(self) -> None:
+ target = resolve_weixin_delivery_target(
+ self.store,
+ tenant_id=self.tenant_id,
+ session_id=self.session_id,
+ delivery={"weixin": {"enabled": True}},
+ resolved_channel="wechat",
+ resolved_chat_id="",
+ resolved_account_id="weixin-default",
+ )
+ self.assertEqual(target["account_id"], "real-acct-9")
+ self.assertEqual(target["chat_id"], "wx-user-9")
+ self.assertEqual(target["context_token"], "ctx-xyz")
+
+
+class EnqueueWeixinReplyTests(unittest.TestCase):
+ @patch("interfaces.http.weixin_ilink_api.enqueue_weixin_outbound_reply", return_value="7")
+ def test_requires_context_token(self, _mock_enqueue: MagicMock) -> None:
+ out = enqueue_weixin_reply(
+ channel="wechat",
+ account_id="acct",
+ chat_id="user",
+ text="hi",
+ context_token="",
+ )
+ self.assertFalse(out.get("ok"))
+ self.assertEqual(out.get("error"), "context_token_missing")
+ _mock_enqueue.assert_not_called()
+
+ @patch("interfaces.http.weixin_ilink_api.enqueue_weixin_outbound_reply", return_value="8")
+ def test_queues_with_context_token(self, mock_enqueue: MagicMock) -> None:
+ out = enqueue_weixin_reply(
+ channel="wechat",
+ account_id="acct",
+ chat_id="user",
+ text="hi",
+ context_token="ctx-1",
+ )
+ self.assertTrue(out.get("ok"))
+ self.assertTrue(out.get("queued"))
+ mock_enqueue.assert_called_once()
+
+ @patch("interfaces.http.weixin_ilink_api.enqueue_weixin_outbound_reply", return_value="9")
+ def test_persists_durable_outbound_when_store_available(self, mock_enqueue: MagicMock) -> None:
+ tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
+ db = Path(tmp.name) / "wx.sqlite"
+ os.environ["OPS_ASSISTANT_DB_PATH"] = str(db)
+ store = SqliteStore(str(db))
+ try:
+ out = enqueue_weixin_reply(
+ channel="wechat",
+ account_id="acct",
+ chat_id="user",
+ text="hi",
+ context_token="ctx-1",
+ store=store,
+ tenant_id="t1",
+ )
+ self.assertTrue(out.get("ok"))
+ self.assertTrue(out.get("durable"))
+ pending = store.list_pending_weixin_outbound_messages(account_id="acct", limit=10)
+ self.assertEqual(len(pending), 1)
+ self.assertEqual(str(pending[0].get("context_token") or ""), "ctx-1")
+ mock_enqueue.assert_called_once()
+ finally:
+ tmp.cleanup()
+
+
+if __name__ == "__main__":
+ unittest.main()