编辑 | blame | 历史 | 原始文档

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: 验证当前基线可编译
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:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net472</TargetFramework>
    <Nullable>enable</Nullable>
    <LangVersion>10.0</LangVersion>
    <ImplicitUsings>disable</ImplicitUsings>
  </PropertyGroup>
</Project>
  • [ ] Step 3: 创建 xUnit 测试工程
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>
  • [ ] Step 4: 加入解决方案并验证
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
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:

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);
        }
    }
}
  • [ ] Step 2: 运行确认失败
dotnet test tests\Aging.MvpTests\Aging.MvpTests.csproj --filter ContractSerializeTests

预期:编译错误 The type LeaseGrant does not exist(工程未引用实现前的自然失败)。

  • [ ] Step 3: 实现契约类型

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 可用;契约库同样加此包)。

  • [ ] Step 4: 运行确认通过
dotnet test tests\Aging.MvpTests\Aging.MvpTests.csproj --filter ContractSerializeTests

预期:Passed! - failed: 0, passed: 2

  • [ ] Step 5: Commit
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:

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: 运行确认失败
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 复制解决)。

  • [ ] Step 3: 实现 DbLeaseStore

先修改 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,保证二者一致(这是本任务唯一的“对齐”工作,测试会立刻暴露不匹配)。

  • [ ] Step 4: Aging.Infrastructure 编译验证
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: 运行租约测试全绿
dotnet test tests\Aging.MvpTests\Aging.MvpTests.csproj --filter DbLeaseStoreTests

预期:failed: 0, passed: 8

  • [ ] Step 6: Commit
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:

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。

  • [ ] Step 4: Bootstrapper 接线

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 降级备选)。

  • [ ] Step 5: 编译 + 测试全绿
& "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
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: 写测试(验证门面依赖的租约语义)
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; }
  • [ ] 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 内新增:

        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
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):

& "...MSBuild.exe" Plugin.IntegrationTest\Plugin.IntegrationTest.csproj /t:Build /p:Configuration=Debug /v:minimal
  • [ ] Step 5: Commit
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、跨机部署。