Ask the whole completion condition before a pass skips an agent invocation - #106
Merged
Conversation
…ation
The completion condition of a state has three parts: exit 0, the declared
`outputs:` on disk, and — when the edge the exit selects lands on a `final:
true` state — the ticket's non-empty terminal result. It lived in two places
with two different rules. The post-exit check asked all three; the scheduling
filter asked only whether the declared outputs existed.
So a ticket that correctly failed the condition on one pass was read on the
next as having nothing left to do. It fell through to callback-only
advancement, took the terminal edge the condition had just refused, and the
engine wrote this into its permanent result:
`rhei run`: this task was finished by callback-only orchestration from
state 'implement'. No agent or program ran in that state, so no worker
result was recorded.
An agent had run for twelve minutes, made three commits, and published its
export; only the result file was missing. §FS-rhei-run.3 step 5 says of this
case that no transition fires and the ticket stays where it is, and the same
section says the engine never speaks for a worker that ran.
Reproducer: an agent state with `outputs:` whose agent writes the outputs and
exits 0 without writing $RHEI_RESULT_PATH. The first `rhei run` warns and
halts, correctly. The second advances the ticket and records the sentence
above.
One rule, in one place: `run_completion_condition.rs` answers it per
invocation, and both the scheduler and the post-exit check call it. A state
that has not met it is run again rather than reclassified as finished.
Two things follow from running the state again. The engine had inferred "a
worker ran here, and this is a retry" from a filename and a file's existence,
which was wrong four ways — a header-only log from a spawn that never started
counted as proof, a hyphenated sibling state's log was claimed as this
state's, a re-entry was narrated as a retry because an uncounted state's visit
number is pinned at 1, and one canned reason was printed whatever had ended
the previous attempt. That inference is now a record written when a subprocess
ends, keyed by the ticket's move count, which changes when the ticket moves
and holds while it stalls.
And a retry that cannot converge is only spend. A re-spawned invocation is
told it is retrying and which artifact the previous attempt left unwritten,
and an `attempts:` budget bounds a visit — per state, then `defaults.attempts`,
then 2 — after which the ticket halts where it is and the run says what it
owes. The budget rides the same record, so it holds across separate runs; a
genuine re-entry starts a fresh one. Without it this fix would trade a false
result for unbounded re-spawning, so the two land together.
Fixes #105
Open
5 tasks
3 tasks
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.
Fixes #105.
The bug
The completion condition of a state has three parts: exit 0, the declared
outputs:on disk, and — when the edge the exit selects lands on afinal: truestate — the ticket's non-empty terminal result. It lived in two places with two different rules. The post-exit check asked all three; the scheduling filter inrun_agent_mode.rsasked only whether the declared outputs existed.So a ticket that correctly failed the condition on one pass was read on the next as having nothing left to do. It fell through to callback-only advancement, took the terminal edge the condition had just refused, and the engine wrote this into its permanent result:
An agent had run for twelve minutes, made three commits, and published its export; only the result file was missing. §FS-rhei-run.3 step 5 says of this case that no transition fires and the ticket stays where it is, and the same section says the engine never speaks for a worker that ran. Both were violated.
Reproducer. An agent state with
outputs:whose agent writes the outputs and exits 0 without writing$RHEI_RESULT_PATH. The firstrhei runwarns and halts, correctly. The second advances the ticket and records the sentence above.The fix
One rule, in one place.
run_completion_condition.rsanswers it per invocation, and both the scheduler and the post-exit check call it. A state that has not met the condition is run again rather than reclassified as finished — the recovery §FS-rhei-run.3 already prescribed. The same weak filter on the parallel refill path is fixed with it.Two things follow from running the state again.
Evidence, not inference. The engine had inferred "a worker ran here, and this is a retry" from a filename and a file's existence. That was wrong four ways: a header-only log from a spawn that never started counted as proof; a hyphenated sibling state's log was claimed as this state's; a re-entry was narrated as a retry, because an uncounted state's visit number is pinned at 1; and one canned reason was printed whatever had ended the previous attempt. It is now a record written when a subprocess ends, keyed by the ticket's move count — which changes when the ticket moves and holds while it stalls (§FS-rhei-agents.8.4).
A bounded retry. A re-spawn that repeats the previous prompt byte-for-byte is only spend, so a retried invocation is told it is retrying and which artifact the previous attempt left unwritten. An
attempts:budget bounds a visit — per state, thendefaults.attempts, then 2 — after which the ticket halts where it is and the run says what it owes (§FS-rhei-agents.3.2.3). The budget rides the same record, so it holds across separate runs; a genuine re-entry starts a fresh one.visits:bounds how many times a ticket may enter a state;attempts:bounds how many times one entry may be spawned. Poll states keep their ownpoll.max_attempts; an interrupted spawn does not spend budget.Without the budget this fix would trade a false result for unbounded re-spawning, so the two land together.
Measured
Twelve consecutive
rhei runinvocations, agent writes its outputs and never writes the result:mainimplementGates
cargo fmt --check,clippy -D warnings,build, and the workspace test suite all pass.grund checkoutput is byte-identical tomain.fissile check --stagedis clean but for findings pre-existing onmain;run_parallel_spawn.rscrossed the size cap and its program half was split out rather than granted an exception.Five
init_*tests inintegration_markdown_plansfail identically on cleanmainand are untouched by this change.Not in scope
Recovering the result from the agent's transcript (the issue's "Expected #2") conflicts with "the engine never speaks for a worker that ran" and is agent-format-specific. The issue also notes that the built-in
## Resultprompt section reads as description rather than obligation — that wording is a separate question, and worth revisiting if the budget turns out to be routinely spent.🤖 Generated with Claude Code
https://claude.ai/code/session_01ANPWuKoibZ6NS84tJxeDC8