Files
hermes-agent/tests/hermes_cli/test_web_server_cron_profiles.py
Hermes Agent 17c6a566f5 fix(cron): reconcile run history per execution and read it in the owner's scope
Review of the run-history fallback:

- Merge agent sessions with script-only output docs per execution instead of
  branching on 'any sessions exist': a job converted to no_agent keeps its id
  (update_job supports it), so a surviving historical agent session must not
  hide newer script-only fires. Same-execution rows dedupe by session span.
- Decode output-doc filenames and read the execution ledger inside the owner
  profile's home scope: hermes_time resolves the configured zone through the
  current HERMES_HOME, so a cross-profile request decoded with the
  dashboard's zone and read the dashboard's executions.db.
- Attach per-run status from the execution ledger (one row per fire) matched
  by claim window instead of last_run_at proximity, and surface terminal
  attempts with no surviving doc as their own rows — the latest failed run
  no longer disappears behind (or relabels) an older successful document.
- Read output docs with encoding='utf-8-sig' (Windows footgun: PowerShell and
  some editors BOM files; plain utf-8 breaks on BOM-prefixed docs).

Three regressions cover the converted-job merge, the per-profile timezone
decode, and the missing-newest-doc case.
2026-09-26 17:19:04 -05:00

1564 lines
56 KiB
Python

"""Regression tests for dashboard cron job profile routing."""
from concurrent.futures import ThreadPoolExecutor
import json
import threading
from datetime import datetime
from pathlib import Path
import pytest
from fastapi import HTTPException
import hermes_cli.web_models as _web_models
import hermes_cli.web_routers.cron as _rt_cron
import hermes_cli.web_server_cron as _web_server_cron
@pytest.fixture()
def isolated_profiles(tmp_path, monkeypatch):
"""Give profile discovery an isolated default home with one named profile."""
from hermes_cli import profiles
default_home = tmp_path / ".hermes"
profiles_root = default_home / "profiles"
worker_home = profiles_root / "worker_alpha"
for home in (default_home, worker_home):
(home / "cron").mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("model: test-model\n", encoding="utf-8")
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
return {"default": default_home, "worker_alpha": worker_home}
def test_standalone_jobs_remain_discoverable_for_dashboard_management(isolated_profiles):
home = isolated_profiles["worker_alpha"]
(home / "config.yaml").write_text("gateway:\n standalone: true\n")
job = _web_server_cron._call_cron_for_profile(
"worker_alpha", "create_job", prompt="local job", schedule="every 1h",
)
assert isinstance(job, dict)
assert "worker_alpha" in {p["name"] for p in _web_server_cron._cron_profile_dicts()}
assert _web_server_cron._find_cron_job_profile(job["id"]) == "worker_alpha"
def test_fire_cron_job_scopes_store_and_runtime_home_together(
isolated_profiles,
monkeypatch,
):
"""A profile fire must execute and persist under the same profile home."""
from cron import jobs as cron_jobs
from cron import scheduler
from hermes_constants import (
reset_hermes_home_override,
set_hermes_home_override,
)
default_home = isolated_profiles["default"]
worker_home = isolated_profiles["worker_alpha"]
monkeypatch.setattr(scheduler, "_hermes_home", None)
captured = {}
class RecordingProvider:
def fire_due(self, job_id, *, adapters=None, loop=None):
captured["job_id"] = job_id
captured["runtime_home"] = scheduler._get_hermes_home()
captured["jobs_file"] = cron_jobs._current_cron_store().jobs_file
return True
monkeypatch.setattr(
"cron.scheduler_provider.resolve_cron_scheduler",
lambda: RecordingProvider(),
)
outer_token = set_hermes_home_override(default_home)
try:
assert _web_server_cron._fire_cron_job_for_profile("worker_alpha", "worker-job") is True
assert captured == {
"job_id": "worker-job",
"runtime_home": worker_home,
"jobs_file": worker_home / "cron" / "jobs.json",
}
assert scheduler._get_hermes_home() == default_home
finally:
reset_hermes_home_override(outer_token)
def test_create_registers_scheduler_inside_target_profile(
isolated_profiles,
monkeypatch,
):
"""Dashboard create must resolve and register under the selected profile."""
from cron import jobs as cron_jobs
from cron.scheduler_provider import CronScheduler
from hermes_constants import get_hermes_home
worker_home = isolated_profiles["worker_alpha"]
captured = {}
class RecordingProvider(CronScheduler):
@property
def name(self):
return "recording"
def start(self, stop_event, **kw):
pass
def register_job(self, job):
captured["job"] = job
captured["runtime_home"] = get_hermes_home()
captured["jobs_file"] = cron_jobs._current_cron_store().jobs_file
monkeypatch.setattr(
"cron.scheduler_provider.resolve_cron_scheduler",
lambda: RecordingProvider(),
)
job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="managed by named profile",
schedule="every 1h",
name="named-profile-job",
)
assert captured["job"]["id"] == job["id"]
assert captured["runtime_home"] == worker_home
assert captured["jobs_file"] == worker_home / "cron" / "jobs.json"
assert job["profile"] == "worker_alpha"
def test_dashboard_create_reports_saved_but_unregistered(
isolated_profiles,
monkeypatch,
):
"""Dashboard callers can distinguish persistence from remote registration."""
from cron.scheduler import CronSchedulerRegistrationError
job = {"id": "saved-job", "name": "saved job"}
failure = CronSchedulerRegistrationError(
job,
RuntimeError("private callback URL and token"),
)
def fail_create(*args, **kwargs):
raise failure
monkeypatch.setattr(_web_server_cron, "_call_cron_for_profile", fail_create)
with pytest.raises(HTTPException) as exc_info:
_web_server_cron._create_cron_job_sync(
_web_models.CronJobCreate(
prompt="managed by named profile",
schedule="every 1h",
name="named-profile-job",
),
profile="worker_alpha",
)
assert exc_info.value.status_code == 424
assert exc_info.value.detail == {
"error": str(failure),
"job_id": "saved-job",
"job_saved": True,
"scheduler_registered": False,
"retry_create": False,
}
assert "private callback URL and token" not in str(exc_info.value.detail)
def test_notify_cron_provider_scopes_store_and_runtime_home_together(
isolated_profiles,
monkeypatch,
):
"""Provider reconciliation must observe the mutated profile, not default."""
from cron import jobs as cron_jobs
from cron import scheduler
from hermes_constants import (
reset_hermes_home_override,
set_hermes_home_override,
)
default_home = isolated_profiles["default"]
worker_home = isolated_profiles["worker_alpha"]
monkeypatch.setattr(scheduler, "_hermes_home", None)
monkeypatch.setattr(
_web_server_cron,
"_cron_profile_dicts",
lambda: [{"name": "worker_alpha"}],
)
captured = {}
class RecordingProvider:
def on_jobs_changed(self):
captured["runtime_home"] = scheduler._get_hermes_home()
captured["jobs_file"] = cron_jobs._current_cron_store().jobs_file
monkeypatch.setattr(
"cron.scheduler_provider.resolve_cron_scheduler",
lambda: RecordingProvider(),
)
outer_token = set_hermes_home_override(default_home)
try:
_web_server_cron._notify_cron_provider_for_profile("worker_alpha")
assert captured == {
"runtime_home": worker_home,
"jobs_file": worker_home / "cron" / "jobs.json",
}
assert scheduler._get_hermes_home() == default_home
finally:
reset_hermes_home_override(outer_token)
def test_notify_cron_provider_failure_is_best_effort(
isolated_profiles,
monkeypatch,
):
class FailNotifyProvider:
@property
def name(self):
return "fail-notify"
def register_job(self, job):
return None
def on_jobs_changed(self):
raise RuntimeError("provider unavailable")
monkeypatch.setattr(
"cron.scheduler_provider.resolve_cron_scheduler",
lambda: FailNotifyProvider(),
)
created = _web_server_cron._mutate_cron_for_profile(
"worker_alpha",
"create_job",
prompt="survives provider failure",
schedule="every 1h",
name="best-effort-notify",
)
assert created["profile"] == "worker_alpha"
assert created["name"] == "best-effort-notify"
def test_external_provider_reconcile_fails_closed_with_multiple_profiles(
isolated_profiles,
monkeypatch,
):
"""Multi-profile dashboard + external provider: the unscoped reconcile
must NOT run — its orphan cleanup would disarm the other profiles'
armed one-shots in the shared NAS registry. The mutation itself still
succeeds (fail-closed only skips the remote converge)."""
from cron import scheduler
monkeypatch.setattr(scheduler, "_hermes_home", None)
monkeypatch.setattr(
_web_server_cron,
"_cron_profile_dicts",
lambda: [{"name": "default"}, {"name": "worker_alpha"}],
)
notified = []
class ExternalProvider:
@property
def name(self):
return "chronos"
def register_job(self, job):
return None
def on_jobs_changed(self):
notified.append(True)
monkeypatch.setattr(
"cron.scheduler_provider.resolve_cron_scheduler",
lambda: ExternalProvider(),
)
created = _web_server_cron._mutate_cron_for_profile(
"worker_alpha",
"create_job",
prompt="must not disarm siblings",
schedule="every 1h",
name="multi-profile-guard",
)
assert created["profile"] == "worker_alpha"
assert notified == [], (
"external provider reconcile must stay fail-closed on a "
"multi-profile dashboard"
)
def test_builtin_provider_hook_still_fires_with_multiple_profiles(
isolated_profiles,
monkeypatch,
):
"""The built-in provider re-reads jobs.json per tick — its hook is a
safe no-op and must NOT be blocked by the multi-profile guard."""
from cron import scheduler
from cron.scheduler_provider import InProcessCronScheduler
monkeypatch.setattr(scheduler, "_hermes_home", None)
monkeypatch.setattr(
_web_server_cron,
"_cron_profile_dicts",
lambda: [{"name": "default"}, {"name": "worker_alpha"}],
)
notified = []
class BuiltinProbe(InProcessCronScheduler):
def on_jobs_changed(self):
notified.append(True)
monkeypatch.setattr(
"cron.scheduler_provider.resolve_cron_scheduler",
lambda: BuiltinProbe(),
)
created = _web_server_cron._mutate_cron_for_profile(
"worker_alpha",
"create_job",
prompt="builtin notify",
schedule="every 1h",
name="builtin-notify",
)
assert created["profile"] == "worker_alpha"
assert notified == [True]
def test_profile_call_cannot_retarget_ticker_store_mid_write(
isolated_profiles,
monkeypatch,
):
"""A dashboard profile call must not redirect a concurrent ticker save."""
from cron import jobs as cron_jobs
default_cron = isolated_profiles["default"] / "cron"
worker_cron = isolated_profiles["worker_alpha"] / "cron"
default_file = default_cron / "jobs.json"
worker_file = worker_cron / "jobs.json"
default_job = {
"id": "default-job",
"name": "default job",
"schedule": {"kind": "interval", "minutes": 60},
"next_run_at": "2026-07-09T00:00:00+00:00",
}
worker_job = {
"id": "worker-job",
"name": "worker job",
"schedule": {"kind": "interval", "minutes": 60},
"next_run_at": "2026-07-09T00:00:00+00:00",
}
default_file.write_text(json.dumps({"jobs": [default_job]}), encoding="utf-8")
worker_file.write_text(json.dumps({"jobs": [worker_job]}), encoding="utf-8")
monkeypatch.setattr(cron_jobs, "CRON_DIR", default_cron)
monkeypatch.setattr(cron_jobs, "JOBS_FILE", default_file)
monkeypatch.setattr(cron_jobs, "OUTPUT_DIR", default_cron / "output")
monkeypatch.setattr(
cron_jobs,
"compute_next_run",
lambda _schedule, _last_run_at=None: "2026-07-10T00:00:00+00:00",
)
ticker_loaded = threading.Event()
release_ticker = threading.Event()
profile_entered = threading.Event()
ticker_done = threading.Event()
ticker_thread = threading.local()
original_load_jobs = cron_jobs.load_jobs
def blocking_load_jobs():
loaded = original_load_jobs()
if getattr(ticker_thread, "active", False):
ticker_loaded.set()
assert release_ticker.wait(5), "profile call did not enter in time"
return loaded
def hold_profile_call():
profile_entered.set()
assert ticker_done.wait(5), "ticker did not finish in time"
return True
def run_ticker_write():
ticker_thread.active = True
try:
return cron_jobs.advance_next_run("default-job")
finally:
ticker_done.set()
monkeypatch.setattr(cron_jobs, "load_jobs", blocking_load_jobs)
monkeypatch.setattr(cron_jobs, "_hold_profile_call", hold_profile_call, raising=False)
with ThreadPoolExecutor(max_workers=2) as pool:
ticker_future = pool.submit(run_ticker_write)
assert ticker_loaded.wait(5), "ticker did not load the default store"
profile_future = pool.submit(
_web_server_cron._call_cron_for_profile,
"worker_alpha",
"_hold_profile_call",
)
assert profile_entered.wait(5), "profile call did not retarget its store"
release_ticker.set()
assert ticker_future.result(timeout=5) is True
assert profile_future.result(timeout=5) is True
default_saved = json.loads(default_file.read_text(encoding="utf-8"))["jobs"]
worker_saved = json.loads(worker_file.read_text(encoding="utf-8"))["jobs"]
assert [job["id"] for job in worker_saved] == ["worker-job"]
assert [job["id"] for job in default_saved] == ["default-job"]
assert default_saved[0]["next_run_at"] == "2026-07-10T00:00:00+00:00"
@pytest.mark.asyncio
async def test_cron_mutation_without_profile_finds_named_profile_job(isolated_profiles):
worker_job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="managed by named profile",
schedule="every 1h",
name="named-profile-job",
)
paused = await _rt_cron.pause_cron_job(worker_job["id"])
assert paused["profile"] == "worker_alpha"
assert paused["enabled"] is False
default_jobs = await _rt_cron.list_cron_jobs(profile="default")
worker_jobs = await _rt_cron.list_cron_jobs(profile="worker_alpha")
assert default_jobs == []
assert len(worker_jobs) == 1
assert worker_jobs[0]["id"] == worker_job["id"]
assert worker_jobs[0]["enabled"] is False
@pytest.mark.asyncio
async def test_dashboard_cron_mutations_notify_selected_profile_provider(
isolated_profiles,
monkeypatch,
):
notified_profiles = []
monkeypatch.setattr(
_web_server_cron,
"_notify_cron_provider_for_profile",
notified_profiles.append,
)
created = await _rt_cron.create_cron_job(
_web_models.CronJobCreate(
prompt="managed by named profile",
schedule="every 1h",
name="provider-notify-job",
),
profile="worker_alpha",
)
await _rt_cron.update_cron_job(
created["id"],
_web_models.CronJobUpdate(updates={"name": "provider-notify-job-updated"}),
profile="worker_alpha",
)
await _rt_cron.pause_cron_job(created["id"], profile="worker_alpha")
await _rt_cron.resume_cron_job(created["id"], profile="worker_alpha")
await _rt_cron.delete_cron_job(created["id"], profile="worker_alpha")
assert notified_profiles == ["worker_alpha"] * 5
@pytest.mark.asyncio
async def test_blueprint_instantiation_notifies_selected_profile_provider(
isolated_profiles,
monkeypatch,
):
notified_profiles = []
monkeypatch.setattr(
_web_server_cron,
"_notify_cron_provider_for_profile",
notified_profiles.append,
)
created = await _rt_cron.instantiate_blueprint(
_web_models.AutomationBlueprintInstantiate(
blueprint="morning-brief",
values={"time": "07:30", "deliver": "local"},
),
profile="worker_alpha",
)
assert created["profile"] == "worker_alpha"
assert notified_profiles == ["worker_alpha"]
@pytest.mark.asyncio
async def test_trigger_cron_job_fires_only_selected_job_and_returns_refreshed_state(
isolated_profiles,
monkeypatch,
):
from cron import jobs as cron_jobs
selected = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="run immediately",
schedule="every 1h",
name="selected-trigger-job",
)
sibling = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="leave scheduled",
schedule="every 1h",
name="sibling-job",
)
fired = []
class RecordingProvider:
def fire_due(self, job_id, *, adapters=None, loop=None, force=False, manual=False):
fired.append(
{
"job_id": job_id,
"jobs_file": cron_jobs._current_cron_store().jobs_file,
"force": force,
"manual": manual,
}
)
cron_jobs.mark_job_run(job_id, success=True)
return True
monkeypatch.setattr(
"cron.scheduler_provider.resolve_cron_scheduler",
lambda: RecordingProvider(),
)
monkeypatch.setattr(
cron_jobs,
"trigger_job",
lambda _job_id: (_ for _ in ()).throw(
AssertionError("manual fire must not expose the job to the ticker first")
),
)
triggered = await _rt_cron.trigger_cron_job(
selected["id"],
profile="worker_alpha",
)
assert fired == [
{
"job_id": selected["id"],
"jobs_file": isolated_profiles["worker_alpha"] / "cron" / "jobs.json",
"force": False,
# Off-tick run-now: the claim must not stamp next_run_at as the occurrence (#104790).
"manual": True,
}
]
assert triggered["last_status"] == "ok"
assert triggered["last_run_at"] is not None
untouched = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"get_job",
sibling["id"],
)
assert untouched["last_run_at"] is None
@pytest.mark.asyncio
async def test_trigger_cron_job_reports_lost_claim_as_conflict(
isolated_profiles,
monkeypatch,
):
job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="already running",
schedule="every 1h",
name="claimed-trigger-job",
)
class ClaimLostProvider:
def fire_due(self, job_id, *, adapters=None, loop=None, force=False):
return False
monkeypatch.setattr(
"cron.scheduler_provider.resolve_cron_scheduler",
lambda: ClaimLostProvider(),
)
with pytest.raises(HTTPException) as exc:
await _rt_cron.trigger_cron_job(job["id"], profile="worker_alpha")
assert exc.value.status_code == 409
@pytest.mark.asyncio
async def test_trigger_cron_job_forces_paused_job_atomically(
isolated_profiles,
monkeypatch,
):
from cron import jobs as cron_jobs
job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="resume me",
schedule="every 1h",
name="paused-trigger-job",
)
_web_server_cron._call_cron_for_profile("worker_alpha", "pause_job", job["id"])
observed = {}
class ForceProvider:
def fire_due(self, job_id, *, adapters=None, loop=None, force=False):
observed["force"] = force
assert cron_jobs.claim_job_for_fire(job_id, force=force) is True
cron_jobs.mark_job_run(job_id, success=True)
return True
monkeypatch.setattr(
"cron.scheduler_provider.resolve_cron_scheduler",
lambda: ForceProvider(),
)
triggered = await _rt_cron.trigger_cron_job(
job["id"],
profile="worker_alpha",
)
assert observed["force"] is True
assert triggered["enabled"] is True
assert triggered["state"] == "scheduled"
assert triggered["last_status"] == "ok"
@pytest.mark.asyncio
async def test_trigger_paused_job_rejects_legacy_provider_without_mutating_job(
isolated_profiles,
monkeypatch,
):
from fastapi import HTTPException
job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="stay paused",
schedule="every 1h",
name="legacy-paused-trigger-job",
)
_web_server_cron._call_cron_for_profile("worker_alpha", "pause_job", job["id"])
calls = []
class LegacyProvider:
def fire_due(self, job_id, *, adapters=None, loop=None):
calls.append(job_id)
return True
monkeypatch.setattr(
"cron.scheduler_provider.resolve_cron_scheduler",
lambda: LegacyProvider(),
)
with pytest.raises(HTTPException) as exc:
await _rt_cron.trigger_cron_job(job["id"], profile="worker_alpha")
assert exc.value.status_code == 409
assert calls == []
persisted = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"get_job",
job["id"],
)
assert persisted["state"] == "paused"
assert persisted["enabled"] is False
@pytest.mark.asyncio
async def test_trigger_cron_job_returns_refreshed_execution_failure(
isolated_profiles,
monkeypatch,
):
from cron import jobs as cron_jobs
job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="fail visibly",
schedule="every 1h",
name="failed-trigger-job",
)
class FailedProvider:
def fire_due(self, job_id, *, adapters=None, loop=None, force=False):
cron_jobs.mark_job_run(job_id, success=False, error="expected failure")
return False
monkeypatch.setattr(
"cron.scheduler_provider.resolve_cron_scheduler",
lambda: FailedProvider(),
)
triggered = await _rt_cron.trigger_cron_job(
job["id"],
profile="worker_alpha",
)
assert triggered["last_status"] == "error"
assert triggered["last_error"] == "expected failure"
@pytest.mark.asyncio
async def test_trigger_cron_job_returns_completed_snapshot_for_retained_oneshot(
isolated_profiles,
monkeypatch,
):
from cron import jobs as cron_jobs
job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="run once",
schedule="in 30m",
name="completed-trigger-job",
)
class SuccessfulProvider:
def fire_due(self, job_id, *, adapters=None, loop=None, force=False):
cron_jobs.mark_job_run(job_id, success=True)
return True
monkeypatch.setattr(
"cron.scheduler_provider.resolve_cron_scheduler",
lambda: SuccessfulProvider(),
)
triggered = await _rt_cron.trigger_cron_job(
job["id"],
profile="worker_alpha",
)
assert triggered["state"] == "completed"
assert triggered["enabled"] is False
# Completed one-shots are retained for the retention window (#80624) with
# their terminal status inspectable — the trigger response is the real
# record, not a synthetic pre-removal snapshot.
assert triggered["last_status"] == "ok"
assert triggered["last_run_at"] is not None
retained = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"get_job",
job["id"],
)
assert retained is not None
assert retained["state"] == "completed"
@pytest.mark.asyncio
async def test_update_cron_job_normalizes_dashboard_core_fields(isolated_profiles, tmp_path):
scripts_dir = isolated_profiles["worker_alpha"] / "scripts"
scripts_dir.mkdir()
(scripts_dir / "collect.py").write_text("print('ok')\n", encoding="utf-8")
job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="managed by named profile",
schedule="every 1h",
name="normalizes-dashboard-fields",
)
updated = await _rt_cron.update_cron_job(
job["id"],
_web_models.CronJobUpdate(
updates={
"base_url": "https://example.invalid/v1/",
"script": str(scripts_dir / "collect.py"),
"context_from": "",
"no_agent": True,
}
),
profile="worker_alpha",
)
assert updated["base_url"] == "https://example.invalid/v1"
assert updated["script"] == "collect.py"
assert updated["context_from"] is None
assert updated["no_agent"] is True
@pytest.mark.asyncio
async def test_create_cron_job_rejects_script_outside_profile_scripts(
isolated_profiles, tmp_path
):
outside = tmp_path / "outside.py"
outside.write_text("print('nope')\n", encoding="utf-8")
with pytest.raises(HTTPException) as exc:
await _rt_cron.create_cron_job(
_web_models.CronJobCreate(
schedule="every 1h",
script=str(outside),
no_agent=True,
),
profile="worker_alpha",
)
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_create_cron_job_rejects_empty_agent_job(isolated_profiles):
with pytest.raises(HTTPException) as exc:
await _rt_cron.create_cron_job(
_web_models.CronJobCreate(schedule="every 1h"),
profile="worker_alpha",
)
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_update_cron_job_no_agent_reuses_existing_script(isolated_profiles):
scripts_dir = isolated_profiles["worker_alpha"] / "scripts"
scripts_dir.mkdir()
(scripts_dir / "collect.py").write_text("print('ok')\n", encoding="utf-8")
job = await _rt_cron.create_cron_job(
_web_models.CronJobCreate(
schedule="every 1h",
script=str(scripts_dir / "collect.py"),
),
profile="worker_alpha",
)
updated = await _rt_cron.update_cron_job(
job["id"],
_web_models.CronJobUpdate(updates={"no_agent": True}),
profile="worker_alpha",
)
assert updated["no_agent"] is True
assert updated["script"] == "collect.py"
@pytest.mark.asyncio
async def test_dashboard_cron_rejects_missing_context_from(isolated_profiles):
with pytest.raises(HTTPException) as create_exc:
await _rt_cron.create_cron_job(
_web_models.CronJobCreate(
prompt="process missing upstream",
schedule="every 1h",
context_from=["missing-job-id"],
),
profile="worker_alpha",
)
assert create_exc.value.status_code == 400
assert "missing-job-id" in create_exc.value.detail
job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="managed by named profile",
schedule="every 1h",
name="context-update-target",
)
with pytest.raises(HTTPException) as update_exc:
await _rt_cron.update_cron_job(
job["id"],
_web_models.CronJobUpdate(
updates={
"context_from": ["missing-job-id"],
}
),
profile="worker_alpha",
)
assert update_exc.value.status_code == 400
assert "missing-job-id" in update_exc.value.detail
@pytest.mark.asyncio
async def test_update_cron_job_rejects_id_mutation(isolated_profiles, monkeypatch):
"""Dashboard surfaces a 400 (not a 500 or silent rename) when an
id-mutation attempt is rejected by cron/jobs.update_job."""
notified_profiles = []
monkeypatch.setattr(
_web_server_cron,
"_notify_cron_provider_for_profile",
notified_profiles.append,
)
worker_job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="managed by named profile",
schedule="every 1h",
name="immutable-id-job",
)
with pytest.raises(HTTPException) as exc:
await _rt_cron.update_cron_job(
worker_job["id"],
_web_models.CronJobUpdate(updates={"id": "../escape"}),
profile="worker_alpha",
)
assert exc.value.status_code == 400
assert "id" in exc.value.detail
assert notified_profiles == []
worker_jobs = await _rt_cron.list_cron_jobs(profile="worker_alpha")
assert [job["id"] for job in worker_jobs] == [worker_job["id"]]
@pytest.mark.asyncio
async def test_cron_delete_with_profile_deletes_only_target_profile(isolated_profiles):
default_job = _web_server_cron._call_cron_for_profile(
"default",
"create_job",
prompt="same-ish default",
schedule="every 1h",
name="shared-name",
)
worker_job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="same-ish worker",
schedule="every 1h",
name="shared-name-worker",
)
deleted = await _rt_cron.delete_cron_job(worker_job["id"], profile="worker_alpha")
assert deleted == {"ok": True}
remaining_default = await _rt_cron.list_cron_jobs(profile="default")
remaining_worker = await _rt_cron.list_cron_jobs(profile="worker_alpha")
assert [job["id"] for job in remaining_default] == [default_job["id"]]
assert remaining_worker == []
@pytest.mark.asyncio
async def test_cron_profile_validation_errors(isolated_profiles):
with pytest.raises(HTTPException) as bad_name:
await _rt_cron.list_cron_jobs(profile="../bad")
assert bad_name.value.status_code == 400
with pytest.raises(HTTPException) as missing:
await _rt_cron.list_cron_jobs(profile="missing_profile")
assert missing.value.status_code == 404
@pytest.mark.asyncio
async def test_create_cron_job_without_profile_uses_backend_own_profile(
isolated_profiles, monkeypatch
):
"""A pool backend scoped to a named profile must not default creates to
``~/.hermes`` when the request carries no explicit ``profile`` (the
Desktop app's pre-profileScoped clients sent none)."""
monkeypatch.setenv(
"HERMES_HOME", str(isolated_profiles["worker_alpha"])
)
job = await _rt_cron.create_cron_job(
_web_models.CronJobCreate(
prompt="runs in my own profile",
schedule="every 1h",
name="own-profile-job",
),
profile=None,
)
assert job["profile"] == "worker_alpha"
assert (isolated_profiles["worker_alpha"] / "cron" / "jobs.json").exists()
assert not (isolated_profiles["default"] / "cron" / "jobs.json").exists()
@pytest.mark.asyncio
async def test_create_cron_job_without_profile_defaults_when_unscoped(
isolated_profiles, monkeypatch
):
"""HERMES_HOME at the default home (or unrecognized) keeps the legacy
``default`` fallback."""
monkeypatch.setenv("HERMES_HOME", str(isolated_profiles["default"]))
job = await _rt_cron.create_cron_job(
_web_models.CronJobCreate(
prompt="runs in default",
schedule="every 1h",
name="default-job",
),
profile=None,
)
assert job["profile"] == "default"
assert (isolated_profiles["default"] / "cron" / "jobs.json").exists()
def test_list_cron_jobs_carries_each_profiles_ticker_heartbeat_age(isolated_profiles):
"""#114309 — the dashboard must be able to say "scheduler last ticked X hours ago":
every listed job carries its own profile's ticker heartbeat age (None = never/unknown)."""
import time
for name, home in isolated_profiles.items():
with _web_server_cron._cron_store_scope(home) as cron_jobs:
cron_jobs.create_job(prompt=f"{name} hourly", schedule="every 1h")
(isolated_profiles["worker_alpha"] / "cron" / "ticker_heartbeat").write_text(
str(time.time() - 25 * 3600)
)
ages = {job["profile"]: job["scheduler_heartbeat_age_s"] for job in _rt_cron._list_cron_jobs_sync("all")}
assert ages["default"] is None # no heartbeat file: cannot date the last tick
assert 25 * 3600 <= ages["worker_alpha"] < 25 * 3600 + 60
@pytest.mark.asyncio
async def test_cron_run_history_resolves_the_jobs_owner_profile(isolated_profiles, monkeypatch):
"""#115345 — the Desktop lists jobs cross-profile (``?profile=all``) while its
per-item calls carry the ambient active profile. The run lookup treated that
hint as the owning profile, opened the wrong profile's state.db and answered
``200 {"runs": []}``, so every job owned by another profile rendered
"No runs yet" for history that existed."""
worker_job = _web_server_cron._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="owned by worker",
schedule="every 1h",
name="cross-profile-runs",
)
default_job = _web_server_cron._call_cron_for_profile(
"default",
"create_job",
prompt="owned by default",
schedule="every 1h",
name="default-owned-runs",
)
# Run sessions live in the state.db of the profile that owns the job, keyed
# by the canonical id in the session id (cron_{job_id}_{timestamp}).
runs_by_profile = {
("worker_alpha", worker_job["id"]): [
{"id": f"cron_{worker_job['id']}_1", "started_at": 1.0}
],
("default", default_job["id"]): [
{"id": f"cron_{default_job['id']}_1", "started_at": 2.0}
],
}
opened = []
class _FakeRunsDB:
def __init__(self, profile):
self.profile = profile
def list_cron_job_runs(self, job_id, limit=20, offset=0):
opened.append(self.profile)
return [dict(row) for row in runs_by_profile.get((self.profile, job_id), ())]
def close(self):
pass
monkeypatch.setattr(
_rt_cron,
"_open_session_db_for_profile",
lambda profile, *, read_only: _FakeRunsDB(profile),
)
# Active profile is `default`; the listed job belongs to worker_alpha.
ambient = await _rt_cron.list_cron_job_runs(worker_job["id"], profile="default")
assert [run["id"] for run in ambient["runs"]] == [f"cron_{worker_job['id']}_1"]
assert ambient["runs"][0]["profile"] == "worker_alpha"
assert opened == ["worker_alpha"]
# Scope is resolved, not widened: the owning-profile hint and the omitted
# legacy lookup still read the owner's store, and profile="default" still
# reads default's own job (and only that job) out of default's state.db.
opened.clear()
owner = await _rt_cron.list_cron_job_runs(worker_job["id"], profile="worker_alpha")
omitted = await _rt_cron.list_cron_job_runs(worker_job["id"])
own = await _rt_cron.list_cron_job_runs(default_job["id"], profile="default")
assert [run["id"] for run in owner["runs"]] == [f"cron_{worker_job['id']}_1"]
assert [run["id"] for run in omitted["runs"]] == [f"cron_{worker_job['id']}_1"]
assert [run["id"] for run in own["runs"]] == [f"cron_{default_job['id']}_1"]
assert opened == ["worker_alpha", "worker_alpha", "default"]
@pytest.mark.asyncio
async def test_cron_job_mutations_resolve_the_owner_when_the_hint_is_another_profile(isolated_profiles):
"""#115345 sibling: the per-job get/pause/resume/delete calls carry the same ambient-profile
hint as the run lookup. A hint that does not hold the job must resolve to the owner instead of
404ing (or acting on the wrong profile's store); a hint that does hold it still wins."""
worker_job = _web_server_cron._call_cron_for_profile(
"worker_alpha", "create_job", prompt="owned by worker", schedule="every 1h", name="worker-mutations",
)
job_id = worker_job["id"]
got = await _rt_cron.get_cron_job(job_id, profile="default")
assert got["id"] == job_id and got["name"] == "worker-mutations"
paused = await _rt_cron.pause_cron_job(job_id, profile="default")
assert paused["id"] == job_id and paused.get("enabled") is False
owner_view = _web_server_cron._call_cron_for_profile("worker_alpha", "get_job", job_id)
assert owner_view["enabled"] is False # mutation landed in the owner's jobs.json
assert _web_server_cron._call_cron_for_profile("default", "list_jobs", True) == [] # not copied into the hint profile
await _rt_cron.delete_cron_job(job_id, profile="default")
assert _web_server_cron._call_cron_for_profile("worker_alpha", "get_job", job_id) is None
with pytest.raises(HTTPException) as exc:
await _rt_cron.get_cron_job(job_id, profile="default")
assert exc.value.status_code == 404
class TestCronJobsCrossProfileDedup:
"""Regression tests for #51721: GET /api/cron/jobs (profile=all) aggregated
jobs from every profile without deduplication — a job copied into a second
profile's cron/jobs.json during profile creation showed up twice. Uses real
cron.jobs storage (direct jobs.json writes for the id-less case) so the
tests exercise cron.jobs._normalize_job_record()'s real "unknown" sentinel
behavior, not a mocked list.
"""
def _write_jobs_file(self, home, jobs):
path = home / "cron" / "jobs.json"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(jobs), encoding="utf-8")
def test_shared_id_default_profile_copy_wins(self, isolated_profiles):
"""The same job id in both profiles: the default profile's copy must
survive, regardless of which profile's list was appended first during
aggregation."""
self._write_jobs_file(isolated_profiles["default"], [{
"id": "shared-job-1", "name": "shared", "prompt": "DEFAULT COPY",
"enabled": True, "schedule": {"type": "interval", "every": "1h"},
}])
self._write_jobs_file(isolated_profiles["worker_alpha"], [{
"id": "shared-job-1", "name": "shared", "prompt": "WORKER COPY",
"enabled": True, "schedule": {"type": "interval", "every": "1h"},
}])
result = _rt_cron._list_cron_jobs_sync("all")
matches = [j for j in result if j.get("id") == "shared-job-1"]
assert len(matches) == 1, f"Duplicate not resolved: {matches}"
assert matches[0]["prompt"] == "DEFAULT COPY", (
"Default profile's copy must always win, got: " + str(matches[0])
)
assert matches[0]["is_default_profile"] is True
def test_worker_first_shared_id_still_resolves_to_default_copy(self, isolated_profiles):
"""Order-independence: _cron_profile_dicts() lists the default profile
first today, but the dedup must not depend on that. Feed the worker
profile's copy through the loop first by listing only it, then verify a
full aggregate still prefers the default copy."""
self._write_jobs_file(isolated_profiles["default"], [{
"id": "shared-job-2", "name": "shared", "prompt": "DEFAULT COPY",
"enabled": True, "schedule": {"type": "interval", "every": "1h"},
}])
self._write_jobs_file(isolated_profiles["worker_alpha"], [{
"id": "shared-job-2", "name": "shared", "prompt": "WORKER COPY",
"enabled": True, "schedule": {"type": "interval", "every": "1h"},
}])
worker_first = _web_server_cron._call_cron_for_profile("worker_alpha", "list_jobs", True)
assert worker_first[0]["prompt"] == "WORKER COPY"
result = _rt_cron._list_cron_jobs_sync("all")
matches = [j for j in result if j.get("id") == "shared-job-2"]
assert len(matches) == 1
assert matches[0]["prompt"] == "DEFAULT COPY"
def test_unique_ids_all_kept(self, isolated_profiles):
self._write_jobs_file(isolated_profiles["default"], [{
"id": "job-a", "name": "a", "prompt": "a",
"enabled": True, "schedule": {"type": "interval", "every": "1h"},
}])
self._write_jobs_file(isolated_profiles["worker_alpha"], [{
"id": "job-b", "name": "b", "prompt": "b",
"enabled": True, "schedule": {"type": "interval", "every": "1h"},
}])
result = _rt_cron._list_cron_jobs_sync("all")
ids = {j.get("id") for j in result}
assert ids == {"job-a", "job-b"}
def test_idless_records_from_different_profiles_both_preserved(self, isolated_profiles):
"""cron.jobs._normalize_job_record() fills a missing id with the literal
sentinel string "unknown" before this data reaches the aggregation
logic. Two genuinely DIFFERENT id-less jobs (hand-edited or pre-id-field
legacy records) from different profiles both normalize to id="unknown" —
a naive by-id dedup would wrongly collapse them into one. Both survive."""
self._write_jobs_file(isolated_profiles["default"], [{
"name": "legacy-default", "prompt": "legacy job in default",
"enabled": True, "schedule": {"type": "interval", "every": "1h"},
}])
self._write_jobs_file(isolated_profiles["worker_alpha"], [{
"name": "legacy-worker", "prompt": "legacy job in worker",
"enabled": True, "schedule": {"type": "interval", "every": "1h"},
}])
result = _rt_cron._list_cron_jobs_sync("all")
prompts = {j.get("prompt") for j in result}
assert prompts == {"legacy job in default", "legacy job in worker"}
class TestCronRunHistoryFallback:
"""Script-only (no_agent) jobs deliberately skip SessionDB, so their run
history was permanently empty ("No runs" despite hundreds of completed
fires — #42433). When no session rows exist, the runs endpoint falls back
to the job's per-fire output docs under cron/output/<job_id>/."""
@staticmethod
def _script_only_job(job_id):
return {"id": job_id, "no_agent": True, "script": "sync.sh", "prompt": ""}
@pytest.fixture()
def no_session_rows(self, monkeypatch):
class _EmptyDB:
def list_cron_job_runs(self, job_id, limit=20, offset=0):
return []
def close(self):
pass
monkeypatch.setattr(
_rt_cron, "_open_session_db_for_profile", lambda profile, *, read_only: _EmptyDB()
)
def _write_output_doc(self, monkeypatch, home, job_id, filename, text):
output_dir = Path(home) / "cron" / "output" / job_id
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / filename).write_text(text, encoding="utf-8")
def test_falls_back_to_output_docs_when_no_session_runs_exist(
self, isolated_profiles, monkeypatch, no_session_rows
):
job_id = "job-script-only"
home = isolated_profiles["default"]
self._write_output_doc(monkeypatch, home, job_id, "2026-07-08_09-00-00.md", "older output\n")
self._write_output_doc(monkeypatch, home, job_id, "2026-07-08_09-05-00.md", "latest output\n")
monkeypatch.setattr(_rt_cron, "_job_owner_profile", lambda _job_id, _profile: "default")
monkeypatch.setattr(
_rt_cron, "_call_cron_for_profile",
lambda _profile, cmd, *_args, **_kwargs: {
**self._script_only_job(job_id), "last_status": "ok",
} if cmd == "get_job" else None,
)
result = _rt_cron._list_cron_job_runs_sync(job_id, limit=2)
runs = result["runs"]
assert result["limit"] == 2
assert [run["id"] for run in runs] == [
f"cron_output:{job_id}:2026-07-08_09-05-00",
f"cron_output:{job_id}:2026-07-08_09-00-00",
]
assert runs[0]["source"] == "cron_output"
# Status is attached per execution from the ledger, never from
# last_run_at/last_status proximity (#42433 review): with no ledger
# rows the doc's own output is the honest title.
assert runs[0]["title"] == "latest output"
assert runs[1]["title"] == "older output"
assert all(run["is_active"] is False for run in runs)
def test_output_doc_timestamps_honor_configured_timezone(
self, isolated_profiles, monkeypatch, no_session_rows
):
"""The filename stem is written by save_job_output via hermes_time.now()
— the configured zone. Reading it back must use that zone, not the
server's (review of #61403: a fixed-offset snapshot was off by hours
when HERMES_TIMEZONE differs from the server zone)."""
from zoneinfo import ZoneInfo
from hermes_time import _tz_cache
job_id = "job-tz"
home = isolated_profiles["default"]
# Wall time 2026-07-10_00-14-10 in Asia/Taipei (+08:00) = 2026-07-09 16:14:10 UTC.
self._write_output_doc(monkeypatch, home, job_id, "2026-07-10_00-14-10.md", "tz output\n")
monkeypatch.setenv("HERMES_TIMEZONE", "Asia/Taipei")
_tz_cache.clear()
monkeypatch.setattr(_rt_cron, "_job_owner_profile", lambda _job_id, _profile: "default")
monkeypatch.setattr(
_rt_cron, "_call_cron_for_profile",
lambda _profile, cmd, *_args, **_kwargs: {
**self._script_only_job(job_id), "last_status": "ok",
} if cmd == "get_job" else None,
)
try:
result = _rt_cron._list_cron_job_runs_sync(job_id, limit=5)
finally:
monkeypatch.delenv("HERMES_TIMEZONE", raising=False)
_tz_cache.clear()
started = result["runs"][0]["started_at"]
naive = datetime.strptime("2026-07-10_00-14-10", "%Y-%m-%d_%H-%M-%S")
expected = naive.replace(tzinfo=ZoneInfo("Asia/Taipei")).timestamp()
assert abs(started - expected) < 1, (
f"Filename wall time misread: got epoch {started}, expected {expected}"
)
def test_keeps_session_runs_when_db_history_exists(
self, isolated_profiles, monkeypatch
):
"""An agent fire writes BOTH a session and an output doc; the session row
is the richer representation, so the doc for the same execution is
dropped and the session survives the merge."""
from zoneinfo import ZoneInfo
job_id = "job-with-sessions"
home = isolated_profiles["default"]
# Doc written at the END of the session's run (2026 wall time): the
# session span covers it, so it is the same execution.
doc_epoch = datetime.strptime(
"2026-07-08_09-05-00", "%Y-%m-%d_%H-%M-%S").replace(tzinfo=ZoneInfo("UTC")).timestamp()
self._write_output_doc(monkeypatch, home, job_id, "2026-07-08_09-05-00.md", "fallback output\n")
class _FakeDB:
def list_cron_job_runs(self, canonical, limit=20, offset=0):
return [{
"id": f"cron_{canonical}_1", "source": "cron",
"started_at": doc_epoch - 120.0, "last_active": doc_epoch - 10.0,
"ended_at": doc_epoch - 5.0, "archived": False,
}]
def close(self):
pass
monkeypatch.setattr(
_rt_cron, "_open_session_db_for_profile", lambda profile, *, read_only: _FakeDB()
)
monkeypatch.setattr(_rt_cron, "_job_owner_profile", lambda _job_id, _profile: "default")
monkeypatch.setattr(
_rt_cron, "_call_cron_for_profile",
lambda _profile, cmd, *_args, **_kwargs: {"id": job_id} if cmd == "get_job" else None,
)
monkeypatch.setattr(_rt_cron.time, "time", lambda: 200.0)
result = _rt_cron._list_cron_job_runs_sync(job_id, limit=5)
assert [run["id"] for run in result["runs"]] == [f"cron_{job_id}_1"]
assert result["runs"][0]["source"] == "cron"
assert result["runs"][0]["is_active"] is False
def test_surfaces_latest_run_when_only_job_metadata_exists(
self, isolated_profiles, monkeypatch, no_session_rows
):
"""No output docs survived pruning, but the job HAS completed runs:
one metadata row from last_run_at/last_status beats a bare 'No runs'."""
job_id = "job-last-run-only"
monkeypatch.setattr(_rt_cron, "_job_owner_profile", lambda _job_id, _profile: "default")
monkeypatch.setattr(
_rt_cron, "_call_cron_for_profile",
lambda _profile, cmd, *_args, **_kwargs: {
**self._script_only_job(job_id),
"last_status": "ok",
"last_run_at": "2026-07-08T09:05:00+00:00",
} if cmd == "get_job" else None,
)
result = _rt_cron._list_cron_job_runs_sync(job_id, limit=5)
runs = result["runs"]
assert len(runs) == 1
assert runs[0]["source"] == "cron_output"
assert runs[0]["id"] == f"cron_output:{job_id}:latest"
assert runs[0]["title"].startswith("OK")
assert runs[0]["started_at"] == datetime.fromisoformat("2026-07-08T09:05:00+00:00").timestamp()
def test_agent_job_with_no_history_stays_empty(
self, isolated_profiles, monkeypatch, no_session_rows
):
"""The fallback must not invent history for an agent job that simply
hasn't run yet: no sessions, no output docs, no last_run_at → []."""
job_id = "agent-job-never-run"
monkeypatch.setattr(_rt_cron, "_job_owner_profile", lambda _job_id, _profile: "default")
monkeypatch.setattr(
_rt_cron, "_call_cron_for_profile",
lambda _profile, cmd, *_args, **_kwargs: {"id": job_id} if cmd == "get_job" else None,
)
result = _rt_cron._list_cron_job_runs_sync(job_id, limit=5)
assert result["runs"] == []
def test_converted_job_merges_agent_sessions_with_script_fires(
self, isolated_profiles, monkeypatch, no_session_rows
):
"""Review of #42433: updating an existing job to no_agent=True keeps the
job id, so a surviving historical agent session must not hide later
script-only executions. Reconcile per execution: the newer script run
appears first, the older agent session stays exactly once."""
from zoneinfo import ZoneInfo
job_id = "j1"
home = isolated_profiles["default"]
# Old agent session (survives in SessionDB) and a NEWER script-only fire.
agent_started = datetime.strptime(
"2026-09-24_08-00-00", "%Y-%m-%d_%H-%M-%S").replace(tzinfo=ZoneInfo("UTC")).timestamp()
self._write_output_doc(monkeypatch, home, job_id, "2026-09-25_00-14-10.md", "script run output\n")
class _FakeDB:
def list_cron_job_runs(self, canonical, limit=20, offset=0):
return [{
"id": f"cron_{canonical}_old", "source": "cron",
"started_at": agent_started, "last_active": agent_started + 60.0,
"ended_at": agent_started + 60.0, "archived": False,
}]
def close(self):
pass
monkeypatch.setattr(
_rt_cron, "_open_session_db_for_profile", lambda profile, *, read_only: _FakeDB()
)
monkeypatch.setattr(_rt_cron, "_job_owner_profile", lambda _job_id, _profile: "default")
monkeypatch.setattr(
_rt_cron, "_call_cron_for_profile",
lambda _profile, cmd, *_args, **_kwargs: {
**self._script_only_job(job_id),
} if cmd == "get_job" else None,
)
result = _rt_cron._list_cron_job_runs_sync(job_id, limit=10)
runs = result["runs"]
assert [run["id"] for run in runs] == [
f"cron_output:{job_id}:2026-09-25_00-14-10",
f"cron_{job_id}_old",
]
# The older agent session appears exactly once — not shadowed, not doubled.
assert sum(1 for run in runs if run["id"] == f"cron_{job_id}_old") == 1
def test_output_doc_timestamps_decode_in_the_owners_profile_timezone(
self, isolated_profiles, monkeypatch, no_session_rows
):
"""Review of #42433: the filename stem is written with the OWNER
profile's configured zone. A cross-profile request must decode it in
that zone — not the dashboard's — with no global HERMES_TIMEZONE set
(both profiles configure config.yaml timezones, so a shared env var
cannot mask the mix-up)."""
from zoneinfo import ZoneInfo
from hermes_time import _tz_cache
job_id = "job-owner-tz"
worker_home = isolated_profiles["worker_alpha"]
# worker_alpha runs in Asia/Taipei; the dashboard profile stays UTC.
(worker_home / "config.yaml").write_text(
"model: test-model\ntimezone: Asia/Taipei\n", encoding="utf-8")
# Wall time 2026-09-25_00-14-10 in Asia/Taipei (+08:00) = 2026-09-24 16:14:10 UTC.
self._write_output_doc(monkeypatch, worker_home, job_id, "2026-09-25_00-14-10.md", "tz output\n")
_tz_cache.clear()
monkeypatch.setattr(_rt_cron, "_job_owner_profile", lambda _job_id, _profile: "worker_alpha")
monkeypatch.setattr(
_rt_cron, "_call_cron_for_profile",
lambda _profile, cmd, *_args, **_kwargs: {
**self._script_only_job(job_id), "last_status": "ok",
} if cmd == "get_job" else None,
)
try:
result = _rt_cron._list_cron_job_runs_sync(job_id, profile="default", limit=5)
finally:
_tz_cache.clear()
naive = datetime.strptime("2026-09-25_00-14-10", "%Y-%m-%d_%H-%M-%S")
expected = naive.replace(tzinfo=ZoneInfo("Asia/Taipei")).timestamp()
started = result["runs"][0]["started_at"]
assert abs(started - expected) < 1, (
f"Decoded with the dashboard's zone instead of the owner's: got epoch {started}, expected {expected}"
)
def test_latest_attempt_without_a_doc_gets_its_own_row(
self, isolated_profiles, monkeypatch, no_session_rows
):
"""Review of #42433: two runs, only the newest run's output doc missing.
The surviving older doc must keep its own status — not inherit the
latest attempt's — and the failed latest attempt must appear as its own
row carrying its timestamp and error (a 120s filename window cannot
establish that the newest file belongs to last_run_at)."""
from cron import executions as cron_executions
from zoneinfo import ZoneInfo
job_id = "job-missing-newest-doc"
home = isolated_profiles["default"]
ledger_path = home / "cron" / "executions.db"
monkeypatch.setattr(cron_executions, "EXECUTIONS_FILE", ledger_path)
def _at(name):
return datetime.strptime(name, "%Y-%m-%d_%H-%M-%S").replace(tzinfo=ZoneInfo("UTC"))
# Attempt 1 (10:00): succeeded; its doc survives.
monkeypatch.setattr(cron_executions, "_hermes_now", lambda: _at("2026-09-25_10-00-00"))
first = cron_executions.create_execution(job_id, source="direct")
monkeypatch.setattr(cron_executions, "_hermes_now", lambda: _at("2026-09-25_10-00-30"))
cron_executions.finish_execution(first["id"], success=True)
# Attempt 2 (10:01): failed; its doc is gone.
monkeypatch.setattr(cron_executions, "_hermes_now", lambda: _at("2026-09-25_10-01-00"))
second = cron_executions.create_execution(job_id, source="direct")
monkeypatch.setattr(cron_executions, "_hermes_now", lambda: _at("2026-09-25_10-01-20"))
cron_executions.finish_execution(second["id"], success=False, error="new run failed")
self._write_output_doc(monkeypatch, home, job_id, "2026-09-25_10-00-00.md", "successful older run\n")
monkeypatch.setattr(_rt_cron, "_job_owner_profile", lambda _job_id, _profile: "default")
monkeypatch.setattr(
_rt_cron, "_call_cron_for_profile",
lambda _profile, cmd, *_args, **_kwargs: {
**self._script_only_job(job_id),
"last_run_at": "2026-09-25T10:01:00+00:00",
"last_status": "error",
"last_error": "new run failed",
} if cmd == "get_job" else None,
)
result = _rt_cron._list_cron_job_runs_sync(job_id, limit=10)
runs = result["runs"]
assert len(runs) == 2
newest, older = runs[0], runs[1]
# The failed latest attempt is present as its own row, with its own
# timestamp and error — not silently dropped behind the older doc.
assert newest["id"].startswith(f"cron_output:{job_id}:exec:")
assert newest["started_at"] == _at("2026-09-25_10-01-20").timestamp()
assert newest["preview"] == "new run failed"
assert newest["title"].startswith("FAILED")
# The surviving older doc keeps its own output and its own completed
# status — not relabeled with the newer attempt's error.
assert older["id"] == f"cron_output:{job_id}:2026-09-25_10-00-00"
assert older["title"].startswith("COMPLETED · successful older run")
assert older["started_at"] == _at("2026-09-25_10-00-00").timestamp()