Make a halt record itself as a halt, not as a completion - #254
Merged
Conversation
added 5 commits
July 28, 2026 08:30
…es cost tests/unit measured 73409 against a ceiling of 73410 and src/bonfire/onboard measured 4416 against a ceiling of 4416 -- one line of headroom and none at all. Both numbers are the measurement taken after the code was written, not an estimate and not padded. tests/unit +367 buys twenty tests over four records that reported a failure as a completion; src/bonfire/onboard +44 buys the two of those four that live in this package. Each raise carries its reason and the alternatives rejected in the package_raises ledger. This is the second consecutive lane to arrive at a ratchet sitting on its own measurement. That the ceiling keeps landing back on the number is a design question about the tests/unit budget, not something a lane can settle.
Four records reported a failure as a success, or gave a reason that was not the reason that occurred. The cost ledger is the money-facing one. A run that died in the builder and a run that finished one stage wrote rows differing only in timestamp, so `bonfire cost` -- the operator's record of what they were charged and why -- could report the spend but not whether it bought a finished run or a crash. PipelineRecord gains `outcome`, plus `failed_stage` and `error_message` copied from the event rather than inferred. `outcome` defaults to "unknown", NOT "completed": rows already on disk genuinely do not record how the run ended, and defaulting them to success would fabricate exactly the history the defect corrupted. CostAnalyzer does not list the field in _PIPELINE_REQUIRED_FIELDS, so those rows keep validating and keep aggregating. The onboard orchestrator reported a scanner that CRASHED as ScanComplete(item_count=0) -- identical to a scanner that ran clean and found nothing, so the browser said "we scanned and found nothing" over a scan that had died. _run_one now reports (count, failed) with the real exception, run_scan totals failed panels so an all-crashed run does not summarise as total_items=0, and ui.html renders a failed panel distinctly instead of as a completion tick. The onboard flow reported every ValidationError on a client frame as message_too_long, including a frame with no text field at all and one whose text was a number -- sending the user to shorten a message that was never long. Only a genuine string_too_long keeps that code now. The XP consumer's reported defect did NOT reproduce: success=False already reached the tracker and the on-disk store was honest. What was broken is the reason -- the penalty announced "Pipeline failed with 1 stage failures" while the event's own failed_stage and error_message were discarded. The count is left alone deliberately: every PipelineFailed emit site halts on the first failing stage, so 1 is the measured truth, not a placeholder. Every fix is paired with a negative control asserting a genuine success still records as a success, so none of this can be satisfied by code that reports everything as broken.
…numbers exemptions.json anchors a suppression by line number OR enclosing symbol. Five suppressions in src/bonfire/onboard were registered by LINE. They pre-date this branch, they are all still exactly where their authors put them, and none of them changed -- but inserting lines above them moved the code while the anchors stayed put, so the gate reported them as "a self-issued suppression is not an exemption". They were registered exemptions whose anchors drifted. Re-anchoring to run_front_door and _run_one fixes it permanently: a symbol anchor does not care what is inserted above it, where a corrected line number would drift again on the very next insertion. Nothing is added and nothing is raised: 52 entries in, 52 entries out, frozen_count untouched at 52. Every reason and approver is carried across verbatim.
The previous commit made the right five-row change the wrong way: it rewrote exemptions.json through a json.dumps round-trip, which escaped every em-dash in the file to — and turned a 5-row edit into 71 changed lines. The semantics were correct -- 52 entries in, 52 out, only the intended rows differing -- but 66 lines of the diff were encoding noise that buried the actual change. Two concrete costs, not just aesthetics. It guaranteed a textual conflict with the sibling lane, which is re-anchoring two rows in the same file with a surgical edit; redone this way the two changes touch different rows and merge cleanly in either order. And a 5-row change presented as 71 changed lines cannot be reviewed without parsing the file, which defeats the point of the fix being legible. This commit restores the file to origin/main byte-for-byte and reapplies only the five "symbol_or_line" values as an in-place text edit. Net against origin/main: 5 insertions, 5 deletions, 0 escape sequences, and no line changed other than those five. Never round-trip a hand-maintained JSON file through a serializer to change one field.
…not-a-completion # Conflicts: # file-budget.json
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.
Four records reported a failure as a completion, or gave a reason that was not the reason that occurred. All four were reported second-hand; each was verified against the tree before it was touched. One did not reproduce as reported — the correction is below and is worth more than that fix.
1. Per defect — reproduced or not
Verified by running the real code before any edit (imports of the worktree source, no billed dispatch, nothing written to the tree).
1. Cost ledger — REPRODUCED, worse than reported. A
PipelineFailedand a one-stagePipelineCompletedproduced rows differing intimestamponly:PipelineFailedalready carriedfailed_stageanderror_message;_on_pipeline_faileddiscarded both. The operator's real ledger already holds such a row.2. XP consumer — the reported defect does NOT reproduce. The synthesised
PipelineCompletedis a local wrapper, never emitted on the bus, andsuccess=Falsereaches the tracker. The store on disk was already honest:{'xp_total': 25, 'success': False, 'respawn': False}. A consumer can tell failure from success.What is actually broken is the reason. From a failure carrying
failed_stage="builder",error_message="builder raised RuntimeError: disk full", the emitted event wasXPPenalty(reason='Pipeline failed with 1 stage failures')— the real cause dropped, a count invented.I also reported earlier that
stages_failed=1was hardcoded sorespawncould never fire. That was wrong and I did not "fix" it. EveryPipelineFailedemit site inengine/pipeline.pyhalts on the first failing stage, so exactly one stage failed and 1 is the measured truth. Changing it would have substituted one fabricated number for another.3. Onboard orchestrator — REPRODUCED. A crashed scanner and one that found nothing emitted byte-identical frames.
4. Onboard flow — REPRODUCED, all three causes. A genuinely oversize payload, a frame with no
textfield at all, andtext: 12345all returnedmessage_too_long.2. The ledger: what distinguishes a halt, and what happens to rows on disk
Field:
outcome: Literal["completed","failed","unknown"], set explicitly to"completed"on the success path and"failed"on the halt path, alongsidefailed_stage/error_messagecopied from the event rather than inferred.Default is
"unknown", not"completed". Rows written before the field existed genuinely do not record how the run ended; defaulting them to success would fabricate exactly the history this defect corrupted.Rows already on disk keep working. The sole reader is
cost/analyzer.py, whose_PIPELINE_REQUIRED_FIELDSis(timestamp, session_id, total_cost_usd, duration_seconds, stages_completed)— the new field is not required, so legacy rows keep validating and keep aggregating. Two tests pin this: one asserts a pre-field row loads as"unknown", one asserts it still aggregates throughCostAnalyzer.all_sessions().How a genuine success still records as a success: the completion path sets
"completed"explicitly, never by omission — and a negative control assertsoutcome == "completed",failed_stage is None,error_message is None, with cost and stage count intact.3. Control rods — both halves, per defect
Each fix was broken, confirmed red for its own reason, restored, confirmed green.
Rod 1a — halt path stops carrying outcome/stage/error:
Rod 1b —
origin/mainexactly, both paths write the identical record:Restored:
5 passed.Rod 2 — drop
halt_reason, fall back to the invented count:Restored:
6 passed.Rod 3 — crash reported as clean again:
Restored:
4 passed.Rod 4 — every validation error called
message_too_longagain:Restored:
4 passed.Every defect also carries a negative control asserting a genuine success still records as a success, so none of this is satisfiable by code that reports everything as broken.
4. Budget
Measured with the gate's own logic, after the code was written:
tests/unitsrc/bonfire/onboardOne line of headroom and none at all. Zero headroom in
onboardwas verified by a one-line probe producingpackage src/bonfire/onboard is 4417 lines. Both raises are the measurement, not an estimate and not padded; the test file was tightened first (shared sink helper, shared legacy-row constant, two parametrisations), taking it from 389 to 368. The raise is a separate commit with full reasons and rejected alternatives inpackage_raises.This is the second consecutive lane to hit a ratchet sitting on its own measurement — the previous raise moved it 73199 → 73410 and landed it right back on the number. That is a design question about the ceiling, not something a lane can settle.
5. Verification
pytest tests/ -x --tb=short(the exact CI invocation): 5766 passed, 3 skipped, 37 xfailed, 20 xpassed. Baseline before the change was 5673 ontests/unit tests/integration; after, 5693 — +20, the new tests.ruff check src/ tests/→ All checks passed.ruff format --check src/ tests/→ 375 files already formatted.cf-file-budget check→ clean.~/.bonfire/cost/cost_ledger.jsonlmtime1785248222and 23 lines, identical before and after every full suite run. Theconftest.pyfixture holds and was not defeated.6. Needs a follow-up from the docs lane
protocol.pyshifted, so citations indocs/scan-front-door-protocol.mdneed updating. I did not editdocs/— another lane owns it. The citation gate still exits 0, but 2 citations now land outside any symbol, so this is real drift the gate cannot fail on:Concretely:
protocol.py:100-104→108-116,107-110→120-123,113-118→126-131,121-126→134-139,147-157→160-170,164-173→177-186,175-177→188-190,195-197→208-210,200-202→213-215, and the60-202span →60-215.7. What I did NOT prove
ui.htmlwas changed to render a failed panel distinctly; that is verified by reading, not by driving a browser. The Python side of the contract is tested; the rendering is not._halt_reasonbounce-target and__outer__sentinel branches are tested against synthesisedPipelineFailedevents, not against a real engine bounce.AllScansComplete.failed_panelsis slightly beyond the four defects as briefed. It is the same defect one level up — without it an all-crashed run still summarises astotal_items=0— and it is called out in the budget raise rather than slipped in.outcomefield this branch now writes), not from my runs.8. Third commit: five drifted exemptions re-anchored
CI's
gate / gatewent red on fiveUNREGISTERED_SUPPRESSIONfindings. This branch added no suppressions. The complete diff ofnoqalines across the whole change is one character-level edit:— binding
excso the scanner's error could be reported instead of erased, which is the point of the PR.All five suppressions exist unchanged on
7bddd71and are all already registered inexemptions.json:_matches()resolves an entry bysymbol_or_line == str(suppression.line) or symbol_or_line == suppression.symbol. These were anchored by line number. Inserting lines above them moved the code to 166 / 258 / 116 while the anchors stayed put, silently un-registering all five.The gate's message is actively misleading: "a self-issued suppression is not an exemption" is exactly wrong here — it is a registered exemption with a named approver whose anchor drifted. A reader who trusts the message goes hunting for a suppression somebody sneaked in.
This is a defect in the registry's design, not in this branch. The sibling lane hit the identical failure in the same hour on
engine/pipeline.py(entries [15] and [16], lines 154→158 and 589→599 — the top-level pipeline barrier and the per-stage boundary). Two lanes, two files, one root cause. It will keep firing on every future PR that inserts a line above a line-anchored suppression.Fix: re-anchored by symbol, not by corrected line number — a corrected number drifts again on the very next insertion, which is precisely what just happened. 52 entries in, 52 entries out;
frozen_countuntouched at 52. Nothing added, nothing raised. Every reason and approver carried across verbatim.cf-exemptions: OK.Rejected: the two fixes first prescribed to me. Splitting
run_front_dooris collateral scope on a function this branch never touched, carrying a v1.0.1 grandfather. Narrowing theflow.pyshutdown drain contradicts its own exemption text — "theasyncio.CancelledErrorarm is load-bearing (deliberately swallows the cancel during drain)" — andorchestrator.py's boundary is registered precisely because scanners are a pluggable open set that must not abort onboarding. Also rejected: an in-file line-number fix, which would mean deleting the docstring explaining the fix to holdorchestrator.py:106, and paddingflow.pywith five blank lines because therun_front_dooranchor breaks upward too (net −5 there).9. Local did not equal CI, and that is the bigger lesson
I verified against
ci.yml— 4 checks (ruff, ruff-format, the citation script, pytest) — pluscf-file-budget, and reported green. The failing check wasgate / gate, fromquality.yml, which calls the shared kit workflow and runs 11 gates. I ran 4 of 11 and assumed I had covered the gate. I verified against the workflow I found instead of the one that was failing.cf-gatenow runs locally. Same command, same venv, both trees:origin/maincf-exemptionscf-import-contract,mypy,complexipy,pytestThose four are red on bare
maintoo in this local environment — pre-existing or environmental, not introduced here (pytestexits 2 = a usage/collection error under cf-gate's invocation, whilepytest tests/ -xpasses 5766). After the re-anchor this branch's gate profile is identical tomain's. Whether those four were also red in CI was left unproven when this was written; CI has since answered it.gate / gateruns the same 11-gate kit and now passes on this branch, socf-import-contract,mypy,complexipyandpytestare green in CI and red only in my local environment — local-environment artifacts, confirmed, not repo state. The one gate that genuinely differed wascf-exemptions, and it is fixed.8b. Fourth commit: the re-anchor's encoding, redone surgically
The re-anchor above was semantically right but written the wrong way: the edit went through a
json.dumpsround-trip, which escaped every em-dash in the file to—and turned a 5-row change into 71 changed lines, 66 of them pure encoding noise.Two concrete costs, not aesthetics:
engine/pipeline.py, entries [15][16]) with a surgical edit. A 71-line rewrite against a 2-line edit conflicts in a file where both sets of re-anchors must survive. Redone surgically, the two changes touch different rows and merge cleanly in either order.The fourth commit restores
exemptions.jsontoorigin/mainbyte-for-byte and reapplies only the fivesymbol_or_linevalues as an in-place text edit. Verified:History is append-only (four commits, no force-push) because force-push is denied by policy in this environment; the PR diff against
origin/main— what GitHub shows and what merges — is the clean +5/-5.Rule worth keeping past this PR: never round-trip a hand-maintained JSON file through a serializer to change one field. It reformats escapes, key order, indentation and whitespace you did not intend, and buries the real change. Edit the text. Credit to the sibling lane, which hit this trap first and flagged it.
10. Rebased onto
bb2f7c6(#253 + #255 landed)origin/mainmoved from7bddd71tobb2f7c6while this was open. Merged in (merge commit, not a rebase — force-push is denied by policy here).exemptions.jsonauto-merged;file-budget.jsonconflicted and was resolved keeping both sides.Resolved
packages— every entry is one lane's number, none overwritten:package_raises: 8 entries — the 3 predating this round, #255's 3 (engine/pipeline.py989→994,engine1903→1981,tests/integration1363→1702), and my 2 appended at the end. None dropped.Re-measured against the merged tree rather than re-typed. Every package now lands exactly on its ceiling, and my two figures were unchanged by the merge:
src/bonfire/dispatchsrc/bonfire/enginesrc/bonfire/handlerssrc/bonfire/onboardtests/integrationtests/unitMy
from:values (73410,4416) still matchorigin/main's ceilings exactly, so neither entry is a stale anchor — the thing I diagnosed this morning, checked against my own work.exemptions.jsonafter the merge: 52 entries /frozen_count52, unchanged. #255's two symbol re-anchors survived alongside my five —[15] PipelineEngine.run,[16] PipelineEngine._execute_stage,[38-41] run_front_door,[44] _run_one. Seven symbol-anchored rows where there were none this morning, in two independent lanes.Conflict resolution was done as a text operation on
origin/main's bytes, not a serializer round-trip — so #255's and #253's entries are byte-identical to what they merged, and the diff againstmainis18/2onfile-budget.json(2 ceiling values + my 2 appended entries) and5/5onexemptions.json. Escape count unchanged on both files:file-budget.jsoncarries the same 2 pre-existing—linesmainhas, and I added none.Verification against the new base: full suite
5774 passed, 3 skipped, 37 xfailed, 20 xpassed(up from 5766 — #255's 8 new checkpoint tests).cf-gate:cf-exemptionsandcf-file-budgetboth PASS. CI green on all four checks againstbb2f7c6.