Repository navigation
fix(scheduler): skip cron runs while an automation is active - #283
trungminhdo4-glitch wants to merge 4 commits into
Conversation
|
HUMAN: (Owner-authorized; posted by my agent on my behalf.) I am supervising this contribution, and it remains a draft by design. Before I decide it is ready for review, I want the concurrency contract to remain explicit: one batch query for due automations; |
|
👋 This PR needs a couple of things fixed before OpenHands can review it:
Push an update once this is addressed and this check re-runs automatically. This is an automated check - no AI was used to generate this comment. |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was posted by an AI agent (OpenHands).
Overall the overlap-guard design is sound and the new logic in openhands/automation/scheduler.py matches the stated contract: one batched query on automation_runs(automation_id, status) limited to PENDING/RUNNING, evaluated inside the same transaction that already holds the automation-row lock; a skip still updates last_polled_at (set earlier for every fetched automation), leaves last_triggered_at untouched, creates no run row, and emits no telemetry. Terminal statuses are correctly non-blocking, and manual/event dispatch paths (router.py /dispatch, utils/webhook.py::create_automation_run) are untouched. The 8 new scheduler tests pass against this head, and ruff check / ruff format --check are clean on both changed files.
Blocking finding: the branch is based on a pre-#486 main, so its new test assertions go stale on merge.
The head still carries automation_run_scheduled in scheduler.py, but that event was deleted from main by commit 2b8110e ("fix: cut redundant/noisy PostHog telemetry events (#486)"), which is an ancestor of current main (2522a54) but not of this branch. The four new assert scheduler_telemetry_events == ["automation_run_scheduled", "automation_run_created"] blocks were written against the stale base.
A three-way auto-merge of this head into current main resolves scheduler.py correctly (only automation_run_created survives) but leaves the test assertions intact, producing a tree that fails:
test_poll_overlap_guard_allows_terminal_run[COMPLETED|FAILED|CANCELLED|SKIPPED]test_poll_overlap_guard_allows_automation_without_runtest_poll_overlap_guard_is_per_automationtest_poll_overlap_guard_preserves_batch_rotation
That is 7 failing tests after merge, even though the head itself is green. The full backend tests.yml / ci.yml workflows have never run on this fork PR (they are action_required), so CI does not currently surface this. Please rebase/cherry-pick this branch onto current main and update the new assertions to expect only automation_run_created (drop the removed automation_run_scheduled).
I did not find other material issues: no security concern, and the skip path's logging-only use of the batched result (last status wins for an automation with multiple in-flight rows) does not affect scheduling behavior.
🔄 CHANGES REQUESTED
|
|
||
| assert [run.automation_id for run in created_runs] == [automation_id] | ||
| assert scheduler_telemetry_events == [ | ||
| "automation_run_scheduled", |
There was a problem hiding this comment.
These new assertions are stale relative to current main. automation_run_scheduled was removed from scheduler.py on main by commit 2b8110e (#486, "cut redundant/noisy PostHog telemetry events"); this branch is based on a pre-#486 main, so it still emits that event. The same assertion appears at lines 530, 578, and 620.
When this branch is auto-merged into current main, scheduler.py correctly loses the event but these test lines remain, so test_poll_overlap_guard_allows_terminal_run[*], test_poll_overlap_guard_allows_automation_without_run, test_poll_overlap_guard_is_per_automation, and test_poll_overlap_guard_preserves_batch_rotation fail (7 tests). Please rebase onto current main and assert only ["automation_run_created"].
enyst
left a comment
There was a problem hiding this comment.
I'm an AI agent (Claude, based on Opus 5.5) helping Engel Nyst (@enyst) with project work.
Requesting changes. The head (6d075c5) is the commit all-hands-bot reviewed on 2026-09-22, and the bot's finding still stands: the new tests assert automation_run_scheduled, an event #486 removed. The branch also no longer merges cleanly with current main.
Merge state. git merge-tree against current main now reports content conflicts in openhands/automation/scheduler.py and tests/test_scheduler.py. #438 changed this same call to create_pending_run(session, automation, trigger_source="cron") and added the AutomationState imports. When you resolve the conflict, keep trigger_source="cron" on the guarded call.
Scheduling checks (new points, not blocking on their own):
- This defers the cron run rather than skipping it. A skipped automation keeps its
last_triggered_at, sois_automation_duestays true. The missed fire then runs on the first 60 s poll after the active run ends, off schedule. For example,*/5with 7-minute runs effectively runs back to back. The "Skipping cron run for active automation" INFO line also repeats on every poll while the automation is blocked. If coalescing is intended, say so in the docstring and the PR description. If a true skip is intended, advancelast_triggered_aton the skip path. - A stuck RUNNING row now stops the schedule silently. The guard makes cron liveness depend on the watchdog. Normally that is bounded by
timeout_at(run timeout plus margin, plus the still-running hard grace). Butwatchdog.py(theTRANSIENT_ERRORbranch of_verify_and_mark_run) leaves a run RUNNING with no cap while verification keeps returning a transient error. Before this PR such a row did not stop the automation. After it, the automation stops firing, and the only trace is a log line: no run row, nothing in run history. Consider recording the skip visibly, for example as aSKIPPEDrun with a skip reason like the dispatcher's concurrency-limit path, or bounding that watchdog branch. - Multi-replica: I agree with the PR's concurrency claim for the cron path. The in-flight query runs in the same transaction that holds the
FOR UPDATE SKIP LOCKEDlock on the automation row. A second replica can only claim that row after the commit, and by then it sees the new PENDING run. Manual and event runs can still overlap a cron run, which matches #218's scope.
What I verified: the diff against the merge base; git merge-tree against current main and its conflict hunks; scheduler.py, watchdog.py, utils/cron.py and utils/run.py on main; and that the fork's test/CI workflows still have not run on this head. I did not run the tests.
…overlap-guard # Conflicts: # openhands/automation/scheduler.py # tests/test_scheduler.py
|
I’ve updated the branch against current The stale I also changed the overlap behavior to a true skip rather than a deferred catch-up: a blocked cron occurrence is recorded as consumed. The new Local validation passed with 9 focused overlap/true-skip tests, 14 cron utility tests, Ruff check/format, Pyright with zero diagnostics, and I also verified the separate watchdog |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was posted by an AI agent (OpenHands).
Reviewed head efbd3da (the earlier 6d075c5 findings are superseded). The previous blockers are resolved on this revision:
- The stale
automation_run_scheduledassertions are gone; no reference to that event remains inopenhands/automation/scheduler.pyortests/test_scheduler.py. - The branch now merges cleanly with current
main(git merge-tree --write-treeexits 0, no conflicts;mergeable: true, onlybehind). - #438's
create_pending_run(session, automation, trigger_source="cron")is preserved on the guarded call.
The implementation matches the stated concurrency contract:
- One batched query (
_get_in_flight_run_statuses) overautomation_runsrestricted to the due automation ids and toPENDING/RUNNING, executed inside the same transaction that holds theFOR UPDATE SKIP LOCKEDlock on the automation row. A second replica cannot observe the row before commit and then sees the new run. - Terminal statuses (
COMPLETED,FAILED,CANCELLED,SKIPPED) do not block. - On a skip,
last_polled_atis already updated for every fetched automation (fair batch rotation),last_triggered_atis advanced to the blocked scheduled slot so the occurrence is consumed rather than replayed, no run row is created, and no run-created telemetry is emitted. Because the slot is consumed, the skip does not repeat on every poll for the same occurrence. - Manual and event dispatch paths (
router.py/dispatch,utils/webhook.py) are untouched, so the guard is cron-only as documented.
Validation on the exact head: the ci, Docker, and Run tests workflows all completed successfully against efbd3da (I confirmed each run's head_sha). Locally, the 11 focused overlap/true-skip scheduler tests pass, including on a materialized merge of this head with current main; ruff check and ruff format --check are clean on both changed files. The remaining 19 errors in the full file are Testcontainers/PostgreSQL fixtures failing because no Docker daemon is available here, not code failures.
Non-blocking residual risk (already raised in-thread and explicitly scoped out by the author, so I am not treating it as a blocker): the guard makes cron liveness depend on in-flight runs reaching a terminal state. The TRANSIENT_ERROR branch of watchdog.py::_verify_and_mark_run leaves a run RUNNING and does not extend or cap timeout_at, so during a sustained agent-server outage a row can stay RUNNING indefinitely and silently stop that automation's cron schedule (only an INFO log line, no run row). This is pre-existing watchdog behavior that this PR converts into a scheduling stall; worth a follow-up, but outside #218's scope.
✅ APPROVED
enyst
left a comment
There was a problem hiding this comment.
I'm an AI agent (Claude, based on Opus 5.5) helping Engel Nyst (@enyst) with project work.
My earlier findings are addressed. The stale assertions are gone, and a blocked slot is now consumed: last_triggered_at is set to the skipped fire time, so is_automation_due stays false until the next slot.
Two things before merge:
- Conflict with
mainagain. #544, merged after the bot's approval, wrappedcreate_pending_runin anautomation.scheduler.firespan inscheduler.py. Keep the in-flight check ahead of that span. - The new tests run only on SQLite. They use the in-memory
sqlite_session_factory(tests/test_scheduler.py:47), and on SQLitepoll_and_scheduleskipsFOR UPDATE SKIP LOCKED. The existing scheduler tests use conftest's PostgreSQLasync_session_factory. Please use it here too, so the guard runs on the locking path its concurrency argument relies on. I missed this in my last review.
HUMAN: I reviewed the updated scheduler behavior, the final diff, and the reported local and GitHub validation results before publication. The implementation and automated test execution were performed with AI assistance under my direction.
Summary
PENDINGorRUNNINGrunWhy
A cron automation can currently schedule another run while its previous run is still pending or running. Long-running automations can therefore stack multiple concurrent sandboxes.
Concurrency contract
The guard is evaluated inside the scheduler transaction that already locks the automation row with
FOR UPDATE SKIP LOCKEDon PostgreSQL.This PR intentionally applies the guard only to cron scheduling. Manual and event-triggered dispatch behavior remains unchanged, and no database uniqueness constraint is introduced, leaving room for an explicit per-automation override later.
SQLite remains covered under the repository's existing single-process assumption.
Behavior
Blocking statuses:
PENDINGRUNNINGNon-blocking statuses:
COMPLETEDFAILEDCANCELLEDSKIPPEDWhen a cron occurrence is blocked:
last_polled_atis updated normallyFor example, with a
*/5schedule, if the minute-5 occurrence is blocked by an active run that finishes at minute 7, no catch-up run is created at minute 7 or 9; the next eligible occurrence is minute 10.Tests
Local validation on the updated branch:
The full scheduler test file was not completed locally because the locked environment's pinned
litellmdependency required a Rust toolchain unavailable on that execution host, and the repository-wide scheduler fixture uses PostgreSQL/Testcontainers.GitHub workflows on the published head subsequently passed:
ciDockerRun testsThe PostgreSQL multi-worker serialization property follows the scheduler's existing automation-row lock.
Fixes #218
Disclosure: This contribution was prepared with AI assistance. I reviewed the resulting behavior, diff, and validation evidence before publication.