Prhub

#2815 dashboard: land Rollouts on the newest step that has data

原始 PR 作者 Shi-Dong 合并时间 2026-09-01 04:32 文件变更 4 提交数 4 评论 3 代码增减 +68 / -13

执行摘要

Rollouts 标签落在首个可读 step,修复实时点击报错

PR body 明确描述了痛点:“A step is listed as soon as its rollout dump file appears, which is earlier than that step can be read, so during a live run clicking the tab regularly landed on an error page——and the only way out was editing the step number in the URL by hand.” 作者系统枚举了 5 种破坏着陆的状态:dump 仍在写入(503 DumpStillWriting)、截断且 mtime 过期(torch.loadRuntimeError,500)、记录但 0 样本(summary() 返回 0 行 0 列,groups()group_by("group_index")ColumnNotFoundError,500)、文件已轮转(404)。其中“无样本”正是 live run 中最常见的形态。核心设计哲学是:不把修复 tuned 到单一错误形状,而是把“抛错”与“加载成功但无样本”同等视为 not usable yet,统一走回退探测。

值得精读。改动虽小但完整呈现了一个前端修复的工程方法论:先枚举全部失败形态再决定统一处理策略(threw 与 empty 同等对待)、为回退探测设置语义边界(5 步后停止并暴露真实错误)、处理异步间隙的竞态守卫、考虑历史栈与重试预算。“有界回看 + 显式请求不重定向”的组合对同类 dashboard 导航很有借鉴意义。若后续要为 dashboard 前端补测试设施,本 PR 的 Playwright 验证矩阵(3 种 dump × 4 种检查)是现成的回归用例清单。

讨论亮点

线程一:异步竞态(claude[bot] → 已修复)

claude[bot] 在 views_rollout.js 行内评论指出:renderRollout 等待最多 5 次顺序 API 调用后无条件 location.replace,若用户在此期间点击 Metrics 等导航离开,hash 会被改回并强制回到 rollout 视图;base 版本同步解析、不存在此异步间隙。作者提交 e71d9df 修复:进入异步前记录 entryHash,解析完成后若 location.hash 已变化则跳过 location.replace。claude[bot] 复评确认:“the follow-up commit addresses the mid-resolve navigation race I flagged... Checked the guard logic and the rest of the resolveLatest/renderRollout/parseRoute flow; no other issues found.”

线程二:修饰键点击回归(claude[bot] → 未修复,作为可选 nit 合入)

claude[bot] 在 app.js 行内评论指出 replaceInPlace 无条件 preventDefault(),导致在 rollout 视图内 ctrl/cmd/中键点击 Rollouts 无法在新标签页打开(base 版本是普通锚点,始终支持修饰键)。claude[bot] 自标 severity: nit(minor UX regression),并明确“no need to push a fix before merging”,最终未修复即合入。

线程三:合并者直接上手的两个工程修复

Zhichenzzz 在 review 之外直接提交了两个修复:4902d01 解决历史栈重复条目问题(点击标签页会 push 两个相同 fragment,Back 键之间不触发 hashchange),6d9679e 为探测关闭 503 重试预算。这是“reviewer 发现真实问题、直接改代码”的协作模式,最终由 Zhichenzzz 批准合入。

实现拆解

  1. 路由层(miles/dashboard/static/app.js)parseRoute 新增 latest 分支,把 #/rollout/latest 映射为 { view: "rollout", rolloutId: null }crumbs 中 Rollouts 链接从 #/rollout/${latest} 改为常量 #/rollout/latest,并新增 replaceInPlace 处理器——当用户已在 rollout/tokens 视图内再点标签页时 preventDefaultlocation.replace 原地替换,避免路由仅监听 hashchange 导致历史栈压入两个相同 fragment、Back 键失效。step 面包屑在 rolloutId === null 时跳过渲染。
  2. 视图层(miles/dashboard/static/views_rollout.js):新增 resolveLatest(ids, evaluation),从 ids.slice(-LANDING_LOOKBACK).reverse() 顺序探测 summary 接口,命中 rows.length > 0 即返回。LANDING_LOOKBACK = 5 是刻意设定的语义边界:超过 5 步就不再是“新 dump 尚未写完”的竞态,宁可让真实错误浮现,也不把用户静默带进旧数据。renderRollout 增加 null id 分支:先处理“无任何 step”的空态文案,再进入解析;解析完成后用 location.replace 把 hash 重写为真实 step id,使刷新、Prev/Next、面包屑都基于真实 id;entryHash 守卫确保用户在解析期间导航离开后不会被拽回。
  3. 请求层(miles/dashboard/static/api.js)fetchOkapi 增加 retry503 选项。探测时传 retry503: false,因为此场景下 503 是“step 尚不可读”的确定性回答而非瞬态故障;若沿用默认重试,每探测一个坏 step 会白耗 4 个请求与 4.5 秒,最多 5 个候选会让页面长时间停在“finding the newest step with data…”。
  4. 配套与验证README.md 同步更新 Rollouts 行为描述。仓库无前端 JS 测试设施(无 package.jsontests/fast/dashboard/ 的 Python 测试不加载静态文件),作者用 Playwright 驱动真实 Chrome,对 dummy_dump.py 构造的正常、空样本、截断、双截断 dump 逐一验证着陆结果,并确认显式 #/rollout/2、刷新、Back、Metrics→Rollouts→sample→token 全链路不回环。
文件 模块 状态 重要度
miles/dashboard/static/views_rollout.js 视图层 modified 6.86
miles/dashboard/static/app.js 路由层 modified 6.67
miles/dashboard/static/api.js 请求层 modified 5.48
miles/dashboard/README.md 文档 modified 1.5

关键符号

parseRoute crumbs replaceInPlace renderRollout resolveLatest fetchOk api

关键源码片段

miles/dashboard/static/views_rollout.js core-logic

核心改动文件:新增 resolveLatest 有界回看解析与 renderRollout 的 null id 分支,是“着陆到首个可读 step”这一功能的实现主体。

// views_rollout.js:有界回看 + 着陆解析 + 竞态守卫
const LANDING_LOOKBACK = 5;// 从最新 step 往回逐个探测:step 被列出(dump 文件出现)往往早于可读
// (写一半的 503、截断的 500、或“有记录但 0 样本”的 500),
// 所以取第一个能读到且至少有一行样本的 step 作为落点
async function resolveLatest(ids, evaluation) {
  for (const id of ids.slice(-LANDING_LOOKBACK).reverse()) {
    try {
      const summary = await api(`/api/rollout/${id}/summary`, { eval: evaluation }, { retry503: false });
      if (summary.rows.length) return id;
    } catch {
      // 写一半、被截断或已被轮转掉:继续往前探测前一个 step
    }
  }
  // 附近没有可读 step:仍回落到最新 step,让真实错误呈现给读者
  return ids.at(-1);
}export async function renderRollout(view, meta, route) {
  const { evaluation } = route;
  let { rolloutId } = route;
  if (rolloutId === null) {
    // "#/rollout/latest" 分支:候选人列表限定为 train 或 eval
    const candidates = evaluation ? meta.rollout_ids.eval : meta.rollout_ids.train;
    if (!candidates.length) {
      const kind = evaluation ? "eval" : "rollout";
      view.replaceChildren(el("p", { class: "muted" }, [`No ${kind} steps have been dumped yet.`]));
      return;
    }
    view.replaceChildren(el("p", { class: "muted" }, ["finding the newest step with data…"]));
    // resolve 会发起多次请求;若用户在此期间已导航离开,此时重写 URL
    // 会把用户拖回本视图,所以在进入异步前先记录入口 hash
    const entryHash = location.hash;
    rolloutId = await resolveLatest(candidates, evaluation);
    if (location.hash !== entryHash) return;
    // 把 URL 重写为实际展示的 step:刷新、Prev/Next 与面包屑都基于真实 id;
    // 用 replace 而非赋值,避免把未解析的 latest 残留进历史栈
    location.replace(`#/rollout/${rolloutId}${evaluation ? "?eval=1" : ""}`);
    return;
  }
  // 显式 step id:保持原逻辑,用户输入什么就展示什么,绝不重定向
  const [summary, groups] = await Promise.all([
    api(`/api/rollout/${rolloutId}/summary`, { eval: evaluation }),
    api(`/api/rollout/${rolloutId}/groups`, { eval: evaluation }),
  ]);
  // ... 后续渲染逻辑不变
}
miles/dashboard/static/app.js core-logic

路由与导航入口:parseRoute 识别 latest 保留路由,crumbs 把 Rollouts 链接改为常量并新增 replaceInPlace 原地替换,避免历史栈重复与 Back 失效。

// app.js:latest 保留路由与原地替换
function parseRoute() {
  const [path, query] = (location.hash.slice(1) || "/").split("?");
  const segments = path.split("/").filter(Boolean);
  const params = new URLSearchParams(query || "");
  // ...timeline / metrics 等其他分支省略 ...
  if (segments[0] === "rollout" && segments.length >= 2) {
    const evaluation = params.get("eval") === "1";
    // "latest" 是保留路由:在渲染期针对 dump 解析,而不是在链接里烘焙死 id;
    // 手输的 step 号始终精确兑现,不参与解析
    if (segments[1] === "latest") {
      return { view: "rollout", rolloutId: null, evaluation };
    }
    const rolloutId = Number(segments[1]);
    if (segments[2] === "sample" && segments.length === 4) {
      return { view: "tokens", rolloutId, sampleIndex: Number(segments[3]), evaluation };
    }
    return { view: "rollout", rolloutId, evaluation };
  }
  return { view: "metrics" };
}function crumbs(route, meta) {
  const nav = (label, href, active, onclick = null) =>
    el("a", { class: `nav${active ? " active" : ""}`, href, onclick }, [label]);
  const parts = [nav("Metrics", "#/", route.view === "metrics")];
  // ...Compute Utilization 等导航省略 ...
  // Rollouts 链接恒为 "#/rollout/latest";已在 rollout/tokens 视图内时改用
  // preventDefault + location.replace 原地替换,避免 hashchange 不触发时
  // 历史栈积压两个相同 fragment、Back 键失效
  if (meta.rollout_ids.train.length) {
    const onRollout = route.view === "rollout" || route.view === "tokens";
    const replaceInPlace = (event) => {
      event.preventDefault();
      location.replace("#/rollout/latest");
    };
    parts.push(nav("Rollouts", "#/rollout/latest", onRollout, onRollout ? replaceInPlace : null));
  }
  // 未解析出真实 id 前不渲染 step 面包屑
  if ((route.view === "rollout" || route.view === "tokens") && route.rolloutId !== null) {
    // ...step crumb 渲染省略 ...
  }
  // ...
}
miles/dashboard/static/api.js entrypoint

请求封装层新增 retry503 开关,支撑 resolveLatest 探测时跳过 503 重试预算,避免每个坏 step 白等 4.5 秒。

// api.js:可关断的 503 重试
const RETRIES_503 = 3;async function fetchOk(path, params, { retry503 = true } = {}) {
  const url = new URL(path, location.origin);
  for (const [k, v] of Object.entries(params)) {
    if (v !== undefined && v !== null) url.searchParams.set(k, v);
  }
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url);
    // 默认对 503 重试 3 次、每次间隔 1.5 秒(DumpStillWriting 场景);
    // 但 landing 探测时 503 是“step 尚不可读”的确定性回答,传 retry503: false
    // 跳过重试,否则每探测一个坏 step 会白等 4.5 秒
    if (res.status === 503 && retry503 && attempt < RETRIES_503) {
      await new Promise((r) => setTimeout(r, 1500));
      continue;
    }
    if (!res.ok) {
      let detail = "";
      try {
        detail = (await res.json()).detail ?? "";
      } catch {
        /* non-json error body */
      }
      throw new Error(`HTTP ${res.status}: ${detail || url.pathname}`);
    }
    return res;
  }
}export async function api(path, params = {}, options = {}) {
  return (await fetchOk(path, params, options)).json();
}

评论区精华

resolveLatest 异步竞态:中途导航被拽回 正确性

claude[bot] 指出 renderRollout 等待最多 5 次顺序 API 调用后无条件 location.replace,用户若在解析期间点击 Metrics 等导航离开,hash 会被改回并强制回到 rollout 视图;base 版本同步解析、不存在此异步间隙。

结论:作者提交 e71d9df 在进入异步前记录 entryHash,解析完成后若 location.hash 与入口不一致则跳过 replace;claude[bot] 复评确认修复有效、无其他问题。 · 已解决

replaceInPlace 破坏 ctrl/cmd/ 中键开新标签页 设计

claude[bot] 指出 crumbs 的 onclick 无条件 preventDefault,当 nav 处于 active 状态时,ctrl/cmd/ 中键打开新标签页的意图被吞掉,当前标签页被 location.replace 导航;base 版本是普通锚点,始终支持修饰键。

结论:标注为可选 nit(minor UX regression),作者未修复,claude[bot] 明确 no need to push a fix before merging,合入时保留。 · acknowledged

探测时不该消耗 503 重试预算 性能

提交 6d9679e 论证:api() 默认对 503 重试 3 次、间隔 1.5 秒,而 DumpStillWriting 正是 503 的来源;resolveLatest 每拒绝一个 mid-write 候选就耗 4 个请求和 4.5 秒,最多 5 个候选,页面长时间停在 finding the newest step with data…。这里 503 是确定性回答而非瞬态故障。

结论:fetchOk 与 api 增加 retry503 选项,resolveLatest 探测时传 false;由合并者 Zhichenzzz 直接提交修复。 · 已解决

风险与影响

  1. 测试覆盖缺口:仓库没有前端 JS 测试设施,本 PR 的验证完全依赖 Playwright 手动驱动;resolveLatest 的探测顺序、entryHash 守卫等逻辑后续被改动时,CI 不会自动发现回归。建议把 PR 中的验证矩阵沉淀为文档化的回归用例清单。
  2. 修饰键 UX 回归:已在 review 中确认并接受——当位于 rollout/tokens 视图时,ctrl/cmd/中键点击 Rollouts 会吞掉“新标签页打开”意图。影响范围小但属于 base 行为退化。
  3. 竞态守卫的边界entryHash 守卫只比较 hash 字符串。若未来路由包含会随渲染变化的查询参数或 hash 规范化逻辑,守卫可能失效;目前 dashboard 路由简单,风险可控。
  4. 保留字约定latest 成为哈希路由保留段,未来新增 rollout 路由需避开;显式数字 step id 不受影响(id 恒为数字)。
  5. 行为契约:显式 #/rollout/N 永不重定向的设计保证了共享链接语义,但也意味着“URL 必须精确表达意图”的约定需要团队知晓。

用户:live run 期间监控训练时,点击 Rollouts 不再遇到 503/500 死胡同,会落到最近的可读 step;显式 URL 与共享链接语义完全不变;代价是 ctrl/cmd/中键打开新标签页的交互在当前视图内失效。系统:纯前端改动,不触及 API、dump schema 与读取逻辑,dashboard 的 Python 测试全部不受影响。团队:确立了两个路由约定——latest 作为保留字、显式 step id 不参与解析;后续新增视图或改动导航时需要遵守,否则可能引入历史栈或竞态回归。

缺少前端自动化测试覆盖 导航竞态已加守卫但无回归测试 修饰键打开新标签页回归未修复 新增 latest 保留路由约定

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论