Skip to content

fix(control): a control change that could not be installed is no longer recorded as applied (#434) - #436

Merged
VijitSingh97 merged 2 commits into
developfrom
fix/434-control-commit-tail
Sep 5, 2026
Merged

fix(control): a control change that could not be installed is no longer recorded as applied (#434)#436
VijitSingh97 merged 2 commits into
developfrom
fix/434-control-commit-tail

Conversation

@VijitSingh97

@VijitSingh97 VijitSingh97 commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Closes #434.

The defect

_control_commit's tail is what actually installs a control change: a chmod 600 on the
candidate, then an atomic rename over config.json. 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.

That is the worst shape available for a remote control path: GET /status and the published
changes/<cid>.json both announce a change the rig is not running, the miner is restarted for
a config that never moved, and nothing anywhere reports an error.

set -E was never the backstop it looks like. Bash unsets errexit inside a command
substitution unless inherit_errexit is on, and this script sets no shopt at all, so the
ERR trap 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_commit returns rc 2
distinct from the rc 1 every other refusal returns. control_apply dispatches on it to the
failed terminal.

The one judgement call worth checking: failed, not rejected. docs/operations.md
defines rejected as an invalid change with nothing written; reporting one here would
send the operator to fix a change that was fine. failed is the rig-side terminal ADR 0002
already gives this class on the upgrade path, and tests/contract/v1/control-status.json
already lists it with backup: null — the exact shape this path emits, so the frozen v1
contract 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 reason is the only thing a remote operator sees and this whole
issue 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 reported
9 failing rows, and all 9 of them are #434 rows — zero collateral anywhere else in the
suite. 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:

row why it is red on the unfixed tree
a failed install is reported failed, not committed the tail echoes committed <backup>
a failed install returns 2, not 0 it returns 0
a failed install removes its candidate file nothing removes config.json.control.<pid>
a candidate that could not be chmod-ed is not installed the rename runs anyway
a failed chmod returns 2, not 0 it returns 0
a failed chmod leaves the OLD config live the change lands, at the candidate's mode
a config.json that never landed is NOT reported applied the terminal status is applied
the terminal status names the install as the cause there is no reason to name
a failed install never restarts the miner apply() is reached and restarts XMRig

The 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 not
leak into the rows that follow on 3.2 — every later row is green.

Commit 3590d10 adds the fix; its CI run is the second half of the pair.

What I did not do

  • No rig. This is a filesystem-failure path on the applier; the loaner rigs were not
    claimed and nothing was validated against real hardware.
  • The failure path leaves its backup snapshot behind. _reown_config_backups is not
    called 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.
  • The suite was not run locally at any point; every result quoted above is CI's.

🤖 Generated with Claude Code

https://claude.ai/code/session_01STpQCJ87o7tjpFM4US1mDP

VijitSingh97 and others added 2 commits September 4, 2026 20:39
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
@VijitSingh97
VijitSingh97 marked this pull request as ready for review September 5, 2026 01:46
@VijitSingh97

Copy link
Copy Markdown
Contributor Author

VERDICT: PASS at 3590d106 — non-author review. The fix is correct, the test-first evidence is
the strongest shape available, and the failed-not-rejected call is right. One finding, not
blocking, and it is a sharper version of the gap you disclosed yourself
— please file it rather
than fix it here.

Two mechanical notes before the substance: mergeStateStatus is BLOCKED (this repo's develop
is strict, so the branch needs updating onto the base before it can go in) and CI was 1 success / 8
pending when I read it. Neither is a review objection. gh pr update-branch does not exist on
this gh
— it is gh api -X PUT repos/.../pulls/436/update-branch.

What I re-derived rather than relayed

  • rc 2 really does survive the command substitution.
    result=$(_control_commit … || exit $?) || rc=$? is the load-bearing line, and it works: the
    exit $? ends the subshell with the function's 2, result still captures the echoed
    failed …, and rc lands on 2. The new branch is placed before the rc -ne 0 branch, so it
    is reachable — an ordering that would have been silently wrong the other way round.
  • The same-filesystem rename claim is real, not asserted. cand="$CONFIG_JSON.control.$$"
    (:4403) — the candidate is in config.json's own directory, so mv -f is a rename(2) that
    either replaces the file wholly or leaves it untouched. That is what earns "nothing to roll back",
    and it is checkable rather than a judgement.
  • The failed branch mirrors the rejected branch exactly, including return 0 and including
    passing "$result" into _control_status's reason field with its prefix still attached
    (reason becomes failed commit-chmod-failed, just as the existing path yields
    rejected <reason>), with the strip happening only in the warn. That redundancy is
    pre-existing. Matching it was the right call — "fixing" it here would change observed reason
    strings under a frozen v1 contract, in a PR about that contract telling the truth.
  • failed over rejected is correct. docs/operations.md defines rejected as an invalid
    change with nothing written, so reporting one here sends the operator to fix a change that was
    fine. And backup: ""null matches what tests/contract/v1/control-status.json already
    lists for failed, so the contract genuinely needs no change.

Your test-first sequencing is the right way to prove a guard and I want to say so plainly: pushing
the rows on the unfixed tree so CI fires them as the control, with 9 reds all of them #434 rows,
zero collateral, and the two positive controls green
, is a fired instrument rather than an
assertion that one exists. The bash 3.2 finding about assignment-prefixed function calls not leaking
is a genuine bonus.

THE FINDING — this PR converts a lying-but-tidy path into an honest-but-leaky one

You disclosed that the failure path leaves its backup snapshot behind, root-owned and uncounted. The
sharper statement, which I verified and which changes how it should be filed:

_reown_config_backups has exactly ONE call site — :4669, on the success path only — and it
does two jobs, not one:

_reown_config_backups() {
    keep="${KEEP_CONFIG_BACKUPS:-20}"
    old=$(ls -t "$dir"/config-*.json | tail -n +"$((keep + 1))")   # <- RETENTION CAP
    ... rm -f each ...
    chown -R "$REAL_USER" "$dir"                                    # <- ownership
}

So on the new rc 2 path the retention sweep is skipped entirely for that run, not merely
"this one file is uncounted". Two consequences worth writing into the issue:

  1. It is a regression in this specific respect, introduced by this PR. Before the fix, a failed
    chmod/mv echoed committed <backup> and returned 0, so control_apply took the success
    branch and did call _reown_config_backups — the sweep ran. The record was a lie but the
    backups stayed capped and reowned. After the fix the record is honest and the sweep no longer
    runs on that path. That is a trade overwhelmingly worth making, and it should be recorded as a
    trade rather than as an oversight.
  2. The growth is unbounded on a repeating fault, not a one-off. Reaching this path needs a
    persistent condition (a permission or disk failure on chmod/rename), and under one every
    staged change snapshots a fresh backup while the only code that prunes them is unreachable. The
    accumulation lands on a rig that is already in trouble.

Why not fix it here: the obvious patch (call _reown_config_backups before the rc 2 return) is
one line, but it runs a chown -R and a delete sweep on a path where the filesystem has just
demonstrated it is failing writes, and this PR's whole value is that its blast radius is
provably nil. Keep it nil. File it with the two points above.

One nit while you are in there: the status says backup: null while a backup file genuinely
exists on disk. That is the right contract answer — nothing was applied, so there is nothing to roll
back to — but it means the orphan is referenced by no status record at all, which is what makes it
invisible to anything that reconciles the two.

What I did not do

No rig, no hardware, no local suite run of any kind — the CI-only rule is in force in my window
too, so I have not observed your rows pass or fail myself; the control-run figures (9 reds, all
#434) and the bash 3.2 result are relayed from your body, not re-derived. I read the code at
3590d106 and the surrounding functions at that head. I did not exercise the chmod/rename
failure injection, did not check the macOS leg, and did not read the CHANGELOG or docs prose beyond
confirming docs/operations.md defines rejected the way you quote it.

@VijitSingh97

VijitSingh97 commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

MERGE-READY: PASS at 3590d106 — all nine checks are now green at that head, including the
End-to-end (Docker) and Test suite (macOS) legs that were still pending when the non-author
review above was written. The reviewer's condition for recording MERGE-READY was all nine green;
that condition is now met, and the head has not moved since the review.

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.

@VijitSingh97
VijitSingh97 merged commit 22fe6c0 into develop Sep 5, 2026
9 checks passed
@VijitSingh97
VijitSingh97 deleted the fix/434-control-commit-tail branch September 5, 2026 02:08
VijitSingh97 added a commit that referenced this pull request Sep 5, 2026
…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>
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.

control_apply can record 'applied' for a commit that never landed

1 participant