Repository navigation
Conversation
Contributor
Author
|
@TonsOfFun ready to go! |
This was referenced Nov 7, 2026
Merged
A worker that dies after claiming a run leaves it running, and nothing in the run says whether anyone is still working on it: started_at is a start, and the progress events a generation emits do not move updated_at. This is the first of three steps toward recovering such runs, and changes no outcome on its own. Migration 033 adds execution_token and execution_expires_at to agent_runs, with an index on status and expiry. Each claim of a run for an execution segment (AgentExecutionJob#claim_pending, AgentResumeJob#claim_pause, Agent#test_execute) writes a new token and an expiry ActionAgent.run_lease_duration ahead, 30 minutes by default. Each progress event renews the expiry while the run is running under that token. The token is per segment, so a resumed run is a new owner. A host that has not run the migration, or sets the duration to nil, takes no leases, detected from the columns as the scoring lease does.
unless_cancelled kept a cancel from being overwritten by the result of a worker that was still running, but nothing else: a run ended any other way while its worker was slow, failed as lost say, was turned complete by the late result, with the other ending's error message left beside the output. Agent#test_execute had no fence at all and overwrote a cancel too. unless_cancelled now takes the worker's lease token, and yields only while the run still holds it. Every write that ends a segment (the result, the pause, the failure) releases the lease, so anything that ends the run, or a later segment that claims it under a new token, makes a late result a no-op that is logged. AgentExecutionJob, AgentResumeJob and test_execute pass the token their claim took; test_execute now records through unless_cancelled like the jobs. Without a lease (no migration 033, or no lease duration) the token is nil and the check is the cancel alone, as before.
A run whose worker died after claiming it stayed running forever: a redelivered job leaves a running run alone, and nothing else looked. AgentRun#expire_if_lost! fails a running run whose lease ran out in one conditional update on its id, status, token and expiry, so a renewal, a result, a cancel or another sweep that lands first wins. The run is recorded as failed with error_class ActionAgent::AgentRun::WorkerLost, no new status, so lists, filters and scorecards read it as they read any failure. Its message says the run was not executed again and that its provider and tool calls may already have taken effect; nothing is retried, and a worker that was only slow has its result discarded by the fence. The run's page and the run list expire the lost runs they reach, and AgentRunExpiryJob, not scheduled by default, reaches the rest, as input requests already expire. A run with no lease, such as one started before migration 033, is never expired.
The dashboard guide gains a section on what happens when a run's worker is lost: the lease each claim takes and what renews it, when a lost run is failed and how to schedule AgentRunExpiryJob, that nothing is executed again and what that leaves to check, the cost of a provider call longer than the lease, and what happens to runs that predate the migration. The changelog lists migration 033 among the upgrade steps and describes the change under Fixed.
yorzi
force-pushed
the
fix/agent-run-recovery
branch
from
November 13, 2026 04:10
096b278 to
1ddf840
Compare
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.
What this does
Since #664 a run is claimed once, so a redelivered job never runs it twice. The other side of that, which #664 left out of scope: a worker that dies after its claim (a kill, an out-of-memory error, a deploy that does not wait for jobs) leaves the run
runningfor good. The redelivery sees a running run and leaves it, and nothing else ever looks at it. The same happens afterAgentResumeJobclaims a paused run, and for a web process that dies insideAgent#test_execute. Nothing in the run says whether anyone is still working on it:started_atis a start, andappend_eventwrites withupdate_column, soupdated_atdoes not move while the run works.Ending such a run is only safe by cancelling it.
unless_cancelleddiscards a late result over a cancel, but any other ending is overwritten. A run failed by anything other than a cancel while its worker was only slow came backcomplete, with the late output and the other ending's error message side by side.This follows two things already in the engine: the scoring lease from #614 (a token, an expiry, a fenced write, column detection for hosts that have not migrated) and
InputRequestExpiryJob(expired when read, plus an optional recurring job). It is four commits:execution_tokenandexecution_expires_attoagent_runs, indexed withstatus. Every claim of a run for an execution segment writes a new token and an expiryconfig.run_lease_durationahead (30 minutes by default). Those claims areclaim_pending,claim_pauseand the creation intest_execute. Each progress event (one per LLM or tool call) renews the expiry while the run is running under that token. Without the columns, or with the duration set tonil, no lease is taken.unless_cancelledtakes the worker's token and yields only while the run still holds it. Every write that ends a segment releases the lease (result, pause, failure), so whatever ended the run first is kept, and a late result is a logged no-op.test_executenow records through it as the jobs do, which also stops it overwriting a cancel.AgentRun#expire_if_lost!is one conditional update on id, status, token and expiry. It recordsfailedwitherror_classActionAgent::AgentRun::WorkerLostand a message that says the run was not executed again. The run's page and the run list expire what they reach.ActionAgent::AgentRunExpiryJobreaches the rest and is not scheduled by default. A run with no lease, such as one running when the migration is installed, is never expired.docs/framework/dashboard.md, the upgrade note and a Fixed entry in the CHANGELOG, and the option in the generated initializer.Nothing is executed again, by design. The worker may have died after the provider billed the call or after a tool acted, so the run's message and the docs say so, and running the prompt again is a new run made by a person.
Choices made here that are yours to change:
failedwitherror_classWorkerLost(whatrecord_failure!already writes), so lists, filters and scorecards need no change. The alternative is a newinterruptedstatus.InputRequestExpiryJob. Without a scheduler, a lost run nobody opens staysrunning, as today.JUDGE_LEASE_DURATION, renewed only by progress events. A single provider call longer than that looks lost, and its result is then discarded. A heartbeat thread would avoid that but would also keep a wedged process alive. Raising the setting is the documented answer.Tests, all in
actionagent/test/run_lease_test.rbplus one ininstall_generator_test.rb, 32 in all:test_executeeach take a lease; two claims never share a token; an event renews; a renewal leaves a run that finished or changed token alone; no lease without a duration or without the columns; the migration installs under a custom prefix and reverses.test_execute. A cancel duringtest_executestays. A worker whose run was taken under another token records nothing. A completed, paused or failed segment releases its lease. Plus one real-connection test: the worker held in its generation on its own connection while the run is failed as lost on another.WorkerLost, its existing metadata kept, announced once, one log line. Not executed again. A live run, a renewed one or one re-taken since it was read is left running, and a live run costs no query. Only running runs expire, never one without a lease, and nothing without the columns. A cancel that lands first wins. The job fails every lost run and only those. The run's page and the run list show it failed. A late worker's result is discarded after expiry. A run that keeps reporting progress outlasts the lease. Plus a real-connection race of two sweeps (one transition, one broadcast).Each part of each commit reverted in turn fails at least one test (7 parts in the lease commit, 8 in the fence, 13 in the expiry). Each commit passes its own tests when checked out alone.
Backends: SQLite on Rails 8.1.4 and 7.2.3.2. PostgreSQL 17 through a standalone Active Record probe of the same method bodies, since the dummy schema does not load on PostgreSQL (an index name over 63 characters). One claim in 50 races. The expiry writes the JSON metadata through
update_all. Two sweeps make one transition in 50 races. A renewal racing a sweep was consistent in 50 races. A late worker is fenced whichever commits first. The engine suite on PostgreSQL itself is not run.Not in this PR: stopping a generation early when its renewal fails (it would save spend, but changes no guarantee); a "run again" action; a task for runs that predate the migration (cancel them).
Type of change
Scope check
Related issue
None filed. Follow-up to #664, which names this gap as out of scope. Precedents: #614.
Quality check
bin/rubocopon the changed files; the repo has no commit hooks installed)bin/testwith the CI placeholder keys. Rails 7.2, database built as the CI job builds it (db:create db:migrate, so migration 033 applies from scratch): 4426 runs, 0 failures, 0 errors. Rails 8.1: 4422 runs, 0 failures, 10 errors, allCliContractTest(rack-sessionundefined method 'id' for Hash), which fail identically on an untouchedmainin this environment. Each commit also passes the lease, lifecycle, generator and input request tests on its owndocs/framework/dashboard.md, CHANGELOG with the upgrade note, the generated initializer)db:create db:migratebuilds the same columns)Ownership
API changes
AgentRun#expire_if_lost!,#renew_lease!,.expire_lost!,.lease_attributes,.execution_leases?,.lost,AgentRun::WorkerLost,AgentRunExpiryJob,config.run_lease_duration)AgentRun#unless_cancelledtakes an optionaltoken:; callers without it behave as before)