Skip to content

Ask the whole completion condition before a pass skips an agent invocation - #106

Merged
vjovanov merged 2 commits into
mainfrom
fix/issue-105-callback-only-result
Aug 29, 2026
Merged

Ask the whole completion condition before a pass skips an agent invocation#106
vjovanov merged 2 commits into
mainfrom
fix/issue-105-callback-only-result

Conversation

@vjovanov

Copy link
Copy Markdown
Owner

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 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 in run_agent_mode.rs 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. Both were violated.

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.

The fix

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 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, then defaults.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 own poll.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 run invocations, agent writes its outputs and never writes the result:

spawns result file
main 1, then falsely advances the false stub above
this branch 2, then halts none — ticket stays in implement

Gates

cargo fmt --check, clippy -D warnings, build, and the workspace test suite all pass. grund check output is byte-identical to main. fissile check --staged is clean but for findings pre-existing on main; run_parallel_spawn.rs crossed the size cap and its program half was split out rather than granted an exception.

Five init_* tests in integration_markdown_plans fail identically on clean main and 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 ## Result prompt 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

…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
@vjovanov
vjovanov merged commit 92e3353 into main Aug 29, 2026
4 checks passed
@vjovanov
vjovanov deleted the fix/issue-105-callback-only-result branch August 29, 2026 18:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rhei run completes an agent state with a result saying no agent ran, when the agent ran and only the result file was missing

1 participant