A card never shows 0 MH/s without a reason word (the project lead, 6 October 2026, watching PC 1 during the table run): the Mine row reads 'tuning: step k of n · measuring W W' with the live rate, 'held for a remote job: <title>' while a job holds the miners, and the Tuned line at the end; the big button reads 'Tuning, mining again in about N min'; the strip carries one tune line; a measurement engine prints a TUNE progress line every 10 s and the playbook forwards it (and TUNE chosen as done) to the installed app's POST /api/tune-progress, a post of state, never quit, pause or resume; the rule in ember-tune.md 6a; UI tests for the three states
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
7bc0e1ce1a
commit
7ed0d75160
9 changed files with 211 additions and 13 deletions
|
|
@ -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) };
|
||||
|
|
|
|||
|
|
@ -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<String, String>),
|
||||
/// 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -325,6 +325,11 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
shared.send(Cmd::PowerControl(on));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
// a measurement engine's live tune state for one card (forwarded by its job playbook): the row shows the step
|
||||
"/api/tune-progress" => {
|
||||
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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
5 minutes on every screen: "New card: <name>, mining", "New card: <name>, 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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
<title>`, `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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue