Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions api/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,24 @@ def __enter__(self):
_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
Expand All @@ -275,6 +293,12 @@ def __exit__(self, exc_type, exc_val, exc_tb):
_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
Expand Down Expand Up @@ -313,6 +337,20 @@ def __enter__(self):
_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
Expand All @@ -333,6 +371,12 @@ def __exit__(self, exc_type, exc_val, exc_tb):
_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
Expand Down Expand Up @@ -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.
Expand Down
145 changes: 113 additions & 32 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -2430,7 +2497,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
Expand Down Expand Up @@ -5518,8 +5585,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"],
Expand All @@ -5528,7 +5596,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)

Expand All @@ -5540,11 +5610,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):
Expand Down Expand Up @@ -5590,7 +5670,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"})


Expand Down
Binary file added docs/pr-media/617/task-profile-badges.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-media/617/task-profile-selector.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 27 additions & 0 deletions static/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,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.',
Expand Down Expand Up @@ -1738,6 +1741,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: 'スキル一覧は作成後に編集できません。',
Expand Down Expand Up @@ -2463,6 +2469,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: 'Список навыков нельзя изменить после создания.',
Expand Down Expand Up @@ -3288,6 +3297,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.',
Expand Down Expand Up @@ -3858,6 +3870,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.',
Expand Down Expand Up @@ -4960,6 +4975,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: '创建后无法再编辑技能列表。',
Expand Down Expand Up @@ -5998,6 +6016,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: '定義要載入的技能',
Expand Down Expand Up @@ -6740,6 +6761,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',
Expand Down Expand Up @@ -7617,6 +7641,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.',
Expand Down
Loading
Loading