# 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、跨机部署。
---