Skip to content

fix(scheduler): skip cron runs while an automation is active - #283

Open
trungminhdo4-glitch wants to merge 4 commits into
OpenHands:mainfrom
trungminhdo4-glitch:fix/issue-218-cron-overlap-guard
Open

trungminhdo4-glitch wants to merge 4 commits into
OpenHands:mainfrom
trungminhdo4-glitch:fix/issue-218-cron-overlap-guard

Conversation

@trungminhdo4-glitch

@trungminhdo4-glitch trungminhdo4-glitch commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

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

  • skip cron-triggered run creation when the same automation already has a PENDING or RUNNING run
  • consume a blocked cron occurrence instead of replaying it after the active run finishes
  • preserve normal scheduling from the next legitimate cron occurrence
  • keep manual and event-triggered dispatch behavior unchanged
  • preserve scheduler polling fairness and telemetry semantics

Why

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 LOCKED on 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:

  • PENDING
  • RUNNING

Non-blocking statuses:

  • COMPLETED
  • FAILED
  • CANCELLED
  • SKIPPED

When a cron occurrence is blocked:

  • last_polled_at is updated normally
  • scheduler bookkeeping advances to the blocked scheduled fire time
  • no run row is created
  • no run-created telemetry is emitted
  • the missed occurrence is not replayed when the active run terminates
  • scheduling resumes at the next legitimate cron occurrence

For example, with a */5 schedule, 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:

  • focused overlap and true-skip scheduler regressions: 9 passed
  • cron utility regressions: 14 passed
  • Ruff check: passed
  • Ruff format check: passed
  • Pyright on changed files: 0 errors / 0 warnings
  • Python compilation check: passed

The full scheduler test file was not completed locally because the locked environment's pinned litellm dependency 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:

  • ci
  • Docker
  • Run tests

The 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.

@github-actions github-actions Bot added the type: fix A bug fix label Jul 30, 2026
@trungminhdo4-glitch

Copy link
Copy Markdown
Contributor Author

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; PENDING and RUNNING block cron scheduling only; a skip updates last_polled_at without changing last_triggered_at or emitting scheduled/created telemetry; PostgreSQL relies on the existing automation-row lock, while SQLite retains its single-process assumption. The two lightweight PR checks pass; the four full fork workflows remain action_required and require maintainer approval before they can run. I will make the final ready-for-review and requested-change decisions.

@trungminhdo4-glitch
trungminhdo4-glitch marked this pull request as ready for review August 22, 2026 08:29
@all-hands-bot

Copy link
Copy Markdown
Contributor

👋 This PR needs a couple of things fixed before OpenHands can review it:

  • the PR description's HUMAN: section needs at least 20 characters describing what you tested, not just the template placeholder

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 all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_run
  • test_poll_overlap_guard_is_per_automation
  • test_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

Comment thread tests/test_scheduler.py Outdated

assert [run.automation_id for run in created_runs] == [automation_id]
assert scheduler_telemetry_events == [
"automation_run_scheduled",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 enyst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

  1. This defers the cron run rather than skipping it. A skipped automation keeps its last_triggered_at, so is_automation_due stays true. The missed fire then runs on the first 60 s poll after the active run ends, off schedule. For example, */5 with 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, advance last_triggered_at on the skip path.
  2. 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). But watchdog.py (the TRANSIENT_ERROR branch 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 a SKIPPED run with a skip reason like the dispatcher's concurrency-limit path, or bounding that watchdog branch.
  3. 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 LOCKED lock 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
@trungminhdo4-glitch

Copy link
Copy Markdown
Contributor Author

I’ve updated the branch against current main without rewriting history and addressed the review findings.

The stale automation_run_scheduled assertions from before #486 are gone, and the cron path still preserves #438’s trigger_source="cron".

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 */5 regression verifies that a minute-5 occurrence blocked by an active run does not run after that run finishes at minute 7 or at minute 9; the next run is allowed at minute 10.

Local validation passed with 9 focused overlap/true-skip tests, 14 cron utility tests, Ruff check/format, Pyright with zero diagnostics, and py_compile. The GitHub ci, Docker, and Run tests workflows on the pushed head are also green.

I also verified the separate watchdog TRANSIENT_ERROR concern mentioned in review. It can leave a run RUNNING and therefore block later cron occurrences, but I left that existing watchdog-liveness issue outside the scope of this PR rather than expanding #283 into a watchdog change.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_scheduled assertions are gone; no reference to that event remains in openhands/automation/scheduler.py or tests/test_scheduler.py.
  • The branch now merges cleanly with current main (git merge-tree --write-tree exits 0, no conflicts; mergeable: true, only behind).
  • #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) over automation_runs restricted to the due automation ids and to PENDING/RUNNING, executed inside the same transaction that holds the FOR UPDATE SKIP LOCKED lock 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_at is already updated for every fetched automation (fair batch rotation), last_triggered_at is 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 enyst left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Conflict with main again. #544, merged after the bot's approval, wrapped create_pending_run in an automation.scheduler.fire span in scheduler.py. Keep the in-flight check ahead of that span.
  2. The new tests run only on SQLite. They use the in-memory sqlite_session_factory (tests/test_scheduler.py:47), and on SQLite poll_and_schedule skips FOR UPDATE SKIP LOCKED. The existing scheduler tests use conftest's PostgreSQL async_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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: fix A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a minimal per-automation overlap guard (skip when a run is already in-flight)

3 participants