From 22e087e92ab7cdf1e2e2aab231e74a78e5f29ba6 Mon Sep 17 00:00:00 2001 From: isheng Date: Mon, 6 Jul 2026 14:20:24 +0800 Subject: [PATCH 1/2] fix(cron): reject past one-shot timestamps in update_job and resume_job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_job already guards against past one-shot schedules (#59410), but update_job (schedule change and fallback-recompute paths) and resume_job still accepted them — persisting next_run_at=None and creating ghost jobs that silently never fire. This extends the same guard (raise ValueError on unreachable one-shot) to all three remaining call sites in cron/jobs.py: - update_job schedule-changed branch (L1152) - update_job fallback-recompute branch (L1165) - resume_job (L1197) Fixes the full bug class, not just the create entry point. Closes #59395 --- cron/jobs.py | 15 +++++++++++- tests/cron/test_jobs.py | 54 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/cron/jobs.py b/cron/jobs.py index 851e15b5eeb5..62633ff2d735 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -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) @@ -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"], { diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index 4ba0e966dc5e..cf02be2038e3 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -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): @@ -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).""" From d0675f571c6fbf3fa6a3e9dea10cb65e79a1408b Mon Sep 17 00:00:00 2001 From: isheng Date: Mon, 6 Jul 2026 15:56:24 +0800 Subject: [PATCH 2/2] chore: add ishengeqi@163.com to AUTHOR_MAP --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 01275e6e8d0a..7fafacf71d34 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -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)