diff --git a/api/profiles.py b/api/profiles.py index 11511da3..b56868a1 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -258,6 +258,24 @@ class cron_profile_context_for_home: _cj.OUTPUT_DIR = _cj.CRON_DIR / 'output' except (ImportError, AttributeError): logger.debug("cron_profile_context_for_home: cron.jobs unavailable") + + # cron.scheduler snapshots _hermes_home at import time and run_job() + # reads config/.env from that module global. Patch it alongside + # cron.jobs so manual WebUI runs actually execute under the selected + # profile, not merely write output metadata there (#617). + self._prev_cs = None + try: + import cron.scheduler as _cs + self._prev_cs = ( + getattr(_cs, '_hermes_home', None), + getattr(_cs, '_LOCK_DIR', None), + getattr(_cs, '_LOCK_FILE', None), + ) + _cs._hermes_home = self._home + _cs._LOCK_DIR = self._home / 'cron' + _cs._LOCK_FILE = _cs._LOCK_DIR / '.tick.lock' + except (ImportError, AttributeError): + logger.debug("cron_profile_context_for_home: cron.scheduler unavailable") except Exception: _cron_env_lock.release() raise @@ -275,6 +293,12 @@ class cron_profile_context_for_home: _cj.HERMES_DIR, _cj.CRON_DIR, _cj.JOBS_FILE, _cj.OUTPUT_DIR = self._prev_cj except (ImportError, AttributeError): pass + if getattr(self, '_prev_cs', None) is not None: + try: + import cron.scheduler as _cs + _cs._hermes_home, _cs._LOCK_DIR, _cs._LOCK_FILE = self._prev_cs + except (ImportError, AttributeError): + pass finally: _cron_env_lock.release() return False @@ -313,6 +337,20 @@ class cron_profile_context: _cj.OUTPUT_DIR = _cj.CRON_DIR / 'output' except (ImportError, AttributeError): logger.debug("cron_profile_context: cron.jobs unavailable; env-var only") + + self._prev_cs = None + try: + import cron.scheduler as _cs + self._prev_cs = ( + getattr(_cs, '_hermes_home', None), + getattr(_cs, '_LOCK_DIR', None), + getattr(_cs, '_LOCK_FILE', None), + ) + _cs._hermes_home = home + _cs._LOCK_DIR = home / 'cron' + _cs._LOCK_FILE = _cs._LOCK_DIR / '.tick.lock' + except (ImportError, AttributeError): + logger.debug("cron_profile_context: cron.scheduler unavailable; env-var only") except Exception: _cron_env_lock.release() raise @@ -333,6 +371,12 @@ class cron_profile_context: _cj.HERMES_DIR, _cj.CRON_DIR, _cj.JOBS_FILE, _cj.OUTPUT_DIR = self._prev_cj except (ImportError, AttributeError): pass + if getattr(self, '_prev_cs', None) is not None: + try: + import cron.scheduler as _cs + _cs._hermes_home, _cs._LOCK_DIR, _cs._LOCK_FILE = self._prev_cs + except (ImportError, AttributeError): + pass finally: _cron_env_lock.release() return False @@ -462,6 +506,14 @@ def _set_hermes_home(home: Path): except (ImportError, AttributeError): logger.debug("Failed to patch cron.jobs module") + try: + import cron.scheduler as _cs + _cs._hermes_home = home + _cs._LOCK_DIR = home / 'cron' + _cs._LOCK_FILE = _cs._LOCK_DIR / '.tick.lock' + except (ImportError, AttributeError): + logger.debug("Failed to patch cron.scheduler module") + def _reload_dotenv(home: Path): """Load .env from the profile dir into os.environ with profile isolation. diff --git a/api/routes.py b/api/routes.py index 5ae91e30..7f278512 100644 --- a/api/routes.py +++ b/api/routes.py @@ -245,52 +245,119 @@ def _cron_output_content_window(text: str, limit: int = _CRON_OUTPUT_CONTENT_LIM return text[-limit:] -def _run_cron_tracked(job, profile_home=None): + + +def _cron_job_for_api(job: dict) -> dict: + """Return a cron job payload with the #617 optional profile field present. + + Legacy jobs intentionally persist without ``profile`` so they keep the + scheduler's server-default behavior. The API still returns ``profile: None`` + so the UI can label that state explicitly instead of guessing. + """ + payload = dict(job or {}) + payload.setdefault("profile", None) + return payload + + +def _cron_jobs_for_api(jobs) -> list[dict]: + return [_cron_job_for_api(job) for job in (jobs or [])] + + +def _available_cron_profile_names() -> set[str]: + from api.profiles import list_profiles_api + + names = {"default"} + for profile in list_profiles_api(): + try: + name = str(profile.get("name") or "").strip() + except AttributeError: + continue + if name: + names.add(name) + return names + + +def _normalize_cron_profile_value(value) -> str | None: + if value is None: + return None + profile = str(value).strip() + if not profile: + return None + if profile not in _available_cron_profile_names(): + raise ValueError(f"Unknown profile: {profile}") + return profile + + +def _profile_home_for_cron_job(job: dict): + """Resolve the execution profile for a cron job, with graceful fallback. + + A missing/blank profile preserves legacy server-default behavior. If a job + points at a profile that was deleted after save, fall back to the active + server profile and log a warning instead of crashing the Run Now path. + """ + from api.profiles import get_active_hermes_home, get_hermes_home_for_profile + + raw = str((job or {}).get("profile") or "").strip() + if not raw: + return get_active_hermes_home() + if raw not in _available_cron_profile_names(): + logger.warning( + "Cron job %s references missing profile %r; falling back to server default", + (job or {}).get("id", "?"), raw, + ) + return get_active_hermes_home() + return get_hermes_home_for_profile(raw) + + +def _run_cron_tracked(job, profile_home=None, execution_profile_home=None): """Wrapper that tracks running state around cron.scheduler.run_job. - ``profile_home`` pins HERMES_HOME for this worker thread so output files - and run metadata land in the profile that triggered the run, not the - process-global default. Captured at dispatch time because the thread runs - after the HTTP request (and its TLS profile) has already been cleared. + ``profile_home`` is the cron store that owns the job row/output metadata. + ``execution_profile_home`` is the selected per-job profile used to load + agent config/.env while running. When no job profile is selected, both homes + are the same and legacy server-default behavior is preserved. """ from cron.scheduler import run_job # import here — runs inside a worker thread from cron.jobs import mark_job_run, save_job_output job_id = job.get("id", "") + execution_profile_home = execution_profile_home or profile_home - # Pin HERMES_HOME for the duration of this thread using a dedicated - # context manager variant that accepts the profile home directly - # (threads have no TLS, so get_active_hermes_home() can't resolve). - ctx = None - if profile_home is not None: + def _with_cron_home(home, fn): + if home is None: + return fn() from api.profiles import cron_profile_context_for_home - ctx = cron_profile_context_for_home(profile_home) - ctx.__enter__() + with cron_profile_context_for_home(home): + return fn() try: - success, output, final_response, error = run_job(job) - save_job_output(job_id, output) + success, output, final_response, error = _with_cron_home( + execution_profile_home, lambda: run_job(job) + ) - # Match the scheduled cron path: an apparently successful run with no - # final response should not leave the job looking healthy. - if success and not final_response: - success = False - error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)" + # Persist output and run metadata back to the job's owning cron store, + # even when the selected execution profile is different. + def _persist_success(): + save_job_output(job_id, output) - mark_job_run(job_id, success, error) + # Match the scheduled cron path: an apparently successful run with no + # final response should not leave the job looking healthy. + _success, _error = success, error + if _success and not final_response: + _success = False + _error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)" + + mark_job_run(job_id, _success, _error) + + _with_cron_home(profile_home, _persist_success) except Exception as e: logger.exception("Manual cron run failed for job %s", job_id) try: - mark_job_run(job_id, False, str(e)) + _with_cron_home(profile_home, lambda: mark_job_run(job_id, False, str(e))) except Exception: logger.debug("Failed to mark manual cron run failure for %s", job_id) finally: - if ctx is not None: - try: - ctx.__exit__(None, None, None) - except Exception: - logger.debug("Failed to release cron_profile_context for %s", job_id) _mark_cron_done(job_id) _PROVIDER_ALIASES = { @@ -2612,7 +2679,7 @@ def handle_get(handler, parsed) -> bool: from api.profiles import cron_profile_context with cron_profile_context(): - return j(handler, {"jobs": list_jobs(include_disabled=True)}) + return j(handler, {"jobs": _cron_jobs_for_api(list_jobs(include_disabled=True))}) if parsed.path == "/api/crons/output": from api.profiles import cron_profile_context @@ -5729,8 +5796,9 @@ def _handle_cron_create(handler, body): except ValueError as e: return bad(handler, str(e)) try: - from cron.jobs import create_job + from cron.jobs import create_job, update_job + profile = _normalize_cron_profile_value(body.get("profile")) job = create_job( prompt=body["prompt"], schedule=body["schedule"], @@ -5739,7 +5807,9 @@ def _handle_cron_create(handler, body): skills=body.get("skills") or [], model=body.get("model") or None, ) - return j(handler, {"ok": True, "job": job}) + if profile is not None: + job = update_job(job["id"], {"profile": profile}) or job + return j(handler, {"ok": True, "job": _cron_job_for_api(job)}) except Exception as e: return j(handler, {"error": str(e)}, status=400) @@ -5751,11 +5821,21 @@ def _handle_cron_update(handler, body): return bad(handler, str(e)) from cron.jobs import update_job - updates = {k: v for k, v in body.items() if k != "job_id" and v is not None} + try: + updates = {} + for k, v in body.items(): + if k == "job_id": + continue + if k == "profile": + updates[k] = _normalize_cron_profile_value(v) + elif v is not None: + updates[k] = v + except ValueError as e: + return bad(handler, str(e)) job = update_job(body["job_id"], updates) if not job: return bad(handler, "Job not found", 404) - return j(handler, {"ok": True, "job": job}) + return j(handler, {"ok": True, "job": _cron_job_for_api(job)}) def _handle_cron_delete(handler, body): @@ -5801,7 +5881,8 @@ def _handle_cron_run(handler, body): from api.profiles import get_active_hermes_home _profile_home = get_active_hermes_home() - threading.Thread(target=_run_cron_tracked, args=(job, _profile_home), daemon=True).start() + _execution_profile_home = _profile_home_for_cron_job(job) + threading.Thread(target=_run_cron_tracked, args=(job, _profile_home, _execution_profile_home), daemon=True).start() return j(handler, {"ok": True, "job_id": job_id, "status": "running"}) diff --git a/docs/pr-media/617/task-profile-badges.png b/docs/pr-media/617/task-profile-badges.png new file mode 100644 index 00000000..ae54288c Binary files /dev/null and b/docs/pr-media/617/task-profile-badges.png differ diff --git a/docs/pr-media/617/task-profile-selector.png b/docs/pr-media/617/task-profile-selector.png new file mode 100644 index 00000000..81f067e2 Binary files /dev/null and b/docs/pr-media/617/task-profile-selector.png differ diff --git a/static/i18n.js b/static/i18n.js index a67a924c..777e575f 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -904,6 +904,9 @@ const LOCALES = { cron_prompt_label: 'Prompt', cron_deliver_label: 'Deliver output to', cron_deliver_local: 'Local (save output only)', + cron_profile_label: 'Profile', + cron_profile_server_default: 'server default', + cron_profile_server_default_hint: 'Uses the WebUI server default profile at run time. Existing jobs without a profile keep this legacy behavior.', cron_skills_label: 'Skills', cron_skills_placeholder: 'Add skills (optional)…', cron_skills_edit_hint: 'Skill list is not editable after creation.', @@ -1874,6 +1877,9 @@ const LOCALES = { cron_prompt_label: 'プロンプト', cron_deliver_label: '出力先', cron_deliver_local: 'ローカル (出力を保存のみ)', + cron_profile_label: 'プロフィール', + cron_profile_server_default: 'サーバーデフォルト', + cron_profile_server_default_hint: '実行時に WebUI サーバーのデフォルトプロフィールを使用します。プロフィールのない既存ジョブはこの従来の動作を維持します。', cron_skills_label: 'スキル', cron_skills_placeholder: 'スキルを追加 (任意)…', cron_skills_edit_hint: 'スキル一覧は作成後に編集できません。', @@ -2657,6 +2663,9 @@ const LOCALES = { cron_prompt_label: 'Запрос', cron_deliver_label: 'Доставлять вывод', cron_deliver_local: 'Локально (только сохранение)', + cron_profile_label: 'Профиль', + cron_profile_server_default: 'по умолчанию сервера', + cron_profile_server_default_hint: 'Использует профиль WebUI-сервера по умолчанию во время запуска. Существующие задания без профиля сохраняют это поведение.', cron_skills_label: 'Навыки', cron_skills_placeholder: 'Добавить навыки (необязательно)…', cron_skills_edit_hint: 'Список навыков нельзя изменить после создания.', @@ -3549,6 +3558,9 @@ const LOCALES = { cron_prompt_label: 'Prompt', cron_deliver_label: 'Entregar salida a', cron_deliver_local: 'Local (solo guardar salida)', + cron_profile_label: 'Perfil', + cron_profile_server_default: 'predeterminado del servidor', + cron_profile_server_default_hint: 'Usa el perfil predeterminado del servidor WebUI durante la ejecución. Los trabajos existentes sin perfil conservan este comportamiento heredado.', cron_skills_label: 'Habilidades', cron_skills_placeholder: 'Añadir habilidades (opcional)…', cron_skills_edit_hint: 'La lista de habilidades no es editable después de crear.', @@ -4186,6 +4198,9 @@ const LOCALES = { cron_prompt_label: 'Prompt', cron_deliver_label: 'Ausgabe senden an', cron_deliver_local: 'Lokal (nur speichern)', + cron_profile_label: 'Profil', + cron_profile_server_default: 'Serverstandard', + cron_profile_server_default_hint: 'Verwendet zur Laufzeit das Standardprofil des WebUI-Servers. Bestehende Jobs ohne Profil behalten dieses Legacy-Verhalten.', cron_skills_label: 'Fähigkeiten', cron_skills_placeholder: 'Fähigkeiten hinzufügen (optional)…', cron_skills_edit_hint: 'Die Fähigkeitenliste kann nach der Erstellung nicht bearbeitet werden.', @@ -5355,6 +5370,9 @@ const LOCALES = { cron_prompt_label: '提示词', cron_deliver_label: '输出位置', cron_deliver_local: '本地(仅保存输出)', + cron_profile_label: '配置档', + cron_profile_server_default: '服务器默认', + cron_profile_server_default_hint: '运行时使用 WebUI 服务器默认配置档。没有配置档的现有作业会保留此旧行为。', cron_skills_label: '技能', cron_skills_placeholder: '添加技能(可选)…', cron_skills_edit_hint: '创建后无法再编辑技能列表。', @@ -6405,6 +6423,9 @@ const LOCALES = { cron_prompt_label: '提示', cron_deliver_label: '發送至', cron_deliver_local: '僅本地儲存', + cron_profile_label: '設定檔', + cron_profile_server_default: '伺服器預設', + cron_profile_server_default_hint: '執行時使用 WebUI 伺服器預設設定檔。沒有設定檔的既有工作會保留此舊行為。', cron_skills_label: '技能', cron_skills_placeholder: '選用技能(逗號分隔)', cron_skills_edit_hint: '定義要載入的技能', @@ -7215,6 +7236,9 @@ const LOCALES = { cron_prompt_label: 'Prompt', cron_deliver_label: 'Entregar output para', cron_deliver_local: 'Local (salvar output apenas)', + cron_profile_label: 'Perfil', + cron_profile_server_default: 'padrão do servidor', + cron_profile_server_default_hint: 'Usa o perfil padrão do servidor WebUI no momento da execução. Tarefas existentes sem perfil mantêm esse comportamento legado.', cron_deliver_origin: 'Origem (mesmo chat)', cron_deliver_telegram: 'Telegram', cron_deliver_discord: 'Discord', @@ -8153,6 +8177,9 @@ const LOCALES = { cron_prompt_label: 'Prompt', cron_deliver_label: 'Deliver output to', cron_deliver_local: 'Local (save output only)', + cron_profile_label: 'Profile', + cron_profile_server_default: 'server default', + cron_profile_server_default_hint: 'Uses the WebUI server default profile at run time. Existing jobs without a profile keep this legacy behavior.', cron_skills_label: 'Skills', cron_skills_placeholder: 'Add skills (optional)…', cron_skills_edit_hint: 'Skill list is not editable after creation.', diff --git a/static/panels.js b/static/panels.js index ec5bf078..043d98b2 100644 --- a/static/panels.js +++ b/static/panels.js @@ -270,6 +270,58 @@ function _cronStatusMeta(job) { }; } + +function _cronProfileName(profile){ + return (profile || '').toString().trim(); +} + +function _cronProfileLabel(profile){ + const name = _cronProfileName(profile); + return name || (t('cron_profile_server_default') || 'server default'); +} + +function _cronProfileTitle(profile){ + const name = _cronProfileName(profile); + if (name) return (t('cron_profile_label') || 'Profile') + ': ' + name; + return t('cron_profile_server_default_hint') || 'Uses the WebUI server default profile at run time'; +} + +async function loadCronProfiles(){ + if (_cronProfilesCache) return _cronProfilesCache; + try { + const data = await api('/api/profiles'); + _cronProfilesCache = Array.isArray(data.profiles) ? data.profiles : []; + } catch(e) { + _cronProfilesCache = []; + } + return _cronProfilesCache; +} + +function _cronProfileOptions(selected){ + const current = _cronProfileName(selected); + const profiles = Array.isArray(_cronProfilesCache) ? _cronProfilesCache : []; + const seen = new Set(['']); + const opts = [``]; + for (const p of profiles) { + const name = _cronProfileName(p && p.name); + if (!name || seen.has(name)) continue; + seen.add(name); + const label = p && p.is_default ? `${name} (${t('default') || 'default'})` : name; + opts.push(``); + } + if (current && !seen.has(current)) { + opts.push(``); + } + return opts.join(''); +} + +function _refreshCronProfileSelect(selected){ + const sel = $('cronFormProfile'); + if (!sel) return; + const keep = selected === undefined ? sel.value : selected; + sel.innerHTML = _cronProfileOptions(keep); +} + function _cronDiagnostics(job) { const fields = { id: job.id, @@ -297,6 +349,7 @@ async function loadCrons(animate) { refreshBtn.disabled = true; } try { + await loadCronProfiles(); const data = await api('/api/crons'); _cronList = data.jobs || []; if (!_cronList.length) { @@ -311,10 +364,13 @@ async function loadCrons(animate) { item.id = 'cron-' + job.id; const status = _cronStatusMeta(job); const isNewRun = _cronNewJobIds.has(String(job.id)); + const profileLabel = _cronProfileLabel(job.profile); + const profileTitle = _cronProfileTitle(job.profile); item.innerHTML = `