…ing it to the logs
The full-screen sync takeover's whole operator-facing text was one headline, and it named
neither mechanism behind the wait. An operator who flipped dashboard.tari_required on a
syncing machine and watched nothing happen for several minutes had no way to tell that from
a fault — which is what #1886 gap 3 records.
Two clocks are involved and neither is a progress bar. Workers are readmitted on
NodeHealthMonitor.healthy — reachable continuously for NODE_RECOVERY_AFTER_SEC, which
service/node_health.py documents as deliberately distinct from "not down" — so mining does
not necessarily begin the instant the chains are ready. And TARI_REQUIRED is read at import,
so a change to it reaches the dashboard only when apply recreates the container.
The readmission sentence is scoped to a node that went away and came back. Review of this PR
found the unscoped wording promised a wait that cannot occur on the screen's own primary
path: _apply_worker_rejection has exactly one call site (data_service.py:1120) and it sits
under `if self.miner_released:`, a one-way latch that is False until the sync gate first
releases, so a machine on its first sync has no rejected workers to readmit. The sentence was
not false — it is reachable for a machine that released, lost monerod, had workers rejected
and caught up inside the recovery window — but it sat directly under "the first time you
start the stack", where it could not tick. A test row now pins the scope clause.
No durations are printed: both windows are env-tunable, and a number on a screen goes stale
in the one direction that reads as a promise.
Tier 3 (node), in a new sibling file — there was no syncview test at all and the nearest
candidates sit at their recorded ceilings. docs/dashboard.md quotes the old headline
verbatim, so it is updated here too and the headline is pinned by a test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZuzdtxNX8ae4rnDtBqEqR
Closes nothing on its own — this is half (b) of #1886 gap 3, the copy half. Half (a) (a tier-1 test pinning "tari_required false releases the gate on monerod alone") was closed separately by the fixes lane and is not touched here.
What the operator sees change on the appliance
The full-screen sync takeover gains a second paragraph under its headline. Today that screen's entire operator-facing text is one sentence — "System is currently synchronizing with the network." — and it names neither mechanism behind the wait.
That is the screen the operator in #1886 gap 3 was looking at when they flipped
dashboard.tari_requiredoff on a syncing machine and then waited several minutes for anything to happen. Nothing on the page could distinguish "working, on a clock you cannot see" from "stuck".The two clocks, and why they belong on the screen rather than in the log
Neither is a progress bar, which is the whole problem — an operator watching only the bars reads a working machine as a broken one.
_apply_worker_rejectionreadmits onself.monero_health.healthy, andNodeHealthMonitor(service/node_health.py) setshealthyonly after the node has been continuously reachable forNODE_RECOVERY_AFTER_SEC. Its own docstring calls this "distinct fromnot down" and says why: a dashboard restart mid-outage must not readmit workers to a stack that cannot mine.TARI_REQUIREDis read at import. So a change todashboard.tari_requiredreaches the dashboard only whenapplyrecreates the container. The operator's flip could not have been visible to the running page at all.No durations are printed, deliberately. Both windows are env-tunable (
NODE_RECOVERY_AFTER_SECdefaults to 60), and a number rendered on a screen goes stale in the one direction that reads as a promise.Tier and coverage
Tier 3 (node), in a new sibling file
dashboard/tests/frontend/syncview.test.mjs— there was nosyncviewtest at all, and the nearest existing files sit at their recorded ceilings indocs/dev/file-budget.tsv, where ceilings only go down. Five rows; both new files stay well under 400 lines, so no budget row is added (make lint-file-budgetrc 0).These assert prose, which is the weakest kind of test there is, so they are built to fail for the reason they name: every needle names the subject of its sentence (a bare
/recovery/or/apply/would match a chain card or unrelated copy), each reads aheader-placeholderblock extracted from the render rather than the whole page, and the fixture carries a near-miss sibling — cards whose own text contains "syncing", a mode string and "blocks left" — so a needle that has quietly widened to match the page is visible here rather than in review.What was RUN
node --test dashboard/tests/frontend/— 584 passed, 0 failed.syncview.mjsreverted toorigin/developand the new test file kept: 3 failed, 2 passed. The three that assert the new copy went red; the two control rows (the headline the docs quote, and the placement check) stayed green on both sides, which is what they are for — an all-red result would have meant my needles were matching nothing in particular rather than matching this change. I proved the revert actually applied rather than assuming it (grep -c 'recovery window'→0reverted,1restored), because a mutation that silently fails to apply passes for the wrong reason.make lint-js(which also lints CSS),lint-md,lint-docs-voice,lint-operator-strings,lint-file-budget— all rc 0, run aftergit add(agit ls-filesgate is blind to work that is not staged).syncview.mjstooklint-jsto rc 2, and a banned marketing word seeded intodocs/dashboard.mdtooklint-docs-voiceto rc 2; both files restored and verified byte-identical bysha256sum -c.lint-docs-voice,lint-operator-stringsandlint-file-budgetalso run their own self-test.lint-md: rc 0, and now shown able to fail — the earlier caveat here was about my SEED, not the gate. I first wrote that this green was not evidence, because a well-formed prose sentence seeded intodocs/dashboard.mdleft it rc 0. That seed was satisfiable, not the gate blind: an MD022 seed (a heading with no blank line around it) redslint-mdat rc 2 and restores to rc 0, measured by me on this file. Corrected in place rather than deleted, because the wrong version was published. The lesson is promoted to the fleet's verification notes: a control that does not fire indicts the seed first, and the tool's config says which rules are live.The review finding, and what changed after the PASS at
2a80ad21The
fixeslane recorded a non-author PASS at2a80ad21with one non-blocking finding, and it was the right one. I re-derived it at source rather than taking the relay:_apply_worker_rejectionhas exactly one production call site (data_service.py:1120) and it sits underif self.miner_released:(:1119) — a one-way latch that is False until the sync gate first releases (:308-313,:404-409). So on a first sync there are no rejected workers to readmit, and the unscoped sentence "Mining does not always begin the moment the bars fill" promised a wait that cannot occur on the screen's own primary path — while sitting directly under the docs line "the first time you start the stack".The sentence was not false. It is reachable for a machine that had released, lost monerod, had workers rejected, and caught up inside the recovery window. It was unscoped, which is a different defect and the one this lane keeps shipping.
The fix is one clause — "If the node went unreachable and is catching up again, workers are readmitted once the node has stayed reachable for a recovery window, rather than on the first check that succeeds" — in the screen copy, the same clause in
docs/dashboard.md(plus a sentence naming what a first run sees instead), and the guard recorded in the in-code comment so the next reader does not re-derive it.One correction to the review, offered because a row would have gone red on merge: the suggested wording said "workers are readmitted once it has stayed reachable", and the existing needle is
/workers are readmitted once **the node** has stayed reachable/. That clause would have reddened the row it was described as preserving. The wording shipped here keeps both original needles verbatim; verified by running them, not by reading them.Added a sixth assertion (not a sixth row — it joins the workers row) pinning the scope clause, so a future trim of it reddens rather than silently restoring the unscoped promise.
What was RUN at
f32dde0a(the pushed head), re-measured rather than carried forwardnode --test dashboard/tests/frontend/— 584 passed, 0 failed.syncview.mjsatorigin/develop, test file kept — 3 failed, 2 passed, the same shape as before, with the two control rows green on both sides.1 → 0 → 1, restored byte-identical by sha256. That is what makes the new assertion a measurement of the fix rather than of the paragraph's existence.What was NOT run
make lint-sh,tests/stack/run.sh, pytest, docker, KVM, and any browser. The appliance lane holds the bench for the #1318 battery, so this lane is node/pytest-only this cycle; nothing here is Python or shell, so the node tier is the tier that covers it. CI is the gate for everything above that. No CSS class was added —dashboard.cssis at its ceiling, so the new paragraph reusestext-muted.Shared-file disclosure
docs/dashboard.mdis edited (it is in_shared.paths, disclosed here per lane policy). It is not optional housekeeping: that file quotes the old headline verbatim, so adding copy around it risked leaving prose false in a file this change does not otherwise touch. The headline is kept and is now pinned by its own test row so a later edit cannot silently retire the quoted sentence.Over-engineering pass (ponytail gate)
Two findings on my own diff. One acted on, one deliberately kept, both recorded rather than quietly resolved.
syncview.mjsalone cannot reconstruct — the two mechanism citations and the reason no duration is printed. Corrected atf32dde0a: that block is now 13 lines, because the review finding below put a third thing in it that a reader of this file alone cannot reconstruct — the guard that scopes the readmission sentence. Re-ran the suite and the lints after the trim rather than assuming a comment edit is inert.No third finding. The change adds no abstraction, no new CSS class, no helper beyond a four-line
headerOfused by every row, and no configuration surface.