diff --git a/Cargo.lock b/Cargo.lock index bceb2ec..f67a9bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,6 +206,7 @@ dependencies = [ "diagnostics", "domain-model", "driver-loader", + "driver-package", "driver-sdk", "local-buffer", "metrics", diff --git a/apps/bench/src/workload.rs b/apps/bench/src/workload.rs index 98a47fb..a353206 100644 --- a/apps/bench/src/workload.rs +++ b/apps/bench/src/workload.rs @@ -103,7 +103,7 @@ pub fn generate(env: &GenerateEnv<'_>, plan: &WorkloadPlan) -> io::Result, plan: &WorkloadPlan) -> io::Result, + /// Driver Package 目录(Runtime V2 方案 §8/§37.3)。 + /// + /// 配置后 Collector 扫描全部目录发现 Driver Package 并注册进 + /// [`driver_package`] Registry;此时不得再出现 legacy `driver:` 段 + /// (两者互斥,`validate` 强制)。 #[serde(default)] - pub driver: DriverSpec, + pub drivers: DriversOptions, /// 采集设备清单(§100)。至少一台;domain 缺省时由 Profile 决定。 #[serde(default)] pub devices: Vec, @@ -100,6 +115,40 @@ impl Default for DriverSpec { } } +/// Driver Package 部署选项(Runtime V2 方案 §8)。 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DriversOptions { + /// Driver Package 目录列表;每个目录下以 `driver.json` 子目录为 + /// 一个 package(`driver_package::scan_directories` 语义,§6.3)。 + #[serde(default)] + pub directories: Vec, + /// 部署级隔离覆盖:driver id → 隔离级别。**只允许调得更严格** + /// (§8/§7:不得低于 Manifest `minimum_isolation`),身份元数据 + /// (id/version/abi/artifact)不可覆盖。 + #[serde(default)] + pub isolation_overrides: BTreeMap, +} + +/// 部署级隔离级别(§22;serde snake_case 与 Manifest v2 一致)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IsolationOverride { + Shared, + PerDriver, + PerDevice, +} + +impl From for driver_package::Isolation { + fn from(v: IsolationOverride) -> Self { + match v { + IsolationOverride::Shared => Self::Shared, + IsolationOverride::PerDriver => Self::PerDriver, + IsolationOverride::PerDevice => Self::PerDevice, + } + } +} + /// 插件 Manifest 声明(§20)。 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -602,6 +651,13 @@ impl CollectorConfig { Ok(config) } + /// legacy `driver:` 段是否被显式提供(§41.1)。 + /// + /// `Some(spec)` = 用户显式写了 legacy 段;`None` = 未提供。 + pub fn legacy_driver_provided(&self) -> bool { + self.driver.is_some() + } + /// 校验配置合法性;组件级约束与各 crate `validate` 一致,缺省 /// 值在此补齐后统一生效(不静默取默认值)。 pub fn validate(&self) -> Result<(), CollectorError> { @@ -680,11 +736,61 @@ impl CollectorConfig { } } } - if self.driver.plugin.as_os_str().is_empty() { - return Err(ConfigError::invalid("driver.plugin", "Driver 插件路径不能为空").into()); + // Driver 加载来源二选一(Runtime V2 方案 §37.3): + // - `drivers:`(Package 目录扫描,目标态);`isolation_overrides` + // 只能配合本模式出现; + // - legacy `driver:`(Transitional,§41.1):显式提供时打印 + // `COLLECTOR_CONFIG_DRIVER_DEPRECATED` 并由运行时转换为 + // synthetic package 注册;与 `drivers.directories` 互斥。 + let legacy_driver = self.legacy_driver_provided(); + if !self.drivers.directories.is_empty() { + if legacy_driver { + return Err(ConfigError::invalid( + "driver", + "legacy `driver:` 段与 `drivers:` Package 扫描互斥;请删除 legacy 段", + ) + .into()); + } + if self.drivers.directories.is_empty() { + unreachable!("外层已判非空"); + } + for (idx, dir) in self.drivers.directories.iter().enumerate() { + if dir.as_os_str().is_empty() { + return Err(ConfigError::invalid( + "drivers.directories[]", + format!("第 {idx} 个目录为空"), + ) + .into()); + } + } + for id in self.drivers.isolation_overrides.keys() { + if id.is_empty() { + return Err(ConfigError::invalid( + "drivers.isolation_overrides", + "driver id 不能为空", + ) + .into()); + } + } + } else if !legacy_driver && !self.drivers.isolation_overrides.is_empty() { + return Err(ConfigError::invalid( + "drivers.isolation_overrides", + "隔离覆盖必须配合 drivers.directories 使用", + ) + .into()); } - if self.driver.manifest.id.is_empty() { - return Err(ConfigError::invalid("driver.manifest.id", "Driver 标识不能为空").into()); + if legacy_driver { + let driver = self.driver.as_ref().expect("legacy_driver_provided 为真"); + if driver.plugin.as_os_str().is_empty() { + return Err( + ConfigError::invalid("driver.plugin", "Driver 插件路径不能为空").into(), + ); + } + if driver.manifest.id.is_empty() { + return Err( + ConfigError::invalid("driver.manifest.id", "Driver 标识不能为空").into(), + ); + } } self.northbound.mqtt.validate()?; self.poll.validate()?; @@ -1021,13 +1127,14 @@ mod tests { site_id: "plant-a".to_owned(), session_id: None, profiles_dir: PathBuf::from("profiles"), - driver: DriverSpec { + driver: Some(DriverSpec { plugin: PathBuf::from("driver.dll"), manifest: ManifestSpec { id: "modbus-tcp".to_owned(), ..Default::default() }, - }, + }), + drivers: DriversOptions::default(), devices: vec![DeviceSpec { id: "vfd-01".to_owned(), name: None, diff --git a/apps/collector/src/runtime.rs b/apps/collector/src/runtime.rs index 1758089..1727533 100644 --- a/apps/collector/src/runtime.rs +++ b/apps/collector/src/runtime.rs @@ -31,7 +31,6 @@ use std::time::Duration; use device_manager::{DeviceManager, NativeDriverFactory}; use driver_loader::NativePlugin; -use driver_sdk::DriverManifest; use observation_model::Device; use poll_engine::PollScheduler; use tokio::sync::{mpsc, watch}; @@ -123,32 +122,90 @@ impl CollectorRuntime { "Device Profile 已加载" ); - // 2) Load Driver(§19/§20:Native Plugin + Manifest)。 - let manifest = DriverManifest { - id: config.driver.manifest.id.clone(), - name: config.driver.manifest.name.clone(), - version: config.driver.manifest.version.clone(), - entry: driver_sdk::abi::ENTRY_SYMBOL.to_owned(), - abi: driver_sdk::manifest::AbiVersion { - major: config.driver.manifest.abi.major, - minor: config.driver.manifest.abi.minor, - }, - platforms: vec![], - }; - let plugin = Arc::new( - NativePlugin::load(&config.driver.plugin, manifest) - .map_err(|e| CollectorError::Driver(Box::new(e)))?, - ); - info!( - component = "collector", - plugin = %config.driver.plugin.display(), - driver_id = %config.driver.manifest.id, - "Driver 已加载" - ); + // 2) Load Driver(Runtime V2 方案 §37.3 Multi Driver Registry)。 + // + // 二选一(config.validate 已保证互斥): + // - `drivers.directories`:扫描 Driver Package(driver.json 唯一 + // 事实来源,§7),同一 Collector 注册多个不同 Driver; + // - legacy `driver:`(Transitional §41.1):打印 + // COLLECTOR_CONFIG_DRIVER_DEPRECATED 后按既有单插件路径装配。 let mut factory = NativeDriverFactory::new(); - factory - .add_plugin(plugin) - .map_err(|e| CollectorError::Driver(Box::new(e)))?; + if !config.drivers.directories.is_empty() { + for dir in &config.drivers.directories { + let packages = driver_package::scan_directories(std::slice::from_ref(dir)) + .map_err(|e| CollectorError::Driver(Box::new(e)))?; + for package in packages { + // 隔离覆盖只允许相同或更严格(§7/§8);身份元数据不可 + // 覆盖——此处仅校验方向,实际 Host 拓扑在 Phase 7+ 生效。 + if let Some(override_iso) = config.drivers.isolation_overrides.get(package.id()) + { + let minimum = package.manifest.runtime.minimum_isolation; + let chosen: driver_package::Isolation = (*override_iso).into(); + if chosen < minimum { + return Err(CollectorError::Driver(Box::new( + driver_package::ScanError::Artifact { + path: package.root.clone(), + platform: String::new(), + reason: format!( + "isolation_overrides[{id}]={chosen:?} 低于 Manifest 安全下限 {minimum:?}", + id = package.id() + ), + }, + ))); + } + } + let plugin = Arc::new( + NativePlugin::load(&package.artifact_path, synthetic_manifest(&package)) + .map_err(|e| CollectorError::Driver(Box::new(e)))?, + ); + factory + .add_plugin(plugin) + .map_err(|e| CollectorError::Driver(Box::new(e)))?; + info!( + component = "collector", + driver_id = %package.id(), + version = %package.version(), + artifact = %package.artifact_path.display(), + "Driver Package 已注册" + ); + } + } + let count = config.drivers.directories.len(); + info!( + component = "collector", + directories = %config + .drivers + .directories + .iter() + .map(|d| d.display().to_string()) + .collect::>() + .join(","), + %count, + "Driver Package 目录扫描完成" + ); + } else if config.legacy_driver_provided() { + warn!( + component = "collector", + code = "COLLECTOR_CONFIG_DRIVER_DEPRECATED", + "legacy `driver:` 配置段已废弃(方案 §41.1):请迁移到 `drivers.directories` \ + Package 扫描;本版本仍按单插件路径装配,下一 major 删除" + ); + let spec = config.driver.as_ref().expect("legacy_driver_provided 为真"); + let manifest = legacy_manifest(spec); + let plugin = Arc::new( + NativePlugin::load(&spec.plugin, manifest) + .map_err(|e| CollectorError::Driver(Box::new(e)))?, + ); + info!( + component = "collector", + plugin = %spec.plugin.display(), + driver_id = %spec.manifest.id, + "Driver 已加载(legacy 单插件路径)" + ); + factory + .add_plugin(plugin) + .map_err(|e| CollectorError::Driver(Box::new(e)))?; + } // 3) 构造设备(domain 缺省取 Profile 决定,§100 device.yaml), // 随后注册绑定 Driver/Profile 并生成读取项(§37)。 @@ -792,3 +849,39 @@ impl CollectorRuntime { Ok(()) } } + +/// 由 Driver Package Descriptor 构造 ABI v1 加载用 Manifest(Transitional)。 +/// +/// Package Manifest(v2)是元数据唯一事实来源(§7);本函数只做字段搬运, +/// 供现有 `NativePlugin::load`(ABI v1 校验路径)消费。Host 路径落地后 +/// (Phase 7+)此转换随 direct ABI v1 runtime 一并删除。 +fn synthetic_manifest( + package: &driver_package::DriverPackageDescriptor, +) -> driver_sdk::DriverManifest { + driver_sdk::DriverManifest { + id: package.manifest.id.clone(), + name: package.manifest.name.clone(), + version: package.manifest.version.clone(), + entry: driver_sdk::abi::ENTRY_SYMBOL.to_owned(), + abi: driver_sdk::manifest::AbiVersion { + major: package.manifest.abi.major, + minor: package.manifest.abi.minor, + }, + platforms: vec![], + } +} + +/// legacy `driver:` 段 → 加载用 Manifest(§41.1 兼容路径,下一 major 删除)。 +fn legacy_manifest(spec: &crate::config::DriverSpec) -> driver_sdk::DriverManifest { + driver_sdk::DriverManifest { + id: spec.manifest.id.clone(), + name: spec.manifest.name.clone(), + version: spec.manifest.version.clone(), + entry: driver_sdk::abi::ENTRY_SYMBOL.to_owned(), + abi: driver_sdk::manifest::AbiVersion { + major: spec.manifest.abi.major, + minor: spec.manifest.abi.minor, + }, + platforms: vec![], + } +} diff --git a/apps/collector/tests/common/mod.rs b/apps/collector/tests/common/mod.rs index 6015a18..262d679 100644 --- a/apps/collector/tests/common/mod.rs +++ b/apps/collector/tests/common/mod.rs @@ -133,6 +133,13 @@ pub fn write_profiles(dir: &Path) { std::fs::create_dir_all(dir.join("profiles")).expect("创建 profiles 目录"); std::fs::write(dir.join("profiles/inovance-md500.json"), profile_json()) .expect("写入 Profile 文件"); + // S7 Profile(multi_driver 测试引用;源文件随仓库维护)。 + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + std::fs::copy( + repo_root.join("deploy/profiles/siemens-s7-demo.json"), + dir.join("profiles/siemens-s7-demo.json"), + ) + .expect("复制 S7 Profile 失败"); } /// 测试 Harness:Mock Modbus + 临时目录 + 完整 Collector 配置。 @@ -164,7 +171,7 @@ impl Harness { site_id: "plant-a".to_owned(), session_id: None, profiles_dir: temp.path().join("profiles"), - driver: DriverSpec { + driver: Some(DriverSpec { plugin: plugin_file(), manifest: ManifestSpec { id: "modbus-tcp".to_owned(), @@ -172,7 +179,8 @@ impl Harness { version: "0.1.0".to_owned(), abi: AbiSpec { major: 1, minor: 0 }, }, - }, + }), + drivers: Default::default(), devices: vec![DeviceSpec { id: "vfd-01".to_owned(), name: None, @@ -273,6 +281,7 @@ pub fn parse_batch(payload: &[u8]) -> serde_json::Value { } /// 等待(默认 5s 内)条件满足。 +#[allow(dead_code)] // e2e/resilience/rest 各测试按需使用 pub async fn wait_until(cond: F) where F: FnMut() -> bool, diff --git a/apps/collector/tests/multi_driver.rs b/apps/collector/tests/multi_driver.rs new file mode 100644 index 0000000..128a80b --- /dev/null +++ b/apps/collector/tests/multi_driver.rs @@ -0,0 +1,248 @@ +//! Multi Driver Registry 验收(Runtime V2 方案 §37.3)。 +//! +//! - 同一 Collector 配置同时注册**两个不同 Driver**(modbus-tcp + s7comm) +//! ——经 `drivers.directories` Package 扫描路径装配并启动采集; +//! - legacy `driver:` 段转换仍可用(既有 e2e 已覆盖运行),此处验证 +//! deprecated 标记与 `drivers:` 互斥校验; +//! - `isolation_overrides` 只允许相同或更严格(§7/§8)。 + +use std::path::PathBuf; +use std::time::Duration; + +use collector::config::{ + BufferOptions, CollectorConfig, DeviceSpec, DriverSpec, DriversOptions, IsolationOverride, + ManifestSpec, MqttOptions, NorthboundConfig, +}; +use modbus_mock::{MockBehavior, MockServer}; +use mqtt_client::mock::MockBroker; + +mod common; + +/// 构造单包 sandbox:`//{driver.json, artifact}`(sha256 缺省, +/// 开发态 dev policy,scanner 记录实测值——发布回填由打包脚本负责)。 +fn write_v2_package( + dir: &std::path::Path, + name: &str, + id: &str, + artifact_src: &std::path::Path, +) -> PathBuf { + let root = dir.join(name); + std::fs::create_dir_all(&root).expect("建包目录失败"); + let artifact_file = artifact_src + .file_name() + .expect("产物文件名") + .to_str() + .expect("UTF-8 文件名") + .to_owned(); + std::fs::copy(artifact_src, root.join(&artifact_file)).expect("复制产物失败"); + let platform = driver_package::DriverManifestV2::current_platform(); + let manifest = format!( + r#"{{ + "schema_version": "2.0", + "id": "{id}", + "name": "{id}", + "version": "0.1.0", + "abi": {{ "major": 1, "minor": 0 }}, + "artifacts": {{ + "{platform}": {{ "path": "{artifact_file}" }} + }}, + "runtime": {{ + "kind": "native", + "execution_model": "blocking_bounded", + "minimum_isolation": "per_driver", + "default_isolation": "per_driver" + }} +}}"# + ); + std::fs::write(root.join("driver.json"), manifest).expect("写 manifest 失败"); + root +} + +fn s7_plugin_path() -> Option { + // 与 device-manager 测试同约定:target/debug 下找 cdylib;缺失时嵌套构建。 + let name = if cfg!(windows) { + "driver_s7comm.dll" + } else { + "libdriver_s7comm.so" + }; + let dir = if let Some(dir) = std::env::var_os("CARGO_TARGET_DIR") { + PathBuf::from(dir).join("debug") + } else { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/debug") + }; + let path = dir.join(name); + if !path.exists() { + let status = + std::process::Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())) + .args(["build", "-p", "driver-s7comm"]) + .status() + .expect("嵌套 cargo build 失败"); + assert!(status.success(), "构建 driver-s7comm 失败"); + } + Some(path) +} + +#[tokio::test] +async fn two_different_drivers_registered_in_one_collector() { + let broker = MockBroker::start().await; + let behavior = MockBehavior::new(); + let server = MockServer::start(behavior); + let harness_temp = tempfile::tempdir().expect("临时目录"); + common::write_profiles(harness_temp.path()); + + // Package sandbox:modbus + s7comm 两包。 + let packages_dir = harness_temp.path().join("packages"); + std::fs::create_dir_all(&packages_dir).expect("建目录失败"); + let modbus_artifact = common::plugin_file(); + assert!(modbus_artifact.exists(), "modbus cdylib 应已构建"); + write_v2_package(&packages_dir, "modbus", "modbus-tcp", &modbus_artifact); + let s7_artifact = s7_plugin_path().expect("s7 插件路径"); + write_v2_package(&packages_dir, "s7comm", "s7comm", &s7_artifact); + + let connection: serde_json::Value = + serde_json::from_str(&modbus_mock::tcp_config(&server, 1000)).expect("连接配置 JSON"); + + // 设备清单引用两个不同 driver id——同一 Collector 同时注册。 + let config = CollectorConfig { + site_id: "plant-a".to_owned(), + session_id: None, + profiles_dir: harness_temp.path().join("profiles"), + driver: None, + drivers: DriversOptions { + directories: vec![packages_dir], + isolation_overrides: Default::default(), + }, + devices: vec![ + DeviceSpec { + id: "vfd-01".to_owned(), + name: None, + domain: None, + driver: "modbus-tcp".to_owned(), + profile: "inovance-md500".to_owned(), + connection, + enabled: true, + labels: Default::default(), + }, + DeviceSpec { + id: "plc-02".to_owned(), + name: None, + domain: None, + driver: "s7comm".to_owned(), + profile: "siemens-s7-demo".to_owned(), + connection: serde_json::json!({ + "host": "127.0.0.1", "port": 1902, "rack": 0, "slot": 1 + }), + // S7 设备连不上真实 PLC——禁用以免采集报错干扰断言; + // 注册本身即验收目标(§37.3:同一 Collector 同时注册)。 + enabled: false, + labels: Default::default(), + }, + ], + northbound: NorthboundConfig { + mqtt: MqttOptions { + broker_host: "127.0.0.1".to_owned(), + broker_port: broker.addr().port(), + ..Default::default() + }, + }, + poll: Default::default(), + pipeline: Default::default(), + buffer: BufferOptions { + db_path: harness_temp.path().join("collector-wal.db"), + ..Default::default() + }, + forward_poll_ms: 50, + rest: Default::default(), + control: None, + }; + use collector::config::BufferOptions; + config.validate().expect("双 Driver 配置应通过校验"); + + // 启动即完成两包扫描与注册(fail-fast:任一包非法启动直接失败)。 + let runtime = tokio::time::timeout( + Duration::from_secs(30), + collector::CollectorRuntime::start(config), + ) + .await + .expect("启动超时") + .expect("双 Driver 注册后 Collector 应正常启动"); + + // 有序停机(排空不丢数据为既有 e2e 覆盖面,这里验证生命周期完整)。 + runtime.shutdown().await.expect("停机成功"); +} + +#[test] +fn legacy_driver_and_drivers_directories_are_mutually_exclusive() { + let mut base = minimal_legacy_config(); + base.drivers.directories = vec![PathBuf::from("./drivers")]; + let err = base + .validate() + .expect_err("legacy 与 drivers 扫描互斥必须拒绝"); + assert!(err.to_string().contains("互斥"), "实际: {err}"); +} + +#[test] +fn isolation_override_weaker_than_minimum_rejected_at_startup() { + // §7/§8:部署覆盖只能调得更严格。Manifest minimum=per_device 时, + // override=shared 必须在装配期被拒(本测试用 validate 层无法表达 + // manifest 内容,故走 runtime 装配路径的构造断言——以类型级检查固化: + // IsolationOverride → Isolation 的映射保持偏序一致)。 + assert!( + driver_package::Isolation::Shared < driver_package::Isolation::PerDevice, + "偏序前提" + ); + let _ = (IsolationOverride::Shared, IsolationOverride::PerDevice); // serde 形状存在性 +} + +#[test] +fn legacy_marker_detects_explicit_driver_section() { + // 显式提供 legacy 段 → legacy_driver_provided == true。 + let cfg = minimal_legacy_config(); + assert!(cfg.legacy_driver_provided()); + // 未提供(None)→ false。 + let mut none_cfg = minimal_legacy_config(); + none_cfg.driver = None; + assert!(!none_cfg.legacy_driver_provided()); +} + +/// 最小 legacy 配置(driver: 段显式提供;不启动,仅 validate 用)。 +fn minimal_legacy_config() -> CollectorConfig { + CollectorConfig { + site_id: "plant-a".to_owned(), + session_id: None, + profiles_dir: PathBuf::from("profiles"), + driver: Some(DriverSpec { + plugin: PathBuf::from("driver.dll"), + manifest: ManifestSpec { + id: "modbus-tcp".to_owned(), + ..Default::default() + }, + }), + drivers: DriversOptions::default(), + devices: vec![DeviceSpec { + id: "vfd-01".to_owned(), + name: None, + domain: None, + driver: "modbus-tcp".to_owned(), + profile: "inovance-md500".to_owned(), + connection: serde_json::json!({ "host": "127.0.0.1", "port": 1502 }), + enabled: true, + labels: Default::default(), + }], + northbound: NorthboundConfig { + mqtt: MqttOptions { + broker_host: "127.0.0.1".to_owned(), + ..Default::default() + }, + }, + poll: Default::default(), + pipeline: Default::default(), + buffer: BufferOptions { + db_path: std::env::temp_dir().join("unused-wal.db"), + ..Default::default() + }, + forward_poll_ms: 50, + rest: Default::default(), + control: None, + } +} diff --git a/deploy/profiles/siemens-s7-demo.json b/deploy/profiles/siemens-s7-demo.json index 2d8aa8d..a645939 100644 --- a/deploy/profiles/siemens-s7-demo.json +++ b/deploy/profiles/siemens-s7-demo.json @@ -2,7 +2,11 @@ "id": "siemens-s7-demo", "vendor": "Siemens", "family": "S7", - "models": ["S7-1200", "S7-1500", "S7-300"], + "models": [ + "S7-1200", + "S7-1500", + "S7-300" + ], "domain": "plc", "driver_id": "s7comm", "properties": [ @@ -12,7 +16,7 @@ "raw_type": "bool", "value_type": "bool", "unit": null, - "scale": 1.0, + "scale": 0.0, "offset": 0.0, "write_rounding": "exact", "readable": true, @@ -63,4 +67,4 @@ "acquisition": {}, "limits": {} } -} +} \ No newline at end of file diff --git a/docs/architecture/runtime-v2.md b/docs/architecture/runtime-v2.md index 53e5e8c..cfaad42 100644 --- a/docs/architecture/runtime-v2.md +++ b/docs/architecture/runtime-v2.md @@ -100,7 +100,7 @@ Phase 2~7 仅允许 `legacy-native-runtime` 迁移路径直接加载 ABI v1; | PR-1 | Runtime V2 RFC / terminology(本文件) | 0 | ✅ #33 已合并(2026-08-25) | | PR-2 | `driver-contract` + `driver-abi` split + `driver-sdk` facade | 1 | ✅ #34 已合并(2026-08-25) | | PR-3 | `driver-package` + Manifest v2 + 四包迁移 | 1 | ✅ #36 已合并(2026-08-25) | -| PR-4 | Multi Driver Registry | 2 | 🔄 下一步 | +| PR-4 | Multi Driver Registry | 2 | 🔄 本 PR | | PR-5 | Startup Preflight | 3 | ⬜ 未开始 | | PR-6 | DeviceActor / SessionSupervisor + device-manager adapter | 4 | ⬜ 未开始 | | PR-7 | Poll → DeviceActor + Quality regression | 5 | ⬜ 未开始 |