fix(control): a control change that could not be installed is no longer recorded as applied (#434) - #436
Conversation
These rows are pushed on the UNFIXED tree on purpose, so CI fires them as the control for the fix that follows. Each one is expected RED here. `_control_commit`'s tail installs the new config.json with `chmod 600` and an atomic rename, and neither is checked, so a failing install still echoes "committed <backup>" and returns 0. `control_apply` believes it and records a terminal `applied` for a config the rig never took. Three cases, all keyed on the candidate's `.control.` infix so the stubs can only ever fire on the install itself: - the un-sabotaged positive control, which must still land the change; - the rename made to fail, at the unit and at the orchestration level; - the chmod made to fail, which must stop before the rename (0600 is the contract for a file holding ACCESS_TOKEN and pool credentials). Stubbed as shell functions rather than by making the filesystem refuse: root ignores a mode-based refusal in the kcov container, and a directory where config.json goes fails the earlier backup `cp` instead, never reaching the tail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STpQCJ87o7tjpFM4US1mDP
…not recorded as applied (#434) `_control_commit`'s tail is what installs the new config.json — a `chmod 600` on the candidate, then an atomic rename — and neither step was checked. If either failed the function still echoed "committed <backup>" and returned 0, so `control_apply` believed it and wrote a terminal `applied`: `GET /status` and the published `changes/<cid>.json` both announcing a change the rig was not running, with no error anywhere and the miner restarted for a config that had not moved. Both steps are now guarded, each with its own reason, and the function returns rc 2 — distinct from the rc 1 every other refusal returns, because the change itself is VALID and the caller must not report it as rejected. `control_apply` dispatches on rc 2 to the `failed` terminal that ADR 0002 already gives this class on the upgrade path; `rejected` is documented as an invalid change with nothing written, and would send the operator to fix a change that was fine. Nothing is applied on this path and there is nothing to roll back: the rename lands in config.json's own directory, so it either replaces the file wholly or leaves it untouched, and the miner is never reached. `set -E` was never the backstop it looks like here. Bash unsets errexit inside a command substitution unless `inherit_errexit` is on, and this script sets no shopt, so the ERR trap bought a log line and never a guard. Pre-existing, not a regression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STpQCJ87o7tjpFM4US1mDP
|
VERDICT: PASS at Two mechanical notes before the substance: What I re-derived rather than relayed
Your test-first sequencing is the right way to prove a guard and I want to say so plainly: pushing THE FINDING — this PR converts a lying-but-tidy path into an honest-but-leaky oneYou disclosed that the failure path leaves its backup snapshot behind, root-owned and uncounted. The
So on the new rc 2 path the retention sweep is skipped entirely for that run, not merely
Why not fix it here: the obvious patch (call One nit while you are in there: the status says What I did not doNo rig, no hardware, no local suite run of any kind — the CI-only rule is in force in my window |
|
MERGE-READY: PASS at I am the author and do not merge my own, so this needs another session. The reviewer's finding is filed as #438 rather than fixed here, as it asked. |
…407) (#443) * test(control): run the accepted control-apply path as an executed script (#435) Every applied/rolled_back/fast-path row in this section reaches control_apply through ca_exec, which SOURCES rigforge.sh. Sourcing sets _RIGFORGE_SOURCED=1, so the ERR trap is never armed, and ca_exec then does `set +e` before the call. That is evidence about orchestration and not about errexit behaviour on the accepted branch — the same blind shape #426 indicted for the rejection branch. Add the accepted-path counterpart to the #426 rejection row: a separate bash process (never a subshell, for the #364 reason), errexit live and the ERR trap armed, driving a valid change end to end. Because apply() is not stubbed here, this is also the first row that shows the accepted path reaching the pipeline the miner actually reads — asserted by its effect on the rig, not by a log line. Measured before the row was written: seeding the #426 defect class onto the accepted branch (`backup="${result#committed }"` as a bare assignment from a substitution that exits non-zero) leaves every ca_exec row green and still recording `applied`, while the new row goes rc 1 with no status file and "aborted while" on stderr. Also folds in the non-blocking #436 review nit: the two `failed` outcomes are now told apart by `backup`, not by the reason string alone — a change that never landed records none, a change that could not be rolled back hands back the snapshot the operator has to restore by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STpQCJ87o7tjpFM4US1mDP * feat(lint): add a file-budget ratchet so the big files stop growing (#407) Four files here are over 400 lines and two are over 1,000, with no gate of any kind. pithead paid for that twice on the same curve: its linter reached 7 GB of RSS on one grown test file and took whole sessions down, then reached CI and blocked every PR until the invocation was split; and retrofitting the split ran about twenty PRs, because the coupling a monolith accumulates only surfaces once you pull it apart. A ceiling recorded today costs nothing. Two rules. A file over 400 lines must record its current line count in docs/dev/file-budget.tsv; over 800 with no row, the refusal names the hard ceiling instead. Ceilings only ever go DOWN, checked against develop, and a row's first appearance must record the real count rather than reserve headroom under it -- slack in a ratchet is the one thing it exists to prevent. Ported from pithead's, with two deliberate differences. Its develop/develop-v2 twin resolution is dropped: this repo has one integration branch, and carrying that logic would be dead code asserting a branch model that does not exist here. And rigforge.sh is NOT exempt. pithead exempts its own shipped script because that file is a build product generated from lib/pithead/*.sh and the slices carry the rows; rigforge.sh is not generated from anything, and exempting it would exempt most of what this gate exists to hold. One thing this port adds that the original does not have: FILE_BUDGET_REQUIRE_BASE. The monotonic half needs origin/develop to diff against, and actions/checkout is shallow by default, so in CI that half would have skipped and the job would still have gone green -- a gate proving less than it looks like it does. The lint job now asks for fetch-depth: 0 and sets the flag, so dropping either one reds the job instead of quietly halving the gate. The self-test builds a throwaway repo per case and asserts on the message as well as the verdict, with the clean control beside each refusal so a fixture that has stopped arming shows up as an unexpected pass. Sixteen cases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STpQCJ87o7tjpFM4US1mDP * fix(lint): make the file-budget self-test hermetic against the CI job env (#407) CI sets FILE_BUDGET_REQUIRE_BASE=1 at JOB level, so it was exported into the self-test as well and leaked into its synthetic repos. The "no base ref" fixture deliberately builds a repo with no base ref, so under an inherited =1 it refused and the whole self-test went red in CI while passing locally. Run every fixture under `env -u FILE_BUDGET_REQUIRE_BASE` instead. A fixture whose verdict depends on the caller's environment is not a fixture; each case now states its own condition, and the one that WANTS strict mode sets it on its own command line. Verified by running the self-test both with and without the job variable set — the condition CI was actually in, which the first push had never reproduced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STpQCJ87o7tjpFM4US1mDP * fix(ci): unpack the pinned linters in RUNNER_TEMP, not the workspace (#407) The file-budget gate measures untracked, non-gitignored files as well as tracked ones — that is how a new file is caught before `git add`, which is the moment a budget gate exists for. The lint job downloads and extracts ShellCheck into the repo root, so `shellcheck-v0.11.0/LICENSE.txt` read as a 674-line unbudgeted source file and the gate refused it. The gate was right; the job was littering. Both installs now `cd "$RUNNER_TEMP"` first. Reproduced the failure locally by planting an over-target file at that path, and confirmed the gate refuses it and passes once it is gone — so this is a fix for a condition I have actually seen, not for one I inferred from a log line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STpQCJ87o7tjpFM4US1mDP --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes #434.
The defect
_control_commit's tail is what actually installs a control change: achmod 600on thecandidate, then an atomic rename over
config.json. Neither step was checked. If eitherfailed the function still echoed
committed <backup>and returned 0, socontrol_applybelieved it and wrote a terminal
applied.That is the worst shape available for a remote control path:
GET /statusand the publishedchanges/<cid>.jsonboth announce a change the rig is not running, the miner is restarted fora config that never moved, and nothing anywhere reports an error.
set -Ewas never the backstop it looks like. Bash unsetserrexitinside a commandsubstitution unless
inherit_errexitis on, and this script sets noshoptat all, so theERRtrap here bought a log line and never a guard. Pre-existing, not a regression.The fix
Both steps are guarded, each with its own reason, and
_control_commitreturns rc 2 —distinct from the rc 1 every other refusal returns.
control_applydispatches on it to thefailedterminal.The one judgement call worth checking:
failed, notrejected.docs/operations.mddefines
rejectedas an invalid change with nothing written; reporting one here wouldsend the operator to fix a change that was fine.
failedis the rig-side terminal ADR 0002already gives this class on the upgrade path, and
tests/contract/v1/control-status.jsonalready lists it with
backup: null— the exact shape this path emits, so the frozen v1contract needs no change.
Nothing is applied on this path and there is nothing to roll back: the rename lands in
config.json's own directory, so it either replaces the file wholly or leaves it untouched,and the miner is never reached. The two steps are guarded separately, with distinct
reasons, because the status
reasonis the only thing a remote operator sees and this wholeissue is about that record lying. A reviewer may reasonably prefer one combined guard; it
would cost one branch and the ability to say which step lost the change.
How it was proven
The test rows were pushed first, on the unfixed tree, so CI fired them as the control
before the fix existed. Both runs are on this PR's history and can be re-read.
The control run (commit
3461249, test rows only, unfixed tree): the suite reported9 failing rows, and all 9 of them are
#434rows — zero collateral anywhere else in thesuite. The two positive controls in the new block are green, so the fixture works and the
reds are the guard's absence, not a broken harness.
Each red is red for the predicted reason:
a failed install is reported failed, not committedcommitted <backup>a failed install returns 2, not 0a failed install removes its candidate fileconfig.json.control.<pid>a candidate that could not be chmod-ed is not installeda failed chmod returns 2, not 0a failed chmod leaves the OLD config livea config.json that never landed is NOT reported appliedappliedthe terminal status names the install as the causea failed install never restarts the minerapply()is reached and restarts XMRigThe rows that stay green on the unfixed tree are the ones that cannot discriminate there (a
failed rename does leave the old config in place, and the spool is drained either way); they
are kept because they pin the blast radius, not the guard.
The macOS leg runs Apple's bash 3.2, which also settled a question I could not measure
locally: an assignment prefixing a function call (
CA_COMMIT_MV_FAIL=1 ca_run …) does notleak into the rows that follow on 3.2 — every later row is green.
Commit
3590d10adds the fix; its CI run is the second half of the pair.What I did not do
claimed and nothing was validated against real hardware.
_reown_config_backupsis notcalled on it, so the snapshot taken just before the failed install stays root-owned and is
not counted against the retention cap. Reaching this path at all needs the backup write to
succeed while the install fails, which is narrow, so it is named here rather than folded
into this change.
🤖 Generated with Claude Code
https://claude.ai/code/session_01STpQCJ87o7tjpFM4US1mDP