Repository navigation
fix(task): a step completion a later attempt fenced off raises instead of vanishing - #276
Merged
Merged
Conversation
…d of vanishing record_step_complete_sync declined a completion dispatched under an attempt epoch older than the instance's, or journaled before a re-register, by returning None, and the async record_step_complete swallowed every error. A dispatcher still on an old epoch therefore lost every step's writes without a word, and its dependents ran on a context without them. Both refusals now raise SupersededAttemptError (logged as a warning), which record_step_complete lets through while still only logging a store error. Task.run's own dispatch is unaffected: it takes its epoch from its registration, and a drained straggler's late write lands in a cancelled future. A run a re-register superseded now stops at its first refused write instead of running on with every write dropped. register_task_instance documents the epoch contract for callers that dispatch steps themselves. The loud error also exposed a journal test whose step was recorded under a stale epoch, so it passed without the step ever landing; it now records under the new run's epoch. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… failure A step whose completion a later attempt refused now ends the run even when the step tolerates failure or has a retry budget: neither can make the run's writes land. A retry was already stopped by the fenced undo; a tolerant step let the superseded run carry on to its dependents. If deleting the refused step's journal row also fails, the refusal is still what the caller gets. The orphan row is harmless, since replay drops a step with no completion. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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
A step completion that the task store refuses because a later attempt owns the instance now raises
SupersededAttemptErrorinstead of returning without a trace.Before this,
record_step_complete_syncdeclined such a write silently in two cases:attempt_epocholder than the instance's;The async
record_step_completealso swallowed every exception. A dispatcher that recorded steps under a stale epoch therefore lost every step's writes with no error and no log at all. Its dependents then ran on a context without them, and failed far from the cause with messages like "sandbox not found in context".The easiest way to get there is legitimate-looking: create the instance, then register it again before running it. The re-register advances the instance's
attempt_epoch, which was undocumented, and a dispatcher that keeps its own epoch counter starting at 0 is fenced off from its first step.Changes
TaskInstanceStore.record_step_complete_sync: both refusals (stale epoch, stale generation) raiseSupersededAttemptError, naming the instance, the step and the reason, and log it as a warning. The existing cleanup of the orphaned journal row still runs first. If that cleanup also fails, the refusal is still what's raised. The orphan row is harmless, because replay drops a step with no completion.record_step_complete(async): letsSupersededAttemptErrorthrough and still only logs any other store error, so it's fail-open as before. Retrying a superseded write can't land, so a dispatcher should end the step rather than retry it.register_task_instance: documents the contract. Re-registering advancesattempt_epoch, and a caller that dispatches steps itself must record them under the returned instance's epoch.SupersededAttemptErroris exported fromagent_env.task.Effect on
Task.runTask.runtakes its epoch from its own registration and from each rollback, so a run of its own never trips this.SupersededAttemptErroras terminal, even for a step withfail_task_on_error=Falseor a retry budget, since neither can make this run's writes land.Tests
Task.run, which now ends withSupersededAttemptErrorats0instead of running on tos1.test_a_retired_entry_is_never_replayed_into_the_new_runrecorded its step under epoch 0 after two registrations. So the step never landed, and the test passed without exercising the replay it describes. It now records under the new run's epoch and asserts the step landed before undoing it.test_record_step_concurrency.py;s1;On a Linux VM (Ubuntu 24.04, Python 3.12) at the head commit, running CI's own commands:
uv sync --locked --extra dev,uv lock --check(before and after a version bump) andruff checkare clean.tst/unitplus the protocol tests,-n auto): 9351 passed, 0 failed. One earlier run hittest_cancelling_the_caller_of_abandon_still_abandons_the_run, which asserts on an async teardown's timing under full parallel load. It flakes onmaintoo: 1 failure in 15 isolated runs there, versus 15/15 passing on this branch.main: the eval CLI tests and a bundle-init scopes test, none of which touch the task store.main: no break to the plugin surface.🤖 Generated with Claude Code


Confidence Score: 5/5
The PR appears safe to merge; both previous findings are fixed.Summary
Fenced step completions now raise
SupersededAttemptErrorrather than disappearing silently.Verification was limited to reading code and tests; no tests were run.
Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Step completion] --> B{Later attempt owns instance?} B -- No --> C[Save completion] B -- Yes --> D[Try to remove orphan journal row] D --> E[Raise SupersededAttemptError] E --> F[Task.run stops new work] F --> G[Skip tolerance and retries]Reviews (2) · Last reviewed commit: "End a superseded run at its step, and ke..." · Reviewed by Greptile