Browse Source

release: sm121 0.2.2 — dashboard bench window (params, node, live log, cancel, reopen)

Alexander Korolev 16 giờ trước cách đây
mục cha
commit
d8dc94acad
13 tập tin đã thay đổi với 628 bổ sung và 71 xóa
  1. 19 0
      CHANGELOG.md
  2. 1 1
      Cargo.lock
  3. 1 1
      Cargo.toml
  4. 44 3
      src/bench.rs
  5. 16 1
      src/bench_target.rs
  6. 1 0
      src/main.rs
  7. 5 4
      src/report/page.html
  8. 3 57
      src/web/api.rs
  9. 189 0
      src/web/bench_api.rs
  10. 50 1
      src/web/jobs.rs
  11. 4 1
      src/web/mod.rs
  12. 272 0
      src/web/ui.html
  13. 23 2
      src/web/ui.playwright.mjs

+ 19 - 0
CHANGELOG.md

@@ -3,6 +3,25 @@
 Semantic versioning for the launcher CLI (`sm121`). The add-on image has its
 own version and its own `sm121/CHANGELOG.md`; do not mix them.
 
+## 0.2.2 — 2026-10-28
+
+- **Dashboard bench window.** The recipes panel's `⚡ bench` button now opens a
+  bench window instead of firing the standard run blindly: choose recipe,
+  cluster, target node (`node`), run host (`ssh_host`, e.g. `pluto.lan`),
+  label, seed, concurrency, parallel, timeout, tokenizer, perf-only, depth and
+  no-think. The choices persist in `localStorage` (`sm121-bench-params`).
+- **Live harness log.** `GET /api/bench/log?recipe=…` tail-reads the newest
+  bench log for the recipe; the window polls it while the run starts so early
+  failures (readiness, tokenizer, harness) are visible before a long wait.
+- **Cancel.** `POST /api/bench/cancel` sets the job's cancellation flag;
+  `run_tool` polls it and kills the `tool-eval-bench` child (also the SSH child
+  for remote runs), so the job finishes failed and the log stops.
+- **Reopen.** The bench window is a named window: closing it leaves a dock chip,
+  the dock menu has `bench…`, and the last recipe/job is restored on reload.
+- Bench API now accepts `node`, `seed`, `concurrency`, `parallel`, `timeout`,
+  `tokenizer`, `no_think`; the report row's bench button posts to the parent
+  dashboard and opens the window.
+
 ## 0.2.1 — 2026-10-28
 
 - **FLARE cluster identification.** A cluster is only paired with an orbit if

+ 1 - 1
Cargo.lock

@@ -2941,7 +2941,7 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
 
 [[package]]
 name = "sm121"
-version = "0.2.1"
+version = "0.2.2"
 dependencies = [
  "anyhow",
  "axum",

+ 1 - 1
Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = "sm121"
-version = "0.2.1"
+version = "0.2.2"
 edition = "2024"
 license = "MIT"
 

+ 44 - 3
src/bench.rs

@@ -52,6 +52,9 @@ pub struct Args {
     /// Per-turn request timeout seconds forwarded to the harness (tool
     /// `--timeout`). Raise above the 120 s default for slow/thinking models.
     pub timeout: Option<u64>,
+    /// Job cancellation flag (web bench window). `run_tool` polls it and
+    /// kills the harness child; None for the CLI.
+    pub cancel: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
 }
 
 /// Served model name from the recipe's `--served-model-name` arg.
@@ -192,8 +195,22 @@ pub async fn run(config: &Config, a: &Args, dry_run: bool) -> Result<()> {
     crate::readiness::wait_ready(&base_url, crate::readiness::BENCH_WAIT_SECS).await?;
     eprintln!("bench: API ready — running {} (seed {})", bin, a.seed);
 
-    let code = run_tool(&bin, &args, &tool_scratch, &log_path, false, ssh_host)?;
+    let code = run_tool(
+        &bin,
+        &args,
+        &tool_scratch,
+        &log_path,
+        false,
+        ssh_host,
+        a.cancel.as_ref(),
+    )?;
     if code != 0 {
+        if a.cancel
+            .as_ref()
+            .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
+        {
+            anyhow::bail!("bench: cancelled");
+        }
         anyhow::bail!(
             "bench: tool exited with {code} (log: {})",
             log_path.display()
@@ -217,8 +234,22 @@ pub async fn run(config: &Config, a: &Args, dry_run: bool) -> Result<()> {
             }
         );
         fs::create_dir_all(&depth_scratch)?;
-        let dcode = run_tool(&bin, &depth_args, &depth_scratch, &log_path, true, ssh_host)?;
+        let dcode = run_tool(
+            &bin,
+            &depth_args,
+            &depth_scratch,
+            &log_path,
+            true,
+            ssh_host,
+            a.cancel.as_ref(),
+        )?;
         if dcode != 0 {
+            if a.cancel
+                .as_ref()
+                .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
+            {
+                anyhow::bail!("bench: cancelled in depth suite");
+            }
             eprintln!(
                 "bench: WARNING depth suite exited with {dcode} — main report unaffected ({})",
                 log_path.display()
@@ -390,6 +421,7 @@ mod tests {
             &log,
             false,
             None,
+            None,
         )
         .unwrap();
         let body = fs::read_to_string(&log).unwrap();
@@ -409,7 +441,16 @@ mod tests {
         fs::create_dir_all(&tmp).unwrap();
         let log = tmp.join("append.log");
         let run = |a: &str, append| {
-            run_tool("sh", &["-c".into(), a.into()], &tmp, &log, append, None).unwrap()
+            run_tool(
+                "sh",
+                &["-c".into(), a.into()],
+                &tmp,
+                &log,
+                append,
+                None,
+                None,
+            )
+            .unwrap()
         };
         assert_eq!(run("echo main", false), 0);
         assert!(fs::read_to_string(&log).unwrap().contains("main\n"));

+ 16 - 1
src/bench_target.rs

@@ -8,7 +8,10 @@ use anyhow::Result;
 use std::fs;
 use std::path::Path;
 use std::process::Stdio;
+use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::{Arc, Mutex};
+use std::thread;
+use std::time::Duration;
 
 pub(crate) fn served_model(recipe: &Recipe) -> Option<String> {
     let args = recipe.args.to_vec();
@@ -92,6 +95,7 @@ pub(crate) fn run_tool(
     log_path: &Path,
     append_log: bool,
     ssh_host: Option<&str>,
+    cancel: Option<&Arc<AtomicBool>>,
 ) -> Result<i32> {
     let file = Arc::new(Mutex::new(if append_log {
         fs::OpenOptions::new()
@@ -150,7 +154,18 @@ pub(crate) fn run_tool(
     let f1 = file.clone();
     let t1 = std::thread::spawn(move || crate::bench_io::pump(stdout_pipe, true, f1));
     let t2 = std::thread::spawn(move || crate::bench_io::pump(stderr_pipe, false, file));
-    let status = child.wait()?;
+    // Bench cancellation: poll the job's flag; on request kill the harness
+    // child (the SSH child for a remote run, so the remote command drops too).
+    let status = loop {
+        if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
+            let _ = child.kill();
+            break child.wait()?;
+        }
+        if let Some(s) = child.try_wait()? {
+            break s;
+        }
+        thread::sleep(Duration::from_millis(200));
+    };
     t1.join().ok();
     t2.join().ok();
     Ok(status.code().unwrap_or(-1))

+ 1 - 0
src/main.rs

@@ -282,6 +282,7 @@ async fn main() -> Result<()> {
                 ssh_host,
                 no_think,
                 timeout,
+                cancel: None,
             };
             crate::bench::run(&config, &args, dry_run).await?;
         }

+ 5 - 4
src/report/page.html

@@ -741,9 +741,9 @@ function setNote(recipe, text) {
 // The expanded row carries the controls before the toml link: serve/stop
 // (tracked dashboard jobs), a logs button (opens a logs WINDOW in the parent
 // dashboard via postMessage — same window as the cluster overview, not an
-// inline stream), and a bench launch (POST /api/bench — the standard campaign
-// as a job; the report lands in the shared recipe repo and re-appears in this
-// table via the live stream). The settings button opens the model-settings
+// inline stream), and a bench button (opens the parent dashboard's bench
+// window via postMessage: parameters, target node, live log, cancel). The
+// settings button opens the model-settings
 // dialog (per-agent client config, endpoint included on request).
 function actionBar(r) {
   if (!EMBED) return "";
@@ -752,7 +752,7 @@ function actionBar(r) {
     ? `<button class="act" data-act="stop" data-r="${esc(r.name)}" title="stop (dashboard job)">⏹ stop</button>`
     : `<button class="act" data-act="serve" data-r="${esc(r.name)}" title="serve (dashboard job, cluster auto)">▶ serve</button>`;
   if (running) b += `<button class="act" data-act="logs" data-r="${esc(r.name)}" title="open a logs window (head node)">▤ logs</button>`;
-  b += `<button class="act" data-act="bench" data-r="${esc(r.name)}" title="standard bench (seed 42, hardmode, perf c1,2) as a dashboard job — the report lands in the recipe repo">⚡ bench</button>`;
+  b += `<button class="act" data-act="bench" data-r="${esc(r.name)}" title="open the bench window: choose seed/concurrency/node/run host, watch the live harness log, cancel">⚡ bench</button>`;
   b += `<button class="act" data-act="settings" data-r="${esc(r.name)}" title="client settings for this recipe's model — pick your agent (pi, Claude Code, Codex, Cline, generic) and copy the block">⚙ settings</button>`;
   return `<div class="actbar">${b}<span class="actnote" data-r="${esc(r.name)}">${esc(NOTES[r.name] || "")}</span></div>`;
 }
@@ -763,6 +763,7 @@ document.addEventListener("click", async (e) => {
   e.stopPropagation(); // no row toggle under the action button
   const kind = b.dataset.act, recipe = b.dataset.r;
   if (kind === "logs") { parent.postMessage({ sm121: "openLogs", recipe }, "*"); return; }
+  if (kind === "bench") { parent.postMessage({ sm121: "openBench", recipe }, "*"); return; }
   if (kind === "settings") { msOpen(recipe); return; }
   const path = kind === "serve" ? "/api/serve" : kind === "stop" ? "/api/stop" : "/api/bench";
   b.disabled = true;

+ 3 - 57
src/web/api.rs

@@ -1,7 +1,9 @@
 //! REST endpoints. P1: `GET /api/state` (latest snapshot; probes once if
 //! the supervisor hasn't ticked yet).
 
-use axum::{extract::State, http::StatusCode, response::Json};
+use axum::extract::State;
+use axum::http::StatusCode;
+use axum::response::Json;
 use serde::Deserialize;
 
 use super::AppState;
@@ -118,62 +120,6 @@ pub(crate) async fn stop(
     Ok(Json(serde_json::json!({ "job": id })))
 }
 
-#[derive(Deserialize)]
-pub(crate) struct BenchReq {
-    pub cluster: String,
-    pub recipe: String,
-    /// Label for the run. A quality run without one is auto-labeled
-    /// `web-<UTC-stamp>` (dashboard campaigns stay distinguishable from
-    /// operator campaigns); perf-only runs default to `perf` in bench::run.
-    #[serde(default)]
-    pub label: Option<String>,
-    #[serde(default)]
-    pub perf_only: bool,
-    /// Run the context-depth suite after the main pass (off by default).
-    #[serde(default)]
-    pub depth: bool,
-    /// Run the harness on a remote host over SSH (its network stays off the
-    /// measurement path); None = the launcher host itself.
-    #[serde(default)]
-    pub ssh_host: Option<String>,
-}
-
-/// `POST /api/bench` — the standard bench (seed 42, hardmode, perf,
-/// concurrency 1,2) as a tracked job; the report + run package land in the
-/// shared recipe repo (`recipes/<lane>/reports/`).
-pub(crate) async fn bench(
-    app: State<AppState>,
-    Json(req): Json<BenchReq>,
-) -> Result<Json<serde_json::Value>, ApiError> {
-    let id = super::jobs::new_job(&app, "bench", &req.cluster, Some(&req.recipe))?;
-    let config = app.config.clone();
-    let app2 = (*app).clone();
-    let label = match req.label {
-        Some(l) => Some(l),
-        None if !req.perf_only => Some(format!("web-{}", crate::stamp::utc_stamp())),
-        None => None,
-    };
-    let args = crate::bench::Args {
-        recipe: req.recipe.clone(),
-        cluster: req.cluster.clone(),
-        node: None,
-        label,
-        concurrency: String::from("1,2"),
-        seed: 42,
-        parallel: None,
-        perf_only: req.perf_only,
-        depth: req.depth,
-        tokenizer: None,
-        ssh_host: req.ssh_host,
-        no_think: false,
-        timeout: None,
-    };
-    super::jobs::spawn(app2, id.clone(), async move {
-        crate::bench::run(&config, &args, false).await
-    });
-    Ok(Json(serde_json::json!({ "job": id })))
-}
-
 #[derive(Deserialize)]
 pub(crate) struct PowerReq {
     /// "off" | "on" | "switch"

+ 189 - 0
src/web/bench_api.rs

@@ -0,0 +1,189 @@
+//! Bench endpoints: parameterized batch run, live harness log, cancellation.
+//!
+//! `POST /api/bench` starts the same `bench::run` the CLI uses as a tracked job.
+//! The dashboard bench window chooses target node, run host, seed/concurrency,
+//! tokenizer and the optional perf/depth modes; `GET /api/bench/log` tail-reads
+//! the newest harness log for the recipe; `POST /api/bench/cancel` sets the
+//! job's cancellation flag, which kills the `tool-eval-bench` child.
+
+use axum::extract::{Query, State};
+use axum::http::StatusCode;
+use axum::response::Json;
+use serde::Deserialize;
+
+use super::AppState;
+
+type ApiError = (StatusCode, String);
+
+#[derive(Deserialize)]
+pub(crate) struct BenchReq {
+    pub cluster: String,
+    pub recipe: String,
+    /// Label for the run. A quality run without one is auto-labeled
+    /// `web-<UTC-stamp>` (dashboard campaigns stay distinguishable from
+    /// operator campaigns); perf-only runs default to `perf` in bench::run.
+    #[serde(default)]
+    pub label: Option<String>,
+    #[serde(default)]
+    pub perf_only: bool,
+    /// Run the context-depth suite after the main pass (off by default).
+    #[serde(default)]
+    pub depth: bool,
+    /// Run the harness on a remote host over SSH (its network stays off the
+    /// measurement path); None = the launcher host itself.
+    #[serde(default)]
+    pub ssh_host: Option<String>,
+    /// Bench target node inside the cluster (default: rank 0).
+    #[serde(default)]
+    pub node: Option<String>,
+    #[serde(default)]
+    pub seed: Option<u64>,
+    #[serde(default)]
+    pub concurrency: Option<String>,
+    #[serde(default)]
+    pub parallel: Option<u32>,
+    #[serde(default)]
+    pub no_think: bool,
+    #[serde(default)]
+    pub timeout: Option<u64>,
+    /// Local tokenizer path (uploaded to `ssh_host` when remote); needed when
+    /// the served alias is not a resolvable HF model id (e.g. FLARE lanes).
+    #[serde(default)]
+    pub tokenizer: Option<String>,
+}
+
+/// `POST /api/bench` — the standard bench (seed 42, hardmode, perf,
+/// concurrency 1,2) as a tracked job; the report + run package land in the
+/// shared recipe repo (`recipes/<lane>/reports/`).
+pub(crate) async fn bench(
+    app: State<AppState>,
+    Json(req): Json<BenchReq>,
+) -> Result<Json<serde_json::Value>, ApiError> {
+    let id = super::jobs::new_job(&app, "bench", &req.cluster, Some(&req.recipe))?;
+    let config = app.config.clone();
+    let app2 = (*app).clone();
+    let label = match req.label {
+        Some(l) => Some(l),
+        None if !req.perf_only => Some(format!("web-{}", crate::stamp::utc_stamp())),
+        None => None,
+    };
+    let cancel = super::jobs::cancel_flag(&app, &id);
+    let args = crate::bench::Args {
+        recipe: req.recipe.clone(),
+        cluster: req.cluster.clone(),
+        node: req.node,
+        label,
+        concurrency: req.concurrency.unwrap_or_else(|| String::from("1,2")),
+        seed: req.seed.unwrap_or(42),
+        parallel: req.parallel,
+        perf_only: req.perf_only,
+        depth: req.depth,
+        tokenizer: req.tokenizer,
+        ssh_host: req.ssh_host,
+        no_think: req.no_think,
+        timeout: req.timeout,
+        cancel,
+    };
+    super::jobs::spawn(app2, id.clone(), async move {
+        crate::bench::run(&config, &args, false).await
+    });
+    Ok(Json(serde_json::json!({ "job": id })))
+}
+
+#[derive(Deserialize)]
+pub(crate) struct BenchCancelReq {
+    pub job: String,
+}
+
+/// `POST /api/bench/cancel` — set the bench job's cancellation flag; the
+/// harness child is killed by `run_tool`, and the job finishes failed.
+pub(crate) async fn bench_cancel(
+    app: State<AppState>,
+    Json(req): Json<BenchCancelReq>,
+) -> Result<Json<serde_json::Value>, ApiError> {
+    if !super::jobs::cancel_bench(&app, &req.job) {
+        return Err((
+            StatusCode::NOT_FOUND,
+            format!("no running bench job '{}'", req.job),
+        ));
+    }
+    Ok(Json(
+        serde_json::json!({ "job": req.job, "cancelling": true }),
+    ))
+}
+
+#[derive(Deserialize)]
+pub(crate) struct BenchLogReq {
+    pub recipe: String,
+    #[serde(default)]
+    pub lines: Option<usize>,
+}
+
+/// `GET /api/bench/log?recipe=<name>` — the newest bench log for the recipe;
+/// the UI polls this in the bench window to show the harness early.
+pub(crate) async fn bench_log(
+    app: State<AppState>,
+    Query(req): Query<BenchLogReq>,
+) -> Result<Json<serde_json::Value>, ApiError> {
+    let file =
+        crate::config::recipe_file(&req.recipe, &app.config.recipe_sources).ok_or_else(|| {
+            (
+                StatusCode::NOT_FOUND,
+                format!("recipe '{}' not found", req.recipe),
+            )
+        })?;
+    let dir = file.parent().map(|p| p.join("reports"));
+    let Some(dir) = dir else {
+        return Err((
+            StatusCode::NOT_FOUND,
+            "recipe has no reports dir".to_string(),
+        ));
+    };
+    let mut entries = tokio::fs::read_dir(&dir)
+        .await
+        .map_err(|e| (StatusCode::NOT_FOUND, format!("no reports dir: {e}")))?;
+    let mut latest: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
+    while let Some(entry) = entries
+        .next_entry()
+        .await
+        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
+    {
+        let path = entry.path();
+        if path.extension() != Some(std::ffi::OsStr::new("log")) {
+            continue;
+        }
+        let md = entry
+            .metadata()
+            .await
+            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+        let m = md.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
+        if latest.as_ref().is_none_or(|(old, _)| m > *old) {
+            latest = Some((m, path));
+        }
+    }
+    let Some((_, path)) = latest else {
+        return Err((StatusCode::NOT_FOUND, "no bench log yet".to_string()));
+    };
+    let text = tokio::fs::read_to_string(&path)
+        .await
+        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+    let n = req.lines.unwrap_or(160).clamp(20, 1000);
+    let all: Vec<&str> = text.lines().collect();
+    let lines = all[all.len().saturating_sub(n)..].to_vec();
+    let (job, running) = {
+        let jobs = app.jobs.lock().unwrap();
+        let j = jobs
+            .iter()
+            .find(|j| j.kind == "bench" && j.recipe.as_deref() == Some(req.recipe.as_str()));
+        (
+            j.map(|j| j.id.clone()),
+            j.is_some_and(|j| j.status == super::jobs::JobStatus::Running),
+        )
+    };
+    Ok(Json(serde_json::json!({
+        "path": path.display().to_string(),
+        "lines": lines,
+        "job": job,
+        "running": running,
+    })))
+}

+ 50 - 1
src/web/jobs.rs

@@ -6,7 +6,8 @@
 use super::AppState;
 use super::events::Event;
 use serde::{Deserialize, Serialize};
-use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
 use std::time::{SystemTime, UNIX_EPOCH};
 
 #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
@@ -27,6 +28,10 @@ pub struct Job {
     pub status: JobStatus,
     pub message: String,
     pub started: u64,
+    /// Bench cancellation flag: `run_tool` polls it and kills the harness
+    /// child. Not persisted (a reload starts a new flag).
+    #[serde(skip)]
+    pub cancel: Option<Arc<AtomicBool>>,
 }
 
 static COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -94,6 +99,7 @@ pub fn new_job(
             status: JobStatus::Running,
             message: "started".into(),
             started: now_ts(),
+            cancel: Some(Arc::new(AtomicBool::new(false))),
         });
         trim_finished(&mut jobs);
         save_locked(&jobs);
@@ -134,6 +140,49 @@ pub fn finish_job(app: &AppState, id: &str, ok: bool, message: String) {
     });
 }
 
+/// The bench job's cancellation flag (None for other kinds).
+pub fn cancel_flag(app: &AppState, id: &str) -> Option<Arc<AtomicBool>> {
+    let jobs = app.jobs.lock().unwrap();
+    jobs.iter()
+        .find(|j| j.id == id && j.kind == "bench")
+        .and_then(|j| j.cancel.clone())
+}
+
+/// Request cancellation of a running bench job. The job stays `Running`
+/// until the harness process exits; the flag makes `run_tool` kill it.
+pub fn cancel_bench(app: &AppState, id: &str) -> bool {
+    let target = {
+        let jobs = app.jobs.lock().unwrap();
+        jobs.iter()
+            .find(|j| j.id == id && j.kind == "bench" && j.status == JobStatus::Running)
+            .map(|j| {
+                (
+                    j.kind.clone(),
+                    j.cluster.clone(),
+                    j.recipe.clone(),
+                    j.cancel.clone(),
+                )
+            })
+    };
+    let Some((kind, cluster, recipe, flag)) = target else {
+        return false;
+    };
+    if let Some(f) = flag {
+        f.store(true, Ordering::Relaxed);
+        emit(
+            app,
+            id,
+            &kind,
+            "cancelling",
+            &cluster,
+            recipe.as_deref(),
+            "cancelling",
+        );
+        return true;
+    }
+    false
+}
+
 fn trim_finished(jobs: &mut Vec<Job>) {
     if jobs.len() <= MAX_JOBS {
         return;

+ 4 - 1
src/web/mod.rs

@@ -9,6 +9,7 @@
 //! Design: `specs/tech-architecture/web-dashboard.md`.
 
 mod api;
+mod bench_api;
 mod cache_api;
 pub mod events;
 pub(crate) mod flare_nodes;
@@ -268,7 +269,9 @@ pub fn router(app: AppState) -> Router {
         .route("/api/state", get(api::state))
         .route("/api/serve", post(api::serve))
         .route("/api/stop", post(api::stop))
-        .route("/api/bench", post(api::bench))
+        .route("/api/bench", post(bench_api::bench))
+        .route("/api/bench/cancel", post(bench_api::bench_cancel))
+        .route("/api/bench/log", get(bench_api::bench_log))
         .route("/api/model-settings", get(model_settings::handle))
         .route("/api/power", post(api::power))
         .route("/api/recipes", get(api::recipes))

+ 272 - 0
src/web/ui.html

@@ -272,6 +272,30 @@
   }
   .setstat { font-size: 12px; color: var(--muted); }
 
+  /* --- bench window: parameters + live harness log ------------------------- */
+  .benchwin .win-body { display: flex; flex-direction: column; padding: 0; }
+  .benchbar {
+    display: flex; gap: 8px; align-items: center; padding: 8px 10px;
+    border-bottom: 1px solid var(--border); flex-wrap: wrap; font-size: 12px;
+  }
+  .benchbar .adv { color: var(--muted); flex: 1; min-width: 100px; }
+  .benchbar .abtn { height: 26px; }
+  .benchform {
+    display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 6px 10px;
+    padding: 8px 10px; border-bottom: 1px solid var(--border); flex-shrink: 0;
+    font-size: 12px;
+  }
+  .benchform label { display: flex; align-items: center; gap: 6px; color: var(--muted); }
+  .benchform input[type=text], .benchform input[type=number], .benchform select {
+    width: 100%; height: 25px; box-sizing: border-box; background: var(--btn);
+    color: var(--fg); border: 1px solid var(--border); border-radius: 5px; padding: 2px 6px;
+    font: inherit;
+  }
+  .benchform .ckrow { display: flex; gap: 14px; flex-wrap: wrap; }
+  .benchform .ckrow label { color: var(--fg); }
+  .benchstatus { padding: 6px 10px; font-size: 12px; color: var(--muted); border-bottom: 1px solid var(--border); }
+  .benchlog { margin: 0; flex: 1; min-height: 80px; }
+
   /* --- polish: skeleton + attention ------------------------------------------ */
   .skeleton { height: 14px; border-radius: 6px; margin: 10px 0;
     background: linear-gradient(90deg, var(--btn) 25%, var(--btn-hover) 50%, var(--btn) 75%);
@@ -856,6 +880,7 @@ function toggleDockMenu() {
   item("logs…", openLogsPicker);
   item("raw", openRaw);
   item("cache…", openCache);
+  item("bench…", () => openBench(readBenchParams().recipe || ""));
   item("settings…", openSettings);
   document.body.appendChild(m);
   // Anchor the menu to the + button that opened it (the chips flow left,
@@ -915,6 +940,7 @@ function openLogsForRecipe(recipe) {
 window.addEventListener("message", (e) => {
   const d = e.data;
   if (d && d.sm121 === "openLogs" && typeof d.recipe === "string") openLogsForRecipe(d.recipe);
+  if (d && d.sm121 === "openBench" && typeof d.recipe === "string") openBench(d.recipe);
 });
 
 // --- SSE -------------------------------------------------------------------
@@ -2139,6 +2165,251 @@ async function openSettings(g) {
   return handle;
 }
 
+// --- bench window: parameters, target node, live harness log, cancel --------
+const BENCH_PARAMS_KEY = "sm121-bench-params";
+const BENCH_JOB_KEY = "sm121-bench-job";
+let benchWin = null;
+let benchTimer = null;
+let benchJobId = null;
+function readBenchParams() {
+  try { return JSON.parse(localStorage.getItem(BENCH_PARAMS_KEY) || "{}"); } catch { return {}; }
+}
+function saveBenchParams(p) {
+  try { localStorage.setItem(BENCH_PARAMS_KEY, JSON.stringify(p)); } catch {}
+}
+function saveBenchJob(job, recipe) {
+  benchJobId = job || null;
+  try { localStorage.setItem(BENCH_JOB_KEY, JSON.stringify({ job: benchJobId, recipe })); } catch {}
+}
+function readBenchJob() {
+  try { return JSON.parse(localStorage.getItem(BENCH_JOB_KEY) || "{}"); } catch { return {}; }
+}
+function stopBenchPoll() { if (benchTimer) { clearInterval(benchTimer); benchTimer = null; } }
+async function openBench(recipeArg) {
+  if (benchWin) {
+    activateWindow(benchWin);
+    if (recipeArg) {
+      const sel = benchWin.body.querySelector("#bRecipe");
+      if (sel) sel.value = recipeArg;
+    }
+    if (benchJobId) startBenchPoll();
+    return benchWin;
+  }
+  const saved = readBenchParams();
+  const jobSaved = readBenchJob();
+  const snap = cur();
+  const clusters = snap ? snap.clusters : [];
+  // Recipe: arg from the recipe row, else the last persisted one.
+  const recipe = recipeArg || saved.recipe || "";
+  // Cluster: prefer where the recipe is live, then the saved choice, then first.
+  let cluster = saved.cluster || (clusters[0] ? clusters[0].name : "");
+  let node = saved.node || "";
+  if (recipe && snap) {
+    for (const c of snap.clusters) {
+      for (const n of c.nodes) {
+        if ((n.containers || []).some((x) => x.recipe === recipe)) {
+          cluster = c.name; node = n.name;
+          break;
+        }
+      }
+    }
+  }
+  const handle = makeWin({
+    title: "bench", short: "bench",
+    x: 70, y: 60, w: Math.min(820, innerWidth - 50), h: Math.min(620, innerHeight * 0.8),
+    bodyHtml: `<div class="benchbar">
+        <span class="adv benchcmd">bench</span>
+        <span class="benchstatus"></span>
+        <button class="abtn bstart">▶ run</button>
+        <button class="abtn bcancel" disabled>⏹ cancel</button>
+      </div>
+      <div class="benchform">
+        <label>recipe</label><select id="bRecipe"></select>
+        <label>cluster</label><select id="bCluster"></select>
+        <label>node</label><select id="bNode"></select>
+        <label>run host</label><input id="bHost" type="text" placeholder="pluto.lan (ssh_host, optional)">
+        <label>label</label><input id="bLabel" type="text" placeholder="blank = web-<UTC stamp>">
+        <label>seed</label><input id="bSeed" type="number" min="0" value="42">
+        <label>concurrency</label><input id="bConc" type="text" value="1,2">
+        <label>parallel</label><input id="bPar" type="number" min="1" placeholder="serial">
+        <label>timeout</label><input id="bTimeout" type="number" min="1" placeholder="120">
+        <label>tokenizer</label><input id="bTokenizer" type="text" placeholder="tokenizer.json (optional)">
+        <div class="ckrow">
+          <label><input id="bPerf" type="checkbox"> perf only</label>
+          <label><input id="bDepth" type="checkbox"> depth suite</label>
+          <label><input id="bNoThink" type="checkbox"> no think</label>
+        </div>
+      </div>
+      <pre class="logpre benchlog"></pre>`,
+    extraCls: "benchwin", removeOnClose: false,
+    onClosed: () => { stopBenchPoll(); },
+  });
+  wins.bench = handle;
+  benchWin = handle;
+  const body = handle.body;
+  const recipeSel = body.querySelector("#bRecipe");
+  const clusterSel = body.querySelector("#bCluster");
+  const nodeSel = body.querySelector("#bNode");
+  const status = body.querySelector(".benchstatus");
+  const pre = body.querySelector(".benchlog");
+  const startBtn = body.querySelector(".bstart");
+  const cancelBtn = body.querySelector(".bcancel");
+  // Recipe list
+  const recipes = state.recipes || [];
+  recipeSel.innerHTML = "";
+  const seen = new Set();
+  for (const r of recipes) {
+    if (seen.has(r.name)) continue;
+    seen.add(r.name);
+    const o = document.createElement("option");
+    o.value = r.name;
+    o.textContent = r.name;
+    recipeSel.appendChild(o);
+  }
+  if (recipe && !seen.has(recipe)) {
+    const o = document.createElement("option");
+    o.value = recipe; o.textContent = recipe;
+    recipeSel.appendChild(o);
+  }
+  recipeSel.value = recipe;
+  function fillClusters() {
+    clusterSel.innerHTML = "";
+    for (const c of clusters) {
+      const o = document.createElement("option");
+      o.value = c.name; o.textContent = c.name + (c.up ? "" : " (offline)");
+      clusterSel.appendChild(o);
+    }
+    if (!clusters.length) {
+      const o = document.createElement("option");
+      o.value = ""; o.textContent = "no clusters";
+      clusterSel.appendChild(o);
+    }
+    clusterSel.value = cluster || (clusters[0] ? clusters[0].name : "");
+  }
+  function fillNodes() {
+    const cname = clusterSel.value;
+    const c = clusters.find((x) => x.name === cname);
+    nodeSel.innerHTML = "<option value=''>auto (rank 0)</option>";
+    if (c) for (const n of c.nodes) {
+      const o = document.createElement("option");
+      o.value = n.name; o.textContent = n.name + (n.containers.length ? " ★" : "");
+      nodeSel.appendChild(o);
+    }
+    nodeSel.value = node || "";
+  }
+  fillClusters(); fillNodes();
+  // Restore the persisted fields (recipe/cluster/node already; the rest)
+  body.querySelector("#bHost").value = saved.host || "";
+  body.querySelector("#bLabel").value = saved.label || "";
+  body.querySelector("#bSeed").value = saved.seed ?? 42;
+  body.querySelector("#bConc").value = saved.concurrency || "1,2";
+  body.querySelector("#bPar").value = saved.parallel || "";
+  body.querySelector("#bTimeout").value = saved.timeout || "";
+  body.querySelector("#bTokenizer").value = saved.tokenizer || "";
+  body.querySelector("#bPerf").checked = !!saved.perf_only;
+  body.querySelector("#bDepth").checked = !!saved.depth;
+  body.querySelector("#bNoThink").checked = !!saved.no_think;
+  clusterSel.onchange = () => { node = ""; fillNodes(); persist(); };
+  recipeSel.onchange = () => persist();
+  nodeSel.onchange = () => persist();
+  function persist() {
+    saveBenchParams({
+      recipe: recipeSel.value,
+      cluster: clusterSel.value,
+      node: nodeSel.value,
+      host: body.querySelector("#bHost").value.trim(),
+      label: body.querySelector("#bLabel").value.trim(),
+      seed: Number(body.querySelector("#bSeed").value) || 42,
+      concurrency: body.querySelector("#bConc").value.trim() || "1,2",
+      parallel: body.querySelector("#bPar").value ? Number(body.querySelector("#bPar").value) : null,
+      timeout: body.querySelector("#bTimeout").value ? Number(body.querySelector("#bTimeout").value) : null,
+      tokenizer: body.querySelector("#bTokenizer").value.trim() || null,
+      perf_only: body.querySelector("#bPerf").checked,
+      depth: body.querySelector("#bDepth").checked,
+      no_think: body.querySelector("#bNoThink").checked,
+    });
+  }
+  body.querySelectorAll("input, select").forEach((el) => el.addEventListener("change", persist));
+  async function startBench() {
+    const recipe = recipeSel.value;
+    const cluster = clusterSel.value;
+    if (!recipe || !cluster) { status.textContent = "pick recipe and cluster"; return; }
+    const payload = {
+      cluster, recipe,
+      node: nodeSel.value || null,
+      ssh_host: body.querySelector("#bHost").value.trim() || null,
+      seed: Number(body.querySelector("#bSeed").value) || 42,
+      concurrency: body.querySelector("#bConc").value.trim() || "1,2",
+      parallel: body.querySelector("#bPar").value ? Number(body.querySelector("#bPar").value) : null,
+      timeout: body.querySelector("#bTimeout").value ? Number(body.querySelector("#bTimeout").value) : null,
+      tokenizer: body.querySelector("#bTokenizer").value.trim() || null,
+      perf_only: body.querySelector("#bPerf").checked,
+      depth: body.querySelector("#bDepth").checked,
+      no_think: body.querySelector("#bNoThink").checked,
+    };
+    const label = body.querySelector("#bLabel").value.trim();
+    if (label) payload.label = label;
+    persist();
+    startBtn.disabled = true;
+    status.textContent = "starting…";
+    try {
+      const j = await api("/api/bench", payload);
+      saveBenchJob(j.job, recipe);
+      status.textContent = "job " + j.job + " running — live log below";
+      cancelBtn.disabled = false;
+      startBenchPoll();
+    } catch (e) {
+      status.textContent = "error: " + e.message;
+      startBtn.disabled = false;
+    }
+  }
+  cancelBtn.onclick = async () => {
+    if (!benchJobId) return;
+    cancelBtn.disabled = true;
+    status.textContent = "cancelling…";
+    try {
+      const res = await fetch("/api/bench/cancel", {
+        method: "POST", headers: { ...AUTH(), "content-type": "application/json" },
+        body: JSON.stringify({ job: benchJobId }),
+      });
+      const j = await res.json().catch(() => ({}));
+      if (!res.ok) status.textContent = "cancel error: " + (j.error || res.status);
+    } catch (e) { status.textContent = "cancel error: " + e.message; }
+  };
+  async function pollBenchLog() {
+    if (!recipeSel.value) return;
+    try {
+      const res = await fetch("/api/bench/log?recipe=" + encodeURIComponent(recipeSel.value) + "&lines=220", { headers: AUTH() });
+      if (res.status === 404) { status.textContent = "waiting for the first log line…"; return; }
+      const j = await res.json();
+      if (!res.ok) { status.textContent = "log error: " + (j.error || res.status); return; }
+      if (j.job && !benchJobId) { benchJobId = j.job; saveBenchJob(j.job, recipeSel.value); }
+      pre.textContent = (j.lines || []).join("\n");
+      const atBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 24;
+      if (atBottom) pre.scrollTop = pre.scrollHeight;
+      status.textContent = j.running ? "running — " + j.path : "finished — " + j.path;
+      if (j.running) {
+        cancelBtn.disabled = false;
+      } else {
+        cancelBtn.disabled = true;
+        startBtn.disabled = false;
+        stopBenchPoll();
+      }
+    } catch (e) { status.textContent = "log error: " + e.message; }
+  }
+  function startBenchPoll() {
+    if (benchTimer) return;
+    benchTimer = setInterval(pollBenchLog, 2000);
+    pollBenchLog();
+  }
+  startBtn.onclick = startBench;
+  // Reopening a closed window with a running job restores the live view.
+  const savedJob = jobSaved.job;
+  if (savedJob) { benchJobId = savedJob; cancelBtn.disabled = true; startBenchPoll(); }
+  saveWinState();
+  return handle;
+}
+
 async function openRaw(g) {
   if (rawWin) { activateWindow(rawWin); return rawWin; }
   const r = (k, d) => (g && Number.isFinite(parseFloat(g[k])) ? parseFloat(g[k]) : d);
@@ -2464,6 +2735,7 @@ async function boot() {
   // optional windows (their own SSE/API calls are cheap and idempotent)
   restoreWindow("recipes", openRecipes);
   restoreWindow("raw", openRaw);
+  restoreWindow("bench", () => openBench(readBenchParams().recipe || ""));
   setInterval(async () => {
     try {
       const res = await fetch("/api/state", { headers: AUTH() });

+ 23 - 2
src/web/ui.playwright.mjs

@@ -282,8 +282,8 @@ ok(
 // --- 13. raw window (via the dock + new-window menu) -------------------------------
 await page.click("#dock .plus");
 ok(
-  "dock + menu lists topo / state / jobs / recipes / logs / raw / cache / settings",
-  (await page.locator(".dockmenu .dock-chip").count()) === 8,
+  "dock + menu lists topo / state / jobs / recipes / logs / raw / cache / bench / settings",
+  (await page.locator(".dockmenu .dock-chip").count()) === 9,
 );
 await page.click('.dockmenu .dock-chip:has-text("raw")');
 await page.waitForSelector(".rawwin", { timeout: 10000 });
@@ -788,6 +788,27 @@ ok(
 await rzWin.locator(".win-bar .wbtn[data-act=close]").click();
 
 // --- no page JS errors across the whole flow -------------------------------------
+// --- bench window: dialog, remembered params, close/reopen -----------------
+await page.click("#dockplus");
+await page.click('.dockmenu .dock-chip:has-text("bench")');
+await page.waitForSelector(".benchwin", { timeout: 10000 });
+ok("bench window opens with parameter form + log pane",
+  (await page.locator(".benchwin .benchform").count()) === 1 &&
+  (await page.locator(".benchwin .benchlog").count()) === 1 &&
+  (await page.locator(".benchwin #bCluster").count()) === 1 &&
+  (await page.locator(".benchwin #bNode").count()) === 1);
+// params persist in localStorage (even without a live recipe)
+await page.fill(".benchwin #bSeed", "7");
+await page.fill(".benchwin #bConc", "1,2,4");
+await page.evaluate(() => localStorage.getItem("sm121-bench-params"));
+ok("bench params are stored in localStorage",
+  (await page.evaluate(() => localStorage.getItem("sm121-bench-params") || "")).includes("\"seed\":7"));
+await page.click(".benchwin .wbtn[data-act=close]");
+ok("closed bench leaves a dock chip",
+  (await page.locator("#dock .dock-chip:has-text('bench')").count()) === 1);
+await page.click("#dock .dock-chip:has-text('bench')");
+ok("bench window reopens after close", await page.locator(".benchwin").isVisible());
+
 ok("no page JS errors", pageErrors.length === 0, pageErrors.join("; "));
 
 await browser.close();