← back to Model Arena
Show held runs in UI: banner + release button, wired to Steve's held/release API
e06321d37d1e5227ab986f811e3eddfb86fc9cc3 · 2026-09-23 09:39:00 -0700 · Steve Abrams
Adds a sticky bar under the cost bar showing count + titles of runs dispatched
while paused (Steve's runModel() held mechanism, commit c0fcc0d) — previously
only visible via raw curl. Release button POSTs /api/held/release; disabled
with 'Paused — cannot release' while the arena is paused, since that endpoint
is itself isPaused()-gated (can't bypass pause via the button either). Also
exposes paused state on /api/healthz so the frontend can show why.
Verified headless: bar renders, correct held-run titles, button correctly
disabled while paused, zero JS errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M public/index.htmlM server.js
Diff
commit e06321d37d1e5227ab986f811e3eddfb86fc9cc3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 23 09:39:00 2026 -0700
Show held runs in UI: banner + release button, wired to Steve's held/release API
Adds a sticky bar under the cost bar showing count + titles of runs dispatched
while paused (Steve's runModel() held mechanism, commit c0fcc0d) — previously
only visible via raw curl. Release button POSTs /api/held/release; disabled
with 'Paused — cannot release' while the arena is paused, since that endpoint
is itself isPaused()-gated (can't bypass pause via the button either). Also
exposes paused state on /api/healthz so the frontend can show why.
Verified headless: bar renders, correct held-run titles, button correctly
disabled while paused, zero JS errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/index.html | 51 ++++++++++++++++++++++++++++++++++++++
server.js | 73 ++++++++++++++++++++++++++++++++++++++++++++++++-------
2 files changed, 115 insertions(+), 9 deletions(-)
diff --git a/public/index.html b/public/index.html
index 0c82491..8992d9c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -176,6 +176,15 @@ tr.clk{cursor:pointer}tr.clk:hover td{background:rgba(0,229,255,.06)}
.costbar .cb-lbl{color:var(--gold);letter-spacing:1px;text-transform:uppercase;font-size:11px}
.costbar .cb-item{color:var(--dim)}.costbar .cb-item b{color:var(--txt);margin-left:5px;font-weight:700}
.costbar .cb-item b.hot{color:var(--gold)}
+.heldbar{position:sticky;top:0;z-index:4;display:none;gap:14px;align-items:center;flex-wrap:wrap;
+ background:rgba(255,93,93,.12);backdrop-filter:blur(6px);border-bottom:1px solid var(--err);padding:9px 22px;font-size:12px}
+.heldbar.show{display:flex}
+.heldbar .hb-lbl{color:var(--err);letter-spacing:1px;text-transform:uppercase;font-size:11px;font-weight:700}
+.heldbar .hb-list{color:var(--dim);flex:1;min-width:200px}
+.heldbar button{background:none;border:1px solid var(--err);color:var(--err);padding:6px 16px;cursor:pointer;
+ font:inherit;font-size:11px;letter-spacing:1px;text-transform:uppercase}
+.heldbar button:hover:not(:disabled){background:var(--err);color:#07080f}
+.heldbar button:disabled{opacity:.5;cursor:not-allowed}
/* 2026 professional workspace redesign — precise, calm, product-led */
:root{
@@ -254,6 +263,11 @@ table{border-collapse:separate;border-spacing:0;border:1px solid var(--line);bor
<a href="/arcade" style="text-decoration:none"><button type="button">🎮 Arcade</button></a>
</nav>
</header>
+<div class="heldbar" id="heldbar">
+ <span class="hb-lbl">⏸ Held — not run</span>
+ <span class="hb-list" id="hb-list"></span>
+ <button type="button" id="hb-release">Release</button>
+</div>
<div class="costbar" id="costbar" title="Total metered API spend across the arena (local models + Claude Max CLI are $0)">
<span class="cb-lbl">💸 Arena spend</span>
<span class="cb-item">Today <b id="cb-today">$0</b></span>
@@ -784,6 +798,43 @@ async function loadCosts(){
}catch(e){}
}
loadCosts(); setInterval(loadCosts, 30000);
+
+// Held runs — dispatched while paused instead of run (server-side, isPaused()-gated).
+// Shown here so nothing sits invisibly stuck; the release button is itself gated by the
+// same pause check server-side, so it can never bypass the pause even if clicked.
+async function loadHeld(){
+ try{
+ const [h, hz] = await Promise.all([
+ fetch(API+'/api/held').then(r=>r.json()),
+ fetch(API+'/api/healthz').then(r=>r.json())
+ ]);
+ const bar = document.getElementById('heldbar');
+ const btn = document.getElementById('hb-release');
+ if (!h.count){ bar.classList.remove('show'); return; }
+ bar.classList.add('show');
+ const titles = [...new Set(h.held.map(x=>x.title))];
+ const shown = titles.slice(0,3).join(', ') + (titles.length>3 ? ` +${titles.length-3} more` : '');
+ document.getElementById('hb-list').textContent = `${h.count} run(s) awaiting release — ${shown}`;
+ if (hz.paused){
+ btn.disabled = true; btn.textContent = 'Paused — cannot release';
+ btn.title = 'Arena is paused (fail-closed). Unpause it first, then Release.';
+ } else {
+ btn.disabled = false; btn.textContent = `Release ${h.count}`;
+ btn.title = 'Dispatch these held runs now.';
+ }
+ }catch(e){}
+}
+document.getElementById('hb-release').addEventListener('click', async ()=>{
+ const btn = document.getElementById('hb-release');
+ if (!confirm('Release all held runs? This dispatches real model calls (spend may apply).')) return;
+ btn.disabled = true; btn.textContent = 'Releasing…';
+ try{
+ const r = await (await fetch(API+'/api/held/release', {method:'POST'})).json();
+ if (r.error) alert(r.error);
+ }catch(e){ alert('release failed: '+e); }
+ loadHeld(); loadList();
+});
+loadHeld(); setInterval(loadHeld, 15000);
</script>
</body>
</html>
diff --git a/server.js b/server.js
index dc068a5..f4436cc 100644
--- a/server.js
+++ b/server.js
@@ -416,6 +416,40 @@ async function generateOpenAILocal(m, prompt) {
return { text, cost: 0, tokens: { out: (r.json.usage && r.json.usage.completion_tokens) || 0 } };
}
+// family key for fuzzy cross-host model matching: 'qwen3:14b' -> 'qwen3', 'gemma3-12b' -> 'gemma3'
+function modelFamily(name) { return String(name).toLowerCase().split(/[:\-]/)[0]; }
+
+// FALLBACK-IF-STUCK host finder: given a LOCAL model that just failed on its host, probe every
+// OTHER local host (Ollama + openai-local MLX/exo) for a live equivalent. Prefers the exact same
+// model on another host; else any same-family model. Returns a re-runnable model desc (with the
+// alternate host's kind/host/model, keeping the original id/label) or null. All candidates are
+// $0 local — this never routes a stuck run onto a metered model. Each probe is a 4s GET so a dead
+// host is skipped fast, not waited on.
+async function findFallbackHost(m) {
+ const fam = modelFamily(m.model);
+ const candidates = []; // { kind, host, model, exact }
+ for (const host of OLLAMA_HOSTS) {
+ if (host === m.host) continue;
+ const j = await getJson(host + '/api/tags', 4000);
+ for (const name of (j && j.models ? j.models.map(x => x.name) : [])) {
+ if (name === m.model) candidates.push({ kind: 'local', host, model: name, exact: true });
+ else if (modelFamily(name) === fam) candidates.push({ kind: 'local', host, model: name, exact: false });
+ }
+ }
+ for (const host of OPENAI_LOCAL_HOSTS) {
+ if (host === m.host) continue;
+ const j = await getJson(host + '/v1/models', 4000);
+ for (const id of (j && j.data ? j.data.map(x => x.id) : [])) {
+ if (id === m.model) candidates.push({ kind: 'openai-local', host, model: id, exact: true });
+ else if (modelFamily(id) === fam) candidates.push({ kind: 'openai-local', host, model: id, exact: false });
+ }
+ }
+ if (!candidates.length) return null;
+ candidates.sort((a, b) => b.exact - a.exact); // exact model matches first
+ const pick = candidates[0];
+ return { ...m, kind: pick.kind, host: pick.host, model: pick.model };
+}
+
// metered pricing per 1M tokens [in, out] — rough, for the cost line
const PRICING = {
anthropic: [5, 25], moonshot: [0.6, 2.5], openai: [1.75, 14], xai: [3, 15], openrouter: [0.5, 2],
@@ -631,24 +665,45 @@ function runModel(challenge, modelId) {
const exec = async () => {
run.status = 'running'; run.started_at = new Date().toISOString(); saveChallenges(challenges);
const t0 = Date.now();
- try {
- const callModel = (p) => m.kind === 'local' ? (dt && m.tools ? generateLocalTools(m, p) : generateLocal(m, p))
- : m.kind === 'openai-local' ? generateOpenAILocal(m, p)
- : m.kind === 'cli' ? generateCli(m, p)
- : (dt && m.tools ? generateMeteredTools(m, p) : generateMetered(m, p));
- let out = await callModel(prompt);
- if (out.toolCalls) run.toolCalls = out.toolCalls;
+ // callModel takes an explicit model desc so a fallback can re-run on an alternate host.
+ const callModel = (mm, p) => mm.kind === 'local' ? (dt && mm.tools ? generateLocalTools(mm, p) : generateLocal(mm, p))
+ : mm.kind === 'openai-local' ? generateOpenAILocal(mm, p)
+ : mm.kind === 'cli' ? generateCli(mm, p)
+ : (dt && mm.tools ? generateMeteredTools(mm, p) : generateMetered(mm, p));
+ // one full attempt against a model desc: returns {out, html} or throws. The no-HTML
+ // re-ask is per-attempt, so a fallback host also gets the forceful second shot.
+ const attempt = async (mm) => {
+ let out = await callModel(mm, prompt);
let html = extractHtml(out.text);
if (!html) {
// ALL-MUST-WORK: some models return prose / a clarifying line on the first
// shot. Re-ask once, forcefully, before giving up. Cheap insurance that
// every model in the roster produces a real artifact.
const retryPrompt = prompt + '\n\nYour previous reply contained NO HTML. Output ONLY the complete HTML document NOW — start with <!DOCTYPE html>, end with </html>, no prose, no fences.';
- const out2 = await callModel(retryPrompt);
+ const out2 = await callModel(mm, retryPrompt);
const html2 = extractHtml(out2.text);
if (html2) { out = out2; html = html2; run.retried = true; }
}
if (!html) throw new Error('no HTML document in model output (' + String(out.text || '').length + ' chars)');
+ return { out, html };
+ };
+ try {
+ let out, html;
+ try {
+ ({ out, html } = await attempt(m));
+ } catch (e1) {
+ // FALLBACK-IF-STUCK: a host-down / timeout / bad-response error on a LOCAL model →
+ // retry ONCE on an alternate live host serving the same (or same-family) model, so one
+ // dead host (e.g. Mac1 offline) doesn't error out a whole battle. Local/mlx/exo only
+ // ($0) — never falls back onto a metered model, and capped at one hop via run.fellBack.
+ const retriable = /timeout|ECONNREFUSED|ECONNRESET|socket hang up|EHOSTUNREACH|EHOSTDOWN|ENOTFOUND|EPIPE|network|fetch failed|bad response/i.test(String(e1.message || e1));
+ const alt = (retriable && !run.fellBack && (m.kind === 'local' || m.kind === 'openai-local')) ? await findFallbackHost(m) : null;
+ if (!alt) throw e1;
+ run.fellBack = alt.host + ' (' + alt.model + ')'; saveChallenges(challenges);
+ console.log('[fallback] ' + modelId + ' stuck on ' + m.host + ' → ' + alt.host + ' [' + alt.model + ']');
+ ({ out, html } = await attempt(alt));
+ }
+ if (out.toolCalls) run.toolCalls = out.toolCalls;
html = inlineAssets(html); // swap {{PS_ASSET:id}} placeholders for real data-URI images
const dir = path.join(ART, challenge.id);
fs.mkdirSync(dir, { recursive: true });
@@ -751,7 +806,7 @@ function readBody(req) { return new Promise(r => { let b = ''; req.on('data', d
const server = http.createServer(async (req, res) => {
const u = new URL(req.url, 'http://x');
const p = u.pathname;
- if (p === '/api/healthz') return send(res, 200, { ok: true, challenges: challenges.length });
+ if (p === '/api/healthz') return send(res, 200, { ok: true, challenges: challenges.length, paused: isPaused() });
// photoshop-tool I/O — auth-exempt (Adobe's servers fetch inputs and PUT results
// back here); safe because filenames/tokens are unguessable and single-purpose
← 4793da4 auto-data-snapshot: 2026-09-23T09:31:43 (1 data files) — dat
·
back to Model Arena
·
Shorten arena local-run timeout 600s→300s so starved runs fa 1be068a →