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
15 changes: 14 additions & 1 deletion cron/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1186,7 +1186,14 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
updated["model_snapshot"] = model_snapshot

if updated.get("enabled", True) and updated.get("state") != "paused" and not updated.get("next_run_at"):
updated["next_run_at"] = compute_next_run(updated["schedule"])
next_run = compute_next_run(updated["schedule"])
if next_run is None and updated["schedule"].get("kind") == "once":
run_at = updated["schedule"].get("run_at", "unknown")
raise ValueError(
f"Requested one-shot time {run_at} is in the past "
f"(grace window: {ONESHOT_GRACE_SECONDS}s) and cannot be scheduled."
)
updated["next_run_at"] = next_run

jobs[i] = updated
save_jobs(jobs)
Expand Down Expand Up @@ -1217,6 +1224,12 @@ def resume_job(job_id: str) -> Optional[Dict[str, Any]]:
return None

next_run_at = compute_next_run(job["schedule"])
if next_run_at is None and job["schedule"].get("kind") == "once":
run_at = job["schedule"].get("run_at", "unknown")
raise ValueError(
f"Cannot resume: one-shot time {run_at} is in the past "
f"(grace window: {ONESHOT_GRACE_SECONDS}s) and will never fire."
)
return update_job(
job["id"],
{
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"wyuebei@gmail.com": "wyuebei-cloud", # PR #56640 salvage (hermes journey: replace GNU-only %-d strftime with dt.day for Windows)
"yingwaizhiying@gmail.com": "msh01", # PR #58250 salvage (telegram: wall-clock init timeout via daemon-thread deadline + abandon the shielded initialize task on timeout so the retry ladder advances instead of hanging on attempt 1/8 under s6 supervision; #58236). Also covers PR #58276 salvage (compression: preserve a real user turn after compaction; #55677).
"danilo@falcao.org": "danilofalcao", # PR #56674 salvage (update: skip unsupported platform.matrix lazy refresh on native Windows — python-olm has no Windows wheel)
"ishengeqi@163.com": "isheng-eqi",
"huanshan5195@users.noreply.github.com": "huanshan5195", # PR #57601 salvage (custom-provider: emit reasoning_effort at the live CustomProfile path so GLM-5.2/ARK/vLLM/Ollama endpoints receive it; + "max" reasoning level)
"infinitycrew39@gmail.com": "infinitycrew39", # PR #56431 salvage (honor live vLLM context limits on local endpoints)
"jonathan.kovacs999@gmail.com": "CocaKova", # PR #57692 salvage (cron: run jobs under the profile secret scope so get_secret does not fail-close with UnscopedSecretError under profile isolation)
Expand Down
54 changes: 54 additions & 0 deletions tests/cron/test_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,31 @@ def test_update_rejects_id_change(self, tmp_cron_dir):
assert get_job(job["id"]) is not None
assert get_job("../escape") is None

def test_update_rejects_past_oneshot_schedule(self, tmp_cron_dir):
"""Changing a job's schedule to a past one-shot timestamp must raise
ValueError — same guard as create_job (#59395)."""
job = create_job(prompt="Existing", schedule="every 1h")
past = (datetime.now() - timedelta(minutes=10)).isoformat()
past_schedule = parse_schedule(past)
with pytest.raises(ValueError, match="in the past"):
update_job(job["id"], {
"schedule": past_schedule,
"schedule_display": past_schedule["display"],
})

def test_update_accepts_future_oneshot_schedule(self, tmp_cron_dir):
"""Changing a job's schedule to a future one-shot must succeed."""
job = create_job(prompt="Existing", schedule="every 1h")
future = (datetime.now() + timedelta(hours=1)).isoformat()
future_schedule = parse_schedule(future)
updated = update_job(job["id"], {
"schedule": future_schedule,
"schedule_display": future_schedule["display"],
})
assert updated is not None
assert updated["next_run_at"] is not None
assert updated["schedule"]["kind"] == "once"


class TestPauseResumeJob:
def test_pause_sets_state(self, tmp_cron_dir):
Expand All @@ -444,6 +469,35 @@ def test_resume_reenables_job(self, tmp_cron_dir):
assert resumed["paused_at"] is None
assert resumed["paused_reason"] is None

def test_resume_rejects_past_oneshot(self, tmp_cron_dir, monkeypatch):
"""Resuming a paused one-shot whose time is now in the past must raise
ValueError — the revived job would silently never fire."""
now = datetime(2026, 7, 6, 12, 0, 0, tzinfo=timezone.utc)
monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
# Create directly — bypass create_job's past-oneshot guard so we can
# test the resume path independently.
job = {
"id": "test-resume-past",
"name": "test-resume-past",
"prompt": "Past one-shot",
"schedule": {"kind": "once", "run_at": (now - timedelta(minutes=5)).isoformat(), "display": "once"},
"repeat": {"times": 1, "completed": 0},
"enabled": False,
"state": "paused",
"paused_at": now.isoformat(),
"paused_reason": "test",
"next_run_at": None,
"last_run_at": None,
"last_status": None,
"last_error": None,
"last_delivery_error": None,
"created_at": (now - timedelta(hours=1)).isoformat(),
"deliver": "local",
}
save_jobs([job])
with pytest.raises(ValueError, match="in the past"):
resume_job("test-resume-past")


class TestResolveJobRef:
"""Name-based job lookup for CLI/tool callers (PR #2627, @buntingszn)."""
Expand Down
Loading