fix: the breaker's halt ends the cleaning invocation it meets #206
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/halt-stops-the-invocation"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
This fixes the bug that #204's body names as its first follow-up. Cleaning's breaker (ADR 0027) is meant to stop "every running Cleaning execution at its next tool call". The code tried to do that by throwing from the tool. The agent loop, though, answers a tool's exception to the model and carries on (
Agent.cs: "A tool that throws has failed like one that says so: the model hears why, and the run goes on").What happened once the breaker tripped:
Error: the call to '…' failed: StageHaltedException: Cleaning halted: …, and the model kept spending rounds until it gave up or hit its round cap.Not finished in an earlier attempt: The group ended without finalize.instead of…: the stage halted(see the probe below).What's in
CleaningExecutorHaltingToolno longer throws. When the breaker has tripped, it answers the call with the halt words the clean tool already uses (Cleaning has stopped. The reason: …) and cancels the invocation's own token. The loop honours that token: it runs no further call in the turn and sends no further request.InvocationHalt, linked to the stage's token, which also keeps the reason the call met.RunAsyncthrows the halt either way: when the loop throws the cancel, and when it returns normally because the halted call was the last one of its last round.Docs: ADR 0027's breaker bullet now covers the call that trips it, and says the halt ends the invocation. The
ICleaningStageHooksand executor summaries now say "before and after every tool call".FillRefusal: this is Jibril's #204 💡1. The fill tool says each refusal detail as a sentence of its own (char.ToUpperInvariant(r.Detail[0])), so the record now refuses an empty or blank detail where it is made, naming its kind.- ., a line the model can't act on, and every producer goes through the record.Tests
Why the old tests missed it:
StageBench.CallAsynccalled tools directly and let their exceptions through, which the real loop never does. It now calls them the way the agent loop does:Probe: main's executor against the new bench. Two existing tests fail, which shows the bench now runs what production runs:
The_breaker_halts_the_run_at_its_next_tool_call_with_its_ledger: the ledger ends up owing p1r1 twice.CleaningRetryTests.A_halted_attempt_is_redone_after_resume_cleaning_as_the_human_asked: the region carriesThe group ended without finalize.instead ofthe stage halted.New and changed tests in UseCases (
CleaningExecutorTests):The_breaker_halts_the_run_at_its_next_tool_call_with_its_ledger(strengthened): the halted call is answered with the halt words, and thefinalizeafter it never runs.A_call_that_trips_the_breaker_ends_its_invocation_and_halts_the_stage: a scripted server call trips the breaker duringclean. The clean's answer is the only one,finalizenever runs, the ledger owes the refused region alone, and the cost is the survey's plus the first group's.A_halt_on_the_last_call_of_the_last_round_still_halts_the_stage: the invocation returns at its round cap after the halted call, and the attempt still halts.A_stage_cancelled_after_a_halt_stays_cancelled: the engine's cancel is not turned into a halt.New in UseCases (
CleaningToolTests):A_refusal_without_words_is_refused_where_it_is_made: empty and blank details.New in Integration (
OpenRouterLlmGatewayTests), pinning the loop's behaviour through the real gateway and OpenRouter.Net client:A_tool_that_cancels_its_run_ends_it_before_another_call_or_request: a turn callsstop, thenlook.stopcancels the run's token,looknever runs, and only one request is sent.A_tool_that_throws_is_answered_and_the_run_goes_on: the same turn, wherestopthrows. The model hearsError: the call to 'stop' failed: InvalidOperationException: the tool broke(read back from the parsed request), and the run continues to its second request. This is why the halting tool has to cancel rather than throw.Mutation probes: 4 run, 0 survived. Each named test fails when its part of the executor is changed:
&& !cancellationToken.IsCancellationRequestedfrom the cancel filterA_stage_cancelled_after_a_halt_stays_cancelledA_call_that_trips_the_breaker_ends_its_invocation_and_halts_the_stageA_halt_on_the_last_call_of_the_last_round_still_halts_the_stagesource.Cancel()The_breaker_halts_the_run_at_its_next_tool_call_with_its_ledgerandA_call_that_trips_…Counts: build 0 warnings; 5266 passed, 3 skipped (Domain 185, UseCases 3227, Integration 1445 + 3 skipped, BlazorAdapter 409), against 5259 on main
bf6202df(+5 UseCases, +2 Integration).Notes
RunAsyncadds a cost only onOk.Execution.Failonly writes non-empty ones), so the send-back's scope survives.—note delimiter (stored data)label/label_reasonschema rename🤖 Generated with Claude Code
Summary
Summary
Coverage
Orihon.BlazorAdapter - 96.6%
Orihon.Domain - 100%
Orihon.Infrastructure - 98.5%
n
ration
n
one
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlankLines_4
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlockBreaks_1
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__SpaceRuns_3
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__Tags_2
Orihon.Kernel - 90.9%
Orihon.Server - 95.2%
Orihon.UseCases - 94.6%
D7D45893AA49E1445928C26437E2DAC34A6F93741B38DCDA0__BalloonWords_7
D7D45893AA49E1445928C26437E2DAC34A6F93741B38DCDA0__Capital_0
D7D45893AA49E1445928C26437E2DAC34A6F93741B38DCDA0__CoordinateWords_1
D7D45893AA49E1445928C26437E2DAC34A6F93741B38DCDA0__JustBefore_4
D7D45893AA49E1445928C26437E2DAC34A6F93741B38DCDA0__KindWords_5
D7D45893AA49E1445928C26437E2DAC34A6F93741B38DCDA0__OutlineWords_6
D7D45893AA49E1445928C26437E2DAC34A6F93741B38DCDA0__PositionalProse_2
D7D45893AA49E1445928C26437E2DAC34A6F93741B38DCDA0__SideOfTheBox_3
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! The halt finally has teeth~ For the longest time the breaker was politely clearing its throat into the microphone while the model kept chatting away. Cancelling the invocation instead of throwing — because the loop answers a tool's exception to the model and carries on — is exactly the right tooth. ♡
Verdict: ✅ Looks good to me~
✅ What I liked~
external/OpenRouter.Net/.../Agent.cs: a tool that throws gets answered asError: the call to '…' failed: …and the run goes on (lines 470–475), while the run's own cancel stops the very next call (ThrowIfCancellationRequestedat line 388), answers every unrun id, and rethrows (lines 545–558). The two new gateway tests pin both halves through the real gateway. Cancel, don't throw — correct~InvocationHaltlinked to the stage token →RunAgentAsync(..., halt.Token)→agent.ChatAsync(..., halt.Token)→ per-tool token (Agent.cs:462) →AgentToolAdapterhands it toHaltingTool. The before-branch answers withCleanTool.Halted's own words (Cleaning has stopped. The reason: …), so the model hears one consistent voice whether the breaker tripped elsewhere or in its own call.halt.Reasoncheck catches the loop returning normally (round-cap Fail included — the halt rightly wins over the Err), the catch handles the OCE path, and the&& !cancellationToken.IsCancellationRequestedfilter keeps the engine's own cancel a cancel.Reason ??=keeps the first reason a call met. All three belts, all three suspenders~StageBenchnow calls tools the way the loop does — no call once cancelled, exceptions answered with the loop's exact error words. That's what let the old gap become two failing tests on main's executor. TheAsyncLocalflow is per-test, so parallel cases can't eavesdrop on each other.FillRefusal's guard sits where every producer passes —LabFillis the only non-test constructor site and all its details are literal words; refusals are never stored, so nothing dormant can trip it. AndDetailstays a get-only auto-property, so the record's value equality is intact.Halt()pre-check catches it, and the empty ledger keeps the row's earlier regions. Acknowledged and reasoned, not hidden. ♪Mutation probes (mine, 3 run — 0 survived):
halt.Reasoncheck inRunAsyncA_halt_on_the_last_call_of_the_last_round_still_halts_the_stage&& !cancellationToken.IsCancellationRequestedfrom the catch filterA_stage_cancelled_after_a_halt_stays_cancelledhalt.Stop(during)inHaltingToolA_call_that_trips_the_breaker_ends_its_invocation_and_halts_the_stageEvery new branch has a test that dies without it. The bench baseline ran 46/46 green before I started breaking things on purpose~
No blocking issues, no little ideas — the docs (ADR 0027's breaker bullet, the hooks' "before and after every tool call") say what the code now does. Merge it before I change my mind about letting anyone else's halt semantics near my ledger~ fufu~ ♡
Automated review by Jibril · 2026-10-17
CI: green for head
b23fc9dc32(build + test) · Mutation probes: 3 run, 0 survived · Local suite: skipped (CI green)