Pin timeout and retry semantics; establish that fallback fails closed - #444
Merged
Conversation
The last unpinned part of the execution contract. Everything here was
already implemented and none of it was asserted anywhere, so none of it
was safe to write down. Measuring it found two defects and one thing I
had assumed was missing but is deliberately absent.
Retry was double-counted. `Task.fail()` increments retry_count, and it
was called twice for one failure: once by the retry handler in
_execute_task_with_resources, again when the gathered result was
processed. A step with `max_retries: 0` therefore reported one retry.
The outer call now only records a failure the inner path has not already
recorded.
With that fixed, the measured semantics are: `max_retries` bounds the
total number of ATTEMPTS, not retries beyond the first. `max_retries: 2`
is two attempts and one retry; 0 and 1 are both a single attempt. The
name and the behaviour disagree. Changing it would shift every existing
pipeline's retry budget, so the behaviour is pinned by tests and the
mismatch recorded rather than silently corrected.
Timeouts are enforced with asyncio.wait_for and are retried like any
other failure, so worst-case wall time is roughly timeout x max_retries
-- measured, not assumed: 1s timeout with max_retries 2 takes ~2.7s.
StepResult.timed_out distinguishes a timeout from an ordinary failure,
because "raise the timeout" and "fix the step" are different responses.
There is no model fallback on the canonical path, and that is correct
rather than an omission. ModelRegistry.select_model raises
NoEligibleModelsError instead of substituting a model the pipeline did
not ask for; models/model_selector.py has fallback strategies but
nothing on the canonical path uses it. Quietly selecting an unrequested
model is exactly how a cost-control policy gets bypassed, so this is
pinned as a test rather than "fixed". Fallback events had no emission
point because fallback does not happen.
A fail-fast gap is recorded rather than closed. `fail` is the default
policy but fires only for a step that RAISED; a tool returning
{"success": False} without raising leaves its task COMPLETED, so the run
continues past a step the author asked to abort on. Making the policy
consult StepResult.success closes it in one line and was tried -- and
reverted, because the policy aborts by raising, so the run then produced
no result document at all, discarding the trace exactly when it is most
wanted. Two golden tests caught that immediately. Closing it properly
means the execution loop stops scheduling rather than throwing, and
still returns a PipelineResult. ADR 0001 records it, and a test pins the
current behaviour so closing it is a visible change.
Blocking suite 506 -> 515 passed, 13 skipped, 0 failed, 0 xfailed.
Collection 3301 -> 3310.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Member
Author
|
CI 9/9 green.
The −1 failed / +1 passed is within the noise floor and should not be read as an improvement. #443 established that this suite is not deterministic: I mention it because I have twice reported a legacy delta as signal when it was not, and a −1 in the favourable direction is exactly the case where it is tempting to stop checking. |
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.
Closes the last unpinned part of the execution contract, so
06_failure_policycan be written as a real acceptance spec.Everything here was already implemented. None of it was asserted anywhere, so none of it was safe to write down. Measuring it found two defects and one thing I had assumed was missing but is deliberately absent.
Retry was double-counted
Task.fail()incrementsretry_count, and it was called twice for one failure: once by the retry handler in_execute_task_with_resources, again when the gathered result was processed. A step withmax_retries: 0reported one retry.Measured, before and after the fix:
max_retriesmax_retriesbounds attempts, not retriesWith the count honest, the semantics are visible:
max_retries: 2is two attempts and therefore one retry.0and1are both a single attempt.The name and the behaviour disagree. Correcting it would shift every existing pipeline's retry budget, so the behaviour is pinned by tests and the mismatch recorded rather than silently changed. Worth a decision, not a drive-by.
Timeouts
Enforced via
asyncio.wait_for, and retried like any other failure — so worst-case wall time is roughlytimeout × max_retries. Measured:timeout: 1withmax_retries: 2takes ~2.7s, not 1s. That is the kind of thing you want to know before setting these numbers, so there is a test asserting the multiplication actually happens.StepResult.timed_outnow distinguishes a timeout from an ordinary failure, because "raise the timeout" and "fix the step" are different responses.Fallback: nothing to record, because nothing falls back
I said last time that fallback events had "no emission point". The truth is better: there is no fallback on the canonical path at all.
ModelRegistry.select_modelraisesNoEligibleModelsErrorrather than substituting a model the pipeline did not ask for.models/model_selector.pydoes have fallback strategies — and nothing on the canonical path uses it.That is correct rather than an omission, so it is pinned as a test rather than "fixed". Quietly selecting an unrequested model is exactly how a cost-control policy gets bypassed: the pipeline believes it ran on the free model it asked for. Any future fallback has to record what it did and must not cross the free/paid boundary silently.
A gap recorded rather than closed
failis the default policy, but it fires only for a step that raised. A tool returning{"success": False}without raising leaves its taskcompleted, so the run continues past a step the author asked to abort on.Making the policy consult
StepResult.successcloses it in one line. I tried it, and reverted it: the policy aborts by raising, so the run then produced no result document at all — discarding the trace precisely when it is most wanted. Two golden tests caught it immediately.Closing this properly means the execution loop stops scheduling rather than throwing, and still returns a
PipelineResult. That is a change to the loop, not a predicate, and it deserves its own PR. ADR 0001 records it and a test pins the current behaviour, so closing it later is a visible change rather than a silent one.Numbers
uv lock --check/git diff --check/ docs driftNine tests: timeout fires, is distinguishable, and is not set for a step that finishes in time; retries bounded,
max_retries: 0is one attempt, and the timeout×retry multiplication is real;continuelets the run finish while still reporting failure; the non-raising fail-fast gap; and selection failing closed.