diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index d839135e..dcd96daf 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 834631f3..0ae56548 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 b2ddb9f3..e85d949b 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 7ec9a944..6e964ebf 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 + // (the project lead, 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 57c14596..5b8c301d 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 17b6daed..18fafce3 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 470c06ff..40bc4be9 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 (the project lead, 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 (<problem>)`. 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 <card>: 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: <MH/s> at <W> W (<MH/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; the project lead, 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 aa9bc361..f7e1483f 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" (the project lead, 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 8f378b08..ec8e8a64 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