Repository navigation
feat(dispatcher): defer limit-blocked runs instead of dropping them - #674
Open
analisaperlengkapan wants to merge 5 commits into
Open
analisaperlengkapan wants to merge 5 commits into
analisaperlengkapan wants to merge 5 commits into
Conversation
A burst of automations firing together (shared cron tick, webhook burst) could exceed the organization concurrency cap or the daily sandbox quota. The blocked runs were marked terminal SKIPPED, so event-triggered work was consumed with nothing to show and a missed cron fire never came back. Return those runs to the queue instead: - A run blocked by a transient limit goes back to PENDING with deferred_until set from a bounded exponential backoff; the dispatch poll skips a PENDING run whose deferred_until is still in the future. Past AUTOMATION_DEFER_MAX_AGE_SECONDS the run is SKIPPED with a defer_expired status_detail, so the outcome stays truthful. Setting the age to 0 restores the previous immediate-skip behavior. - AUTOMATION_MAX_CONCURRENT_RUNS caps runs in flight: the dispatcher admits only up to the cap per poll and leaves the rest PENDING (the table is the queue) until a slot frees. Unset preserves today's behavior. - automation_runs gains deferred_until + deferral_count (migration 038, single head, PostgreSQL and SQLite aligned). requeue is guarded by status == RUNNING so a run that already reached a terminal state is never resurrected, and cannot double-execute. Co-authored-by: openhands <openhands@all-hands.dev>
analisaperlengkapan
marked this pull request as ready for review
November 3, 2026 02:26
Address review findings on the deferral change: - Serialize the count-and-claim admission step across replicas with a transaction-scoped advisory lock, so two simultaneous polls cannot each admit the full apparent capacity under read-committed isolation. - Sweep-expire deferred runs past their max deferral age on every poll, independent of available capacity, so a run deferred behind a full cap is still reaped instead of sitting PENDING forever. - Raise DeferredRunRequeueError when the requeue write fails, so the caller records the failure and fails the run instead of stranding it as RUNNING with no sandbox. - Bound the retry backoff by the time left before the max deferral age, so a run near the deadline is not rescheduled to start after it. - Preserve the original limit reason (concurrency_limit/daily_sandbox_limit) when deferral is disabled, keeping status kinds and telemetry skip reasons unchanged. Co-authored-by: openhands <openhands@all-hands.dev>
…deferred runs The expiry sweep marked an aged deferred run SKIPPED without releasing its subject or emitting a skip event, so two gaps followed. A deferred run has no started_at (requeue_deferred_run clears it), so a terminal run that kept its subject still answered continue_conversation: the next event for that subject was folded into the SKIPPED run's payload and never dispatched. The sweep now stamps subject_released_at in the same guarded update that sets SKIPPED, keeping the terminal status and the subject release atomic. The sweep also ran on every poll, including one that finds every slot occupied and returns before the dispatcher's own skip handler, so those expirations were absent from telemetry. It now emits automation_run_skipped with skip_reason defer_expired for each row it actually updated, attributed to the run and its automation. The SELECT now returns the rows (with the automation eager-loaded) and the UPDATE returns the ids it matched, so events are emitted only for rows the guarded update really changed.
The capacity gate returns before the dispatcher's skip handler, so the sweep is the only emitter for a run expired while every slot is occupied. Assert the automation_run_skipped event with skip_reason defer_expired on that path, matching the reviewer's example.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue Number
Closes #673
Why
When many automations fire at once (several 5-minute cron schedules sharing a
timestamp, or a burst of webhook events), the runs that hit a limit are lost
rather than queued.
_execute_runmarked an organization concurrency-limit 429(
ConcurrencyLimitReachedError) or a daily-sandbox-quota 429(
DailySandboxLimitReachedError) as terminalSKIPPEDviamark_run_terminal.SKIPPEDis terminal, so the run is never retried: an event-triggered runconsumes its event with nothing to show, and a cron run misses its fire for a
whole interval.
This implements the bounded queue the issue asks for: a run that cannot start
now is deferred and started later instead of being dropped.
Summary
PENDINGwithdeferred_untilset from a bounded exponential backoff(
AUTOMATION_DEFER_BACKOFF_BASE_SECONDS, capped at the max age). The dispatchpoll skips a
PENDINGrun whosedeferred_untilis still in the future.AUTOMATION_DEFER_MAX_AGE_SECONDS(default 6h,measured from the run's creation) the run is marked
SKIPPEDwith adefer_expiredstatus_detail, distinct fromconcurrency_limit/daily_sandbox_limit, so the outcome stays truthful. Setting the age to0disables deferral and restores today's immediate-skip behavior.
AUTOMATION_MAX_CONCURRENT_RUNScaps runs in flight: thedispatcher admits only up to the cap per poll and leaves the rest
PENDING(the table is the queue) until a slot frees. Unset (
0) preserves today'sbehavior. Ordering stays FIFO by
created_at, unchanged from the current poll.automation_runsgainsdeferred_untilanddeferral_count(migration
038, one head, PostgreSQL and SQLite aligned). The requeue writeis guarded by
status == RUNNING, so a run that already reached a terminalstate (callback, cancel, watchdog) is never resurrected and cannot
double-execute.
deferral_countlives on the row becausestatus_detailiscleared when the run goes
RUNNING.automation_run_deferredtelemetry event with the retry timing, andan
automation_run_skippedevent withskip_reason: defer_expiredon expiry.AutomationRunResponsereturnsdeferred_untilso a client can show a queuedrun's retry time.
Hardening after review
A follow-up commit closes five gaps in the first pass, each with a test that
fails without the fix:
claiming rows under
SKIP LOCKEDlet two replicas read the same count underread-committed isolation and claim disjoint rows, so combined admissions could
exceed the cap. A transaction-scoped advisory lock
(
pg_advisory_xact_lock) now spans the count and the commit; SQLite, whichruns single-process, skips it (the same pattern as
conversations.py).a dispatch attempt, but a poll that finds every slot occupied returns before
claiming, so a deferred run could sit
PENDINGforever. An independent sweepnow expires aged deferred runs on every poll, regardless of free capacity.
requeue_deferred_runused to returnFalseon a write error, which thecaller could not tell apart from a lost terminal race, leaving the run
RUNNINGwith no sandbox until the watchdog. It now raisesDeferredRunRequeueError;Falsemeans only "the guarded update matched norow".
left before the max age (
max_age - age), not by the age limit itself, so arun near the deadline is not rescheduled to start after it.
0therun is skipped with its original
concurrency_limit/daily_sandbox_limitstatus kind and skip reason, so existing consumers are unchanged.
A second follow-up closes two gaps in the expiry sweep itself, also each with a
test that fails without the fix:
no
started_at(the requeue clears it), so a terminal run that kept itssubject still answered
continue_conversationand swallowed the next eventfor that subject into a run that would never dispatch. The sweep now stamps
subject_released_atin the same guardedUPDATEthat setsSKIPPED, so theterminal status and the release cannot be observed apart.
finds every slot occupied and returns before the dispatcher's own skip
handler, so those expirations were missing from telemetry. It now captures
automation_run_skippedwithskip_reason: defer_expiredfor each row theguarded update actually changed, attributed to the run and its automation
(the
SELECTreturns the rows with the automation eager-loaded, and theUPDATE ... RETURNING idbounds the events to rows really updated).How to Test
uv run python -m pytest tests/— full suite passes (2444 passed, 7 skipped).New unit tests cover the capacity gate (admit up to capacity, count existing
RUNNING, empty at capacity, deferred runs excluded then eligible after
deferred_until), defer/requeue (returns to PENDING, clearsstarted_at/timeout_at, is idempotent, never resurrects a terminal run),backoff progression and its deadline bound, deadline expiry (including the
capacity-independent sweep, subject release, and the skip event), the
cross-replica admission lock, the failed-requeue path, and
count_running_runs.An end-to-end routing test in
test_conversations.pyreproduces the swallow:an expired deferred run releases its subject, so the next thread event starts a
fresh run instead of folding into the dead one.
uv run ruff check,uv run ruff format --checkanduv run pyrighton thechanged files are clean. Migration 038 verified as the single Alembic head.
Notes
issue. A permanent failure still marks the run
FAILEDunchanged.overlap guards (Add a minimal per-automation overlap guard (skip when a run is already in-flight) #218), hierarchical admission limits (Implement hierarchical concurrency limits for automation runs #72), event-inflow
backpressure (Implement event queue management with backpressure strategies #74), and Canvas UI work (#18576) — this ships the service-side
signal only.
defer_expireddistinction matters for operators:concurrency_limit/daily_sandbox_limitnow mean "waiting to retry", whiledefer_expiredmeans"gave up after the deadline".
This pull request was created by an AI agent (OpenHands) on behalf of the repository maintainers.