feat(all): 任务引擎双阶段解耦、僵尸涡旋修复、动态 CPU 配额与前端详情页重构

将 TLUSTY/SYNSPEC 拆为各自独立的 enabled/policy/strategies 阶段,
以策略链自动弹栈取代单级 seed_step 布尔回退;定向修复 2026-08-02
僵尸任务涡旋事故;新增节点并发配额热调;前端详情页从 1412 行巨型
视图拆为薄控制器 + detail 子模块,并补齐工具层与单测。

引擎与调度(task_engine_decoupling_design.md)
- models.rs: 新增 StagePolicy / EngineStageConfig / TaskSpec 阶段字段、
  normalize_compat() 校正旧版在途消息策略链、failed_stage 归因
- scheduler.rs: resolve_dispatchable_chain 派发门控、
  trigger_strategy_fallback 按 failed_stage 精确弹栈;启动期
  force_recompute/skip_converged(默认)/skip_failed 三策略
- db.rs: tasks 表 +7 列持久化阶段配置;终态守卫
  (mark_grid_point_running 仅 pending/queued→running;
  record_task_report 拒绝迟到失败翻黑 converged);策略弹栈快照

僵尸涡旋修复(runbook-20260802-zombie-vortex-fix.md)
- 全链路跨库活性交叉校验:派发/claim/孤儿回收/回退统一查 MQ 队列活性,
  活则放行、死则清僵尸,结构性消除"每点重复派发"
- stop/重启卫生:清队列同步 delete_tasks_by_ids,杜绝遗留 pending 行
- report_task: 幂等吸收 + 409 区分迟到冗余结果,仅 state_changed 时回退
- MQ: NULL workflow_name 回填 __legacy__、requeue 后迟到上报被 403 竞态修复

动态 CPU 配额(dynamic_cpu_slots_design.md)
- admin.rs: POST /admin/nodes/:id/quota(Option<Option<i32>> 区分
  缺字段/显式 null);nodes 表 +admin_max_slots
- worker.rs: effective_max_slots = min(admin, physical),心跳下发原子生效

科学产物保全(tlusty_result_artifacts.md)
- runner.rs: SYNSPEC 启动前快照 fort.12/fort.14 → .bfac/.emflux 防覆盖
- 半失败点(大气收敛+光谱失败)改判 Failed 并写入 note;仅 SYNSPEC
  场景不再恒判失败;撤销归档 LRU 200 上限改为永久保留
- executor.rs: 透传 synspec_params 数值参数(此前固定 None)

前端(dashboard/)
- workflowDetail.js 1412→328 行,拆出 views/detail/{ctx,overview,
  pointsTable,parSets,pointPanel}.js,AbortController 治理监听/请求生命周期
- 删除 wfActions.js,新增 wfEnginePanel.js(双阶段三维配置编辑面板)
- 新增 utils/{errors,format,icons,polling,yamlStage}.js 纯函数模块
- 路由级动态 import 代码分割;节点配额三点菜单 + Modal 管理
- 首次引入 node:test 单测(format/polling/yamlStage/psCache,644 行)
- 系统性补齐 a11y:skip-link、ARIA、Tab 键盘漫游、toast 关闭、退出动画

文档与工具
- 新增 6 篇设计/调研:引擎解耦、动态配额、涡旋 runbook、
  光谱正确性分析、收敛判断、产物归档
- PIPELINE/design/api/database 等协同重写为分布式 C/S 架构口径
- scripts/fetch_results.sh 跨节点产物备份;import_results 按 cno 升序导入
- workflows/sdB_cno.yaml: 新增 tlusty/synspec_stage 配置块,修正 wstart 笔误
This commit is contained in:
fmq
2026-08-04 23:40:52 +08:00
parent c8fd24b120
commit cd370d88e7
74 changed files with 13386 additions and 3033 deletions
+194
View File
@@ -0,0 +1,194 @@
/* 工作流详情页 · 网格点详情滑入面板(尝试历史 + 阶段链诊断)
*
* 从点表「查看」按钮、Parallel Sets 点名列表 / 单点 drill-down 进入。
* 面板 DOM 挂在 ctx.pointPanelElunmount 由控制器调用 closePointPanel 清理)。
*/
import { fetchPointDetailApi } from '../../api.js';
import { showToast } from '../../components/toast.js';
import { setupFocusTrap, releaseFocusTrap } from '../../components/modal.js';
import {
escapeHtml, pointStatusBadge, pointMethodBadge,
fmtRelc, fmtDur, fmtDateTime, fmtFullTs,
} from '../../utils/format.js';
import { isAbortError } from '../../utils/errors.js';
import { ICONS } from '../../utils/icons.js';
function buildPanelShell(ctx, name) {
const backdrop = document.createElement('div');
backdrop.className = 'point-panel-backdrop';
backdrop.innerHTML = `
<aside class="point-panel card" role="dialog" aria-modal="true" aria-label="网格点详情">
<div class="point-panel-header">
<div class="point-panel-title">
<h3 class="point-panel-name" title="${escapeHtml(name)}">${escapeHtml(name)}</h3>
<span class="point-panel-badges" id="point-panel-badges"></span>
</div>
<div class="point-panel-header-actions">
<button type="button" class="btn btn-secondary btn-sm" data-panel-copy>复制点名</button>
<button type="button" class="modal-close point-panel-close" aria-label="关闭">
${ICONS.x({ size: 16 })}
</button>
</div>
</div>
<div class="point-panel-body" id="point-panel-body">
<p class="text-hint">加载中…</p>
</div>
</aside>
`;
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) {
closePointPanel(ctx);
return;
}
if (e.target.closest('.point-panel-close')) {
closePointPanel(ctx);
return;
}
if (e.target.closest('[data-panel-copy]')) {
navigator.clipboard?.writeText(name).then(
() => showToast('点名已复制', 'success'),
() => showToast('复制失败,请手动复制', 'info')
);
return;
}
// 种子来源点名跳查:换载面板内容到该点
const seedLink = e.target.closest('[data-panel-point]');
if (seedLink) {
e.preventDefault();
openPointPanel(ctx, seedLink.getAttribute('data-panel-point'));
}
});
document.body.appendChild(backdrop);
setupFocusTrap(backdrop.querySelector('.point-panel'));
return backdrop;
}
export function closePointPanel(ctx) {
if (!ctx.pointPanelEl) return;
const panel = ctx.pointPanelEl.querySelector('.point-panel');
if (panel) releaseFocusTrap(panel);
ctx.pointPanelEl.remove();
ctx.pointPanelEl = null;
}
export async function openPointPanel(ctx, pointName) {
if (!ctx.name) return;
closePointPanel(ctx);
ctx.pointPanelEl = buildPanelShell(ctx, pointName);
const body = ctx.pointPanelEl.querySelector('#point-panel-body');
const badges = ctx.pointPanelEl.querySelector('#point-panel-badges');
try {
const res = await fetchPointDetailApi(ctx.name, pointName, { signal: ctx.abortCtl?.signal });
if (res.status === 404) {
body.innerHTML = '<p class="text-hint">该点不存在(可能已被删除)</p>';
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!json.success || !json.data) throw new Error(json.message || '无数据');
const { point, attempts, conv } = json.data;
badges.innerHTML = `${pointStatusBadge(point.status)} ${pointMethodBadge(point.success_method)}`;
body.innerHTML = '';
body.appendChild(buildAttemptSection(attempts));
body.appendChild(buildConvSection(conv));
} catch (err) {
if (isAbortError(err)) return;
body.innerHTML = `<p class="text-hint">加载失败:${escapeHtml(err.message)}(请关闭面板后重试,或检查网络)</p>`;
}
}
function buildAttemptSection(attempts) {
const sec = document.createElement('section');
sec.className = 'point-section';
const list = attempts || [];
const rows = list.map((a, i) => {
const ok = a.status === 'completed';
const seedHtml = a.seed_point_name
? `<a href="#" data-panel-point="${escapeHtml(a.seed_point_name)}" class="seed-link" title="跳查种子来源点">${escapeHtml(a.seed_point_name)}</a>`
: '—';
const resultHtml = ok
? '<span class="activity-result ok">成功</span>'
: `<span class="activity-result fail">${escapeHtml(a.status)}</span>`;
return `
<tr>
<td class="tabular-num">${i + 1}</td>
<td>${pointMethodBadge(a.task_type)}</td>
<td>${seedHtml}</td>
<td class="tabular-num">${fmtRelc(a.max_relc)}</td>
<td>${resultHtml}</td>
<td>${escapeHtml(a.node_id || '—')}</td>
<td class="tabular-num" title="${escapeHtml(fmtFullTs(a.completed_at))}">${fmtDateTime(a.completed_at)}</td>
<td class="tabular-num">${fmtDur(a.elapsed_sec)}</td>
</tr>`;
}).join('') || '<tr><td colspan="8" class="empty-cell">尚无尝试记录(点未被派发)</td></tr>';
sec.innerHTML = `
<h3 class="point-section-title">尝试历史(${list.length}</h3>
<div class="table-responsive">
<table class="data-table attempts-table">
<thead>
<tr><th scope="col">#</th><th scope="col">方法</th><th scope="col">种子来源</th><th scope="col">max_relc</th><th scope="col">结果</th><th scope="col">节点</th><th scope="col">完成时间</th><th scope="col">耗时</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
// 最近一次失败尝试的错误文本:textContent 渲染,杜绝 XSS
const lastErr = [...list].reverse().find(a => a.error_message);
if (lastErr) {
const pre = document.createElement('pre');
pre.className = 'code-pre-wrap point-error';
pre.textContent = `最近错误:${lastErr.error_message}`;
sec.appendChild(pre);
}
return sec;
}
function buildConvSection(conv) {
const sec = document.createElement('section');
sec.className = 'point-section';
if (!conv) {
sec.innerHTML = `
<h3 class="point-section-title">阶段链诊断</h3>
<p class="text-hint">诊断文件不可用(conv.json 缺失或解析失败)</p>`;
return sec;
}
const stagesHtml = (conv.stages || []).map(st => {
// 阶段状态图标用 SVG(与全局图标族一致),不用 emoji——emoji 跨平台渲染不一、
// 无法由 CSS 控色,在暗色模式下尤其违和(no-emoji-icons)。
const icon = st.converged
? ICONS.checkCircle({ size: 14, cls: 'stage-icon stage-icon-ok' })
: ICONS.warn({ size: 14, cls: 'stage-icon stage-icon-warn' });
const relc = st.best_max_relc != null ? Number(st.best_max_relc).toExponential(2) : '—';
const iter = st.last_iter != null ? ` · ${st.last_iter} iter` : '';
const depth = st.worst_depth != null ? ` · 最差深度 ${st.worst_depth}/${st.n_depths ?? '?'}` : '';
const secs = st.elapsed_sec != null ? ` · ${Math.round(st.elapsed_sec)}s` : '';
return `
<div class="stage-chip ${st.converged ? 'stage-ok' : 'stage-warn'}">
<span class="stage-label">${icon} ${escapeHtml(st.label)}</span>
<span class="stage-meta tabular-num">relc ${relc} · chmax ${st.chmax ?? '—'}${iter}${depth}${secs}</span>
</div>`;
}).join('') || '<p class="text-hint">无阶段数据</p>';
const seedTxt = conv.seed ? escapeHtml(String(conv.seed).split('/').pop()) : '无(冷启动)';
// 半失败可见性(审查 #2 后续):大气已收敛但光谱失败时,converged 仍为 true 而
// synspec_rc/synspec_error 非空——必须把这两个量展示出来,否则半失败点无从排查。
const synspecRc = conv.synspec_rc != null ? `<span class="tabular-num">${conv.synspec_rc}</span>` : '—';
const synspecErrHtml = conv.synspec_error
? `<span class="activity-result fail" title="${escapeHtml(conv.synspec_error)}">${escapeHtml(conv.synspec_error)}</span>`
: '—';
sec.innerHTML = `
<h3 class="point-section-title">阶段链诊断</h3>
<div class="stage-chain">${stagesHtml}</div>
<dl class="conv-meta">
<div><dt>最终 max_relc</dt><dd class="tabular-num">${conv.final_max_relc != null ? Number(conv.final_max_relc).toExponential(3) : '—'}</dd></div>
<div><dt>收敛阈值 chmax</dt><dd class="tabular-num">${conv.final_chmax ?? '—'}</dd></div>
<div><dt>种子</dt><dd>${seedTxt}</dd></div>
<div><dt>总耗时</dt><dd class="tabular-num">${conv.elapsed_sec != null ? `${Math.round(conv.elapsed_sec)}s` : '—'}</dd></div>
<div><dt>synspec 耗时</dt><dd class="tabular-num">${conv.synspec_sec != null ? `${Math.round(conv.synspec_sec)}s` : '—'}</dd></div>
<div><dt>synspec 返回码</dt><dd>${synspecRc}</dd></div>
<div><dt>synspec 错误</dt><dd>${synspecErrHtml}</dd></div>
<div><dt>大气 NaN</dt><dd>${conv.atmosphere_has_nan ? '<span class="activity-result fail">是</span>' : '否'}</dd></div>
</dl>
`;
return sec;
}