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
+170 -25
View File
@@ -113,13 +113,12 @@ async fn run_child_async_with_timeout(
timeout_sec: u64,
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> Result<std::process::ExitStatus> {
let timeout_fut = tokio::time::timeout(
tokio::time::Duration::from_secs(timeout_sec),
child.wait(),
);
let timeout_fut =
tokio::time::timeout(tokio::time::Duration::from_secs(timeout_sec), child.wait());
// 若提供了 shutdown 标志,则与超时/正常结束三路 select;否则只等超时/正常结束。
let outcome: Result<std::process::ExitStatus, ShutdownOrTimeout> = if let Some(flag) = shutdown {
let outcome: Result<std::process::ExitStatus, ShutdownOrTimeout> = if let Some(flag) = shutdown
{
let shutdown_watcher = async move {
// 轮询 shutdown 标志(10ms 粒度足够灵敏,开销可忽略)。
loop {
@@ -148,20 +147,12 @@ async fn run_child_async_with_timeout(
Ok(status) => Ok(status),
Err(ShutdownOrTimeout::Shutdown) => {
let _ = child.start_kill();
let _ = tokio::time::timeout(
std::time::Duration::from_secs(30),
child.wait(),
)
.await;
let _ = tokio::time::timeout(std::time::Duration::from_secs(30), child.wait()).await;
anyhow::bail!("节点收到退出信号,子进程已被终止");
}
Err(ShutdownOrTimeout::Timeout) => {
let _ = child.start_kill();
let _ = tokio::time::timeout(
std::time::Duration::from_secs(30),
child.wait(),
)
.await;
let _ = tokio::time::timeout(std::time::Duration::from_secs(30), child.wait()).await;
anyhow::bail!("进程计算超时 (上限: {} 秒)", timeout_sec);
}
}
@@ -173,6 +164,31 @@ enum ShutdownOrTimeout {
Timeout,
}
/// 快照 TLUSTY 最终模型的 b 因子与出射谱,防止被 SYNSPEC 覆盖丢失。
///
/// TLUSTY 在最终迭代(`LFIN=.TRUE.`)经 `OUTPRI` 写出(见 tlusty208.f):
/// - `fort.12`b 因子 / 非 LTE 偏离因子表(头 2I5 + 每深度 TEMP/ELEC/DENS/BFAC,格式 701/702/703)。
/// 随后 SYNSPEC 会复用 unit 12 写谱线证认表并覆盖它(runner 再将其存为 `<name>.iden`),
/// 故 TLUSTY 的 b 因子若不在此快照即静默丢失。
/// - `fort.14`:出射谱(波长 Å + Fλ,格式 614),同样会被 SYNSPEC 的谱线数据覆盖。
///
/// 在收敛链循环结束(链上最后一次 TLUSTY 运行即最终模型)、SYNSPEC 启动前调用,
/// 快照为 `<name>.bfac` / `<name>.emflux`,与科学核心产物一并进入归档白名单
/// (见 `result_filter::is_result_worthy` 的 `bfac`/`emflux` 后缀)。
/// 文件不存在时静默跳过(TLUSTY 未运行/未写出);IO 错误降级为 warn,不阻断主流程。
async fn snapshot_tlusty_outputs(model_dir: &Path, name: &str) {
for (src, suffix) in [("fort.12", "bfac"), ("fort.14", "emflux")] {
let src_path = model_dir.join(src);
if !src_path.is_file() {
continue;
}
let dst = model_dir.join(format!("{}.{}", name, suffix));
if let Err(e) = tokio::fs::copy(&src_path, &dst).await {
warn!("快照 TLUSTY {} 到 {} 失败: {}", src, dst.display(), e);
}
}
}
pub struct ExecutionRunner<'a> {
pub runtime: &'a RuntimePaths,
pub work_dir: PathBuf,
@@ -199,12 +215,19 @@ impl<'a> ExecutionRunner<'a> {
custom_chain,
seed_atmos,
synspec_cfg,
true,
true,
7200,
None,
)
.await
}
/// 阶段独立配置执行入口(见 docs/task_engine_decoupling_design.md §5)。
///
/// `tlusty_enabled` / `synspec_enabled` 控制各阶段是否运行:
/// - TLUSTY 关闭:跳过 chain 循环,直接以 seed_atmos(或单独拉取的大气)作 final_7;
/// - SYNSPEC 关闭:跳过光谱合成块(即便 final_7 存在)。
#[allow(clippy::too_many_arguments)]
pub async fn run_model_with_timeout(
&self,
@@ -214,6 +237,8 @@ impl<'a> ExecutionRunner<'a> {
custom_chain: Option<Vec<StageConfig>>,
seed_atmos: Option<&Path>,
synspec_cfg: Option<&SynspecConfig>,
tlusty_enabled: bool,
synspec_enabled: bool,
timeout_sec: u64,
shutdown: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> Result<ModelSummary> {
@@ -282,7 +307,20 @@ impl<'a> ExecutionRunner<'a> {
let mut final_chmax: Option<f64> = None;
let mut final_max_relc: Option<f64> = None;
// 阶段独立配置(见 docs/task_engine_decoupling_design.md §5):
// TLUSTY 关闭时跳过整个 chain 循环——current_seed 直接作为 final_7 来源,
// 适配「仅 SYNSPEC」场景(用既有大气合成光谱,不重算大气结构)。
if tlusty_enabled {
info!("TLUSTY 阶段启用:执行 {} 步收敛链", chain.len());
} else {
info!("TLUSTY 阶段关闭:跳过大气结构计算,直接进入 SYNSPEC 阶段");
}
let tlusty_skipped = !tlusty_enabled;
for stage_def in &chain {
if tlusty_skipped {
break;
}
let stage_t0 = Instant::now();
let metals = stage_def.metals.as_deref().unwrap_or("cno");
let input5_text = make_input5(
@@ -335,7 +373,8 @@ impl<'a> ExecutionRunner<'a> {
.kill_on_drop(true)
.spawn()?;
let status_res = run_child_async_with_timeout(child, timeout_sec, shutdown.clone()).await;
let status_res =
run_child_async_with_timeout(child, timeout_sec, shutdown.clone()).await;
let rc = match status_res {
Ok(st) => st.code().unwrap_or(-1),
Err(e) => {
@@ -440,17 +479,27 @@ impl<'a> ExecutionRunner<'a> {
let _ = tokio::fs::copy(model_dir.join("fort.7"), &final_7).await;
}
// TLUSTY 最终 b 因子 + 出射谱快照。必须在此处(SYNSPEC 覆盖 fort.12/fort.14 之前)
// 完成:synspec 会复用 unit 12/14 写谱线数据,覆盖 TLUSTY 的最终产物(见上方
// snapshot_tlusty_outputs 的注释)。仅 SYNSPEC 场景(tlusty_enabled=false)下
// fort.12/14 不存在,函数内按文件是否存在静默跳过。
snapshot_tlusty_outputs(&model_dir, name).await;
let atmo_has_nan = atmosphere_has_nan(&final_7);
if atmo_has_nan {
final_converged = false;
}
// Run synspec if final .7 atmosphere exists
// Run synspec if enabled and final .7 atmosphere exists
let mut synspec_rc = None;
let mut synspec_err = None;
let mut synspec_sec = None;
if final_7.is_file() {
if !synspec_enabled {
// SYNSPEC 关闭是合法配置(TLUSTY-only 大气计算),不写入 synspec_error
// 以免污染 conv.json 的错误归因——下游把非空 synspec_error 当「光谱有缺陷」。
info!("SYNSPEC 阶段关闭:跳过光谱合成(TLUSTY-only 模式)");
} else if final_7.is_file() {
let syn_t0 = Instant::now();
// H10synspec 输入文件(fort.8 大气 / fort.55 控制卡)写入失败不可静默吞掉。
// 历史上用 `let _ =` 忽略错误,磁盘满/inode 耗尽时 synspec 会读到旧/缺失的
@@ -480,7 +529,10 @@ impl<'a> ExecutionRunner<'a> {
};
let fort55_text = generate_fort55_content(synspec_cfg.unwrap_or(&default_cfg));
if let Err(e) = tokio::fs::write(&fort55_path, &fort55_text).await {
warn!("synspec 输入 fort.55 (控制卡) 写入失败,跳过 synspec: {}", e);
warn!(
"synspec 输入 fort.55 (控制卡) 写入失败,跳过 synspec: {}",
e
);
synspec_err = Some(format!("fort.55 write failed: {}", e));
} else {
#[cfg(unix)]
@@ -511,7 +563,8 @@ impl<'a> ExecutionRunner<'a> {
let synspec_timeout_sec = 600_u64.min(timeout_sec);
let status_res =
run_child_async_with_timeout(child, synspec_timeout_sec, shutdown.clone()).await;
run_child_async_with_timeout(child, synspec_timeout_sec, shutdown.clone())
.await;
let rc = match status_res {
Ok(st) => st.code().unwrap_or(-1),
Err(e) => {
@@ -549,6 +602,19 @@ impl<'a> ExecutionRunner<'a> {
synspec_err = Some("No atmosphere .7 produced".to_string());
}
// 收敛判定(见 docs/task_engine_decoupling_design.md §5):
// - TLUSTY 启用:final_converged 由 chain 循环内各阶段收敛状态累积得出(既有逻辑)。
// - TLUSTY 关闭(仅 SYNSPEC 场景):final_converged 不能恒为 false——否则成功的
// 光谱合成任务被误判失败并触发策略回退。此时收敛 = 大气加载干净(无 NaN)且
// SYNSPEC 成功(rc=0)或 SYNSPEC 也关闭(TLUSTY-only 等价的纯校验场景,虽罕见)。
// 仅 SYNSPEC 场景下大气来自既有产物(非本任务重算),NaN 检查仍必要(产物可能损坏)。
if !tlusty_enabled && !atmo_has_nan {
final_converged = match synspec_rc {
Some(rc) => rc == 0,
None => !synspec_enabled, // SYNSPEC 也关闭 → 仅校验大气,干净即收敛
};
}
// 清理冗余的裸文件:这些文件的内容已被带阶段标签的快照或重命名的科学产物覆盖,
// 保留它们只会与归档里的 <name>.<label>.* / <name>.iden / <name>.cont 等重复(尤其
// .spec/.cont 是大文件,双份存储浪费磁盘)。删除后归档目录干净无冗余。
@@ -569,6 +635,29 @@ impl<'a> ExecutionRunner<'a> {
}
let elapsed_sec = t0.elapsed().as_secs_f64();
// 汇总 note(修复审查 #2 后续):半失败点(大气已收敛 + 光谱失败)须让
// synspec 的错误可见——此前 synspec rc≠0 时 note 恒为 None,上报的
// error_message 为空,attempts 表与详情面板无从排查失败原因。
let note = {
let mut notes: Vec<String> = Vec::new();
if atmo_has_nan {
notes.push("Invalidated: atmosphere contains >10% NaN lines".to_string());
}
if let Some(ref err) = synspec_err {
notes.push(format!("synspec error: {}", err));
} else if let Some(rc) = synspec_rc {
if rc != 0 {
notes.push(format!("synspec rc={}", rc));
}
}
if notes.is_empty() {
None
} else {
Some(notes.join("; "))
}
};
let summary = ModelSummary {
name: name.to_string(),
params: params.clone(),
@@ -582,11 +671,7 @@ impl<'a> ExecutionRunner<'a> {
synspec_error: synspec_err,
synspec_sec,
elapsed_sec,
note: if atmo_has_nan {
Some("Invalidated: atmosphere contains >10% NaN lines".to_string())
} else {
None
},
note,
};
// Write conv.json
@@ -599,6 +684,7 @@ impl<'a> ExecutionRunner<'a> {
#[cfg(test)]
mod tests {
use super::snapshot_tlusty_outputs;
use crate::models::{GridAxisValue, GridPointParams};
#[test]
@@ -647,4 +733,63 @@ mod tests {
let authoritative_name = point_name; // 即 executor 传入的 task.point_name
assert_eq!(authoritative_name, "t20000_g5.0_he-2_c-4_n-4_o-4");
}
/// 快照 TLUSTY b 因子与出射谱:fort.12→`<name>.bfac`、fort.14→`<name>.emflux`
/// 缺失的源文件静默跳过,未列入快照的 fort.13 不受影响。
#[tokio::test]
async fn test_snapshot_tlusty_outputs() {
let dir = tempfile::tempdir().unwrap();
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
let model_dir = dir.path().join(name);
tokio::fs::create_dir_all(&model_dir).await.unwrap();
// TLUSTY 最终迭代产物:b 因子(fort.12)与出射谱(fort.14
tokio::fs::write(model_dir.join("fort.12"), "bfac payload")
.await
.unwrap();
tokio::fs::write(model_dir.join("fort.14"), "emflux payload")
.await
.unwrap();
// 不参与快照的文件(出射辐射场 fort.13、大气 fort.7
tokio::fs::write(model_dir.join("fort.13"), "emrad payload")
.await
.unwrap();
tokio::fs::write(model_dir.join("fort.7"), "atmo payload")
.await
.unwrap();
snapshot_tlusty_outputs(&model_dir, name).await;
assert_eq!(
tokio::fs::read_to_string(model_dir.join(format!("{}.bfac", name)))
.await
.unwrap(),
"bfac payload"
);
assert_eq!(
tokio::fs::read_to_string(model_dir.join(format!("{}.emflux", name)))
.await
.unwrap(),
"emflux payload"
);
// fort.13 未列入快照,不应生成 <name>.emrad
assert!(!model_dir.join(format!("{}.emrad", name)).exists());
// 原 fort.12/fort.14 保留(后续 synspec 覆盖前仍作为单元文件存在)
assert!(model_dir.join("fort.12").is_file());
assert!(model_dir.join("fort.14").is_file());
}
/// 缺失源文件(仅 SYNSPEC 场景,tlusty 未运行)时快照应是无害 no-op
#[tokio::test]
async fn test_snapshot_tlusty_outputs_noop_when_missing() {
let dir = tempfile::tempdir().unwrap();
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
let model_dir = dir.path().join(name);
tokio::fs::create_dir_all(&model_dir).await.unwrap();
snapshot_tlusty_outputs(&model_dir, name).await;
assert!(!model_dir.join(format!("{}.bfac", name)).exists());
assert!(!model_dir.join(format!("{}.emflux", name)).exists());
}
}