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 工程,见记忆规则)。
| 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(可后续入配置)
Files:
- Create: Aging.ResourceContracts/Aging.ResourceContracts.csproj
- Create: tests/Aging.MvpTests/Aging.MvpTests.csproj
- Modify: WPFProject/TapProject.sln(加入两个新工程)
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。)
创建 Aging.ResourceContracts/Aging.ResourceContracts.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net472</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>10.0</LangVersion>
<ImplicitUsings>disable</ImplicitUsings>
</PropertyGroup>
</Project>
cd d:\TestPlatform\TapProject\tests
dotnet new xunit -n Aging.MvpTests -f net472
编辑 Aging.MvpTests/Aging.MvpTests.csproj 追加对契约库与后续设施的引用与 SQLite 复制:
<ItemGroup>
<ProjectReference Include="..\..\Aging.ResourceContracts\Aging.ResourceContracts.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\Libs\SQLite.Interop.dll" Link="x64\SQLite.Interop.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
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 中手工添加,不阻塞后续任务。
git add Aging.ResourceContracts tests WPFProject/TapProject.sln
git commit -m "chore(mvp): 契约库与测试工程骨架"
Files:
- Create: Aging.ResourceContracts/LeaseTypes.cs
- Create: Aging.ResourceContracts/DeviceOperationTypes.cs
- Create: Aging.ResourceContracts/IpcEnvelope.cs
- Test: tests/Aging.MvpTests/ContractSerializeTests.cs
tests/Aging.MvpTests/ContractSerializeTests.cs:
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<LeaseGrant>(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<IpcEnvelope>(json)!;
Assert.Equal("SubmitTask", back.MessageType);
Assert.Equal("sha256:x", back.ConfigHash);
}
}
}
dotnet test tests\Aging.MvpTests\Aging.MvpTests.csproj --filter ContractSerializeTests
预期:编译错误 The type LeaseGrant does not exist(工程未引用实现前的自然失败)。
Aging.ResourceContracts/LeaseTypes.cs:
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Aging.ResourceContracts
{
/// <summary>与现有 OwnerType 数值对齐:Production=0, Maintenance=1。</summary>
public enum OwnerTypeV3 { Production = 0, Maintenance = 1 }
/// <summary>MVP 相位:Free 可用 / Active 持有 / RecoveryRequired 过期待回收(INV-03)。</summary>
public enum LeasePhase { Free = 0, Active = 1, RecoveryRequired = 2 }
/// <summary>每资源一张租约凭证;栅栏=(AuthorityId, Epoch, ResourceKey, LeaseId, Generation)。</summary>
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; }
/// <summary>boot_id + pid,同机进程区分。</summary>
public string ProcessInstanceId { get; set; } = "";
public string MachineName { get; set; } = Environment.MachineName;
public int HostProcessId { get; set; } = System.Diagnostics.Process.GetCurrentProcess().Id;
/// <summary>申请理由(维护会话填操作员与原因,进 lease_history)。</summary>
public string Reason { get; set; } = "";
/// <summary>Ordinals 升序、去重后申请(组申请 = 单事务全有全无)。</summary>
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; }
/// <summary>RecoveryBlocked 时指向 recovery_checkpoint 的键。</summary>
public string? RecoveryCheckpointKey { get; set; }
}
/// <summary>租约协调器:仲裁域唯一入口。实现方:DbLeaseStore。</summary>
public interface ILeaseCoordinator
{
/// <summary>权威域当前 epoch(重启后递增,旧 Grant 自动失效)。</summary>
long CurrentEpoch { get; }
AcquireOutcome Acquire(AcquireRequest request);
/// <summary>同 LeaseId+Generation 续期;返回 false=已被回收/超越(本地须熔断)。</summary>
bool Renew(LeaseGrant grant, int extendSeconds);
bool Release(LeaseGrant grant, string note);
ConflictInfo? ProbeConflict(string resourceKey);
int SweepExpired();
/// <summary>RecoveryRequired → Free:宿主完成读回探测后登记证据并放行。</summary>
bool ApproveRecovery(string resourceKey, string readbackJson, string approvedBy);
}
}
Aging.ResourceContracts/DeviceOperationTypes.cs:
namespace Aging.ResourceContracts
{
public enum OperationState
{
Succeeded, Failed,
/// <summary>栅栏校验失败:Grant 的 epoch/generation 已失效(含 Broker 重启)。</summary>
RejectedStaleLease,
/// <summary>INV-04 雏形:客户端 configHash 与 Broker 生效配置不一致。</summary>
RejectedConfigMismatch,
/// <summary>会话队列满。</summary>
RejectedQueueFull,
/// <summary>已发送但结果未知(INV-06:禁止当未执行)。</summary>
Unknown
}
/// <summary>一次有界设备动作(MVP=OneShot)。Grants 必须覆盖 resources。</summary>
public sealed class DeviceOperation
{
/// <summary>白名单操作码,如 "CAN_SEND"/"CAN_RECV"/"CAN_AUTO_RESPONSE"/"CAN_STATUS"。</summary>
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:
namespace Aging.ResourceContracts
{
/// <summary>IPC 消息类型常量(MVP 仅四种)。</summary>
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"; // 恢复回收前的读回探测
}
/// <summary>请求/响应共同封套。协议版本不匹配直接拒绝连接。</summary>
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 追加 <PackageReference Include="System.Text.Json" Version="6.0.5" />(net472 可用;契约库同样加此包)。
dotnet test tests\Aging.MvpTests\Aging.MvpTests.csproj --filter ContractSerializeTests
预期:Passed! - failed: 0, passed: 2
git add Aging.ResourceContracts tests
git commit -m "feat(contracts): LeaseGrant/ILeaseCoordinator/DeviceOperation/IPC 封套"
Files:
- Create: Aging.Infrastructure/Core/DbLeaseStore.cs
- Modify: Aging.Infrastructure/Aging.Infrastructure.csproj(加 PackageReference System.Data.SQLite + 契约工程引用)
- Test: tests/Aging.MvpTests/DbLeaseStoreTests.cs
tests/Aging.MvpTests/DbLeaseStoreTests.cs:
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 未被半占用
}
}
}
dotnet test tests\Aging.MvpTests\Aging.MvpTests.csproj --filter DbLeaseStoreTests
预期:编译失败(DbLeaseStore 不存在)。若契约测试工程编译报 System.Data.SQLite 缺失,先在测试 csproj 加 <PackageReference Include="System.Data.SQLite" Version="1.0.118" />(Interop 由 Step 3 的 Content 复制解决)。
先修改 Aging.Infrastructure/Aging.Infrastructure.csproj,ItemGroup 追加:
<ProjectReference Include="..\Aging.ResourceContracts\Aging.ResourceContracts.csproj" />
<PackageReference Include="System.Data.SQLite" Version="1.0.118" />
Aging.Infrastructure/Core/DbLeaseStore.cs:
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
{
/// <summary>
/// 跨进程租约存储(V3.0 §8 MVP)。arb.db 独立于 data.db。
/// 时间一律 DB 侧 UTC 毫秒(DateTime.UtcNow 存 INTEGER),
/// 申请/续期/释放全部走 BEGIN IMMEDIATE 单写事务 —— SQLite 事务即进程间仲裁层。
/// INV-03:过期 → RecoveryRequired,禁止自动移交;ApproveRecovery 登记证据后置 Free。
/// </summary>
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<AuthorityMetaRow>(
"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<LeaseGrant>(keys.Length);
foreach (var key in keys)
{
var st = conn.Query<ResourceStateRow>(
"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<ResourceStateRow>(
"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<ResourceStateRow>(
"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;
}
/// <summary>测试与 Supervisor 用:外部强制提升 epoch(等效 Broker/宿主重启)。</summary>
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<AuthorityMetaRow>(
"SELECT epoch, boot_id FROM authority_meta WHERE authority_id=?", _authorityId)
.First().epoch;
Log.Info($"[Lease] epoch 提升至 {CurrentEpoch}: {reason}");
}
// ================= 辅助 =================
/// <summary>V3 规范:统一小写、固定分隔符、Ordinal 比较(§4.1)。</summary>
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<ResourceStateRow>("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,保证二者一致(这是本任务唯一的“对齐”工作,测试会立刻暴露不匹配)。
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。
dotnet test tests\Aging.MvpTests\Aging.MvpTests.csproj --filter DbLeaseStoreTests
预期:failed: 0, passed: 8
git add Aging.Infrastructure tests
git commit -m "feat(lease): DbLeaseStore 跨进程租约(TTL/栅栏/epoch/RecoveryRequired)"
Files:
- Create: Aging.Infrastructure/Core/LayeredResourceManager.cs
- Modify: Aging.Infrastructure/InfrastructureBootstrapper.cs(注册处)
- Test: tests/Aging.MvpTests/LayeredResourceManagerTests.cs
tests/Aging.MvpTests/LayeredResourceManagerTests.cs:
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<ResourceConflictException>(() =>
_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 签名零改动):
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
{
/// <summary>V3.0 §9 过渡 Adapter 路线:Reserve/Validate/Release 转发 arb.db 租约表,
/// grant 回存 TokenId→LeaseGrant[],供门面/调试会话取用。</summary>
public sealed class LayeredResourceManager : IResourceManager
{
private static readonly ILog Log = LogManager.GetLogger(typeof(LayeredResourceManager));
private readonly ILeaseCoordinator _lease;
private readonly ConcurrentDictionary<string, SemaphoreSlim> _localLocks = new();
private readonly ConcurrentDictionary<string, LeaseGrant[]> _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<ResourceReservation> 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<IOperationLease> 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<IOperationLease>(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。
InfrastructureBootstrapper.Initialize 中,将 var resourceManager = new ResourceManager(); 替换为:
// 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 降级备选)。
& "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。
git add Aging.Infrastructure
git commit -m "feat(lease): LayeredResourceManager 组合仲裁,上层零改动跨进程生效"
Files:
- Modify: Aging.Infrastructure/Contracts/IAutoDeviceFacade.cs
- Modify: Aging.Infrastructure/Services/AutoDeviceFacade.cs
- Test: tests/Aging.MvpTests/FacadeLeaseLifecycleTests.cs
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 接口(叠加成员,既有不动)
/// <summary>V3.0:租约失效(续期连续失败/被回收)时触发;订阅者应停止下发。</summary>
event EventHandler LeaseLost;
/// <summary>V3.0:显式异步释放。Dispose() 仍同步可用。</summary>
System.Threading.Tasks.Task ReleaseAsync(System.Threading.CancellationToken ct = default);
/// <summary>V3.0:维护让渡请求(Draining 预留,MVP 恒 false)。</summary>
bool HandoverRequested { get; }
AutoDeviceFacade.cs 关键点:
1. 构造新增可选参数 ILeaseCoordinator leaseCoordinator = null(Bootstrapper 传入;null 保持旧行为);
2. AcquireAsync 保留既有 Reserve,若持有 LayeredResourceManager 则 TryGetGrants(owner.TokenId, out grants) 后 lease.StartHeartbeat(coordinator, grants);
3. Lease 内新增:
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: 租约已失效");。
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"
背景:范围表中“注册页 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 候选枚举)
| 控件 | 字段 | 规则 |
|---|---|---|
| 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):
& "...MSBuild.exe" Plugin.IntegrationTest\Plugin.IntegrationTest.csproj /t:Build /p:Configuration=Debug /v:minimal
git add Plugin.DeviceConfig Plugin.IntegrationTest\App.config docs
git commit -m "feat(deviceconfig): 注册页执行位置区块+发布门禁(V3.0 §05 最小版)"
推迟项(P2+):多 Authority 跨机候选自动发现、发布预览差异展示、SlotDeviceMapping “共享映射显式标注”。
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 链路)
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)
已知简化(P2+):owner↔clientInstanceId 精确对齐(当前同机 arb.db 只做存在性/相位检查)、周期任务/订阅/Outbox、跨机部署。