fix(fusion): retry the harness author once when the transport stalls - #1534
Conversation
Request changes — please rewrite this in
|
main |
this PR | |
|---|---|---|
| stall after the author staged a harness | harness author exited 1, staging cleaned |
no retry (attempts=[0]), author harness staging target already exists, staging leaked |
| second run, transport healthy | succeeds | fails identically — run_author is never called |
One stall used to cost a lane. Now it costs the lane and wedges every rerun against that output dir until someone deletes .forge_fusion/kernel_harness_<digest>.py by hand. The reason string also changes from the transport failure to a staging failure, and that string is what FusionAbort carries into the manifest.
CI is green because all five tests stub the one new branch:
monkeypatch.setattr(cli, "_prepare_author_harness", lambda *_a, **_k: (True, "", False))2. The classifier already exists, one layer down
fusion/llm_failure.py opens with exactly this PR's proposition:
"""Tell \"the model never answered\" apart from \"the model answered nothing\"."""
and ships classify_llm_error, RETRYABLE_KINDS ("Kinds a retry can still fix"), _error_chain, retry_delay, DEFAULT_ATTEMPTS, DEFAULT_DEADLINE_SEC, env_setting. discover.py already drives the full loop with them. author.py:28 already imports from it, and _run_registered_author holds the exception object while classifying into AUTHOR_RC_SAFETY / AUTHOR_RC_TIMEOUT / AUTHOR_RC_FAILED.
This PR serialises that exception to a log, writes it to disk, reads the last 4 KB back in another module, and substring-matches it. The information is destroyed in one place and reconstructed by grep in another.
3. The reconstruction is wrong — three "never retry" cases come back retryable
Called directly on this head:
| log (real path that produces it) | retry? | _author_failure_is_transient |
|---|---|---|
...; the agent run also failed: APIConnectionError: Connection error. → AUTHOR_RC_SAFETY |
no | True |
TimeoutError: Request timed out after 7200s → AUTHOR_RC_TIMEOUT |
no | True |
...the tool returned an api error earlier so I stopped (task refusal) |
no | True |
API Error: Response stalled mid-stream. → AUTHOR_RC_FAILED |
yes | True |
Neither the safety nor the timeout case is hypothetical. _with_run_error deliberately appends the transport error to a safety verdict, so that path reliably carries a marker — and author.py:1217 says retrying a safety stop "is exactly the anti-pattern the session-resume allowlist already refuses". And _agent_timeout_sec() defaults to 7200 s, so retrying a timeout burns another two hours — the very budget this PR argues it is protecting. "The author costs minutes" only holds on the happy path.
What I'd like instead
Put it where the exception still exists, in _run_registered_author, after the two existing branches:
llm_failure.py classify_llm_error / RETRYABLE_KINDS / retry_delay ← one vocabulary, already there
└─ author.py SAFETY -> never retry (branch + rationale already written)
TIMEOUT -> never retry (it just spent the whole per-attempt budget)
RETRYABLE_KINDS -> retry with backoff and a deadline
└─ command.py unchanged
Smaller than this diff, and it drops out for free:
- no marker table, no log reading, no
harness_author.stalled.log(each attempt writes its own log at that layer); - staging is never touched, so §1 cannot happen;
- safety and timeout already returned, so §3 cannot happen;
- both
run_authorcall sites are covered — this PR only fixescommand.py:307, not the kernel author at:855.
Two more things worth carrying over: there is no backoff (a stalled upstream usually stalls again immediately) and no deadline, while discover.py has both and test_task_preparer_retry_budget.py already encodes the rule — "A retry that cannot plausibly finish must not be started."
Please also fix, in the rewrite
- Move the tests to
test_author_extra.py(28run_authorscenarios,AUTHOR_RC_*assertions already there) and assert on rc: FAILED+retryable retries, SAFETY does not, TIMEOUT does not, OK does not, two failures stop. Don't monkeypatch the function under test, and do assert the returned(ok, error)— none of the five tests currently does. return attempts if ok or True else attemptsis dead —ok or Trueis constant.test_the_retry_is_not_repeatedasserts the same thing astest_a_stalled_stream_is_retried_once.- Two comments state things the code does not do:
# The failed attempt may have left a partial harness staged.(the next line gives up instead of handling it — this is the cause of §1) and "a model that cannot write a conforming harness ... must not be retried" (row 3 of the table above). contextlib.suppress(OSError)around the log copy loses the failed attempt's log silently, right before the retry overwrites it — that is the one piece of evidence this change exists to keep.
Please don't patch around these
Each of these would turn a test green and leave the cause in place:
- deleting the stale staging file in the retry path;
- adding negative markers to exclude safety verdicts;
- adding a "log mentions timeout" exception.
They all compensate for the same root cause: the decision is made in the caller, one layer up from where the exception still exists. Move it down into author.py and all three disappear.
The author is a minutes-long LLM session gating a lane that costs hours, and any non-zero exit aborts the whole fusion run. On 20260916T050331Z-94ee8477 the stream stalled mid-response after the harness was already written and the baseline arms verified within 0.2%; the SDK exited 1 and the lane's remaining ~3h went with it. Deciding this in the caller means reading the decision back out of the author's own log, which destroys the information in one place and reconstructs it by substring in another -- and gets three never-retry cases wrong. _with_run_error deliberately appends the transport error to a safety verdict, so a provider stop reads as retryable; _agent_timeout_sec() defaults to 7200s, so retrying a timeout burns another two hours; and a task-level refusal that merely names an earlier API error reads the same as a stalled stream. Decide it in _run_registered_author, where the exception object is still in hand and llm_failure's classify_llm_error / RETRYABLE_KINDS / retry_delay already are. A safety stop and a timeout return before the retry is reached, a transport failure retries with backoff against a deadline that accounts for the next attempt's cost, and the harness staging path is never involved. Both run_author call sites are covered, not just the fusion harness one.
64bae9d to
cf4bab8
Compare
haishuok0525
left a comment
There was a problem hiding this comment.
What this PR does
HEAD stacks two retry mechanisms for fusion authoring:
author.py_run_registered_author: exception /end_reasonclassification, default 2 attempts, backoff,FORGE_LLM_RETRY_DEADLINE_SEC.command.py_author_baseline_harness: after a failedrun_author, if the log tail matches_TRANSIENT_AUTHOR_MARKERS, callrun_authoragain.
Commit graph: cf4bab81 (exception path) then c0212fe (log-marker path on top). Neither removes the other.
Checked against HEAD c0212fe: author.py retry loop + classification, command.py outer retry + _prepare_author_harness, llm_failure.DEFAULT_DEADLINE_SEC=1800, _agent_timeout_sec default 7200, both test files.
Blocking issues
-
Two retry layers on HEAD. Outer
_author_baseline_harnessalways callsrun_author, which already loops. Keep one path (theauthor.pyone matches the second commit intent) and delete the log-marker loop incommand.py. -
Inner retry is dead under defaults. Gate is
elapsed + delay + timeout_s >= deadlinewithtimeout_sdefault 7200 andDEFAULT_DEADLINE_SEC1800, so after a retryable first failure the second attempt is never started. Non-harnessrun_authorcall sites therefore get no effective retry unless the deadline env is raised above one full attempt. -
Motivating stall after a written harness fails the outer retry. On retry,
_prepare_author_harnessreturns "author harness staging target already exists" when the staging file is still there (command.py~206-207, ~368-371), then returns before_finish_author_harness. Session shape "harness written, then stream stall" does not get a second author run; staging can leak.test_harness_author_retry.pystubs_prepare_author_harnessto always succeed, so CI misses this. -
Outer marker classification retries cases the inner path correctly refuses. Tail markers include
request timed out(rc 124 still retried) and genericconnection error/sdk error/api error, which can match safety/_with_run_errortext or a task refusal that mentions an earlier API error. -
PR body matches only the log-marker approach, not the stacked
author.pyretry. Please align the description with the single remaining design after the fix. -
Missing
CHANGELOG.mdUnreleased entry.
The retry gate asks whether the next attempt can plausibly finish before the deadline: elapsed + delay + timeout_s >= deadline. It read the deadline from llm_failure's DEFAULT_DEADLINE_SEC, which is 1800s -- sized for the short calls discover.py makes. An authoring attempt is allowed timeout_s, defaulting to 7200s, so on the first retryable failure the gate evaluated elapsed + delay + 7200 >= 1800 and the retry could never start. Under defaults the path added in the previous commit was dead. Default the budget to what the configured attempts can legitimately cost. An operator who sets FORGE_LLM_RETRY_DEADLINE_SEC still has it enforced, and a deadline too short to hold another attempt still stops the retry. The existing retry tests passed timeout_s=1, which is the only reason they did not catch this. Two tests now pin the contract at a real attempt budget: a stalled transport is retried at timeout_s=7200, and an operator deadline that cannot hold another attempt stops it.
c0212fe to
02bf9ec
Compare
…harness-author-retry # Conflicts: # CHANGELOG.md
xiaofei-zheng
left a comment
There was a problem hiding this comment.
What this PR does
The retry moved down to where the exception object still exists. _run_registered_author (author.py:1066-1126) now loops over _run_registered_author_once, which returns (rc, retryable); classification uses classify_llm_error / RETRYABLE_KINDS / retry_delay from llm_failure. The log-marker layer in command.py is gone -- command.py is not in this diff at all. The second commit sizes the retry budget to what an attempt actually costs.
No blocking issues -- LGTM
Verified rather than skimmed:
- One retry layer.
command.pyunchanged; the loop lives insiderun_author->_run_registered_author(author.py:1376), so both call sites (command.py:307harness author andcommand.py:855kernel author) are covered without either touching_prepare_author_harness/_finish_author_harness. The "staging target already exists" wedge and the leakedkernel_harness_<digest>.pycannot occur. - The gate is live under defaults.
default_deadline = attempts * (timeout_s + max_delay)(author.py:1088) = 2 * (7200 + 120) = 14640s, so after a first retryable failureelapsed + delay + 7200 >= 14640is false and the second attempt starts. An operator-setFORGE_LLM_RETRY_DEADLINE_SECis still enforced;deadline > 0as the disable sentinel matchesdiscover.py:1177.test_author_extra.pycovers both directions at a realtimeout_s=7200. - Safety and timeout never retry.
author.py:1256/1276/1305return(AUTHOR_RC_SAFETY, False);author.py:1315returns(AUTHOR_RC_TIMEOUT, False), and that branch is checked ahead of the retryable one, so a safety verdict carrying an appended transport error from_with_run_errorcannot be read as retryable. - Non-
Exceptionescapes are preserved.author.py:1290re-raises whenrun_erroris not anException, soKeyboardInterrupt/SystemExitare not swallowed as retryable transport faults. - The marker table, the log tail read,
harness_author.stalled.log,test_harness_author_retry.py, theok or Truedead expression and the duplicate test are all gone (grep-confirmed). New tests assert onAUTHOR_RC_*and real call counts with a scripted backend, monkeypatching onlyauthor.time.sleep. - PR description now matches the
author.pydesign,CHANGELOG.mdhas its Unreleased entry, CI is green.
…harness-author-retry Resolve the CHANGELOG conflict: #1533 landed its Unreleased entry on the same line this branch's entry occupies. Both are kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem
A fusion lane costs hours; an authoring call costs minutes. But an author whose
transport never delivered an answer -- a stream stalled mid-response, a
connection reset -- ended the lane on that first failure, discarding the whole
lane's remaining budget for a fault that one more call would very likely clear.
Fix
The classification this needs already exists one layer down.
llm_failureopens with exactly this proposition -- "Tell 'the model never answered' apart
from 'the model answered nothing'" -- and ships
classify_llm_error,RETRYABLE_KINDS,retry_delayand the env plumbing, whichdiscover.pyalready drives.
So the retry goes in
_run_registered_author, where the exception object isstill in hand, rather than reading a log back and substring-matching it:
RETRYABLE_KINDS-> retry with backoff, against a deadline.session-resume allowlist already refuses, and
_with_run_errordeliberatelyappends the transport error to a safety verdict, so a text-matching
classifier reads that case as retryable when it must not be.
Both
run_authorcall sites are covered, the harness author and the kernelauthor, and staging is never touched, so a retry cannot collide with a harness
the failed attempt already wrote.
The second commit sizes the deadline. The gate asks whether the next attempt
can plausibly finish:
elapsed + delay + timeout_s >= deadline. Read fromllm_failure.DEFAULT_DEADLINE_SECthat is 1800s, sized for discover.py's shortcalls, while an authoring attempt is allowed
timeout_s, defaulting to 7200s-- so the gate evaluated
elapsed + delay + 7200 >= 1800and the retry couldnever start. The default budget is now what the configured attempts can
legitimately cost; an operator-set
FORGE_LLM_RETRY_DEADLINE_SECis stillenforced.
Test plan
src/kernelforge/tests/fusion/test_author_extra.py(54 passed), assertingon
AUTHOR_RC_*: a retryable failure retries, safety does not, timeoutdoes not, a first attempt that works does not, and two failures stop
timeout_s=7200, and an operatordeadline too short to hold another attempt stops it -- the existing tests
passed
timeout_s=1, which is the only reason the dead gate went unseensrc/kernelforge/tests/fusion/(602 passed)ruff check .+ruff format --check .on the changed files