diff --git a/packages/user-intent-kit/src/host-telemetry.js b/packages/user-intent-kit/src/host-telemetry.js index 5757944..1a741d8 100644 --- a/packages/user-intent-kit/src/host-telemetry.js +++ b/packages/user-intent-kit/src/host-telemetry.js @@ -92,6 +92,19 @@ export const defaultSources = { return out; }, readThermalZone: (zone) => readFileSync(`${THERMAL_ROOT}/${zone}/temp`, 'utf8'), + // The zone's own critical trip point, in millidegrees, or '' when the zone + // declares none. A zone advertises several trips (passive, hot, critical); + // only 'critical' is the number a reading should be judged against. + readThermalCriticalMilli: (zone) => { + const dir = `${THERMAL_ROOT}/${zone}`; + for (const entry of readdirSync(dir)) { + const m = entry.match(/^trip_point_(\d+)_type$/); + if (!m) continue; + if (readFileSync(`${dir}/${entry}`, 'utf8').trim() !== 'critical') continue; + return readFileSync(`${dir}/trip_point_${m[1]}_temp`, 'utf8'); + } + return ''; + }, }; /** @@ -318,17 +331,46 @@ function readLinuxTempC(sources) { } let hottest; + let hottestZone; for (const zone of zones || []) { try { const celsius = Number(String(sources.readThermalZone(zone)).trim()) / 1000; if (celsius >= TEMP_MIN_C && celsius <= TEMP_MAX_C) { - if (hottest === undefined || celsius > hottest) hottest = celsius; + if (hottest === undefined || celsius > hottest) { + hottest = celsius; + hottestZone = zone; + } } } catch { // Unreadable zone: skip it, keep whatever the other zones gave us. } } - return hottest === undefined ? undefined : Math.round(hottest * 10) / 10; + if (hottest === undefined) return undefined; + + // The limit MUST come from the same zone as the reading. Pairing the + // hottest zone's temperature with some other zone's critical point would + // produce a headroom figure describing neither. + let limitC; + try { + const raw = String(sources.readThermalCriticalMilli(hottestZone) ?? '').trim(); + const c = Number(raw) / 1000; + // Plausibility is judged on the VALUE ALONE, never against the current + // reading. An earlier cut required `c > hottest`, reasoning that a + // critical point below the temperature had to be a broken table. It is + // the opposite: crossing the critical trip is a protection event the + // kernel acts on, so a box reading 105 C against a 100 C critical is in + // exactly the trouble a dashboard exists to show. That guard deleted the + // limit at the only moment it mattered, and a reader falling back to a + // default would have painted a thermally emergency box calmer than it + // was. (codexmb, 17 Sep 2026.) + if (raw !== '' && Number.isFinite(c) && c >= TEMP_MIN_C && c <= TEMP_MAX_C) { + limitC = Math.round(c * 10) / 10; + } + } catch { + // No trip table: publish the reading without a limit. + } + + return { tempC: Math.round(hottest * 10) / 10, limitC }; } /** @@ -369,8 +411,16 @@ export function collectHostTelemetry({ machine, kind, model, sources = defaultSo try { if (sources.platform() === 'linux') { - const tempC = readLinuxTempC(sources); - if (tempC !== undefined) host.temp_c = tempC; + const reading = readLinuxTempC(sources); + if (reading !== undefined) { + host.temp_c = reading.tempC; + // Published so a dashboard can colour a temperature against THIS + // machine's own limit instead of a guessed scale: 49 C is idle on a + // box that trips at 104 and alarming on one that trips at 60. A host + // that declares no critical point simply omits this, and the reader + // falls back to its own default (petrus, 17 Sep 2026). + if (reading.limitC !== undefined) host.temp_limit_c = reading.limitC; + } } } catch { // No thermal zones exposed; publish without a temperature. diff --git a/packages/user-intent-kit/test/host-telemetry.test.js b/packages/user-intent-kit/test/host-telemetry.test.js index 70584cd..2b9fa82 100644 --- a/packages/user-intent-kit/test/host-telemetry.test.js +++ b/packages/user-intent-kit/test/host-telemetry.test.js @@ -12,7 +12,7 @@ import { collectHostTelemetry, modelFromCommandLine } from '../src/host-telemetr */ function sources({ platform = 'linux', load = [1.8, 1.7, 1.6], cpuCount = 4, zones = {}, totalMem = 16e9, freeMem = 4e9, availMem = 12e9, commands = {}, - processes = [] } = {}) { + processes = [], trips = {} } = {}) { return { platform: () => platform, loadavg: () => load, @@ -34,6 +34,11 @@ function sources({ platform = 'linux', load = [1.8, 1.7, 1.6], cpuCount = 4, zon if (value instanceof Error) throw value; return value; }, + readThermalCriticalMilli: (zone) => { + const value = trips[zone]; + if (value instanceof Error) throw value; + return value ?? ''; + }, }; } @@ -327,3 +332,65 @@ test('a voice stack with lower pids does not become the served model (VTA layout // and with the model server gone, the voice stack still is not "the model" assert.ok(!('model' in collectHostTelemetry({ sources: sources({ processes: table.slice(0, 3) }) }))); }); +// --- the machine's own limit, so a dashboard is not guessing a scale --- + +test('publishes the critical trip point of the SAME zone the reading came from', () => { + const host = collectHostTelemetry({ + sources: sources({ + zones: { thermal_zone0: '49000', thermal_zone1: '72000' }, + // zone1 is the hottest, so zone1's limit is the one that applies. + trips: { thermal_zone0: '104000', thermal_zone1: '95000' }, + }), + }); + assert.equal(host.temp_c, 72); + assert.equal(host.temp_limit_c, 95, "paired the reading with another zone's limit"); +}); + +test('a host that declares no critical point publishes a temperature and no limit', () => { + const host = collectHostTelemetry({ + sources: sources({ zones: { thermal_zone0: '49000' }, trips: {} }), + }); + assert.equal(host.temp_c, 49); + assert.ok(!('temp_limit_c' in host), 'invented a limit the machine never declared'); +}); + +test('an unreadable trip table costs the limit, never the temperature', () => { + const host = collectHostTelemetry({ + sources: sources({ + zones: { thermal_zone0: '49000' }, + trips: { thermal_zone0: new Error('EACCES') }, + }), + }); + assert.equal(host.temp_c, 49); + assert.ok(!('temp_limit_c' in host)); +}); + +test('a critical point is kept when the machine has REACHED it', () => { + // The moment the limit matters most. An earlier cut dropped any critical + // point at or below the reading as "a broken table", so a box sitting on + // its trip published no limit at all and a reader fell back to a calmer + // default. Crossing the critical trip is a protection event, not bad data. + const at = collectHostTelemetry({ + sources: sources({ zones: { thermal_zone0: '100000' }, trips: { thermal_zone0: '100000' } }), + }); + assert.equal(at.temp_c, 100); + assert.equal(at.temp_limit_c, 100, 'dropped the limit at exactly the trip point'); + + const over = collectHostTelemetry({ + sources: sources({ zones: { thermal_zone0: '105000' }, trips: { thermal_zone0: '100000' } }), + }); + assert.equal(over.temp_c, 105); + assert.equal(over.temp_limit_c, 100, 'dropped the limit on a box that is OVER it'); +}); + +test('a nonsense critical point is dropped rather than published', () => { + // Judged on the value alone: unparseable, empty, or hotter than any real + // die. Deliberately NOT "below the current reading" - see the test above. + for (const bad of ['900000', 'not-a-number', '']) { + const host = collectHostTelemetry({ + sources: sources({ zones: { thermal_zone0: '49000' }, trips: { thermal_zone0: bad } }), + }); + assert.equal(host.temp_c, 49, `temp lost for trip=${bad}`); + assert.ok(!('temp_limit_c' in host), `published nonsense limit ${bad}`); + } +});