From 6f46cd40dc1c784a6a9e447e574b02e08c4ce0c5 Mon Sep 17 00:00:00 2001
From: igneum-josh <337424239+igneum-josh@users.noreply.github.com>
Date: Tue, 6 Oct 2026 16:36:07 +0100
Subject: [PATCH] =?UTF-8?q?A=20card=20never=20shows=200=20MH/s=20without?=
=?UTF-8?q?=20a=20reason=20word=20(Josh,=206=20October=202026,=20watching?=
=?UTF-8?q?=20PC=201=20during=20the=20table=20run):=20the=20Mine=20row=20r?=
=?UTF-8?q?eads=20'tuning:=20step=20k=20of=20n=20=C2=B7=20measuring=20W=20?=
=?UTF-8?q?W'=20with=20the=20live=20rate,=20'held=20for=20a=20remote=20job?=
=?UTF-8?q?:=20
'=20while=20a=20job=20holds=20the=20miners,=20and=20?=
=?UTF-8?q?the=20Tuned=20line=20at=20the=20end;=20the=20big=20button=20rea?=
=?UTF-8?q?ds=20'Tuning,=20mining=20again=20in=20about=20N=20min';=20the?=
=?UTF-8?q?=20strip=20carries=20one=20tune=20line;=20a=20measurement=20eng?=
=?UTF-8?q?ine=20prints=20a=20TUNE=20progress=20line=20every=2010=20s=20an?=
=?UTF-8?q?d=20the=20playbook=20forwards=20it=20(and=20TUNE=20chosen=20as?=
=?UTF-8?q?=20done)=20to=20the=20installed=20app's=20POST=20/api/tune-prog?=
=?UTF-8?q?ress,=20a=20post=20of=20state,=20never=20quit,=20pause=20or=20r?=
=?UTF-8?q?esume;=20the=20rule=20in=20ember-tune.md=206a;=20UI=20tests=20f?=
=?UTF-8?q?or=20the=20three=20states?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Fable 5.1
---
app/igneum-app/src/ember.rs | 13 +++++
app/igneum-app/src/engine.rs | 86 ++++++++++++++++++++++++++--
app/igneum-app/src/server.rs | 5 ++
app/igneum-app/src/state.rs | 6 ++
app/igneum-app/ui/app.js | 43 +++++++++++---
app/igneum-app/ui/tune-line.test.mjs | 33 +++++++++++
docs/plans/ember-tune.md | 15 +++++
relay/playbooks/ember-tune-pc1.ps1 | 21 ++++++-
tools/ci/second-engine-check.sh | 2 +-
9 files changed, 211 insertions(+), 13 deletions(-)
diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs
index d839135e5..dcd96dafd 100644
--- a/app/igneum-app/src/ember.rs
+++ b/app/igneum-app/src/ember.rs
@@ -591,6 +591,19 @@ impl Run {
self.rows.first().map(|r| r.mclk).unwrap_or(0.0)
}
+ /// Seconds left in the plan from here: the rest of this step plus (settle + hold + a 10 s apply) per step to come.
+ pub fn eta_s(&self, now: Instant) -> i64 {
+ let per = (self.timing.settle + self.timing.hold).as_secs() as i64 + 10;
+ let this = match &self.phase {
+ Phase::Applying { .. } => per,
+ Phase::Settling { since } => per - 10 - now.duration_since(*since).as_secs().min(per as u64) as i64,
+ Phase::Holding { since } => (self.timing.hold.as_secs() as i64 - now.duration_since(*since).as_secs() as i64).max(0),
+ Phase::Finishing { .. } | Phase::Done => 0,
+ };
+ let left = self.plan.len().saturating_sub(self.rows.len() + 1) as i64;
+ this.max(0) + left * per
+ }
+
/// The phase in words for the card row.
pub fn words(&self, now: Instant) -> String {
let what = |p: &Point| if p.clock_mhz > 0 { format!("{} MHz · {}%", p.clock_mhz, p.power_pct) } else { format!("{}%", p.power_pct) };
diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs
index 146a62c81..7ed7050a8 100644
--- a/app/igneum-app/src/engine.rs
+++ b/app/igneum-app/src/engine.rs
@@ -73,6 +73,8 @@ pub enum Cmd {
SweepHelperDone(Result<(), String>),
/// a tune request (by number) was carried out: what the vendor tool printed, or why it refused
TuneSet(u64, Result),
+ /// POST /api/tune-progress from a measurement engine beside this app: the card's live tune state
+ TuneProgress(Value),
/// restart the node with the verifier decided again (src/verifier.rs): the trust setting changed, or the
/// prover found a host that was not there when the node started
RestartNode(String),
@@ -1142,6 +1144,7 @@ impl Engine {
self.shared.log("sweep helper: exited");
}
}
+ Cmd::TuneProgress(v) => self.tune_progress(&v),
Cmd::TuneSet(seq, r) => match r {
Ok(text) => {
self.tune_acked = Some(seq);
@@ -2527,6 +2530,49 @@ impl Engine {
self.shared.log(&format!("tune: {} MHz, {}% ({w} W) requested on device {device} (request {seq})", if clock > 0 { clock.to_string() } else { "unlocked".into() }, step.point.power_pct));
}
+ /// A measurement engine's progress for one card (POST /api/tune-progress, forwarded by the job playbook from the
+ /// engine's `TUNE progress` lines): the installed app shows the step, the live rate and draw and the time left
+ /// instead of a bare "off" while a job holds its miners. `done` ends it: the row reads the tuned line.
+ fn tune_progress(&mut self, v: &Value) {
+ let key = v.get("key").and_then(|k| k.as_str()).unwrap_or("");
+ let mut st = self.st();
+ let Some(c) = st.mining.cards.iter_mut().find(|c| c.key == key || (!key.is_empty() && c.name.replace(' ', "_") == key)) else { return };
+ let n = |k: &str| v.get(k).and_then(|x| x.as_f64()).unwrap_or(0.0);
+ if v.get("done").and_then(|d| d.as_bool()).unwrap_or(false) {
+ c.sweep_state = "idle".into();
+ c.state = if self.job_hold { "held".into() } else { c.state.clone() };
+ c.tune_step = 0;
+ c.tune_steps = 0;
+ c.tune_eta_s = 0;
+ if n("mhs") > 0.0 && n("watts") > 0.0 {
+ c.sweep_mhs = n("mhs");
+ c.sweep_watts = n("watts");
+ c.sweep_eff = n("mhs") / n("watts");
+ c.sweep_pct = n("power_pct") as u32;
+ c.tune_clock_mhz = n("clock_mhz") as u32;
+ c.tune_source = v.get("plan").and_then(|p| p.as_str()).unwrap_or("full").into();
+ c.tune_line = crate::ember::tuned_line(n("mhs"), n("watts"), n("mhs") / n("watts"));
+ c.sweep_at = crate::platform::unix_now_f();
+ }
+ c.sweep_note = v.get("note").and_then(|x| x.as_str()).unwrap_or("").to_string();
+ return;
+ }
+ c.state = "tuning".into();
+ c.sweep_state = "running".into();
+ c.sweep_note = v.get("note").and_then(|x| x.as_str()).unwrap_or("tuning").to_string();
+ c.tune_step = n("step") as u32;
+ c.tune_steps = n("of") as u32;
+ c.tune_eta_s = n("eta_s") as i64;
+ c.tune_plan = v.get("plan").and_then(|p| p.as_str()).unwrap_or("").into();
+ if n("mhs") > 0.0 {
+ c.hash_now = n("mhs");
+ }
+ if n("watts") > 0.0 {
+ c.power_w = n("watts");
+ }
+ c.message = String::new();
+ }
+
/// Faults and conditions first, then the state machine.
fn sweep_drive(&mut self, now: Instant) {
let Some((idx, device, started, seq)) = self.sweep.as_ref().map(|r| (r.card, r.device.clone(), r.started, r.seq)) else { return };
@@ -2542,7 +2588,7 @@ impl Engine {
Some("a remote job took the GPU".into())
} else if self.st().mining.paused {
Some("mining paused".into())
- } else if c.state != "mining" {
+ } else if c.state != "mining" && c.state != "tuning" {
Some(format!("the card left mining ({})", if c.state == "starting" || c.state == "restarting" { "worker restart, likely the hour boundary" } else { &c.state }))
} else if slot_error {
Some("the worker reported an error".into())
@@ -2591,9 +2637,20 @@ impl Engine {
}
}
}
- if let Some(words) = self.sweep.as_ref().map(|r| r.words(now)) {
+ if let Some((words, step, of, eta, plan)) = self.sweep.as_ref().map(|r| (r.words(now), r.rows.len() as u32 + 1, r.plan.len() as u32, r.eta_s(now), r.plan.kind.name())) {
+ let (key, mhs, watts) = self.st().mining.cards.get(idx).map(|c| (c.key.clone(), c.hash_now, c.power_w)).unwrap_or_default();
if let Some(cc) = self.st().mining.cards.get_mut(idx) {
- cc.sweep_note = words;
+ cc.sweep_note = words.clone();
+ cc.state = if cc.state == "mining" { "tuning".into() } else { cc.state.clone() };
+ cc.tune_step = step;
+ cc.tune_steps = of;
+ cc.tune_eta_s = eta;
+ cc.tune_plan = plan.into();
+ }
+ if self.shared.runtime.sweep_only {
+ // the job playbook forwards this to the installed app's /api/tune-progress
+ println!("TUNE progress key={} step={step} of={of} eta_s={eta} mhs={mhs:.2} watts={watts:.1} plan={plan} note={}", key.replace(' ', "_"), words.replace(' ', "_"));
+ let _ = std::io::stdout().flush();
}
}
}
@@ -2616,6 +2673,12 @@ impl Engine {
c.sweep_mhs = row.mhs;
c.sweep_at = unix as f64;
c.sweep_state = "idle".into();
+ if c.state == "tuning" {
+ c.state = "mining".into();
+ }
+ c.tune_step = 0;
+ c.tune_steps = 0;
+ c.tune_eta_s = 0;
c.tune_clock_mhz = row.point.clock_mhz;
c.tune_source = kind.name().into();
c.tune_line = crate::ember::tuned_line(row.mhs, row.watts, row.eff);
@@ -2701,6 +2764,12 @@ impl Engine {
match st.mining.cards.get_mut(idx) {
Some(c) => {
c.sweep_state = "idle".into();
+ if c.state == "tuning" {
+ c.state = "mining".into();
+ }
+ c.tune_step = 0;
+ c.tune_steps = 0;
+ c.tune_eta_s = 0;
c.sweep_note = format!("tuning stopped: {why}");
if touched {
c.power_pct = before.power_pct;
@@ -2874,6 +2943,13 @@ impl Engine {
}
if self.job_hold && !self.jobs.holds_miners() {
self.job_hold = false;
+ for c in self.st().mining.cards.iter_mut().filter(|c| c.state == "held" || c.state == "tuning") {
+ c.state = "off".into();
+ c.sweep_state = if c.sweep_state == "running" { "idle".into() } else { c.sweep_state.clone() };
+ c.tune_step = 0;
+ c.tune_steps = 0;
+ c.tune_eta_s = 0;
+ }
if self.running {
self.shared.event("info", "job finished; the miners restart");
for m in self.miners.iter_mut() {
@@ -2890,7 +2966,9 @@ impl Engine {
self.job_hold = true;
self.stop_miners(&why);
for c in self.st().mining.cards.iter_mut().filter(|c| c.enabled) {
- c.message = "stopped for a remote job".into();
+ // never a bare "off" at 0 MH/s: the row says why (a job holds the card)
+ c.state = "held".into();
+ c.message = format!("held for a remote job: {}", short(&why, 80));
}
self.jobs.miners_stopped(&self.shared);
}
diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs
index b2ddb9f31..e85d949b9 100644
--- a/app/igneum-app/src/server.rs
+++ b/app/igneum-app/src/server.rs
@@ -325,6 +325,11 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result {
+ shared.send(Cmd::TuneProgress(body));
+ Ok(json!({ "ok": true }))
+ }
"/api/sweep/enable" => {
let on = body.get("on").and_then(|v| v.as_bool()).ok_or("on missing")?;
shared.send(Cmd::SweepEnable(on));
diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs
index 7ec9a944c..4292b4de9 100644
--- a/app/igneum-app/src/state.rs
+++ b/app/igneum-app/src/state.rs
@@ -125,6 +125,12 @@ pub struct CardState {
pub tune_clock_mhz: u32, // the clock cap the last tune chose (0 = unlocked)
pub tune_source: String, // full | confirm | baseline
pub tune_line: String, // "Tuned: 122.3 MH/s at 290 W (0.422 MH/W)" once tuned
+ // a tune in progress on this card, by this engine or by a measurement engine posting /api/tune-progress
+ // (Josh, 6 October 2026: "don't we need to show in the app that tuning is in progress?")
+ pub tune_step: u32,
+ pub tune_steps: u32,
+ pub tune_eta_s: i64,
+ pub tune_plan: String,
// the kernel variant race (docs/design/miner-tuning.md): what the worker's last race chose
pub variant: String,
pub race_mhs: f64,
diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js
index f0c0d6c3e..168fa680d 100644
--- a/app/igneum-app/ui/app.js
+++ b/app/igneum-app/ui/app.js
@@ -13,7 +13,7 @@
5 minutes on every screen: "New card: , mining", "New card: , not usable (Code 43)", "Card removed". */
var Notices = (function () {
'use strict';
- var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, 'update-available': 4, 'job-done': 5, 'card-added': 5, 'card-removed': 5, 'update-installed': 6 };
+ var LEVEL = { 'update-installing': 0, 'update-urgent': 0, 'update-failed': 0, 'job-failed': 1, clock: 2, 'job-running': 3, tuning: 3, 'update-available': 4, 'job-done': 5, 'card-added': 5, 'card-removed': 5, 'update-installed': 6 };
var JOB_DONE_S = 300, INSTALLED_S = 60, CARD_S = 300;
function cap(t) { return t ? t.charAt(0).toUpperCase() + t.slice(1) : ''; }
function firstLine(t, max) { t = String(t || '').split('\n')[0].trim(); max = max || 160; return t.length > max ? t.slice(0, max - 1).trim() + '\u2026' : t; }
@@ -120,8 +120,19 @@ var Notices = (function () {
if (where.dashboard) { var j = jobNotice(s.jobs, s.now || 0); if (j) out.push(j); }
else { var c = clockNotice(s.clock); if (c) out.push(c); }
if (s.mining && s.mining.cards) out = out.concat(cardNotices(s.mining.cards, s.now || 0));
+ var tn = tuneNotice(s.mining && s.mining.cards);
+ if (tn) out.push(tn);
return out;
}
+ // the one line while a card is being tuned: which card, which plan, the step, when mining is back
+ function tuneNotice(cards) {
+ var l = (cards || []).filter(function (c) { return c.state === 'tuning'; });
+ if (!l.length) return null;
+ var c = l[0], plan = c.tune_plan === 'confirm' ? 'confirming the fleet prior' : c.tune_plan === 'baseline' ? 'measuring as it runs' : 'the full tune';
+ var step = c.tune_steps > 0 ? ', step ' + (c.tune_step || 1) + ' of ' + c.tune_steps : '';
+ var eta = c.tune_eta_s > 0 ? ' Mining again in ' + (c.tune_eta_s < 60 ? 'under a minute' : 'about ' + Math.round(c.tune_eta_s / 60) + ' min') + '.' : '';
+ return notice('tuning', 'tuning:' + c.key, 'Tuning ' + c.name + ': ' + plan + step + '.' + eta, { tone: 'info', title: 'Ember Tune' });
+ }
// the lowest level wins; a dismissed key is skipped, so the next one shows in its place
function pick(list, dismissed) {
var best = null;
@@ -132,7 +143,7 @@ var Notices = (function () {
}
return best;
}
- return { LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, CARD_S: CARD_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, cardNotices: cardNotices, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot, kindWord: kindWord };
+ return { tuneNotice: tuneNotice, LEVEL: LEVEL, JOB_DONE_S: JOB_DONE_S, INSTALLED_S: INSTALLED_S, CARD_S: CARD_S, updateNotice: updateNotice, jobNotice: jobNotice, clockNotice: clockNotice, cardNotices: cardNotices, gather: gather, pick: pick, jobTitle: jobTitle, cap: cap, endDot: endDot, kindWord: kindWord };
})();
/* ---------- the update card (pure; update-card.test.mjs loads this block) ----------
@@ -282,9 +293,11 @@ var View = (function () {
function cardRow(cd) {
var removed = cd.removed_at > 0, unusable = !!cd.problem;
var on = !!cd.enabled && !removed && !unusable, st = removed ? 'removed' : unusable ? 'unusable' : (cd.state || 'off');
- var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated faults' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st;
- var tone = removed ? 'off' : unusable ? 'bad' : !on ? 'off' : st === 'mining' ? 'on' : (st === 'failed' || st === 'faulted' || st === 'restarting') ? 'bad' : '';
- var hash = on && st === 'mining' ? (cd.hash_now >= 100 ? cd.hash_now.toFixed(0) : (cd.hash_now || 0).toFixed(1)) : '';
+ // the rule (docs/plans/ember-tune.md, 6 October 2026): a card never shows 0 MH/s without a reason word
+ var tuning = st === 'tuning', held = st === 'held';
+ var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : tuning ? tuneWord(cd) : held ? (cd.message || 'held for a remote job') : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated faults' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st;
+ var tone = removed ? 'off' : unusable ? 'bad' : !on ? 'off' : (st === 'mining' || tuning) ? 'on' : (st === 'failed' || st === 'faulted' || st === 'restarting') ? 'bad' : '';
+ var hash = on && (st === 'mining' || tuning) ? (cd.hash_now >= 100 ? cd.hash_now.toFixed(0) : (cd.hash_now || 0).toFixed(1)) : '';
var temp = cd.temp_gpu > 0 ? Math.round(cd.temp_gpu) + ' °C' : '';
var tempTone = cd.temp_mem > 95 || cd.temp_gpu > 92 ? 'hot' : cd.temp_mem > 90 || cd.temp_gpu > 86 ? 'warm' : '';
var power = cd.power_w > 0 ? Math.round(cd.power_w) + ' W' : '';
@@ -297,12 +310,28 @@ var View = (function () {
return { key: cd.key, name: cd.name, vendor: cd.vendor, kind: cd.kind, kindWord: kindWord(cd.kind), integrated: cd.kind === 'integrated', on: on, removed: removed, unusable: unusable, canToggle: cd.kind !== 'unknown' && !removed && !unusable, word: word, tone: tone, hash: hash, temp: temp, tempTone: tempTone, power: power, cap: cap, sub: sub, meta: meta.join(' · '), title: cardTitle(cd) };
}
+ // "tuning: step 7 of 9 · measuring 220 W" (the live draw; the plan's words when there is no draw yet)
+ function tuneWord(cd) {
+ var step = cd.tune_steps > 0 ? 'step ' + (cd.tune_step || 1) + ' of ' + cd.tune_steps : '';
+ var w = cd.power_w > 0 ? 'measuring ' + Math.round(cd.power_w) + ' W' : '';
+ var rest = [step, w].filter(Boolean).join(' · ');
+ return rest ? 'tuning: ' + rest : (cd.sweep_note || 'tuning');
+ }
+ // "about 12 min" / "under a minute" from a tune's seconds left
+ function tuneEta(s) { s = Math.max(0, Math.round(s || 0)); return s < 60 ? 'under a minute' : 'about ' + Math.round(s / 60) + ' min'; }
+ // the card a tune is running on, or null
+ function tuningCard(cards) { var l = (cards || []).filter(function (c) { return c.state === 'tuning'; }); return l.length ? l[0] : null; }
+
// the big button on Mine: m = state.mining, n = state.node, s = { quitting }
function toggle(m, n, s) {
var cards = shownCards(m.cards).filter(present);
var on = cards.filter(function (c) { return c.enabled; }).length;
var mining = cards.filter(function (c) { return c.state === 'mining'; }).length;
if (s && s.quitting) return { label: 'Stopping', sub: 'miners first, then the node', cls: 'stop', disabled: true, act: '' };
+ var tc = tuningCard(cards);
+ if (tc) return { label: 'Tuning', sub: 'mining again in ' + tuneEta(tc.tune_eta_s) + ' · ' + tc.name, cls: 'stop', disabled: true, act: '' };
+ var heldCards = cards.filter(function (c) { return c.state === 'held'; }).length;
+ if (heldCards && !mining) return { label: 'Held', sub: 'a remote job holds the cards; mining restarts when it ends', cls: 'stop', disabled: true, act: '' };
if (!on) return { label: 'Start mining', sub: cards.length ? 'switch a GPU on below first' : 'no GPU this app can drive', cls: '', disabled: true, act: '' };
if (m.paused) return { label: 'Start mining', sub: 'paused · the node keeps running', cls: '', disabled: false, act: 'resume' };
if (m.state === 'mining') return { label: 'Stop mining', sub: 'mining on ' + mining + ' of ' + on + ' card' + (on === 1 ? '' : 's'), cls: 'stop', disabled: false, act: 'pause' };
@@ -384,7 +413,7 @@ var View = (function () {
else parts.push('Nothing running' + (j.checked_at ? '; checked ' + rel((now || 0) - j.checked_at) : '') + (j.queued ? '; ' + j.queued + ' queued' : '') + '.');
return parts.join(' ');
}
- return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, toggle: toggle, nodeWords: nodeWords, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote };
+ return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, toggle: toggle, nodeWords: nodeWords, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote };
})();
/* ---------- Ember Tune: the card row's tuning line (pure; tune-line.test.mjs loads this block) ----------
One line per card from the card state (src/state.rs, src/ember.rs): running (the phase and the step), tuned
@@ -414,7 +443,7 @@ var TuneLine = (function () {
function rel(s) { s = Math.max(0, Math.floor(s)); return s < 60 ? s + ' s' : s < 3600 ? Math.floor(s / 60) + ' min' : s < 86400 ? Math.floor(s / 3600) + ' h' : Math.floor(s / 86400) + ' d'; }
return { model: model, point: point, rel: rel };
})();
-if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; module.exports.View = View; module.exports.TuneLine = TuneLine; }
+if (typeof module === 'object' && module && module.exports) { module.exports = Notices; module.exports.UpdateCard = UpdateCard; module.exports.View = View; module.exports.TuneLine = TuneLine; module.exports.tuneNotice = Notices.tuneNotice; }
if (typeof document !== 'undefined') (function () {
'use strict';
diff --git a/app/igneum-app/ui/tune-line.test.mjs b/app/igneum-app/ui/tune-line.test.mjs
index 17b6daed0..18fafce33 100644
--- a/app/igneum-app/ui/tune-line.test.mjs
+++ b/app/igneum-app/ui/tune-line.test.mjs
@@ -10,6 +10,8 @@ const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js')
const mod = { exports: {} };
new Function('module', src)(mod);
const { model, point } = mod.exports.TuneLine;
+const View = mod.exports.View;
+const tuneNotice = mod.exports.tuneNotice;
const NOW = 1_800_000_000;
const card = over => ({ vendor: 'nvidia', sweep_state: 'idle', sweep_note: '', sweep_pct: 0, power_pct: 80, sweep_at: 0, tune_line: '', tune_source: '', tune_clock_mhz: 0, tune_control: true, pinned: false, ...over });
@@ -42,3 +44,34 @@ test('running, stopped, idle and off', () => {
assert.equal(model(card({ vendor: 'other' }), NOW).kind, 'off');
assert.equal(point({ tune_clock_mhz: 0, sweep_pct: 0, power_pct: 0 }), '');
});
+
+test('a card never shows 0 MH/s without a reason word: tuning, held and tuned rows, the button, the strip', () => {
+ const base = { key: 'nvidia:0:x', name: 'RTX 5090', vendor: 'nvidia', kind: 'discrete', enabled: true, state: 'tuning', hash_now: 123.4, power_w: 219.6, tune_step: 7, tune_steps: 9, tune_eta_s: 230, tune_plan: 'full', sweep_note: 'tuning: holding 2472 MHz · 100% · 41 s (step 7 of 9)', vram_mb: 32768 };
+ const r = View.cardRow(base);
+ assert.equal(r.word, 'tuning: step 7 of 9 · measuring 220 W');
+ assert.equal(r.hash, '123', 'the live rate stays in the rate column');
+ assert.equal(r.tone, 'on');
+ const h = View.cardRow({ ...base, state: 'held', hash_now: 0, power_w: 0, message: 'held for a remote job: Ember Tune on PC 1', tune_steps: 0 });
+ assert.equal(h.word, 'held for a remote job: Ember Tune on PC 1');
+ assert.equal(h.hash, '', 'blank, never 0');
+ const off = View.cardRow({ ...base, state: 'off', enabled: false, hash_now: 0 });
+ assert.equal(off.word, 'off'); assert.equal(off.hash, '');
+ // the big button during a tune
+ const t = View.toggle({ cards: [base], state: 'mining', paused: false }, { synced: true }, {});
+ assert.equal(t.label, 'Tuning'); assert.equal(t.disabled, true);
+ assert.equal(t.sub, 'mining again in about 4 min · RTX 5090');
+ const held = View.toggle({ cards: [{ ...base, state: 'held' }], state: 'waiting', paused: false }, { synced: true }, {});
+ assert.equal(held.label, 'Held'); assert.equal(held.disabled, true);
+ // the strip
+ const n = tuneNotice([base]);
+ assert.equal(n.kind, 'tuning');
+ assert.equal(n.text, 'Tuning RTX 5090: the full tune, step 7 of 9. Mining again in about 4 min.');
+ assert.equal(tuneNotice([{ ...base, state: 'mining' }]), null);
+ // the finished row: TuneLine's tuned model (the table's "Tuned: ..." line with the point)
+ const done = model({ ...base, state: 'mining', sweep_state: 'idle', tune_line: 'Tuned: 123.4 MH/s at 220 W (0.561 MH/W)', tune_source: 'full', tune_clock_mhz: 2470, sweep_pct: 100, sweep_at: NOW - 60, tune_control: true }, NOW);
+ assert.equal(done.kind, 'tuned');
+ assert.equal(done.text, 'Tuned: 123.4 MH/s at 220 W (0.561 MH/W)');
+ assert.match(done.note, /^2470 MHz at 100%, full tune, 1 min ago/);
+ assert.equal(View.tuneEta(30), 'under a minute');
+ assert.equal(View.tuneEta(700), 'about 12 min');
+});
diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md
index 4f990112f..352698906 100644
--- a/docs/plans/ember-tune.md
+++ b/docs/plans/ember-tune.md
@@ -161,6 +161,21 @@ maximum, 14,001 MHz memory; 9070 XT present on bus 98 with OFFSET ranges `gmax_r
10`). The offset finding changed the AMD mapping (054e041): an offset clock range closes the clock knob and the power
ladder runs on a percent scale bounded by `plimit_range`. The re-run follows the 0.3.11 rollout.
+## 6a. A card never shows 0 MH/s without a reason word (Josh, 6 October 2026, watching PC 1 during run 5)
+
+Rule: the Mine page's rate column is blank, never "0", when a card is not mining, and the row's word says why:
+`mining`, `tuning: step k of n · measuring W W` (the live rate stays in the rate column), `held for a remote job:
+`, `paused`, `waiting for the node`, `restart in N s`, `starting`, `ready`, `stopped after repeated faults`,
+`failed`, `off`, `removed`, `not usable ()`. Checked against `View.cardRow`: every state the engine can set
+has a word; `held` and `tuning` were the two that read "off" before (a job's `--stop-miners` left `state = off`).
+During a tune the big button reads "Tuning, mining again in about N min" (disabled) and the strip carries one line
+("Tuning : the full tune, step k of n. Mining again in about N min."). A tune run by a job beside the installed
+app reaches the installed app's rows through `POST /api/tune-progress` (the playbook forwards the measurement
+engine's `TUNE progress` lines every 10 s and its `TUNE chosen` line as `done`): the posted state is the step, the
+live MH/s and W, the seconds left and the plan; a POST of state, never a quit, pause or resume. At the end the row
+reads `Tuned: at W ()` with the point and the plan. Tests: `ui/tune-line.test.mjs` (tuning, held,
+tuned rows; the button; the strip line).
+
## 7a. One administrator approval, ever (0.3.13; Josh, 6 October 2026, 11:50 UTC)
What 0.3.12 does: Power control on raises one prompt and sets every cap in that step; every later cap (an app start, a
diff --git a/relay/playbooks/ember-tune-pc1.ps1 b/relay/playbooks/ember-tune-pc1.ps1
index 183099ab0..da492d63c 100644
--- a/relay/playbooks/ember-tune-pc1.ps1
+++ b/relay/playbooks/ember-tune-pc1.ps1
@@ -126,6 +126,24 @@ function EndTree([int] $procId, [string] $why) {
}
$seen = 0
$rows = 0
+# the installed app shows the tune instead of a bare "off" (Josh, 6 October 2026): every `TUNE progress` line and the
+# `TUNE chosen` line go to its /api/tune-progress (a POST of state, never quit, pause or resume: the 5 October rule)
+$installedUrl = ''
+try { $installedUrl = (Get-Content -LiteralPath (Join-Path $appDir 'app.url') -Raw -ErrorAction Stop).Trim() } catch { }
+function Forward([string] $line) {
+ if (-not $installedUrl) { return }
+ $kv = @{}
+ foreach ($tok in ($line -split ' ')) { if ($tok -match '^([a-z_]+)=(.*)$') { $kv[$Matches[1]] = $Matches[2] } }
+ $body = @{ key = ($kv['card'] -replace '_', ' '); done = ($line -match '^TUNE chosen ') }
+ if ($kv['key']) { $body.key = ($kv['key'] -replace '_', ' ') }
+ foreach ($n in @('step', 'of', 'eta_s', 'mhs', 'watts', 'clock', 'cap')) { if ($kv[$n] -ne $null) { $body[$n] = [double]$kv[$n] } }
+ if ($kv['cap'] -ne $null) { $body['power_pct'] = [double]$kv['cap'] }
+ if ($kv['clock'] -ne $null) { $body['clock_mhz'] = [double]$kv['clock'] }
+ if ($kv['plan']) { $body.plan = $kv['plan'] }
+ if ($kv['note']) { $body.note = ($kv['note'] -replace '_', ' ') }
+ if ($body.done) { $body.note = ('tuned: ' + $kv['mhs'] + ' MH/s at ' + $kv['watts'] + ' W') }
+ try { Invoke-WebRequest -Uri ($installedUrl + 'api/tune-progress') -Method POST -Body ($body | ConvertTo-Json -Compress) -ContentType 'application/json' -UseBasicParsing -TimeoutSec 3 | Out-Null } catch { }
+}
# the watchdog (coordinator, 6 October 2026): a tune engine that mines nothing for 120 s after its first status line
# (every card "waiting" or 0.00 MH/s: no payout address, no worker, no node) fails the job at once with the engine's
# last log line in the RESULT, its tree ended, mining restored by the job runner; a job that cannot mine never burns
@@ -165,7 +183,8 @@ while (-not $p.HasExited) {
$all = @(); if (Test-Path -LiteralPath $outFile) { $all = @(Get-Content -LiteralPath $outFile -ErrorAction SilentlyContinue) }
while ($seen -lt $all.Count) {
$l = [string]$all[$seen]; $seen++
- if ($l -match '^TUNE ') { Write-Output ('RESULT ' + $l); if ($l -match '^TUNE card=') { $rows++ } }
+ if ($l -match '^TUNE progress ') { Forward $l }
+ elseif ($l -match '^TUNE ') { Write-Output ('RESULT ' + $l); if ($l -match '^TUNE card=') { $rows++ }; if ($l -match '^TUNE chosen ') { Forward $l } }
elseif ($l -match '^SWEEP ') { Write-Output ('RESULT ' + $l) }
elseif ($l -match '^(URL|STATE) ') { }
else { Say $l }
diff --git a/tools/ci/second-engine-check.sh b/tools/ci/second-engine-check.sh
index 8f378b082..ec8e8a647 100755
--- a/tools/ci/second-engine-check.sh
+++ b/tools/ci/second-engine-check.sh
@@ -23,7 +23,7 @@ while IFS= read -r f; do
if grep -qE 'settings\.json|\.json' "$f" && grep -vE '^\s*#' "$f" | grep -qE 'Set-Content[^\n]*-Encoding +utf8'; then
echo "second-engine: $f writes JSON with Set-Content -Encoding utf8 (a BOM the engine refuses: the copy read as defaults, no payout address, nothing mined); use [IO.File]::WriteAllText with UTF8Encoding(\$false)"; fail=1
fi
- if grep -vE '^\s*#' "$f" | grep -qE 'ConvertTo-Json' && grep -qE 'settings\.json' "$f"; then
+ if grep -vE '^\s*#' "$f" | grep -E 'ConvertTo-Json' | grep -qE 'settings\.json|Set-Content|WriteAllText|Out-File'; then
echo "second-engine: $f rewrites settings.json through ConvertTo-Json (a lossy round trip: big integers become doubles and the engine reads the whole file as defaults; run 4, 6 October 2026); copy the file verbatim, the engine applies Settings::for_measurement under --sweep"; fail=1
fi
if ! grep -qE "IGNEUM_APP_NO_OTA *= *'1'" "$f"; then