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
+575 -3
View File
@@ -1466,7 +1466,8 @@ async fn test_import_seed_admin_endpoint() {
// 4. 未收敛点 → 200,但不写 .7、grid_points 维持 pending(未建 converged)。
let conv_fail = make_legacy_conv_json("t20000_g5.0_he-2_c-4_n-4_o-4_fail", false);
let body_bytes = make_import_multipart("boundary4", &conv_fail, b"WONT_BE_USED", "x.7", "cold_run");
let body_bytes =
make_import_multipart("boundary4", &conv_fail, b"WONT_BE_USED", "x.7", "cold_run");
let res = app
.oneshot(
Request::builder()
@@ -1538,8 +1539,13 @@ async fn test_import_seed_python_legacy_conv_json() {
let name = "t20000_g5.0_he-2_c-4_n-4_o-4";
let conv = make_python_legacy_conv_json(name);
let body_bytes =
make_import_multipart("boundaryL", &conv, b"FAKE_ATMOS_7", &format!("{name}.7"), "cold_run");
let body_bytes = make_import_multipart(
"boundaryL",
&conv,
b"FAKE_ATMOS_7",
&format!("{name}.7"),
"cold_run",
);
let res = app
.oneshot(
Request::builder()
@@ -1667,6 +1673,7 @@ async fn test_node_disable_enable_flow() {
timeout_sec: 60,
workflow_name: Some("wf_test".to_string()),
wave: 0,
..Default::default()
};
queue.push_task(&task).await.unwrap();
@@ -1883,6 +1890,232 @@ async fn test_node_disable_enable_flow() {
);
}
#[tokio::test]
async fn test_admin_set_node_quota_and_heartbeat_sync() {
// 验证动态 CPU 槽位配额下发链路(见 docs/dynamic_cpu_slots_design.md):
// admin POST /quota 设 admin_max_slots → 落库 nodes.admin_max_slots →
// list_nodes_with_credentials 返回配额 → 心跳响应体透传 admin_max_slots。
// 覆盖:设值 / 清除(null) / 负数 400 / 不存在节点 404。
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("quota_db.db");
let queue_db_path = temp_dir.path().join("quota_queue.db");
let seeds_dir = temp_dir.path().join("results");
std::fs::create_dir_all(&seeds_dir).unwrap();
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let queue = Arc::new(
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
.await
.unwrap(),
);
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
// 注册并审批 node-quota-test,颁发 token
let reg = common::models::NodeRegisterRequest {
node_id: "node-quota-test".to_string(),
max_slots: 4,
};
db.register_node(&reg).await.unwrap();
let token = db.approve_node("node-quota-test").await.unwrap();
let state = AppState {
db: db.clone(),
queue: queue.clone(),
scheduler,
seeds_dir: seeds_dir.to_string_lossy().to_string(),
rate_limiter: server::api::rate_limit::RateLimiter::new(
5,
std::time::Duration::from_secs(300),
),
admin_token: Some("admin-secret".to_string()),
auth_disabled: false,
admin_sessions: std::sync::Arc::new(tokio::sync::RwLock::new(
std::collections::HashMap::new(),
)),
};
let api_router = axum::Router::new()
.route(
"/node/heartbeat",
axum::routing::post(server::api::node::heartbeat_node),
)
.route(
"/admin/nodes/:node_id/quota",
axum::routing::post(server::api::admin::set_node_quota),
);
let auth_layer =
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
let app = axum::Router::new()
.nest("/api", api_router.layer(auth_layer))
.with_state(state);
// 1. 基线心跳:admin_max_slots 应为 null(无配额限制)
let hb = serde_json::json!({"node_id":"node-quota-test","active_slots":0,"cpu_usage":10.0,"memory_usage":20.0});
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/node/heartbeat")
.header("authorization", format!("Bearer {}", token))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&hb).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body: serde_json::Value = serde_json::from_slice(
&axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
assert_eq!(body["status"], "ok");
assert!(
body["admin_max_slots"].is_null(),
"未设配额时心跳响应 admin_max_slots 应为 null"
);
// 2. admin 设配额 = 2 → 200
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/nodes/node-quota-test/quota")
.header("authorization", "Bearer admin-secret")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({"admin_max_slots": 2})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
// 2a. 配额落库(list_nodes_with_credentials
let nodes = db.list_nodes_with_credentials().await.unwrap();
let me = nodes
.iter()
.find(|n| n.node_id == "node-quota-test")
.unwrap();
assert_eq!(me.admin_max_slots, Some(2), "配额应已落库为 Some(2)");
// 3. 心跳响应透传 admin_max_slots = 2
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/node/heartbeat")
.header("authorization", format!("Bearer {}", token))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&hb).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body: serde_json::Value = serde_json::from_slice(
&axum::body::to_bytes(res.into_body(), usize::MAX)
.await
.unwrap(),
)
.unwrap();
assert_eq!(body["admin_max_slots"], 2, "心跳响应应透传配额 2");
// 3a. 请求体缺 admin_max_slots 字段(畸形输入)→ 400,绝不能静默当作「清除配额」
// (审查修复 M1Option<i32> 缺字段默认 None 会把畸形请求误判为清除限制)。
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/nodes/node-quota-test/quota")
.header("authorization", "Bearer admin-secret")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::BAD_REQUEST,
"缺字段应 400 而非静默清除配额"
);
// 畸形请求无副作用:既有配额仍为 Some(2),未被静默清除
let nodes = db.list_nodes_with_credentials().await.unwrap();
let me = nodes
.iter()
.find(|n| n.node_id == "node-quota-test")
.unwrap();
assert_eq!(me.admin_max_slots, Some(2), "畸形请求不应改动既有配额");
// 4. 设 null 清除配额 → 心跳响应 admin_max_slots = null
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/nodes/node-quota-test/quota")
.header("authorization", "Bearer admin-secret")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({"admin_max_slots": null})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let nodes = db.list_nodes_with_credentials().await.unwrap();
let me = nodes
.iter()
.find(|n| n.node_id == "node-quota-test")
.unwrap();
assert_eq!(me.admin_max_slots, None, "清除后配额应为 None");
// 5. 负数 → 400
let res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/nodes/node-quota-test/quota")
.header("authorization", "Bearer admin-secret")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({"admin_max_slots": -1})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST, "负数配额应 400");
// 6. 不存在节点 → 404
let res = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/nodes/ghost-node/quota")
.header("authorization", "Bearer admin-secret")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({"admin_max_slots": 1})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND, "不存在节点应 404");
}
// ===== 工作流执行观测端点测试(stats / points / point detail =====
/// 测试专用:走真实写路径派发并回报一个网格点任务。
@@ -1907,6 +2140,7 @@ async fn dispatch_and_report(
timeout_sec: 7200,
workflow_name: Some(wf.to_string()),
wave: 0,
..Default::default()
};
db.insert_task(&spec).await.unwrap();
let report = common::models::TaskReport {
@@ -1929,6 +2163,7 @@ async fn dispatch_and_report(
Some("nl stage diverged".to_string())
},
summary_json: "{}".to_string(),
failed_stage: None,
};
db.record_task_report(&report, wf).await.unwrap();
}
@@ -2844,3 +3079,340 @@ async fn test_wf_progress_endpoint() {
.unwrap();
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}
/// 构造仅含 report 字段的 multipart body(模拟节点上报,不带 seed_file)。
fn make_report_only_multipart(boundary: &str, report_json: &str) -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
body.extend_from_slice(b"Content-Disposition: form-data; name=\"report\"\r\n");
body.extend_from_slice(b"Content-Type: application/json\r\n\r\n");
body.extend_from_slice(report_json.as_bytes());
body.extend_from_slice(b"\r\n");
body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());
body
}
/// 端到端回归(2026-08-02 涡旋事故):已收敛点收到迟到的重复失败报告时,
/// 走完整 HTTP 链路(claim → report)后状态保持 converged,且不触发种子回退
/// state_changed=false 跳过 fallback;纵有可用种子也不产生 seed_step 任务)。
#[tokio::test]
async fn test_duplicate_failure_report_cannot_flip_converged() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("flip_db.db");
let queue_db_path = temp_dir.path().join("flip_queue.db");
let seeds_dir = temp_dir.path().join("results");
std::fs::create_dir_all(&seeds_dir).unwrap();
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let queue = Arc::new(
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
.await
.unwrap(),
);
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
// 预置节点与 token。
let reg = common::models::NodeRegisterRequest {
node_id: "node-flip".to_string(),
max_slots: 2,
};
db.register_node(&reg).await.unwrap();
let token = db.issue_node_token("node-flip").await.unwrap();
let state = AppState {
db: db.clone(),
queue: queue.clone(),
scheduler,
seeds_dir: seeds_dir.to_string_lossy().to_string(),
rate_limiter: server::api::rate_limit::RateLimiter::new(
100,
std::time::Duration::from_secs(300),
),
admin_token: None,
auth_disabled: false,
admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
};
let api_router = axum::Router::new()
.route(
"/task/claim",
axum::routing::post(server::api::task::claim_task),
)
.route(
"/task/report",
axum::routing::post(server::api::task::report_task),
);
let auth_layer =
axum::middleware::from_fn_with_state(state.clone(), server::api::auth_middleware);
let app = axum::Router::new()
.nest("/api", api_router.layer(auth_layer))
.with_state(state);
// 工作流 running + 网格点 + 可用种子(若回退被错误触发,必然能匹配到种子并派发任务,
// 使"无 seed_step 任务"断言成为强证据)。
db.upsert_workflow("wf_flip", None, "", "running")
.await
.unwrap();
let params = common::models::GridPointParams {
teff: 35000.0.into(),
logg: 5.5.into(),
loghe: (-1.0).into(),
logc: (-2.0).into(),
logn: (-2.0).into(),
logo: (-2.0).into(),
};
let name = params.model_name();
db.upsert_grid_point(&params, 0, "wf_flip").await.unwrap();
db.insert_seed_named(&name, &params, "/tmp/flip_seed.7")
.await
.unwrap();
// ---- 任务 A:正常收敛 ----
let task_a = common::models::TaskSpec {
task_id: uuid::Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
task_type: common::models::TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 7200,
workflow_name: Some("wf_flip".to_string()),
wave: 0,
..Default::default()
};
db.insert_task(&task_a).await.unwrap();
queue.push_task(&task_a).await.unwrap();
let claim_res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/task/claim")
.header("authorization", format!("Bearer {}", token))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(claim_res.status(), StatusCode::OK);
let report_a = common::models::TaskReport {
task_id: task_a.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "node-flip".to_string(),
status: common::models::TaskStatus::Completed,
converged: true,
max_relc: Some(0.0005),
atmosphere_has_nan: false,
elapsed_sec: 120.0,
error_message: None,
summary_json: "{}".to_string(),
failed_stage: None,
};
let body_a =
make_report_only_multipart("flipbound1", &serde_json::to_string(&report_a).unwrap());
let report_res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/task/report")
.header("authorization", format!("Bearer {}", token))
.header("content-type", "multipart/form-data; boundary=flipbound1")
.body(Body::from(body_a))
.unwrap(),
)
.await
.unwrap();
assert_eq!(report_res.status(), StatusCode::OK);
assert_eq!(
db.get_grid_point_status(&name, "wf_flip")
.await
.unwrap()
.unwrap()
.0,
"converged"
);
// ---- 任务 B:迟到的重复失败报告(涡旋残留任务的典型行为)----
let task_b = common::models::TaskSpec {
task_id: uuid::Uuid::new_v4(),
point_name: name.clone(),
params: params.clone(),
task_type: common::models::TaskType::ColdRun,
seed_point_name: None,
timeout_sec: 7200,
workflow_name: Some("wf_flip".to_string()),
wave: 0,
..Default::default()
};
db.insert_task(&task_b).await.unwrap();
queue.push_task(&task_b).await.unwrap();
let claim_b = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/task/claim")
.header("authorization", format!("Bearer {}", token))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(claim_b.status(), StatusCode::OK);
let report_b = common::models::TaskReport {
task_id: task_b.task_id,
point_name: name.clone(),
params: Some(params.clone()),
node_id: "node-flip".to_string(),
status: common::models::TaskStatus::Failed,
converged: false,
max_relc: Some(9.5e5),
atmosphere_has_nan: false,
elapsed_sec: 130.0,
error_message: Some("nl stage diverged".to_string()),
summary_json: "{}".to_string(),
failed_stage: None,
};
let body_b =
make_report_only_multipart("flipbound2", &serde_json::to_string(&report_b).unwrap());
let report_b_res = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/task/report")
.header("authorization", format!("Bearer {}", token))
.header("content-type", "multipart/form-data; boundary=flipbound2")
.body(Body::from(body_b))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
report_b_res.status(),
StatusCode::OK,
"上报本身应成功(吸收)"
);
// 核心断言:converged 不被翻黑,且未触发任何种子回退任务。
assert_eq!(
db.get_grid_point_status(&name, "wf_flip")
.await
.unwrap()
.unwrap()
.0,
"converged",
"迟到失败报告不得翻黑 converged 点"
);
assert!(
db.has_pending_tasks_for_point(&name, "wf_flip", Some("seed_step"))
.await
.unwrap()
.is_empty(),
"不得因迟到失败报告派发 seed_step"
);
assert!(
!db.has_seed_step_attempt(&name, "wf_flip").await.unwrap(),
"全程不应产生任何 seed_step 行"
);
}
/// save_workflow 阶段配置合法性校验(修复审查 #4/#5):
/// - 双阶段全关 → 400(无任何计算可执行);
/// - 启用阶段空策略链 → 400(调度无顺位可派,任务必失败);
/// - 合法配置(synspec-only 场景 B / 默认双开)→ 200。
#[tokio::test]
async fn test_save_workflow_validates_stage_configs() {
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("wfval_db.db");
let queue_db_path = temp_dir.path().join("wfval_queue.db");
let seeds_dir = temp_dir.path().join("results");
std::fs::create_dir_all(&seeds_dir).unwrap();
let db = Database::new(&db_path.to_string_lossy()).await.unwrap();
let queue = Arc::new(
SqliteTaskQueue::new(&queue_db_path.to_string_lossy())
.await
.unwrap(),
);
let scheduler = Arc::new(GridScheduler::new(db.clone(), queue.clone()));
let state = AppState {
db: db.clone(),
queue,
scheduler,
seeds_dir: seeds_dir.to_string_lossy().to_string(),
rate_limiter: server::api::rate_limit::RateLimiter::new(
5,
std::time::Duration::from_secs(300),
),
admin_token: Some("admin-secret".to_string()),
auth_disabled: false,
admin_sessions: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
};
let app = axum::Router::new()
.route(
"/api/workflows",
axum::routing::post(server::api::workflow::save_workflow),
)
.with_state(state);
let base_yaml = "grid:\n teff: [35000]\n logg: [5.5]\n loghe: [-1]\n logc: [-2]\n logn: [-2]\n logo: [-2]";
let post_save = |name: &str, yaml: &str| {
let app = app.clone();
let body = serde_json::json!({
"name": name,
"description": null,
"config_yaml": yaml,
});
async move {
app.oneshot(
Request::builder()
.method("POST")
.uri("/api/workflows")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap()
}
};
// 1. 双阶段全关 → 400
let both_off = format!(
"{}\ntlusty:\n enabled: false\n policy: skip_converged\n strategies: [cold_run, seed_step]\n\
synspec_stage:\n enabled: false\n policy: skip_converged\n strategies: [standard]\n",
base_yaml
);
let res = post_save("wf_val_a", &both_off).await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST, "双阶段全关应 400");
// 2. 启用阶段空策略链 → 400
let empty_chain = format!(
"{}\ntlusty:\n enabled: true\n policy: skip_converged\n strategies: []\n",
base_yaml
);
let res = post_save("wf_val_b", &empty_chain).await;
assert_eq!(
res.status(),
StatusCode::BAD_REQUEST,
"启用阶段空策略链应 400"
);
// 3. 合法:TLUSTY 关 + SYNSPEC 启(设计 §2.2 场景 B:仅更新光谱)→ 200
let syn_only = format!(
"{}\ntlusty:\n enabled: false\n policy: skip_converged\n strategies: [cold_run, seed_step]\n\
synspec_stage:\n enabled: true\n policy: force_recompute\n strategies: [standard]\n",
base_yaml
);
let res = post_save("wf_val_c", &syn_only).await;
assert_eq!(res.status(), StatusCode::OK, "synspec-only 合法配置应 200");
// 4. 合法:无阶段块(默认双开)→ 200
let res = post_save("wf_val_d", base_yaml).await;
assert_eq!(res.status(), StatusCode::OK, "默认配置应 200");
}