Skip to content

Commit 2bfdd3f

Browse files
VijitSingh97claude
andauthored
feat(control): richer /status contract for control-upgrade — started, noop, throttled, version echo (#320) (#323)
* feat(control): richer /status contract for control-upgrade — started, noop, throttled, version echo (#320) Consumer findings from pithead#597 against the v1.11.2 contract: - Write one non-terminal {status:"started"} record right after the D8 claim, so a poller can tell mid-build from queued — and a started record that outlives the oneshot means the run was lost (poll-forever otherwise). - already-on-target reports noop (idempotent, not red on a dashboard); the throttle window reports throttled (retry-later) — both were an undifferentiated failed distinguishable only by prose reason. - applied's reason echoes the landed version, so the consumer no longer cross-reads :8081/2/summary and hopes no second change raced it. Additive to the ADR 0002 D3/D6 model (D6 amended in place); documented in Operations › Writable control path. Unit suite covers the started mid-run record (incl. the changes/<cid> index), the noop and throttled statuses, and the version echo. Closes #320 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(control): sweep the remaining status-vocabulary drift after #320 Docs-consistency review before merge: ADR 0002 D4 still claimed every guard failure is terminal failed (noop/throttled exist now), D3 predated the started record, _control_status's comment said 'outcome' only, control-server.py's docstring omitted /upgrade and the non-terminal record (and its 202 note read as if already-on-target were a refusal), and pithead-integration.md's ?change_id status list could be misread as the endpoint's full vocabulary for upgrade change ids. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 3070d99 commit 2bfdd3f

7 files changed

Lines changed: 86 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ All notable changes to RigForge are documented here. The format is based on
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **`control-upgrade` status contract: `started` / `noop` / `throttled` + version echo (#320).**
13+
Consumer findings from pithead's one-click worker upgrade (pithead#597): the oneshot now writes a
14+
non-terminal `started` record the moment it claims a staged intent (so a poller can tell mid-build
15+
from queued, and a `started` that outlives the oneshot means the run was lost); the already-on-target
16+
refusal reports `noop` and the throttle window reports `throttled` instead of collapsing into
17+
`failed`; a successful upgrade's `reason` names the landed version. Additive to the ADR 0002 D3/D6
18+
model — D6 amended in place, contract documented in Operations › Writable control path.
19+
1020
## [1.11.2] - 2026-07-18
1121

1222
Consistency-audit patch: CLI-surface and docs cleanups, plus a real fix to the remote-upgrade

docs/adr/0002-remote-worker-upgrade.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ The receiver (`util/control-server.py`) gains a `POST /upgrade` distinct from `/
3333

3434
### D3. Unprivileged receiver stages only; the privileged oneshot fetches and runs
3535

36-
As in ADR 0001 D2, the network-facing receiver stays unprivileged (`DynamicUser`): it authenticates, checks the flag, and **stages** the request; it never fetches or runs code itself. The privileged path-triggered oneshot does the fetch-and-upgrade through the existing gated flow. The response is `202 Accepted` with a change id; the consumer polls `/status` for the terminal outcome.
36+
As in ADR 0001 D2, the network-facing receiver stays unprivileged (`DynamicUser`): it authenticates, checks the flag, and **stages** the request; it never fetches or runs code itself. The privileged path-triggered oneshot does the fetch-and-upgrade through the existing gated flow. The response is `202 Accepted` with a change id; the consumer polls `/status` to a terminal outcome (a non-terminal `started` appears first while the oneshot runs — #320, see D6).
3737

3838
### D4. Dashboard-supplied target, bounded by monotonic + reachability guards
3939

@@ -43,7 +43,7 @@ The staged body is tiny: `{"version": "vX.Y.Z"}`, and it **is** the target. The
4343
- **Reachable-from-main** — refuse a commit that is not an ancestor of `origin/main` (D10; amended by #318).
4444
- **Immutable releases + tag protection** (D10) make the tag→commit binding a platform guarantee.
4545

46-
The rig still `git`-fetches the tag directly from GitHub — the code has to come from somewhere, and that fetch happens only on an actual upgrade (rare, throttled), not on every trigger — but it makes no separate version-check dial. Anything that fails a guard is terminal `failed`. This trades away rig-*independent* confirmation of "is this THE latest" (the dashboard, over Tor, is the deriver) for privacy, fewer moving parts, and no fleet rate-limit coupling; the anti-rollback + reachability guards are what make trusting a supplied target safe.
46+
The rig still `git`-fetches the tag directly from GitHub — the code has to come from somewhere, and that fetch happens only on an actual upgrade (rare, throttled), not on every trigger — but it makes no separate version-check dial. Anything that fails a guard ends terminal: `failed`, except the two benign refusals distinguished since #320`noop` (already on the target) and `throttled` (inside the D6 window). This trades away rig-*independent* confirmation of "is this THE latest" (the dashboard, over Tor, is the deriver) for privacy, fewer moving parts, and no fleet rate-limit coupling; the anti-rollback + reachability guards are what make trusting a supplied target safe.
4747

4848
### D5. Trust model: hash-only, git-tag checkout, no signing (settled)
4949

@@ -55,6 +55,8 @@ GitHub is therefore accepted as the trust root. The residual risk is explicit: a
5555

5656
`control-upgrade` is fail-closed and every step is rollback-guarded, reusing the upgrade flow's existing rebuild-only-if-the-pin-changed logic. A **pre-dial throttle stamp** on the rig bounds how often it will reach out to GitHub, so a looping or hostile consumer cannot turn the fleet into a beacon or a request amplifier. Outcomes use the same `/status` surface as control-apply, extended with a **`failed`** terminal (with reason) alongside `applied` / `rolled_back`, so a pre-apply refusal (non-latest, throttled, fetch/build error) surfaces a clear terminal result to the consumer's poll rather than hanging in a non-terminal state.
5757

58+
*Amended (#320, consumer feedback from pithead #597):* the status vocabulary grew three additive members so a poller never has to string-match `reason`. A non-terminal **`started`** is written the moment the oneshot claims the intent (D8 move done), so "mid-run" and "oneshot died mid-run" are distinguishable from "queued"; **`noop`** replaces `failed` for the already-on-target refusal (idempotent, not an error); **`throttled`** replaces `failed` for the D6 throttle refusal (retry-later, not an error). `applied`'s `reason` echoes the landed version. All other refusals stay `failed`.
59+
5860
### D7. Auth and source-pinning inherited from ADR 0001 D4/D9
5961

6062
`/upgrade` reuses the control path's Bearer `ACCESS_TOKEN` and the `api_allow_from` source pin (which D1 already requires for `control: enabled`), and the same nftables scoping (#142). The stack host is the only trusted writer; the miner never accepts an upgrade trigger from a miner-advertised or arbitrary host. The cleartext-token-on-LAN posture (ADR 0001 D9) applies unchanged — the mining LAN is the trust boundary.
@@ -100,7 +102,7 @@ Low-cost integrity controls that need no standing signing key, shoring up the Gi
100102
- New opt-in surface, off by default, fail-closed. A rig that does not enable `control_upgrade` carries zero new capability; a rig on `control` alone is unchanged.
101103
- Residual risk accepted: GitHub-account/release compromise → fleet-wide root RCE (D5). Mitigation is account hardening, not signing.
102104
- Producer for pithead #597. Backwards compatible (a new MINOR capability). It must ship in a RigForge release before pithead #597 can run end-to-end; pithead #596 (version badge) has no RigForge dependency and ships first.
103-
- Consumer contract: `POST :8082/upgrade {"version":"vX.Y.Z"}``202 Accepted` + change id → poll `:8082/status` for `applied` / `rolled_back` / `failed`. The host never trusts a host/port/token from the intent; the rig bounds the dashboard-supplied target with monotonic anti-rollback + reachable-from-main guards (D4/D10) rather than making its own version-check dial.
105+
- Consumer contract: `POST :8082/upgrade {"version":"vX.Y.Z"}``202 Accepted` + change id → poll `:8082/status` for a non-terminal `started`, then terminal `applied` (reason names the landed version) / `rolled_back` / `noop` (already on target) / `throttled` (retry after the window) / `failed` (#320 amendment, D6). The host never trusts a host/port/token from the intent; the rig bounds the dashboard-supplied target with monotonic anti-rollback + reachable-from-main guards (D4/D10) rather than making its own version-check dial.
104106

105107
## Open questions (resolved 2026-07-17 — see Resolution above)
106108

docs/operations.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,13 @@ and health-gates the swap with rollback. It refuses anything that isn't a real r
601601
the running one. Default off, and never on just because `control` is. The threat model and
602602
decisions are in [ADR 0002](adr/0002-remote-worker-upgrade.md).
603603

604+
Polling an upgrade works like polling a config change, with a slightly richer vocabulary (#320):
605+
`GET :8082/status?change_id=<id>` serves a non-terminal `started` once the root oneshot has claimed
606+
the intent (so a poller can tell "mid-build" from "queued" — and a `started` that outlives the
607+
oneshot means the run was lost), then one terminal outcome: `applied` (the `reason` names the
608+
landed version), `rolled_back`, `noop` (already on the requested version — idempotent, not an
609+
error), `throttled` (inside the anti-beacon window — retry later), or `failed` (with `reason`).
610+
604611
`"control": "disabled"` + `apply` removes the units cleanly; `uninstall` removes them too. Linux-only.
605612
The design and the decisions behind it are recorded in
606613
[ADR 0001](../docs/adr/0001-writable-worker-config-control-path.md).

docs/pithead-integration.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,11 @@ and [ADR 0001](adr/0001-writable-worker-config-control-path.md).
188188
recent* change's outcome, which a concurrent change (another dashboard edit, a local `apply`, an
189189
autotune restart) can step on between your `POST` and your poll. To avoid that race, poll
190190
`GET :8082/status?change_id=<the 16-hex id from the 202>` — it returns *that* change's recorded
191-
outcome (`applied`/`rejected`/`rolled_back`/`failed` + `reason`/`backup`/`changed_keys`/`warnings`), or `404`
192-
if it isn't among the last ~20 recorded. Same bearer auth as `/status`. Pair it with
191+
outcome (for a config change: `applied`/`rejected`/`rolled_back`/`failed` +
192+
`reason`/`backup`/`changed_keys`/`warnings`), or `404` if it isn't among the last ~20 recorded. Same
193+
bearer auth as `/status`. The same endpoint serves remote-*upgrade* change ids with a richer
194+
vocabulary (non-terminal `started`, terminal `noop`/`throttled` alongside the above — #320); see
195+
[Operations › Remote upgrade](operations.md#writable-control-path-opt-in). Pair it with
193196
`config_meta.revision` on the read feed to confirm the effective config actually moved.
194197

195198
**Prefill from a live read (`rigforge.config`).** The enriched feed exposes the rig's current

rigforge.sh

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3812,7 +3812,8 @@ _control_do_apply() {
38123812
_wait_miner_live "${CONTROL_LIVE_TRIES:-20}"
38133813
}
38143814

3815-
# Record the outcome for the receiver's GET /status (mode 644 so the DynamicUser server reads it back).
3815+
# Record a status record for the receiver's GET /status (mode 644 so the DynamicUser server reads it
3816+
# back) — a terminal outcome, or control_upgrade's non-terminal `started` marker (#320).
38163817
_control_status() { # <status-file> <status> <cid> <keys-csv> <reason> <backup>
38173818
local f="$1" cid="$3" body cdir
38183819
mkdir -p "$(dirname "$f")"
@@ -3988,6 +3989,12 @@ control_upgrade() {
39883989
warn "control-upgrade: $cid was a symlink — refused."
39893990
return 0
39903991
fi
3992+
# #320: one NON-terminal record now that the intent is claimed (D8 move done, nothing can swap it).
3993+
# Between 202 Accepted and the terminal write the poller previously saw only the PREVIOUS change's
3994+
# outcome — indistinguishable from "oneshot died before writing status". `started` is overwritten
3995+
# by this run's terminal record below; a poller that still reads it after the oneshot exited knows
3996+
# the run was lost.
3997+
_control_status "$status" started "$cid" version "" ""
39913998
# Strict field whitelist (D4): exactly {"version":"vX.Y.Z"}. Never sourced/evaled; anything else refused.
39923999
target=$(jq -r 'if (type == "object" and (keys | sort) == ["version"] and (.version | type == "string")) then .version else empty end' "$staged" 2>/dev/null)
39934000
rm -f "$staged"
@@ -4003,7 +4010,8 @@ control_upgrade() {
40034010
if [ -n "$installed" ]; then
40044011
local want="${target#v}" highest
40054012
if [ "$want" = "$installed" ]; then
4006-
_control_status "$status" failed "$cid" version "already on $target — nothing to upgrade" ""
4013+
# #320: an idempotent no-op, not a failure — the rig is exactly where the operator wants it.
4014+
_control_status "$status" noop "$cid" version "already on $target — nothing to upgrade" ""
40074015
log "control-upgrade: already on $target."
40084016
return 0
40094017
fi
@@ -4016,15 +4024,17 @@ control_upgrade() {
40164024
fi
40174025
# D6 throttle: bound how often a rig fetches/rebuilds.
40184026
if ! _control_upgrade_throttle_ok "$state"; then
4019-
_control_status "$status" failed "$cid" version "throttled — too soon since the last upgrade attempt" ""
4027+
# #320: distinct from failed — the consumer's right move is retry-later, not alarm.
4028+
_control_status "$status" throttled "$cid" version "throttled — too soon since the last upgrade attempt" ""
40204029
warn "control-upgrade: throttled."
40214030
return 0
40224031
fi
40234032
old_tag=$(git -C "$SCRIPT_DIR" -c safe.directory="$SCRIPT_DIR" describe --tags --exact-match 2>/dev/null || git -C "$SCRIPT_DIR" -c safe.directory="$SCRIPT_DIR" rev-parse HEAD 2>/dev/null || true)
40244033
log "control-upgrade: $cid -> $target (from ${old_tag:-unknown}); fetching + building..."
40254034
local RIGFORGE_CONFIG_SOURCE=control RIGFORGE_CONFIG_CHANGE_ID="$cid"
40264035
if _control_upgrade_do "$target" && _wait_miner_live "${CONTROL_LIVE_TRIES:-20}"; then
4027-
_control_status "$status" applied "$cid" version "" ""
4036+
# #320: echo the landed version — the poller shouldn't have to cross-read the miner API for it.
4037+
_control_status "$status" applied "$cid" version "upgraded to $target" ""
40284038
log "control-upgrade: $cid applied — now on $target."
40294039
else
40304040
# ANY forward failure rolls back — a reachability/build error OR the miner not returning to a live

tests/run.sh

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6166,6 +6166,8 @@ cu_run() { # <staged-json|""> <installed-version> <do:ok|fail|down> -> status.js
61666166
st() { printf '%s' "$1" | jq -r .status 2>/dev/null; }
61676167
s="$(cu_run '{"version":"v9.9.9"}' "1.0.0" ok)"
61686168
assert_eq "upgrade applied on a newer, buildable release" "$(st "$s")" "applied"
6169+
# #320: the applied record must say which version landed — no cross-reading the miner API for it.
6170+
assert_contains "applied reason echoes the landed version (#320)" "$s" "upgraded to v9.9.9"
61696171
s="$(cu_run '{"version":"v9.9.9"}' "1.0.0" down)"
61706172
assert_eq "built but miner stays down -> rolled_back" "$(st "$s")" "rolled_back"
61716173
# #308 security review (HIGH): a build failure AFTER checkout must roll the tree back to the prior
@@ -6180,7 +6182,10 @@ s="$(cu_run '{"version":"v1.0.0"}' "2.0.0" ok)"
61806182
assert_eq "downgrade refused -> failed (never built)" "$(st "$s")" "failed"
61816183
assert_contains "downgrade reason names anti-rollback" "$s" "not newer"
61826184
s="$(cu_run '{"version":"v1.0.0"}' "1.0.0" ok)"
6183-
assert_contains "same version refused (already on)" "$s" "already on v1.0.0"
6185+
# #320: already-on-target is an idempotent no-op, not a failure — a dashboard must not show red
6186+
# for a rig sitting exactly where the operator wants it.
6187+
assert_eq "same version -> noop, not failed (#320)" "$(st "$s")" "noop"
6188+
assert_contains "noop reason still says already on" "$s" "already on v1.0.0"
61846189
s="$(cu_run '{"version":"garbage"}' "1.0.0" ok)"
61856190
assert_contains "malformed target refused, never run" "$s" "malformed"
61866191
s="$(cu_run '{"version":"v9.9.9","evil":"x"}' "1.0.0" ok)"
@@ -6302,7 +6307,41 @@ sThr="$(
63026307
PATH="$STUBS:$PATH" control_upgrade >/dev/null 2>&1
63036308
cat "$cuThr/state/status.json" 2>/dev/null
63046309
)"
6305-
assert_contains "control_upgrade within the throttle window -> failed(throttled) (#308)" "$sThr" "throttled"
6310+
assert_eq "control_upgrade within the throttle window -> status throttled (#308/#320)" "$(st "$sThr")" "throttled"
6311+
assert_contains "throttled reason says why" "$sThr" "too soon"
6312+
6313+
# #320: between the D8 claim and the terminal outcome the verb writes ONE non-terminal `started`
6314+
# record, so a poller can tell "mid-run" (and "oneshot died mid-run": started never superseded)
6315+
# from "queued, path unit hasn't fired" (previous change's terminal record still served).
6316+
cuSt=$(mktemp -d "$SANDBOX/cust.XXXXXX")
6317+
mkdir -p "$cuSt/state/spool"
6318+
printf '1.0.0' >"$cuSt/VERSION"
6319+
printf '{"pools":[{"url":"h:3333"}]}\n' >"$cuSt/config.json"
6320+
printf '{"version":"v9.9.9"}\n' >"$cuSt/state/spool/upgrade-abc123def4567890.json"
6321+
(
6322+
source "$SCRIPT"
6323+
OS_TYPE=Linux
6324+
SCRIPT_DIR="$cuSt"
6325+
CONFIG_JSON="$cuSt/config.json"
6326+
RIGFORGE_CONTROL_STATE="$cuSt/state"
6327+
CONTROL_UPGRADE_MIN_INTERVAL=0
6328+
# Snapshot the status file at the moment the build half runs — the started record must already
6329+
# be there, and must carry this change's id (not the previous change's terminal record).
6330+
_control_upgrade_do() {
6331+
cp "$cuSt/state/status.json" "$cuSt/mid-status.json" 2>/dev/null
6332+
return 0
6333+
}
6334+
_wait_miner_live() { return 0; }
6335+
git() { echo v0.0.1; }
6336+
set +e
6337+
PATH="$STUBS:$PATH" control_upgrade >/dev/null 2>&1
6338+
)
6339+
sMid="$(cat "$cuSt/mid-status.json" 2>/dev/null)"
6340+
assert_eq "started record served while the build runs (#320)" "$(st "$sMid")" "started"
6341+
assert_contains "started record carries this change's id" "$sMid" "abc123def4567890"
6342+
assert_eq "started record indexed under changes/<cid> too (#320)" \
6343+
"$([ -f "$cuSt/state/changes/abc123def4567890.json" ] && echo y || echo n)" "y"
6344+
assert_eq "terminal record supersedes started" "$(st "$(cat "$cuSt/state/status.json" 2>/dev/null)")" "applied"
63066345

63076346
# #308: the control-upgrade oneshot runs as root with NO $HOME, so git can't read root's safe.directory
63086347
# config and fatals on "dubious ownership" of the operator-owned install — every git op then fails and

util/control-server.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
- Body size is capped; the staged file is written atomically (temp + rename + fsync) so the
1717
path unit never sees a half-written request.
1818
19-
Python3 stdlib only. GET /status returns the last applied change's result; POST /apply stages one.
19+
Python3 stdlib only. GET /status returns the last recorded change record — a terminal outcome, or
20+
control-upgrade's non-terminal `started` while the oneshot runs (#320). POST /apply stages a config
21+
change; POST /upgrade stages a release upgrade (#308, gated by control_upgrade).
2022
"""
2123
import hmac
2224
import json
@@ -227,7 +229,7 @@ def _handle_upgrade(self):
227229
except OSError as e:
228230
return self._send(500, "Internal Server Error", {"error": "could not stage upgrade: %s" % e})
229231
self._send(202, "Accepted", {"status": "accepted", "change_id": cid,
230-
"note": "queued; the rig refuses anything that isn't a real, reachable release newer than the one installed (it does not independently verify the target is the newest). poll GET /status"})
232+
"note": "queued; the rig refuses anything that isn't a real, reachable release newer than the one installed (posting the installed version ends 'noop'; it does not independently verify the target is the newest). poll GET /status"})
231233

232234
def _handle_apply(self):
233235
change = self._read_json_body()

0 commit comments

Comments
 (0)