# Local + Broker 仲裁机制 MVP 实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 在现有 TapProject 中以最小代价落地 V3.0 架构的两大核心机制——**Local 直连宿主**与 **DeviceBroker 代理宿主**——并使跨进程租约仲裁贯穿"仪器注册(执行位选择/发布门禁)"与"手动调试(维护写权申请/占用可见/释放)"两条主路径。 **Architecture:** 三层递进:① `Aging.ResourceContracts`(零依赖契约库:LeaseGrant / DeviceOperation / IPC 封套);② `DbLeaseStore`(SQLite `arb.db` 上的跨进程租约:BEGIN IMMEDIATE 事务 CAS + generation 栅栏 + epoch + TTL/心跳 + 过期进 RecoveryRequired 人工回收);③ 执行双通道——Local 设备由当前进程直驱(保持现状路径),Broker 设备由独立 `DeviceBroker.exe` 唯一持有物理会话,客户端经 `BrokeredCanDriver` 代理驱动 + 帧协议 RPC 转发,Broker 出队时再做栅栏与配置哈希双重校验(INV-01/05 闭环)。现有 `IResourceManager/IAutoDeviceFacade/DebugSessionManager` 通过组合(不破坏签名)切换为跨进程实现。 **Tech Stack:** .NET Framework 4.7.2、C# 10、WPF(现有插件 UI)、System.Data.SQLite(arb.db + WAL)、Named Pipes(帧协议:4 字节长度前缀 + UTF8 JSON)、xUnit(新 SDK 风格测试工程)、MSBuild(编译旧式 WPF 工程,见记忆规则)。 --- ## MVP 范围裁剪(明确保留与推迟) | V3.0 能力 | MVP 处置 | 说明 | |-----------|---------|------| | 跨进程租约 + generation 栅栏 + epoch | ✅ 实现 | 核心中的核心 | | INV-03:过期不自动移交 → RecoveryRequired | ✅ 实现(人工回收按钮版) | 回收=宿主读回探测+登记证据+置 Free | | Local 模式(进程内直驱 + 连接守卫 Mutex) | ✅ 实现 | 沿用现有驱动 | | Broker 模式(SessionGate 原子片段 + 栅栏再校验) | ✅ 实现(OneShot 命令同步模型) | 周期任务/订阅/Outbox 推迟 | | 注册页 executionMode/authorityId/sessionKey/accessPolicy + 发布门禁 + 不可变快照哈希 | ✅ 实现(最小版) | Broker 连接携 configHash 对账(INV-04 雏形) | | 调试页维护写权跨进程申请、占用者可见、拒绝语义 | ✅ 实现 | 复用 DebugSessionManager 现有调用链 | | Draining 自动让渡 / 维护抢占 grace 流程 | ⏭ 推迟(MVP 用"占用即拒绝"矩阵合法分支) | 文档 §6.1 允许 | | Task Journal/Outbox、PeriodicTx/Acquisition、父 Permit 多通道、Prepare/Activate 两段式跨域预留、120 空位公式、arb 服务端 DB 化 | ⏭ 推迟 | P2+ | **决策完备性说明(实施者不需再做决定):** - 仲裁库路径:`%LOCALAPPDATA%\PluginIntegrationTest\arb.db`(与 data.db 同目录) - lease_id 直接复用现有 `ResourceOwnerToken.TokenId`,两套体系一个身份 - MVP 权限优先级简化为:**任何 Active 租约 → 他人一律拒绝(ConflictHeld + 占用者信息)**;不区分 Maintenance 抢占 - Broker 域演示设备:`CAN-002`(executionMode=Broker,authorityId=broker-b01,sessionKey=canbus1);Local 演示设备:`CAN-001` - 消息模型为**单连接同步请求-响应**(_sendLock 串行),多路复用推迟 - 心跳 8s / TTL 30s / 探测超时 3s(可后续入配置) --- ## Task 0: 基线确认与工程骨架 **Files:** - Create: `Aging.ResourceContracts/Aging.ResourceContracts.csproj` - Create: `tests/Aging.MvpTests/Aging.MvpTests.csproj` - Modify: `WPFProject/TapProject.sln`(加入两个新工程) - [ ] **Step 1: 验证当前基线可编译** ```powershell cd d:\TestPlatform\TapProject & "C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe" Plugin.IntegrationTest\Plugin.IntegrationTest.csproj /p:Configuration=Debug /p:Platform=AnyCPU /t:Build /v:minimal ``` 预期:最后一行 `Plugin.IntegrationTest -> ...exe`,无 error。(注意:本仓库旧式工程必须用 MSBuild,但本工程链上 SDK-style 工程也可用 MSBuild 编译,统一用 MSBuild。) - [ ] **Step 2: 创建契约类库工程(零依赖)** 创建 `Aging.ResourceContracts/Aging.ResourceContracts.csproj`: ```xml net472 enable 10.0 disable ``` - [ ] **Step 3: 创建 xUnit 测试工程** ```powershell cd d:\TestPlatform\TapProject\tests dotnet new xunit -n Aging.MvpTests -f net472 ``` 编辑 `Aging.MvpTests/Aging.MvpTests.csproj` 追加对契约库与后续设施的引用与 SQLite 复制: ```xml PreserveNewest ``` - [ ] **Step 4: 加入解决方案并验证** ```powershell cd d:\TestPlatform\TapProject dotnet sln WPFProject\TapProject.sln add Aging.ResourceContracts\Aging.ResourceContracts.csproj tests\Aging.MvpTests\Aging.MvpTests.csproj dotnet test tests\Aging.MvpTests\Aging.MvpTests.csproj ``` 预期:`Passed! - 1 failed: 0, passed: 1`(模板自带 UnitTest1)。若 `dotnet sln add` 因旧式工程格式报错,记录后在 VS 中手工添加,不阻塞后续任务。 - [ ] **Step 5: Commit** ```powershell git add Aging.ResourceContracts tests WPFProject/TapProject.sln git commit -m "chore(mvp): 契约库与测试工程骨架" ``` --- ## Task 1: 契约类型(LeaseGrant / AcquireRequest / DeviceOperation / IPC 封套) **Files:** - Create: `Aging.ResourceContracts/LeaseTypes.cs` - Create: `Aging.ResourceContracts/DeviceOperationTypes.cs` - Create: `Aging.ResourceContracts/IpcEnvelope.cs` - Test: `tests/Aging.MvpTests/ContractSerializeTests.cs` - [ ] **Step 1: 写失败测试(JSON 往返不丢字段)** `tests/Aging.MvpTests/ContractSerializeTests.cs`: ```csharp using System.Text.Json; using Aging.ResourceContracts; using Xunit; namespace Aging.MvpTests { public class ContractSerializeTests { [Fact] public void LeaseGrant_RoundTrips_Json() { var g = new LeaseGrant("broker-b01", "can/can02", "lease_abc", "W01.RunA", OwnerTypeV3.Maintenance, epoch: 7, generation: 42, expiresUtcMs: 1793673600000); var json = JsonSerializer.Serialize(g); var back = JsonSerializer.Deserialize(json)!; Assert.Equal(g.Generation, back.Generation); Assert.Equal(g.Epoch, back.Epoch); Assert.Equal(g.LeaseId, back.LeaseId); Assert.Equal(g.OwnerType, back.OwnerType); } [Fact] public void IpcEnvelope_Carries_BodyAndIdentity() { var e = new IpcEnvelope { ProtocolVersion = 1, MessageType = "SubmitTask", RequestId = "req-1", ClientInstanceId = "hmi/bootA/123", AuthorityId = "broker-b01", ConfigHash = "sha256:x", BodyJson = "{\"operationCode\":\"SEND\"}" }; var json = JsonSerializer.Serialize(e); var back = JsonSerializer.Deserialize(json)!; Assert.Equal("SubmitTask", back.MessageType); Assert.Equal("sha256:x", back.ConfigHash); } } } ``` - [ ] **Step 2: 运行确认失败** ```powershell dotnet test tests\Aging.MvpTests\Aging.MvpTests.csproj --filter ContractSerializeTests ``` 预期:编译错误 `The type LeaseGrant does not exist`(工程未引用实现前的自然失败)。 - [ ] **Step 3: 实现契约类型** `Aging.ResourceContracts/LeaseTypes.cs`: ```csharp using System; using System.Threading; using System.Threading.Tasks; namespace Aging.ResourceContracts { /// 与现有 OwnerType 数值对齐:Production=0, Maintenance=1。 public enum OwnerTypeV3 { Production = 0, Maintenance = 1 } /// MVP 相位:Free 可用 / Active 持有 / RecoveryRequired 过期待回收(INV-03)。 public enum LeasePhase { Free = 0, Active = 1, RecoveryRequired = 2 } /// 每资源一张租约凭证;栅栏=(AuthorityId, Epoch, ResourceKey, LeaseId, Generation)。 public sealed class LeaseGrant { public string AuthorityId { get; } public string ResourceKey { get; } public string LeaseId { get; } public string OwnerId { get; } public OwnerTypeV3 OwnerType { get; } public long Epoch { get; } public long Generation { get; } public long ExpiresUtcMs { get; } public LeaseGrant(string authorityId, string resourceKey, string leaseId, string ownerId, OwnerTypeV3 ownerType, long epoch, long generation, long expiresUtcMs) { AuthorityId = authorityId; ResourceKey = resourceKey; LeaseId = leaseId; OwnerId = ownerId; OwnerType = ownerType; Epoch = epoch; Generation = generation; ExpiresUtcMs = expiresUtcMs; } } public sealed class AcquireRequest { public string OwnerId { get; set; } = ""; public OwnerTypeV3 OwnerType { get; set; } /// boot_id + pid,同机进程区分。 public string ProcessInstanceId { get; set; } = ""; public string MachineName { get; set; } = Environment.MachineName; public int HostProcessId { get; set; } = System.Diagnostics.Process.GetCurrentProcess().Id; /// 申请理由(维护会话填操作员与原因,进 lease_history)。 public string Reason { get; set; } = ""; /// Ordinals 升序、去重后申请(组申请 = 单事务全有全无)。 public string[] ResourceKeys { get; set; } = new string[0]; public int WaitTimeoutMs { get; set; } = 1000; public int TtlSeconds { get; set; } = 30; } public enum AcquireResultCode { Acquired, Reused, ConflictHeld, RecoveryBlocked, Timeout } public sealed class ConflictInfo { public string OwnerId { get; set; } = ""; public OwnerTypeV3 OwnerType { get; set; } public string MachineName { get; set; } = ""; public int HostProcessId { get; set; } public string Reason { get; set; } = ""; public long ExpiresUtcMs { get; set; } } public sealed class AcquireOutcome { public AcquireResultCode Code { get; set; } public LeaseGrant[] Grants { get; set; } = new LeaseGrant[0]; public ConflictInfo? Holder { get; set; } /// RecoveryBlocked 时指向 recovery_checkpoint 的键。 public string? RecoveryCheckpointKey { get; set; } } /// 租约协调器:仲裁域唯一入口。实现方:DbLeaseStore。 public interface ILeaseCoordinator { /// 权威域当前 epoch(重启后递增,旧 Grant 自动失效)。 long CurrentEpoch { get; } AcquireOutcome Acquire(AcquireRequest request); /// 同 LeaseId+Generation 续期;返回 false=已被回收/超越(本地须熔断)。 bool Renew(LeaseGrant grant, int extendSeconds); bool Release(LeaseGrant grant, string note); ConflictInfo? ProbeConflict(string resourceKey); int SweepExpired(); /// RecoveryRequired → Free:宿主完成读回探测后登记证据并放行。 bool ApproveRecovery(string resourceKey, string readbackJson, string approvedBy); } } ``` `Aging.ResourceContracts/DeviceOperationTypes.cs`: ```csharp namespace Aging.ResourceContracts { public enum OperationState { Succeeded, Failed, /// 栅栏校验失败:Grant 的 epoch/generation 已失效(含 Broker 重启)。 RejectedStaleLease, /// INV-04 雏形:客户端 configHash 与 Broker 生效配置不一致。 RejectedConfigMismatch, /// 会话队列满。 RejectedQueueFull, /// 已发送但结果未知(INV-06:禁止当未执行)。 Unknown } /// 一次有界设备动作(MVP=OneShot)。Grants 必须覆盖 resources。 public sealed class DeviceOperation { /// 白名单操作码,如 "CAN_SEND"/"CAN_RECV"/"CAN_AUTO_RESPONSE"/"CAN_STATUS"。 public string OperationCode { get; set; } = ""; public string ParametersJson { get; set; } = "{}"; public string IdempotencyKey { get; set; } = ""; public string ConfigHash { get; set; } = ""; public LeaseGrant[] Grants { get; set; } = new LeaseGrant[0]; public long DeadlineUtcMs { get; set; } } public sealed class OperationResult { public string RequestId { get; set; } = ""; public OperationState State { get; set; } public string ErrorCode { get; set; } = ""; public string ReadbackJson { get; set; } = "{}"; public long ServerEpoch { get; set; } } } ``` `Aging.ResourceContracts/IpcEnvelope.cs`: ```csharp namespace Aging.ResourceContracts { /// IPC 消息类型常量(MVP 仅四种)。 public static class IpcMessageTypes { public const string Hello = "Hello"; // 握手:回 epoch/configHash public const string SubmitTask = "SubmitTask"; // body=DeviceOperation public const string CancelTask = "CancelTask"; public const string ProbeReadback = "ProbeReadback"; // 恢复回收前的读回探测 } /// 请求/响应共同封套。协议版本不匹配直接拒绝连接。 public sealed class IpcEnvelope { public int ProtocolVersion { get; set; } = 1; public string MessageType { get; set; } = ""; public string RequestId { get; set; } = ""; public string CorrelationId { get; set; } = ""; public string ClientInstanceId { get; set; } = ""; public string AuthorityId { get; set; } = ""; public string ConfigHash { get; set; } = ""; public long DeadlineUtcMs { get; set; } public string BodyJson { get; set; } = "{}"; } public sealed class HelloResult { public long ServerEpoch { get; set; } public string ConfigHash { get; set; } = ""; public int ProtocolVersion { get; set; } = 1; public string BrokerId { get; set; } = ""; } } ``` 测试工程 csproj 追加 ``(net472 可用;契约库同样加此包)。 - [ ] **Step 4: 运行确认通过** ```powershell dotnet test tests\Aging.MvpTests\Aging.MvpTests.csproj --filter ContractSerializeTests ``` 预期:`Passed! - failed: 0, passed: 2` - [ ] **Step 5: Commit** ```powershell git add Aging.ResourceContracts tests git commit -m "feat(contracts): LeaseGrant/ILeaseCoordinator/DeviceOperation/IPC 封套" ``` --- ## Task 2: DbLeaseStore —— 跨进程租约核心(arb.db) **Files:** - Create: `Aging.Infrastructure/Core/DbLeaseStore.cs` - Modify: `Aging.Infrastructure/Aging.Infrastructure.csproj`(加 PackageReference System.Data.SQLite + 契约工程引用) - Test: `tests/Aging.MvpTests/DbLeaseStoreTests.cs` - [ ] **Step 1: 写失败测试(并发申请互斥 + 栅栏 + 过期回收 + epoch 失效)** `tests/Aging.MvpTests/DbLeaseStoreTests.cs`: ```csharp using System; using System.IO; using System.Threading.Tasks; using Aging.Infrastructure.Core; using Aging.ResourceContracts; using Xunit; namespace Aging.MvpTests { public class DbLeaseStoreTests : IDisposable { private readonly string _dbPath; private readonly DbLeaseStore _store; public DbLeaseStoreTests() { _dbPath = Path.Combine(Path.GetTempPath(), "arb_test_" + Guid.NewGuid().ToString("N") + ".db"); _store = new DbLeaseStore(_dbPath, "local-main"); } public void Dispose() { try { File.Delete(_dbPath); } catch { } } private AcquireRequest Req(string key, OwnerTypeV3 type = OwnerTypeV3.Maintenance, int ttl = 30) => new AcquireRequest { OwnerId = "test-owner", OwnerType = type, ProcessInstanceId = Guid.NewGuid().ToString("N"), ResourceKeys = new[] { key }, TtlSeconds = ttl }; [Fact] public void Acquire_FirstOwner_Succeeds() { var r = _store.Acquire(Req("can/can02")); Assert.Equal(AcquireResultCode.Acquired, r.Code); Assert.Equal(1, r.Grants[0].Generation); } [Fact] public void Acquire_WhileActiveByOther_ReturnsConflictWithHolder() { _store.Acquire(Req("can/can02")); var second = new AcquireRequest { OwnerId = "other-owner", OwnerType = OwnerTypeV3.Production, ProcessInstanceId = "x", ResourceKeys = new[] { "can/can02" } }; var r = _store.Acquire(second); Assert.Equal(AcquireResultCode.ConflictHeld, r.Code); Assert.Equal("test-owner", r.Holder!.OwnerId); } [Fact] public void TwoIndependentStores_SameFile_SimulatesTwoProcesses() { // 两个实例指向同一 arb.db = 等价于两个进程(SQLite 事务为仲裁层) var storeB = new DbLeaseStore(_dbPath, "local-main"); var a = _store.Acquire(Req("psu/psu01/ch/5")); var b = storeB.Acquire(new AcquireRequest { OwnerId = "W02", OwnerType = OwnerTypeV3.Production, ProcessInstanceId = "p2", ResourceKeys = new[] { "psu/psu01/ch/5" } }); Assert.Equal(AcquireResultCode.Acquired, a.Code); Assert.Equal(AcquireResultCode.ConflictHeld, b.Code); } [Fact] public void Concurrent_50Threads_OnlyOneWins() { int winners = 0; Parallel.For(0, 50, _ => { var s = new DbLeaseStore(_dbPath, "local-main"); var r = s.Acquire(new AcquireRequest { OwnerId = Guid.NewGuid().ToString("N"), OwnerType = OwnerTypeV3.Production, ProcessInstanceId = "p", ResourceKeys = new[] { "can/bus" }, WaitTimeoutMs = 3000 }); if (r.Code == AcquireResultCode.Acquired) Interlocked.Increment(ref winners); }); Assert.Equal(1, winners); } [Fact] public void ExpiredLease_EntersRecoveryRequired_NotAutoReclaim() { _store.Acquire(Req("can/can02", ttl: -1)); // 立即过期(测试钩子:负 TTL) _store.SweepExpired(); var r = _store.Acquire(Req("can/can02", OwnerTypeV3.Production)); Assert.Equal(AcquireResultCode.RecoveryBlocked, r.Code); // 人工回收:读回证据合格后放行 Assert.True(_store.ApproveRecovery("can/can02", "{\"output\":\"off\"}", "admin")); var again = _store.Acquire(Req("can/can02", OwnerTypeV3.Production)); Assert.Equal(AcquireResultCode.Acquired, again.Code); Assert.Equal(2, again.Grants[0].Generation); // generation 单调,永不回退 } [Fact] public void Renew_WithStaleGeneration_Fails() { var g = _store.Acquire(Req("can/can02")).Grants[0]; var stale = new LeaseGrant(g.AuthorityId, g.ResourceKey, g.LeaseId, g.OwnerId, g.OwnerType, g.Epoch, generation: 999, g.ExpiresUtcMs); Assert.False(_store.Renew(stale, 30)); Assert.True(_store.Renew(g, 30)); } [Fact] public void BumpEpoch_InvalidatesAllActiveGrants() { var g = _store.Acquire(Req("can/can02")).Grants[0]; _store.BumpEpochForTest("arb.db 重启模拟"); Assert.False(_store.Renew(g, 30)); // 旧 epoch Grant 全部失效 } [Fact] public void GroupAcquire_PartialConflict_RollsBackAll() { _store.Acquire(Req("psu/b")); // 先占 b var r = _store.Acquire(new AcquireRequest { OwnerId = "W09", OwnerType = OwnerTypeV3.Production, ProcessInstanceId = "p", ResourceKeys = new[] { "psu/a", "psu/b", "psu/c" } // 实现内部会 Ordinal 排序 }); Assert.Equal(AcquireResultCode.ConflictHeld, r.Code); Assert.Null(_store.ProbeConflict("psu/a")); // a 未被半占用 } } } ``` - [ ] **Step 2: 运行确认失败** ```powershell dotnet test tests\Aging.MvpTests\Aging.MvpTests.csproj --filter DbLeaseStoreTests ``` 预期:编译失败(`DbLeaseStore` 不存在)。若契约测试工程编译报 `System.Data.SQLite` 缺失,先在测试 csproj 加 ``(Interop 由 Step 3 的 Content 复制解决)。 - [ ] **Step 3: 实现 DbLeaseStore** 先修改 `Aging.Infrastructure/Aging.Infrastructure.csproj`,ItemGroup 追加: ```xml ``` `Aging.Infrastructure/Core/DbLeaseStore.cs`: ```csharp using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text; using Aging.ResourceContracts; using log4net; using SQLite; namespace Aging.Infrastructure.Core { /// /// 跨进程租约存储(V3.0 §8 MVP)。arb.db 独立于 data.db。 /// 时间一律 DB 侧 UTC 毫秒(DateTime.UtcNow 存 INTEGER), /// 申请/续期/释放全部走 BEGIN IMMEDIATE 单写事务 —— SQLite 事务即进程间仲裁层。 /// INV-03:过期 → RecoveryRequired,禁止自动移交;ApproveRecovery 登记证据后置 Free。 /// public sealed class DbLeaseStore : ILeaseCoordinator { private static readonly ILog Log = LogManager.GetLogger(typeof(DbLeaseStore)); private readonly string _dbPath; private readonly string _authorityId; private readonly long _bootNowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); public string AuthorityId => _authorityId; public long CurrentEpoch { get; private set; } public DbLeaseStore(string dbPath, string authorityId) { _dbPath = dbPath; _authorityId = authorityId; EnsureDirectory(); InitializeSchema(); CurrentEpoch = ReadOrInitEpoch(); } private void EnsureDirectory() { var dir = Path.GetDirectoryName(_dbPath); if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); } private SQLiteConnection Open() { var conn = new SQLiteConnection(_dbPath); conn.Execute("PRAGMA journal_mode=WAL;"); conn.Execute("PRAGMA busy_timeout=3000;"); conn.Execute("PRAGMA synchronous=FULL;"); return conn; } private void InitializeSchema() { using var conn = Open(); conn.Execute(@" CREATE TABLE IF NOT EXISTS authority_meta( authority_id TEXT PRIMARY KEY, epoch INTEGER NOT NULL, boot_id TEXT NOT NULL, schema_version INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS resource_state( resource_key TEXT PRIMARY KEY, generation INTEGER NOT NULL DEFAULT 0, phase INTEGER NOT NULL DEFAULT 0, -- LeasePhase lease_id TEXT, owner_id TEXT, owner_type INTEGER, process_instance_id TEXT, machine_name TEXT, host_process_id INTEGER, epoch INTEGER NOT NULL DEFAULT 0, reason TEXT, acquired_utc_ms INTEGER, expires_utc_ms INTEGER, updated_utc_ms INTEGER); CREATE TABLE IF NOT EXISTS lease_history( event_id INTEGER PRIMARY KEY AUTOINCREMENT, resource_key TEXT NOT NULL, generation INTEGER NOT NULL, epoch INTEGER NOT NULL, lease_id TEXT, owner_id TEXT, event_type TEXT NOT NULL, reason TEXT, utc_ms INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS recovery_checkpoint( resource_key TEXT PRIMARY KEY, epoch INTEGER NOT NULL, status INTEGER NOT NULL, -- 0=Pending 1=Approved physical_readback_json TEXT NOT NULL, approved_by TEXT, evidence_utc_ms INTEGER NOT NULL);"); } private long ReadOrInitEpoch() { using var conn = Open(); var row = conn.Query( "SELECT epoch, boot_id FROM authority_meta WHERE authority_id=?", _authorityId) .FirstOrDefault(); long nowMs = NowMs(); if (row == null) { conn.Execute("INSERT INTO authority_meta VALUES(?,?,?,1)", _authorityId, 1L, Guid.NewGuid().ToString("N")); return 1; } // 每次进程打开同一权威域 → boot_id 变化 → epoch+1(重启使旧 Grant 失效) conn.Execute("UPDATE authority_meta SET epoch=epoch+1, boot_id=? WHERE authority_id=?", Guid.NewGuid().ToString("N"), _authorityId); CurrentEpoch = row.epoch + 1; conn.Execute("INSERT INTO lease_history(resource_key,generation,epoch,lease_id,owner_id,event_type,reason,utc_ms)" + " VALUES('*',{0},{1},'','system','EpochBump','open-authority',{2})", CurrentEpoch, "", nowMs); return CurrentEpoch; } // ================= ILeaseCoordinator ================= public AcquireOutcome Acquire(AcquireRequest request) { if (request.ResourceKeys.Length == 0) throw new ArgumentException("ResourceKeys 不能为空"); var keys = request.ResourceKeys.Select(NormKey).OrderBy(k => k, StringComparer.Ordinal) .Distinct().ToArray(); var deadline = NowMs() + Math.Max(0, request.WaitTimeoutMs); while (true) { var outcome = TryAcquireOnce(keys, request); if (outcome.Code != AcquireResultCode.Timeout) return outcome; if (NowMs() >= deadline) { outcome.Code = AcquireResultCode.Timeout; return outcome; } System.Threading.Thread.Sleep(50); } } private AcquireOutcome TryAcquireOnce(string[] keys, AcquireRequest req) { long now = NowMs(); using var conn = Open(); using var tx = conn.BeginTransaction(); // 默认即 IMMEDIATE try { var grants = new List(keys.Length); foreach (var key in keys) { var st = conn.Query( "SELECT * FROM resource_state WHERE resource_key=?", key).FirstOrDefault(); if (st != null && st.Phase == (int)LeasePhase.RecoveryRequired) { tx.Rollback(); return new AcquireOutcome { Code = AcquireResultCode.RecoveryBlocked, RecoveryCheckpointKey = key }; } bool activeHeld = st != null && st.Phase == (int)LeasePhase.Active && st.ExpiresUtcMs > now; if (activeHeld) { // 同一 LeaseId 重入 = 续期语义(MVP 不做跨进程 Reused,冲突即返回) tx.Rollback(); return new AcquireOutcome { Code = AcquireResultCode.ConflictHeld, Holder = new ConflictInfo { OwnerId = st!.OwnerId, OwnerType = (OwnerTypeV3)st.OwnerType, MachineName = st.MachineName, HostProcessId = st.HostProcessId, Reason = st.Reason, ExpiresUtcMs = st.ExpiresUtcMs } }; } long gen = (st?.Generation ?? 0) + 1; string leaseId = "lease_" + Guid.NewGuid().ToString("N").Substring(0, 12); var exp = now + req.TtlSeconds * 1000L; if (st == null) conn.Execute( @"INSERT INTO resource_state(resource_key,generation,phase,lease_id,owner_id, owner_type,process_instance_id,machine_name,host_process_id,epoch,reason, acquired_utc_ms,expires_utc_ms,updated_utc_ms) VALUES(?,?,1,?,?,?,?,?,?,?,?,?,?,?,?)", key, gen, leaseId, req.OwnerId, (int)req.OwnerType, req.ProcessInstanceId, req.MachineName, req.HostProcessId, CurrentEpoch, req.Reason, now, exp, now); else conn.Execute( @"UPDATE resource_state SET generation=?, phase=1, lease_id=?, owner_id=?, owner_type=?, process_instance_id=?, machine_name=?, host_process_id=?, epoch=?, reason=?, acquired_utc_ms=?, expires_utc_ms=?, updated_utc_ms=? WHERE resource_key=?", gen, leaseId, req.OwnerId, (int)req.OwnerType, req.ProcessInstanceId, req.MachineName, req.HostProcessId, CurrentEpoch, req.Reason, now, exp, now, key); conn.Execute( @"INSERT INTO lease_history(resource_key,generation,epoch,lease_id,owner_id,event_type,reason,utc_ms) VALUES(?,?,?,?,?,'Acquire',?,?)", key, gen, CurrentEpoch, leaseId, req.OwnerId, req.Reason, now); grants.Add(new LeaseGrant(_authorityId, key, leaseId, req.OwnerId, req.OwnerType, CurrentEpoch, gen, exp)); } tx.Commit(); return new AcquireOutcome { Code = AcquireResultCode.Acquired, Grants = grants.ToArray() }; } catch { try { tx.Rollback(); } catch { } throw; } } public bool Renew(LeaseGrant grant, int extendSeconds) { long now = NowMs(); using var conn = Open(); using var tx = conn.BeginTransaction(); int n = conn.Execute( @"UPDATE resource_state SET expires_utc_ms=?, updated_utc_ms=? WHERE resource_key=? AND lease_id=? AND generation=? AND epoch=? AND phase=1 AND expires_utc_ms>?", now + extendSeconds * 1000L, now, NormKey(grant.ResourceKey), grant.LeaseId, grant.Generation, grant.Epoch, now); if (n == 1) tx.Commit(); else { tx.Rollback(); Log.Warn($"[Lease] 续期失败(栅栏不符): {grant.ResourceKey} gen={grant.Generation}"); } return n == 1; } public bool Release(LeaseGrant grant, string note) { long now = NowMs(); using var conn = Open(); using var tx = conn.BeginTransaction(); int n = conn.Execute( @"UPDATE resource_state SET phase=0, lease_id=NULL, owner_id=NULL, expires_utc_ms=0, updated_utc_ms=? WHERE resource_key=? AND lease_id=? AND generation=?", now, NormKey(grant.ResourceKey), grant.LeaseId, grant.Generation); conn.Execute( @"INSERT INTO lease_history(resource_key,generation,epoch,lease_id,owner_id,event_type,reason,utc_ms) SELECT ?,?,?,?,?, 'Release', ?, ? WHERE ?=1", NormKey(grant.ResourceKey), grant.Generation, grant.Epoch, grant.LeaseId, grant.OwnerId, note, now, n); if (n == 1) tx.Commit(); else tx.Rollback(); return n == 1; } public ConflictInfo? ProbeConflict(string resourceKey) { long now = NowMs(); using var conn = Open(); var st = conn.Query( "SELECT * FROM resource_state WHERE resource_key=? AND phase=1 AND expires_utc_ms>?", NormKey(resourceKey), now).FirstOrDefault(); if (st == null) return null; return new ConflictInfo { OwnerId = st.OwnerId, OwnerType = (OwnerTypeV3)st.OwnerType, MachineName = st.MachineName, HostProcessId = st.HostProcessId, Reason = st.Reason, ExpiresUtcMs = st.ExpiresUtcMs }; } public LeasePhase PhaseOf(string resourceKey) { using var conn = Open(); var st = connQuery(conn, resourceKey); if (st == null) return LeasePhase.Free; if (st.Phase == (int)LeasePhase.RecoveryRequired) return LeasePhase.RecoveryRequired; if (st.Phase == (int)LeasePhase.Active && st.ExpiresUtcMs <= NowMs()) return LeasePhase.RecoveryRequired; // 读侧惰性判定,写侧由 SweepExpired 落库 return (LeasePhase)st.Phase; } public int SweepExpired() { long now = NowMs(); using var conn = Open(); using var tx = conn.BeginTransaction(); var expired = conn.Query( "SELECT * FROM resource_state WHERE phase=1 AND expires_utc_ms<=?", now); foreach (var st in expired) { conn.Execute("UPDATE resource_state SET phase=2, updated_utc_ms=? WHERE resource_key=?", now, st.ResourceKey); conn.Execute( @"INSERT INTO lease_history(resource_key,generation,epoch,lease_id,owner_id,event_type,reason,utc_ms) VALUES(?,?,?,?,?,'ExpiredToRecovery','ttl-missed',?)", st.ResourceKey, st.Generation, st.Epoch, st.LeaseId, st.OwnerId, now); conn.Execute( @"INSERT OR REPLACE INTO recovery_checkpoint(resource_key,epoch,status,physical_readback_json,approved_by,evidence_utc_ms) VALUES(?,?,0,'{}',NULL,?)", st.ResourceKey, st.Epoch, now); } tx.Commit(); return expired.Count; } public bool ApproveRecovery(string resourceKey, string readbackJson, string approvedBy) { long now = NowMs(); using var conn = Open(); using var tx = conn.BeginTransaction(); conn.Execute( @"INSERT OR REPLACE INTO recovery_checkpoint(resource_key,epoch,status,physical_readback_json,approved_by,evidence_utc_ms) VALUES(?,?,1,?,?,?)", NormKey(resourceKey), CurrentEpoch, readbackJson, approvedBy, now); int n = conn.Execute( @"UPDATE resource_state SET phase=0, lease_id=NULL, owner_id=NULL, updated_utc_ms=? WHERE resource_key=? AND phase=2", now, NormKey(resourceKey)); conn.Execute( @"INSERT INTO lease_history(resource_key,generation,epoch,lease_id,owner_id,event_type,reason,utc_ms) SELECT resource_key,generation,epoch,'','system','RecoveryApproved',?,? FROM resource_state WHERE resource_key=?", approvedBy, now, NormKey(resourceKey)); tx.Commit(); return n == 1; } /// 测试与 Supervisor 用:外部强制提升 epoch(等效 Broker/宿主重启)。 public void BumpEpochForTest(string reason) { using var conn = Open(); conn.Execute("UPDATE authority_meta SET epoch=epoch+1, boot_id=? WHERE authority_id=?", Guid.NewGuid().ToString("N"), _authorityId); CurrentEpoch = conn.Query( "SELECT epoch, boot_id FROM authority_meta WHERE authority_id=?", _authorityId) .First().epoch; Log.Info($"[Lease] epoch 提升至 {CurrentEpoch}: {reason}"); } // ================= 辅助 ================= /// V3 规范:统一小写、固定分隔符、Ordinal 比较(§4.1)。 public static string NormKey(string key) => key.Trim().ToLowerInvariant().Replace(' ', '-'); private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); private ResourceStateRow? connQuery(SQLiteConnection c, string k) => c.Query("SELECT * FROM resource_state WHERE resource_key=?", NormKey(k)).FirstOrDefault(); private class AuthorityMetaRow { public long epoch { get; set; } public string boot_id { get; set; } = ""; } private class ResourceStateRow { public string ResourceKey { get; set; } = ""; public long Generation { get; set; } public int Phase { get; set; } public string? LeaseId { get; set; } public string? OwnerId { get; set; } public int OwnerType { get; set; } public string? MachineName { get; set; } public int HostProcessId { get; set; } public long Epoch { get; set; } public string? Reason { get; set; } public long ExpiresUtcMs { get; set; } public static implicit operator bool(ResourceStateRow? r) => r is not null; } } } ``` > 注意:列名与行属性映射依赖 `SQLite.NET` 的列名匹配(下划线列需显式 `[Column]`)。上表 DDL 用 `resource_key` 等 snake_case,而 `ResourceStateRow` 属性是 PascalCase——实现时在类上启用 `SQLite.NET` 映射修正:**统一改 DDL 列名为与属性一致的 PascalCase**(SQLite 列名大小写不敏感但查询串要一致)。落地执行者以此为准调整 DDL,保证二者一致(这是本任务唯一的“对齐”工作,测试会立刻暴露不匹配)。 - [ ] **Step 4: Aging.Infrastructure 编译验证** ```powershell cd d:\TestPlatform\TapProject & "C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe" Aging.Infrastructure\Aging.Infrastructure.csproj /p:Configuration=Debug /p:Platform=AnyCPU /t:Build /v:minimal ``` 预期:无 error;出现 `Aging.Infrastructure -> ...dll`。 - [ ] **Step 5: 运行租约测试全绿** ```powershell dotnet test tests\Aging.MvpTests\Aging.MvpTests.csproj --filter DbLeaseStoreTests ``` 预期:`failed: 0, passed: 8` - [ ] **Step 6: Commit** ```powershell git add Aging.Infrastructure tests git commit -m "feat(lease): DbLeaseStore 跨进程租约(TTL/栅栏/epoch/RecoveryRequired)" ``` --- ## Task 3: LayeredResourceManager + Bootstrapper 接线(上层零改动打通跨进程) **Files:** - Create: `Aging.Infrastructure/Core/LayeredResourceManager.cs` - Modify: `Aging.Infrastructure/InfrastructureBootstrapper.cs`(注册处) - Test: `tests/Aging.MvpTests/LayeredResourceManagerTests.cs` - [ ] **Step 1: 写失败测试** `tests/Aging.MvpTests/LayeredResourceManagerTests.cs`: ```csharp using System.IO; using System.Threading; using Aging.Infrastructure.Core; using Aging.Infrastructure.Contracts; using Xunit; namespace Aging.MvpTests { public class LayeredResourceManagerTests : System.IDisposable { private readonly string _dbPath; private readonly LayeredResourceManager _rm; public LayeredResourceManagerTests() { _dbPath = Path.Combine(Path.GetTempPath(), "arb_" + System.Guid.NewGuid().ToString("N") + ".db"); _rm = new LayeredResourceManager(new DbLeaseStore(_dbPath, "local-main")); } public void Dispose() { try { File.Delete(_dbPath); } catch { } } [Fact] public void ReserveAsync_Then_ValidateOwner_True() { var owner = new ResourceOwnerToken(OwnerType.Maintenance, "W01"); var key = new ResourceKey("CAN-002", "CAN"); var res = _rm.ReserveAsync(owner, key, CancellationToken.None).GetAwaiter().GetResult(); Assert.Equal(ReservationState.Active, res.State); Assert.True(_rm.ValidateOwner(owner, key)); } [Fact] public void ReserveAsync_Conflict_ExceptionCarriesHolder() { var a = new ResourceOwnerToken(OwnerType.Maintenance, "holder-A"); var b = new ResourceOwnerToken(OwnerType.Production, "requester-B"); var key = new ResourceKey("CAN-002", "CAN"); _rm.ReserveAsync(a, key, CancellationToken.None).GetAwaiter().GetResult(); var ex = Assert.Throws(() => _rm.ReserveAsync(b, key, CancellationToken.None).GetAwaiter().GetResult()); Assert.Contains("holder-A", ex.Message); // UI 直接弹占用者 } [Fact] public void ReleaseReservationAsync_Then_OtherCanReserve() { var a = new ResourceOwnerToken(OwnerType.Maintenance, "A"); var b = new ResourceOwnerToken(OwnerType.Production, "B"); var key = new ResourceKey("CAN-002", "CAN"); _rm.ReserveAsync(a, key, CancellationToken.None).GetAwaiter().GetResult(); _rm.ReleaseReservationAsync(a, key, CancellationToken.None).GetAwaiter().GetResult(); var ok = _rm.ReserveAsync(b, key, CancellationToken.None).GetAwaiter().GetResult(); Assert.Equal(ReservationState.Active, ok.State); } } } ``` - [ ] **Step 2: 运行确认失败** — `dotnet test ... --filter LayeredResourceManagerTests`,预期编译失败(类型不存在)。 - [ ] **Step 3: 实现 LayeredResourceManager** `Aging.Infrastructure/Core/LayeredResourceManager.cs`(内存细锁 + DbLeaseStore 组合,对上层 `IResourceManager` 签名零改动): ```csharp using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; using Aging.Infrastructure.Contracts; using Aging.ResourceContracts; using log4net; namespace Aging.Infrastructure.Core { /// V3.0 §9 过渡 Adapter 路线:Reserve/Validate/Release 转发 arb.db 租约表, /// grant 回存 TokenId→LeaseGrant[],供门面/调试会话取用。 public sealed class LayeredResourceManager : IResourceManager { private static readonly ILog Log = LogManager.GetLogger(typeof(LayeredResourceManager)); private readonly ILeaseCoordinator _lease; private readonly ConcurrentDictionary _localLocks = new(); private readonly ConcurrentDictionary _grantsByToken = new(); public LayeredResourceManager(ILeaseCoordinator lease) => _lease = lease; public ILeaseCoordinator Lease => _lease; public bool TryGetGrants(string tokenId, out LeaseGrant[] grants) => _grantsByToken.TryGetValue(tokenId, out grants!); public async Task ReserveAsync( ResourceOwnerToken owner, ResourceKey resource, CancellationToken ct) { var sem = _localLocks.GetOrAdd(resource.ToString(), _ => new SemaphoreSlim(1, 1)); await sem.WaitAsync(ct).ConfigureAwait(false); try { var req = new AcquireRequest { OwnerId = owner.OwnerId, OwnerType = owner.OwnerType == OwnerType.Maintenance ? OwnerTypeV3.Maintenance : OwnerTypeV3.Production, Reason = "ReserveAsync", ResourceKeys = new[] { resource.ToString() }, WaitTimeoutMs = 0 // MVP:占用即拒,不排队(V3 矩阵合法分支) }; var outcome = _lease.Acquire(req); switch (outcome.Code) { case AcquireResultCode.Acquired: _grantsByToken[owner.TokenId] = outcome.Grants; owner.Generation = outcome.Grants[0].Generation; return new ResourceReservation(resource, owner) { State = ReservationState.Active }; case AcquireResultCode.ConflictHeld: var h = outcome.Holder!; long leftSec = Math.Max(0, (h.ExpiresUtcMs - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()) / 1000); throw new ResourceConflictException( $"资源 {resource} 正被占用:{h.OwnerId}({(h.OwnerType == OwnerTypeV3.Maintenance ? "维护" : "生产")},机器 {h.MachineName} PID {h.HostProcessId},剩余租约 {leftSec}s)"); case AcquireResultCode.RecoveryBlocked: throw new ResourceConflictException( $"资源 {resource} 上次持有者崩溃,待恢复回收(需读回探测证据后在监控页批准)"); default: throw new TimeoutException($"资源 {resource} 申请超时"); } } finally { sem.Release(); } } public Task AcquireOperationAsync( ResourceOwnerToken owner, ResourceKey resource, CancellationToken ct) { // MVP:命令级串行由 SessionGate(Broker 队列)/Local 内 SemaphoreSlim 提供; // 本层只校验栅栏。NoopLease 需按 Contracts/IOperationLease.cs 实际成员补齐空实现。 if (!ValidateOwner(owner, resource)) throw new InvalidOperationException($"资源 {resource} 栅栏校验失败(owner 已失效)"); return Task.FromResult(new NoopLease()); } public async Task ReleaseReservationAsync( ResourceOwnerToken owner, ResourceKey resource, CancellationToken ct) { var sem = _localLocks.GetOrAdd(resource.ToString(), _ => new SemaphoreSlim(1, 1)); await sem.WaitAsync(ct).ConfigureAwait(false); try { if (_grantsByToken.TryGetValue(owner.TokenId, out var grants)) { foreach (var g in grants) _lease.Release(g, "ReleaseReservationAsync"); _grantsByToken.TryRemove(owner.TokenId, out _); } } finally { sem.Release(); } } public bool IsReserved(ResourceKey resource, out ResourceReservation? reservation) { var holder = _lease.ProbeConflict(resource.ToString()); reservation = holder == null ? null : new ResourceReservation(resource, new ResourceOwnerToken(holder.OwnerType == OwnerTypeV3.Maintenance ? OwnerType.Maintenance : OwnerType.Production, holder.OwnerId)) { State = ReservationState.Active }; return holder != null; } public bool ValidateOwner(ResourceOwnerToken owner, ResourceKey resource) { if (!_grantsByToken.TryGetValue(owner.TokenId, out var grants)) return false; string key = resource.ToString(); foreach (var g in grants) { if (!string.Equals(g.ResourceKey, key, StringComparison.OrdinalIgnoreCase)) continue; // 栅栏:用 extendSeconds=1 的续期作存在性检查(epoch/generation/phase 必须全命中) try { return _lease.Renew(g, 1); } catch { return false; } } return false; } private sealed class NoopLease : IOperationLease { public void Dispose() { } } } } ``` > 注:若 `IOperationLease` 含 `ReleaseAsync` 等额外成员,NoopLease 按其实际签名补齐;先看 `Contracts/IOperationLease.cs`。 - [ ] **Step 4: Bootstrapper 接线** `InfrastructureBootstrapper.Initialize` 中,将 `var resourceManager = new ResourceManager();` 替换为: ```csharp // V3.0 MVP:仲裁库 arb.db(与 data.db 同目录) string arbPath = Path.Combine(Path.GetDirectoryName(deviceConfigDbPath)!, "arb.db"); var leaseStore = new DbLeaseStore(arbPath, "local-main"); var resourceManager = new LayeredResourceManager(leaseStore); UtilContainer.RegisterUtil("LeaseCoordinator", leaseStore); ``` 其余注册不变(DeviceService/AutoDeviceFacade 持有的 resourceManager 已是跨进程版)。旧 `ResourceManager` 类保留不删(作为无 DB 降级备选)。 - [ ] **Step 5: 编译 + 测试全绿** ```powershell & "C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe" Aging.Infrastructure\Aging.Infrastructure.csproj /p:Configuration=Debug /p:Platform=AnyCPU /t:Build /v:minimal dotnet test tests\Aging.MvpTests\Aging.MvpTests.csproj ``` 预期:MSBuild 无 error;全部测试 Pass。 - [ ] **Step 6: Commit** ```powershell git add Aging.Infrastructure git commit -m "feat(lease): LayeredResourceManager 组合仲裁,上层零改动跨进程生效" ``` --- ## Task 4: AutoDeviceFacade 升级 —— 心跳续期 + LeaseLost + ReleaseAsync **Files:** - Modify: `Aging.Infrastructure/Contracts/IAutoDeviceFacade.cs` - Modify: `Aging.Infrastructure/Services/AutoDeviceFacade.cs` - Test: `tests/Aging.MvpTests/FacadeLeaseLifecycleTests.cs` - [ ] **Step 1: 写测试(验证门面依赖的租约语义)** ```csharp using System; using System.IO; using System.Threading; using Aging.Infrastructure.Core; using Aging.ResourceContracts; using Xunit; namespace Aging.MvpTests { public class FacadeLeaseLifecycleTests : IDisposable { private readonly string _dbPath; private readonly DbLeaseStore _store; public FacadeLeaseLifecycleTests() { _dbPath = Path.Combine(Path.GetTempPath(), "arb_" + Guid.NewGuid().ToString("N") + ".db"); _store = new DbLeaseStore(_dbPath, "local-main"); } public void Dispose() { try { File.Delete(_dbPath); } catch { } } private LeaseGrant Grab(string key, int ttl) => _store.Acquire(new AcquireRequest { OwnerId = "f", OwnerType = OwnerTypeV3.Production, ResourceKeys = new[] { key }, TtlSeconds = ttl }).Grants[0]; [Fact] public void Heartbeat_Renew_Extends_Expiry() { var g = Grab("can/x", 2); long before = g.ExpiresUtcMs; Thread.Sleep(1200); Assert.True(_store.Renew(g, 2)); Assert.True(_store.ProbeConflict("can/x").ExpiresUtcMs > before); } [Fact] public void Expired_Sweep_StaleRenew_False_And_RecoveryBlocked() { var g = Grab("can/y", 1); Thread.Sleep(1500); Assert.Equal(1, _store.SweepExpired()); // 过期→RecoveryRequired Assert.False(_store.Renew(g, 5)); // 熔断信号:续期必败 Assert.Null(_store.ProbeConflict("can/y")); // 不再显示 Active Assert.Equal(LeasePhase.RecoveryRequired, _store.PhaseOf("can/y")); } } } ``` - [ ] **Step 2: 运行测试** — `dotnet test ... --filter FacadeLeaseLifecycleTests`。两测试直接验证 Task 2 语义,应 Pass;若 Fail 先修 Task 2 再继续。 - [ ] **Step 3: 扩展 IAutoDeviceLease 接口(叠加成员,既有不动)** ```csharp /// V3.0:租约失效(续期连续失败/被回收)时触发;订阅者应停止下发。 event EventHandler LeaseLost; /// V3.0:显式异步释放。Dispose() 仍同步可用。 System.Threading.Tasks.Task ReleaseAsync(System.Threading.CancellationToken ct = default); /// V3.0:维护让渡请求(Draining 预留,MVP 恒 false)。 bool HandoverRequested { get; } ``` - [ ] **Step 4: AutoDeviceLease 实现心跳** `AutoDeviceFacade.cs` 关键点: 1. 构造新增可选参数 `ILeaseCoordinator leaseCoordinator = null`(Bootstrapper 传入;null 保持旧行为); 2. `AcquireAsync` 保留既有 Reserve,若持有 LayeredResourceManager 则 `TryGetGrants(owner.TokenId, out grants)` 后 `lease.StartHeartbeat(coordinator, grants)`; 3. Lease 内新增: ```csharp private LeaseGrant[] _grants = new LeaseGrant[0]; private Timer _beacon; private int _renewFailures; private ILeaseCoordinator _coordinator; public event EventHandler LeaseLost; public bool HandoverRequested => false; internal void StartHeartbeat(ILeaseCoordinator coordinator, LeaseGrant[] grants) { _coordinator = coordinator; _grants = grants; _beacon = new Timer(_ => { if (_disposed) return; try { bool allOk = true; foreach (var g in _grants) if (!_coordinator.Renew(g, 30)) { allOk = false; break; } _renewFailures = allOk ? 0 : _renewFailures + 1; if (_renewFailures >= 2) LeaseLost?.Invoke(this, EventArgs.Empty); // 连2失败→熔断 } catch { if (++_renewFailures >= 2) LeaseLost?.Invoke(this, EventArgs.Empty); } }, null, 8000, 8000); } public Task ReleaseAsync(CancellationToken ct = default) { _beacon?.Dispose(); _beacon = null; // 先停心跳,再走既有释放链 Dispose(); return Task.CompletedTask; } ``` `Dispose()` 开头加 `_beacon?.Dispose()`;`ExecuteAsync/QueryAsync` 开头加 `if (_renewFailures >= 2) throw new InvalidOperationException("LEASE_STALE: 租约已失效");`。 - [ ] **Step 5: 全量测试 + 编译 + Commit** ```powershell dotnet test tests\Aging.MvpTests\Aging.MvpTests.csproj & "...MSBuild.exe" Aging.Infrastructure\Aging.Infrastructure.csproj /p:Configuration=Debug /p:Platform=AnyCPU /t:Build /v:minimal git add Aging.Infrastructure tests git commit -m "feat(facade): 心跳续期+LeaseLost熔断+ReleaseAsync" ``` --- ## Task 5: 注册页"执行位置"UI + 发布门禁(最小版,补齐范围表已承诺项) > 背景:范围表中“注册页 executionMode/authorityId/sessionKey/accessPolicy + 发布门禁 + 不可变快照哈希”此前无对应实施任务,本节为补齐。 **Files:** - Modify: `Plugin.DeviceConfig/Entity/DeviceDefinition.cs`(新增 ExecutionModeKind 枚举 + 5 字段 + 只读派生 AccessPolicy) - Create: `Plugin.DeviceConfig/V3AuthorityCatalog.cs`(仲裁域/Broker 候选由宿主 appSettings 枚举,未配置回退默认 local-main;broker-b01) - Modify: `Plugin.DeviceConfig/Plugin.DeviceConfig.csproj`(引用 System.Configuration) - Modify: `Plugin.DeviceConfig/DeviceEditModal.xaml(.cs)`(“执行位置”区块:Local/Broker 单选、AuthorityId 必填下拉、Broker 时 BrokerId/OwnerProcessId/PhysicalSessionKey 写死候选、AccessPolicy 只读) - Modify: `Plugin.DeviceConfig/Model/DeviceConfigModel.cs`(建表新列 + PRAGMA 迁移、SaveDevice/Map 含新列、ComputeConfigHash 纳入执行位置、新增 ValidatePublishGate) - Modify: `Plugin.DeviceConfig/DeviceConfig.xaml(.cs)`(详情栏展示执行位置/仲裁域;PublishVersion 先跑门禁,未通过拦截) - Create: `Plugin.IntegrationTest/App.config`(V3.AuthorityIds / V3.BrokerIds 候选枚举) - [ ] **Step 1: 字段定义按 V3.0 §05“执行位置”行:** | 控件 | 字段 | 规则 | |---|---|---| | RadioButton ×2 | ExecutionMode=Local/Broker | 默认 Local;互斥联动仲裁域 | | ComboBox | AuthorityId | 必填,候选来自 V3AuthorityCatalog.AuthorityIds(配置枚举) | | ComboBox | BrokerId | 仅 Broker;候选 BrokerIds;门禁要求 BrokerId==AuthorityId(设备域一致) | | 只读 TextBox | OwnerProcessId | MVP 占位当前进程 PID;不入配置哈希(运行期信息) | | 可编辑 ComboBox | PhysicalSessionKey | 仅 Broker;候选为端点规范化的写死字符串(can/{bus}/main、tcp/{ip:port}/main、serial/{COM}/main、{deviceId}/main 等) | | 只读 TextBox | AccessPolicy | 派生:Local→独占直连;Broker→共享仲裁 SessionGate 代理;不落库 | - [ ] **Step 2: 发布门禁 ValidatePublishGate(V3.0 §05 校验列):** AuthorityId 必填且在候选枚举内;Local 设备域必须 local- 前缀;Broker 设备 BrokerId 必填、在枚举内且与 AuthorityId 一致、PhysicalSessionKey 必填;同一 PhysicalSessionKey 只允许一个宿主域(“同一会话只能有一个宿主”)。任一失败阻止发布并列出错误。 - [ ] **Step 3: 兼容性:** 旧库经 PRAGMA+ALTER 补列;存量设备 ExecutionMode 默认 Local、AuthorityId 空——注册页打开时预选本机 Local 域,发布时要求重新保存(门禁提示存量数据);集成测试预置设备(不经发布链路)不受影响;配置哈希新增执行位置段,旧草稿哈希与新计算不一致属预期(配置确实变更)。 - [ ] **Step 4: 手工验证 + 编译** 验证路径:新增设备选 Broker → 确认 BrokerId/会话键候选自动生成且访问策略只读;保存后编辑回显;带空 AuthorityId 存量设备点“发布” → 门禁拦截并提示;重新保存后发布成功且 Hash 变化。编译(用 VS MSBuild,不加 Platform): ```powershell & "...MSBuild.exe" Plugin.IntegrationTest\Plugin.IntegrationTest.csproj /t:Build /p:Configuration=Debug /v:minimal ``` - [ ] **Step 5: Commit** ```powershell git add Plugin.DeviceConfig Plugin.IntegrationTest\App.config docs git commit -m "feat(deviceconfig): 注册页执行位置区块+发布门禁(V3.0 §05 最小版)" ``` 推迟项(P2+):多 Authority 跨机候选自动发现、发布预览差异展示、SlotDeviceMapping “共享映射显式标注”。 --- ## Task 6: Broker IPC 帧协议 + 代理驱动(客户端侧) **Files:** - Create: `Aging.ResourceContracts/BrokerIpc.cs`(4 字节大端长度前缀 + UTF8 JSON 封套编解码;SubmitBody/ResultBody 线格式 DTO,规避 LeaseGrant 只读构造器反序列化限制) - Create: `Aging.Infrastructure/Broker/BrokerClientChannel.cs`(命名管道客户端:Hello 握手验协议主版本;RequestId/CorrelationId 响应关联;deadline/超时;读循环断线感知 + 惰性重连;修复旧 PipeClient “WriteMessage 后立即 return”不可达响应问题) - Create: `Aging.Infrastructure/Broker/BrokerPipeName.cs`(服务名 `aging.{brokerId}.v{协议主版本}`,V3.0 §13) - Create: `Aging.Infrastructure/Broker/DeviceConfigHash.cs`(从 data.db 计算与 DeviceConfigModel.ComputeConfigHash 同规则的快照哈希,表缺失时跳过空段;两侧同函数保证可对账) - Create: `Aging.Infrastructure/Drivers/BrokeredCanDriver.cs`(实现 IDeviceDriver,命令语义与 CanDriverAdapter 对齐;Broker 返回态→本地异常:RejectedStaleLease→LEASE_STALE、RejectedConfigMismatch→CONFIG_MISMATCH、Unknown→TimeoutException 禁止假定未执行) - Modify: `Aging.Infrastructure/InfrastructureBootstrapper.cs`(新增 RegisterBrokeredDrivers:扫 data.db 中 ExecutionMode=Broker 设备→注册代理驱动 + 携启动时 configHash;失败降级不阻塞 Local 链路) - [x] **Step 1: 协议/通道/驱动实现**(帧=4字节大端长度+JSON;OneShot 同步模型:CONNECT/DISCONNECT/SEND/QUERY/STATUS 白名单) - [x] **Step 2: 接线**(上层 IDeviceService/IAutoDeviceFacade/调试页零改动:代理点选在驱动层) - [x] **Step 3: 编译通过 + 后续 Task 7 e2e 验证** --- ## Task 7: DeviceBroker.exe 宿主 + 全链路 e2e + 主程序自动拉起 **Files:** - Create: `DeviceBroker/DeviceBroker.csproj`(net472 Exe,x64;已加入 `WPFProject\TapProject.sln`) - Create: `DeviceBroker/BrokerServer.cs`(管道多实例接受循环;每请求三重关卡:①OpCode 白名单+域内设备过滤 ②INV-04 configHash 对账(2s 惰性重算)③INV-01/03 栅栏再校验(PhaseOf/ProbeConflict,无活跃租约拒绝直接 I/O,仅 DISCONNECT 豁免);SessionGate 按 PhysicalSessionKey 串行原子片段;队列超时→RESOURCE_BUSY(确定未执行可安重试);片段中异常→SEND/QUERY 保守判 Unknown) - Create: `DeviceBroker/Program.cs`(--db/--arb/--broker 参数,默认指向集成测试环境目录;Ctrl+C 优雅退出,租约靠 TTL 过期+人工回收) - Create: `tests/Aging.MvpTests/BrokerE2ETests.cs`(6 用例:有租约正向链路 / 无租约拒绝 / 哈希不匹配拒绝 / 归还后拒绝 / 同会话串行 / 维护写权放行;每用例唯一 brokerId → 独立管道名隔离并行) - Modify: `Plugin.IntegrationTest/MainWindow.xaml.cs`(InitializeAutoServices 检测 Broker 设备→自动拉起 DeviceBroker.exe;Window_Closing 停机清理) - Modify: `tests/Aging.MvpTests/Aging.MvpTests.csproj`(引用 DeviceBroker) - [x] **Step 1: 宿主实现 + 工程入 sln** - [x] **Step 2: e2e 测试(TDD)** — 首轮红:DeviceConfigHash 对缺表崩溃(no such table: DeviceChannel);修复:缺表跳过空段(哈希对“不存在”与“零行”一致);二轮全绿 - [x] **Step 3: 调试页维护写权链路确认** — DeviceDebug.InitFinish 已从 UtilContainer 取 LayeredResourceManager;RequestMaintenance 的维护租约经 arb.db 跨进程互斥(占用即拒绝);Broker 设备维护写 = 维护租约入 arb.db → broker 栅栏检出 Active 放行,无需新代码;e2e 以 MaintenanceLease_BrokeredWrite_Succeeds 覆盖 - [x] **Step 4: 全量验证** — MvpTests 22/22;主程序/宿主 MSBuild exit 0 已知简化(P2+):owner↔clientInstanceId 精确对齐(当前同机 arb.db 只做存在性/相位检查)、周期任务/订阅/Outbox、跨机部署。 ---