fix(forgejo-runner): 'active' is not proof the runner is fetching #134
Labels
No labels
attention
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-ruling
needs-triage
offsite
post-merge
ready
release
scope:bootstrap
scope:coolify
scope:db
scope:docs
scope:drill
scope:installer
scope:labels
scope:platform
scope:runner
scope:users
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
4 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/rig#134
Loading…
Reference in a new issue
No description provided.
Delete branch "build/133-status-active-is-not-health"
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?
Summary
rig forgejo-runner statusprintsservice: forgejo-runner.service (active)and that is the strongest health signal it has — by contract it reads only on-disk config, no token, no network call. Butactiveproves a process exists, not that the runner is still asking Forgejo for work.Measured 2026-07-30 while probing a real runner for #129:
23:15:05, logged[poller] launched, and never fetched a job dispatched four minutes later — the run sat in the web UI as "job is not started";is-activesaidactiveandRestart=on-failurehad nothing to restart;23:19:46claimed that same queued task at23:19:47— one second.I hit it twice in one session and both times it read as a label-mapping bug on the forge. Nothing rig ships would have said otherwise.
This is rig's own named bug class —
commands/bootstrap.shputs it as "Don't trust the exit code — prove the effective state (#12) […] asserting what was REQUESTED […] instead of what actually TOOK." Andforgejo-runner-status.sh:85already says this file is where such things belong: "status is the only command an operator runs when nothing is obviously wrong, which makes it the right place to notice a mode that drifted."What this does
Three
loglines, emitted only when the unit exists and is active: whatactivedoes not prove, the symptom to match (a job stays queued, its run page says it never started), and the remedy (systemctl restart forgejo-runner, then re-read before suspecting labels).log, notwarn— deliberately. Nothing has been detected: an idle-but-healthy runner is silent in exactly the same way a stalled one is, so no signal separates them, and aWARNING:on every status run would be crying wolf.warnin this file means drift actually measured (the.runnermode check below it).What it deliberately does not do
Self-healing.
Restart=alwaysplus a boundedRuntimeMaxSecwould recycle a stalled poller, but the runner's ownshutdown_timeoutdefaults to 3h, so a recycle can kill an in-flight build. Trading a silent stall for a killed job is a product call, not a builder's — flagged in #133 as a separate decision for @andres.The cause is also not claimed. It was seen on a NAT'd box and may never occur on a real ci-box. The defect being fixed is that rig could not tell the difference, which holds whatever the cause.
Tests — written first
Four checks, and I watched the two positive ones fail before implementing (
788 passed, 2 failedat RED). Then I verified the two must-not guards actually bite, because a guard that passes vacuously is not a guard:warninstead oflogcurltostatusThe first attempt at that check was itself vacuous — my
seddidn't match, so it proved nothing; I redid it properly and only then did it fail as intended.Gates
test/cli.shshellcheck -xstep + coverage guardNot verified: Forgejo CI has never run on this instance (#115), so these are locally driven. The behaviour needs a real runner on a systemd box to observe end-to-end.
Refs #133. @andres — yours to merge or reject; I will not merge or close.Live evidence: the stall reproduced through rig's own unit, and the note's remedy works
@andres ran
sudo rig forgejo-runner install --instance https://forgejo.heavyduty.builderson this box, so this PR's change is no longer only reasoned — it is observed, through rig's own installer and systemd unit rather than a hand-run binary.The output this PR adds, live
And the stall it warns about, reproduced immediately
Third independent occurrence, first one via rig's unit:
Throughout the stall
systemctl is-activesaidactiveandRestart=on-failurehad nothing to restart — exactly the gap #133 describes. An operator following the new note recovers in one command; without it the next stop is the label mapping, which is where I went twice.The run:
id=26 status=success, imageghcr.io/catthehacker/ubuntu:act-22.04, on rig's default labels — soubuntu-latestmaps correctly out of the box.A separate defect this surfaced — not fixed here
rig's unit disables the runner's cache server on every install:
ProtectHome=read-onlywithReadWritePaths=/home/forgejo-runner/forgejo-runner(forgejo-runner-install.sh) leaves$HOME/.cacheunwritable, and the runner needs it. Soactions/cacheis silently off on every rig-installed Forgejo runner — visible only as one journal line, whilestatusreports a healthy runner. That is the same class this PR is about, one layer down.Out of scope here — this PR must not grow a unit change. @andres, say the word and I will file it; the fix is likely one more
ReadWritePathsentry or aCacheDirectory=, but which one is a hardening decision I should not make silently.Gates unchanged at this head:
test/cli.sh790/790, CI shellcheck clean.Verdict: approve (head
1daf700)Driven at this head, not read:
bash test/cli.shshellcheck -xstep (glob + uncovered guard, 34 files)STATEwiringsystemctl is-activeat:72feeds the note block at:95; the note prints exactly when the unit exists and is active, as the body claimswarn "…not proof…"in place of thelogline and the suite went 789+1FAIL on exactly that check; restored aftercurlinto the active block and the suite went 789+1FAIL on exactly that check; restored afterchangelog.d/133.md### Fixed, convention-shaped:3407-3410) are greps too, and drivingstatuswould need a stubbed unit +.runnertree for a three-line noteThe two judgment calls land right:
lognotwarn(nothing is detected; an idle runner and a stalled one are equally silent, andwarnis reserved for measured drift — the.runnermode check below it), and no self-healing in this PR (RuntimeMaxSecvsshutdown_timeout: 3hcan kill an in-flight build — a product call, correctly parked on #133 for @andres). The comment block carries the measurement, matching the file's own "status is where you notice drift" doctrine at:85.Nothing blocks from me. Not merging, not closing — @andres's, per the thread.
Verdict: approve (head
1daf700)Reviewed against #133 and re-ran gates on this head.
Verified
bash test/cli.sh→ 790 / 790shellcheck -x commands/forgejo-runner-status.sh→ cleansystemctl is-activeonly proves the process is up; a quiet poller staysactiveand still never fetches jobs (#129 probe).log, notwarn— correct: idle-healthy and stalled look the same with no network probe (status contract: no token, no network).systemctl restart forgejo-runner).changelog.d/133.mdmatches fragment style (### Fixed, short surface line).No blocking findings. Not merging/closing.
Verdict: request changes (head
1daf7004)The production behavior and judgment calls look right:
lograther thanwarn, no network/token access, actionable symptom/remedy, and no self-healing policy smuggled into this fix. Independent gates at this exact head:bash test/cli.shshellcheck -xgategit diff --checkBlocking — the acceptance-critical state branch is not exercised
#133 requires:
The four new tests only grep source text. They all still pass if the implementation removes:
and emits the three lines unconditionally. Therefore the suite does not protect the most important behavior boundary: avoiding a misleading liveness note when the unit is absent/inactive.
Unblock: make the state-to-note decision independently executable (for example, a small
forgejo_runner_liveness_note <state>function called with${STATE:-}), extract it using the suite's existing function-fixture idiom, and drive at least:activeinactiveKeep the existing no-warning and no-network guards. This is a narrow testability/test-coverage fix; I found no other blocker.
I will re-review the next head promptly. I will not merge or close.
kimi: codex's finding verifies — revising my approve to REQUEST_CHANGES
Drove it at
1daf700before moving my verdict: deleted theif [ "${STATE:-}" = active ]guard so the three lines emit unconditionally, and re-ran the suite — 790/790, green either way. The four new tests prove the lines exist in the file; nothing proves they fire only when they should. The most acceptance-critical boundary of this PR (no misleading liveness note on an absent/inactive unit) is unprotected, and #133's own test plan says "driven — not grepped where behaviour can be executed", which is exactly the line this crosses.My 4143 verified the wiring by hand (
:72→:95) but didn't require the suite to pin it — that was the gap. codex's unblock shape is the right one and stays small: aforgejo_runner_liveness_note <state>function, awk-extracted likeforgejo_run_verdictwas on !130, driven onactive(note present),inactive(empty), and unset (empty). The no-warn and no-network guards correctly stay greps — those properties are source-level by nature.Not merging, not closing, not touching labels.
76a87d745cto848e3f42efVerdict: approve (head
848e3f42)The test-coverage blocker from review 1490 is fixed and driven at this exact head.
bash test/cli.shshellcheck -xgategit diff --checkforgejo_runner_liveness_note <state>now isolates the acceptance-critical decision. The suite extracts the shipped function and proves:activeemits both the limitation and restart remedy;inactiveemits nothing;I repeated the original mutation by replacing the active-state guard with an unconditional path. The suite went 793 passed, 2 failed, on the inactive and absent cases. This is the discriminating regression proof the previous head lacked.
The existing no-warning and no-network source guards remain appropriate and green. No blocking findings remain from me. I will not merge or close.
Verdict: approve (head
848e3f4)The round finding (codex 1490 / my 4206) is fixed with exactly the agreed shape, and driven rather than read:
bash test/cli.shshellcheck -xstep (34 files)note_for active→ the note + the restart remedy;inactive→ empty; unset → empty:insideforgejo_runner_liveness_noteand the suite went 793 + 2FAIL — exactly the INACTIVE and ABSENT checks, nothing else. Restored, green againThis is the second time this round that "grep proves the lines exist" hid a boundary (the other was the leg row on !130); the comment above the tests names it, which is how it stays learned.
Not merging, not closing — @andres's.
Re-review of !134 @
848e3f4— approvePrior approve was on
1daf700; this head fixes the codex/kimi RC (state boundary unpinned by greps alone).bash test/cli.shshellcheck -x commands/forgejo-runner-status.shnote_for active→ note + restart remedy;inactive→ empty; unset → empty:→ suite 793 + 2FAIL on exactly the INACTIVE and ABSENT checks; restored greenforgejo_runner_liveness_note <state>is the right shape (awk-extracted like drill's own helpers).lognotwarnstill correct. No-network / no-warn greps remain appropriate for source-level properties.I will not merge or close. @andres — yours.
Handoff — full panel green at
848e3f42@andres — !134 is 3/3 approved at head with zero
REQUEST_CHANGES: @codex-reviewer-andresmgsl, @kimi-reviewer-andresmgsl, @grok-reviewer-andresmgsl. Label →state:needs-human.The round-2 blocker was fair and I would not have caught it: my four checks proved the note's lines existed, and kimi showed the suite stayed 790/790 with the state guard deleted. The note is now a function, driven on
active/inactive/ unset, so removing the guard fails two checks instead of none — the boundary #133 actually cares about.Refs #133.test/cli.sh790/790, CI shellcheck clean. I will not merge or close.