Explorar o código

docs(plan): D2-02 第一阶段实现计划(control后端 bite-sized TDD)+ 进度回写

- 计划按 Scope Check 拆 3 子阶段:①control后端(会话/分发/钳位/超时回收,
  纯 xUnit 单测+curl 真机冒烟,Task0-10 完整)②MJPEG预览(提纲)③operate接入+真机V-012(提纲)
- 进度状态.yaml/工作计划表/交接卡 同步:D2-02 设计+计划已出、代码待执行
- 纠正旧断点笔误(配置收敛实已在 main,非"待并 main")

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
huangjie hai 2 días
pai
achega
7acddfdc59

+ 1022 - 0
项目文档/开发计划/2026-06-23-阶段2-D2-02-调试页命令代理实现计划.md

@@ -0,0 +1,1022 @@
+# D2-02 调试页命令代理 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: 用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 按任务逐个实现。步骤用 `- [ ]` 复选框跟踪。
+> 设计依据:`需求文档/specs/2026-06-23-D2-02-调试页命令代理-design.md`。
+
+**Goal:** operate 调试页在双进程下经 control 本地 HTTP 跨进程借用某舱、连续操作硬件、看实时预览、归还恢复采集。
+
+**Architecture:** control 新增"调试会话"后端(会话表 + 心跳 TTL 自动回收 + 通用命令分发 + 红线钳位),挂在现有 `ControlHttpServer` 上;operate 新增 `DebugSessionClient`/`MjpegStreamClient`,2 个调试 VM 改走 HTTP。安全地基是"租约+心跳+control端超时自动回收",扛 operate 崩溃/丢消息。
+
+**Tech Stack:** C# net6.0-windows;xUnit(control 单测);`System.Net.HttpListener`(control)、`HttpClient`(operate);Newtonsoft.Json。
+
+---
+
+## 子计划拆分(Scope Check)
+
+D2-02 含三个有依赖顺序、各自可独立验证的子系统。本文档**完整写第一阶段**,后两阶段列提纲(待第一阶段真机验证后各自展开为独立计划):
+
+| 阶段 | 子系统 | 验证方式 | 本文档 |
+|---|---|---|---|
+| **一** | control 端会话后端(acquire/command/release/heartbeat + 钳位 + 超时回收) | 纯 xUnit 单测 + curl 真机 | **bite-sized 完整** |
+| 二 | MJPEG 预览(control 推流 + operate 解码) | 真机出图 | 提纲(§后续) |
+| 三 | operate 接入(2 client + 2 VM + 2 View)+ 真机 V-012 | 真机门控 | 提纲(§后续) |
+
+---
+
+## 第一阶段 · 文件结构
+
+| 文件 | 责任 |
+|---|---|
+| Create `control/ivf_tl_ControlHost/Debug/DebugSession.cs` | 单个会话的状态(sessionId/houseSn/lease/最后心跳/当前电机位) |
+| Create `control/ivf_tl_ControlHost/Debug/MotorClamp.cs` | 红线钳位纯函数(水平/垂直边界 + 相对运动算目标位) |
+| Create `control/ivf_tl_ControlHost/Debug/DebugCommandResult.cs` | 命令返回体(ok/result/error/code) |
+| Create `control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs` | 会话表 + acquire/release/heartbeat/execute + SweepExpired;依赖 `IHardwareAccessLayer` + 注入时钟,可纯单测 |
+| Modify `control/ivf_tl_ControlHost/ControlHttpServer.cs` | 加 `/debug/*` 路由,转给注入的 manager |
+| Modify `control/ivf_tl_ControlHost/Program.cs` | 装配 `DebugSessionManager` + 起 TTL 看门狗线程 |
+| Create `control/IvfTl.ControlHost.Tests/` | xUnit 工程(net6.0-windows),加入 control sln |
+
+**钳位常量(spec §10)**:水平 `[0, 220000]`、垂直 Z `[0, 125000]`。
+
+---
+
+### Task 0: 新建 control 单测工程
+
+**Files:**
+- Create: `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj`
+- Modify: `ivf_tl_operate_2.0/control/ivf_tl_Control.sln`
+
+- [ ] **Step 1: 建 csproj**
+
+```xml
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFramework>net6.0-windows</TargetFramework>
+    <IsPackable>false</IsPackable>
+    <Nullable>disable</Nullable>
+    <LangVersion>latest</LangVersion>
+  </PropertyGroup>
+  <ItemGroup>
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
+    <PackageReference Include="xunit" Version="2.6.2" />
+    <PackageReference Include="xunit.runner.visualstudio" Version="2.5.4" />
+  </ItemGroup>
+  <ItemGroup>
+    <ProjectReference Include="..\ivf_tl_ControlHost\ivf_tl_ControlHost.csproj" />
+    <ProjectReference Include="..\IvfTl.Hardware\IvfTl.Hardware.csproj" />
+  </ItemGroup>
+</Project>
+```
+
+- [ ] **Step 2: 加入解决方案**
+
+Run: `dotnet sln ivf_tl_operate_2.0/control/ivf_tl_Control.sln add ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj`
+Expected: `Project ... added to the solution.`
+
+- [ ] **Step 3: 占位测试确认工程能编译能跑**
+
+Create `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/SmokeTest.cs`:
+```csharp
+using Xunit;
+namespace IvfTl.ControlHost.Tests
+{
+    public class SmokeTest
+    {
+        [Fact] public void Smoke() => Assert.True(true);
+    }
+}
+```
+
+- [ ] **Step 4: 跑通**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/IvfTl.ControlHost.Tests.csproj`
+Expected: PASS(1 passed)。若 ControlHost 是 WinExe 被引用报错,在其 csproj 确认 `OutputType` 仍可被测试工程 ProjectReference(net6 可引用 exe 程序集)。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests ivf_tl_operate_2.0/control/ivf_tl_Control.sln
+git commit -m "test(d2-02): 新建 control 单测工程 IvfTl.ControlHost.Tests"
+```
+
+---
+
+### Task 1: DebugCommandResult + DebugSession 数据类
+
+**Files:**
+- Create: `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugCommandResult.cs`
+- Create: `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSession.cs`
+
+- [ ] **Step 1: DebugCommandResult**
+
+```csharp
+using Newtonsoft.Json;
+namespace IvfTl.ControlHost.Debug
+{
+    /// <summary>命令/借用统一返回体。code 见 spec §4。</summary>
+    public class DebugCommandResult
+    {
+        [JsonProperty("ok")] public bool Ok { get; set; }
+        [JsonProperty("result", NullValueHandling = NullValueHandling.Ignore)] public object Result { get; set; }
+        [JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)] public string Error { get; set; }
+        [JsonProperty("code", NullValueHandling = NullValueHandling.Ignore)] public string Code { get; set; }
+
+        public static DebugCommandResult Okay(object result = null) => new DebugCommandResult { Ok = true, Result = result };
+        public static DebugCommandResult Fail(string code, string error) => new DebugCommandResult { Ok = false, Code = code, Error = error };
+    }
+}
+```
+
+- [ ] **Step 2: DebugSession**
+
+```csharp
+using System;
+using IvfTl.Hardware;
+namespace IvfTl.ControlHost.Debug
+{
+    /// <summary>一次调试借用会话。lease 是 control 进程内借到的舱句柄;LastSeen 用注入时钟刷新。</summary>
+    public sealed class DebugSession
+    {
+        public string SessionId { get; }
+        public int HouseSn { get; }
+        public IHardwareLease Lease { get; }
+        public DateTime LastSeen { get; set; }
+        // 相对运动钳位用:control 端跟踪的当前电机位(初值 -1 = 未知)。
+        public int CurrentHor { get; set; } = -1;
+        public int CurrentVer { get; set; } = -1;
+
+        public DebugSession(string sessionId, int houseSn, IHardwareLease lease, DateTime now)
+        {
+            SessionId = sessionId; HouseSn = houseSn; Lease = lease; LastSeen = now;
+        }
+    }
+}
+```
+
+- [ ] **Step 3: 编译**
+
+Run: `dotnet build ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ivf_tl_ControlHost.csproj -c Debug`
+Expected: 0 错(operate.exe 不在跑,不锁 DLL)。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug
+git commit -m "feat(d2-02): control 调试会话数据类(DebugSession/DebugCommandResult)"
+```
+
+---
+
+### Task 2: MotorClamp 红线钳位(TDD)
+
+**Files:**
+- Create: `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/MotorClamp.cs`
+- Test: `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/MotorClampTests.cs`
+
+- [ ] **Step 1: 写失败测试**
+
+```csharp
+using IvfTl.ControlHost.Debug;
+using Xunit;
+namespace IvfTl.ControlHost.Tests
+{
+    public class MotorClampTests
+    {
+        [Theory]
+        [InlineData(0, true)]
+        [InlineData(125000, true)]
+        [InlineData(90000, true)]
+        [InlineData(-1, false)]
+        [InlineData(125001, false)]
+        public void Vertical_InRange(int pulse, bool ok)
+            => Assert.Equal(ok, MotorClamp.IsVerticalInRange(pulse));
+
+        [Theory]
+        [InlineData(0, true)]
+        [InlineData(220000, true)]
+        [InlineData(220001, false)]
+        [InlineData(-5, false)]
+        public void Horizontal_InRange(int pulse, bool ok)
+            => Assert.Equal(ok, MotorClamp.IsHorizontalInRange(pulse));
+
+        [Fact] // 相对运动:当前位 + 增量算目标位再判定
+        public void Relative_Target_Computed()
+        {
+            Assert.Equal(90000, MotorClamp.RelativeTarget(80000, 10000)); // forward
+            Assert.False(MotorClamp.IsVerticalInRange(MotorClamp.RelativeTarget(120000, 10000))); // 130000 越界
+        }
+    }
+}
+```
+
+- [ ] **Step 2: 跑验证失败**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter MotorClampTests`
+Expected: FAIL("MotorClamp 不存在")。
+
+- [ ] **Step 3: 实现 MotorClamp**
+
+```csharp
+namespace IvfTl.ControlHost.Debug
+{
+    /// <summary>红线电机钳位(spec §10)。水平[0,220000]、垂直Z[0,125000]。纯函数,无副作用。</summary>
+    public static class MotorClamp
+    {
+        public const int HorMin = 0, HorMax = 220000;
+        public const int VerMin = 0, VerMax = 125000;
+
+        public static bool IsHorizontalInRange(int pulse) => pulse >= HorMin && pulse <= HorMax;
+        public static bool IsVerticalInRange(int pulse) => pulse >= VerMin && pulse <= VerMax;
+
+        /// <summary>当前位 + 增量 = 目标绝对位(forward 传正、backward 传负)。</summary>
+        public static int RelativeTarget(int current, int delta) => current + delta;
+    }
+}
+```
+
+- [ ] **Step 4: 跑验证通过**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter MotorClampTests`
+Expected: PASS(全绿)。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/MotorClamp.cs ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/MotorClampTests.cs
+git commit -m "feat(d2-02): 红线电机钳位 MotorClamp + 单测"
+```
+
+---
+
+### Task 3: 测试替身 FakeHardware(借用闸门 + 串口)
+
+> DebugSessionManager 依赖 `IHardwareAccessLayer`/`IHouseGate`/`IHardwareLease`/`ISerialChannel`(全是接口)。手写 fake 记录调用,不引 Moq。
+
+**Files:**
+- Create: `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/Fakes/FakeHardware.cs`
+
+- [ ] **Step 1: 写 fake(无测试,仅替身)**
+
+```csharp
+using System;
+using System.Collections.Generic;
+using IvfTl.Hardware;
+namespace IvfTl.ControlHost.Tests.Fakes
+{
+    // 记录被调方法名;电机/写操作记参数,供断言分发正确。
+    public class FakeSerial : ISerialChannel
+    {
+        public List<string> Calls = new List<string>();
+        public bool IsOpen => true;
+        public string PortName => "COMTEST";
+        public Action<string> Log { get; set; }
+        public int MoveReadTimeoutMs { get; set; }
+        public int QueryReadTimeoutMs { get; set; }
+        public int MotorSettleMs { get; set; }
+        public bool Open() { Calls.Add("Open"); return true; }
+        public void Close() => Calls.Add("Close");
+        public byte[] SendWait(byte[] f, int e = 0) { Calls.Add("SendWait"); return null; }
+        public int ShakeHandsWait() { Calls.Add("ShakeHands"); return 5; }
+        public int ReadCcdSnWait() => -1;
+        public int ReadLightBrightnessWait() { Calls.Add("ReadLight"); return 500; }
+        public int ReadWellHorizontalPosWait(int w) => -1;
+        public int ReadWellFocusZeroWait(int w) => -1;
+        public int ReadScanStepWait() => -1;
+        public bool WriteWellHorizontalPosWait(int well, int p) { Calls.Add($"WriteWellHor({well},{p})"); return true; }
+        public bool WriteScanStepWait(int p) { Calls.Add($"WriteScanStep({p})"); return true; }
+        public bool WriteOpenIntakeTimeWait(int v, bool b = false) { Calls.Add($"WriteIntake({v})"); return true; }
+        public bool WriteOpenVentTimeWait(int v) { Calls.Add($"WriteVent({v})"); return true; }
+        public int ReadOpenVentTimeWait() { Calls.Add("ReadVent"); return 200; }
+        public bool WriteLightBrightnessWait(int v) { Calls.Add($"WriteLight({v})"); return true; }
+        public bool VerticalMoveToWait(int p, int d = -1) { Calls.Add($"VMoveTo({p})"); return true; }
+        public bool VerticalForwardWait(int p, int d = -1) { Calls.Add($"VFwd({p})"); return true; }
+        public bool VerticalBackwardWait(int p, int d = -1) { Calls.Add($"VBack({p})"); return true; }
+        public bool VerticalResetWait(int d = -1) { Calls.Add("VReset"); return true; }
+        public int ReadVerticalPositionWait() => 0;
+        public bool HorizontalMoveToWait(int p, int d = -1) { Calls.Add($"HMoveTo({p})"); return true; }
+        public bool HorizontalForwardWait(int p, int d = -1) { Calls.Add($"HFwd({p})"); return true; }
+        public bool HorizontalBackwardWait(int p, int d = -1) { Calls.Add($"HBack({p})"); return true; }
+        public bool HorizontalResetWait(int d = -1) { Calls.Add("HReset"); return true; }
+        public int ReadHorizontalPositionWait() => 0;
+        public decimal TemperatureWait() { Calls.Add("Temp"); return 37.0m; }
+        public decimal ShangTemperatureWait() => 37.0m;
+        public decimal BoLiTemperatureWait() => 37.0m;
+        public decimal PressureWait() { Calls.Add("Pressure"); return 1.0m; }
+        public (decimal, decimal, decimal) BufferBottleStateWait() => (1m, 37m, 37m);
+        public DoorState DoorStatusWait() { Calls.Add("Door"); return DoorState.关闭; }
+        public bool OpenLedWait() { Calls.Add("OpenLed"); return true; }
+        public bool CloseLedWait() { Calls.Add("CloseLed"); return true; }
+        public bool OpenIntakeValveWait() { Calls.Add("OpenIntake"); return true; }
+        public bool CloseIntakeValveWait() { Calls.Add("CloseIntake"); return true; }
+        public bool OpenExhaustValveWait() { Calls.Add("OpenExhaust"); return true; }
+        public bool CloseExhaustValveWait() { Calls.Add("CloseExhaust"); return true; }
+        public bool HouseAerationWait() { Calls.Add("Aeration"); return true; }
+        public bool HouseVentWait() { Calls.Add("Vent"); return true; }
+        public bool BufferBottleAerationWait() => true;
+        public bool AutoAirSwapWait(bool on) => true;
+        public object RawComBin => null;
+        public void Dispose() => Calls.Add("Dispose");
+    }
+
+    public class FakeLease : IHardwareLease
+    {
+        public HardwareUser Owner { get; }
+        public ISerialChannel Serial { get; }
+        public ICamera Camera => null;
+        public bool Disposed;
+        public FakeLease(ISerialChannel s, HardwareUser u) { Serial = s; Owner = u; }
+        public void Dispose() => Disposed = true;
+    }
+
+    // 可控是否借得到(模拟 BUSY)。
+    public class FakeGate : IHouseGate
+    {
+        private readonly ISerialChannel _serial;
+        public bool CanAcquire = true;
+        public FakeLease LastLease;
+        public FakeGate(int sn, ISerialChannel s) { HouseSn = sn; _serial = s; }
+        public int HouseSn { get; }
+        public bool IsCapturePaused { get; private set; }
+        public event Action OnPauseCapture;
+        public event Action OnResumeCapture;
+        public IHardwareLease Acquire(HardwareUser u, int t = 30000)
+        {
+            if (!CanAcquire) return null;
+            IsCapturePaused = true;
+            LastLease = new FakeLease(_serial, u);
+            return LastLease;
+        }
+        public bool TryAcquire(HardwareUser u, out IHardwareLease l) { l = Acquire(u); return l != null; }
+        public void PauseCapture() => IsCapturePaused = true;
+        public void ResumeCapture() => IsCapturePaused = false;
+    }
+}
+```
+
+- [ ] **Step 2: 编译测试工程**
+
+Run: `dotnet build ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests`
+Expected: 0 错(确认 fake 实现了 ISerialChannel 全部成员;若漏成员编译器会报)。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/Fakes
+git commit -m "test(d2-02): 测试替身 FakeSerial/FakeLease/FakeGate"
+```
+
+---
+
+### Task 4: DebugSessionManager — acquire / release / 幂等(TDD)
+
+**Files:**
+- Create: `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs`
+- Test: `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugSessionManagerTests.cs`
+
+> manager 不直接依赖 `HardwareAccessLayer` 单例,而是注入"按舱取 gate"的委托 `Func<int,IHouseGate>` + 注入时钟 `Func<DateTime>`,以便单测。
+
+- [ ] **Step 1: 写失败测试**
+
+```csharp
+using System;
+using IvfTl.ControlHost.Debug;
+using IvfTl.ControlHost.Tests.Fakes;
+using Xunit;
+namespace IvfTl.ControlHost.Tests
+{
+    public class DebugSessionManagerTests
+    {
+        private DateTime _now = new DateTime(2026, 6, 23, 0, 0, 0);
+        private (DebugSessionManager mgr, FakeGate gate) New(bool canAcquire = true)
+        {
+            var serial = new FakeSerial();
+            var gate = new FakeGate(5, serial) { CanAcquire = canAcquire };
+            var mgr = new DebugSessionManager(sn => gate, () => _now, ttlMs: 10000, log: _ => { });
+            return (mgr, gate);
+        }
+
+        [Fact]
+        public void Acquire_Returns_SessionId_And_Pauses()
+        {
+            var (mgr, gate) = New();
+            var r = mgr.Acquire(5);
+            Assert.True(r.Ok);
+            Assert.False(string.IsNullOrEmpty((string)r.Result));
+            Assert.True(gate.IsCapturePaused);
+        }
+
+        [Fact]
+        public void Acquire_Busy_Returns_BUSY()
+        {
+            var (mgr, _) = New(canAcquire: false);
+            var r = mgr.Acquire(5);
+            Assert.False(r.Ok);
+            Assert.Equal("BUSY", r.Code);
+        }
+
+        [Fact]
+        public void Release_Disposes_Lease_And_Resumes()
+        {
+            var (mgr, gate) = New();
+            string sid = (string)mgr.Acquire(5).Result;
+            var r = mgr.Release(sid);
+            Assert.True(r.Ok);
+            Assert.True(gate.LastLease.Disposed);
+        }
+
+        [Fact]
+        public void Release_Unknown_Session_Is_Idempotent_Ok()
+        {
+            var (mgr, _) = New();
+            Assert.True(mgr.Release("not-a-session").Ok); // 幂等:不认识也回 ok
+        }
+    }
+}
+```
+
+- [ ] **Step 2: 跑验证失败**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter DebugSessionManagerTests`
+Expected: FAIL("DebugSessionManager 不存在")。
+
+- [ ] **Step 3: 实现 acquire/release(命令分发下个 Task 补)**
+
+```csharp
+using System;
+using System.Collections.Concurrent;
+using IvfTl.Hardware;
+namespace IvfTl.ControlHost.Debug
+{
+    /// <summary>
+    /// control 端调试会话后端:会话表 + 借用/归还 + 心跳续约 + 超时自动回收 + 命令分发。
+    /// 安全地基(spec §5):绝不指望 operate 主动还,SweepExpired 超时回收兜底。
+    /// </summary>
+    public sealed class DebugSessionManager
+    {
+        private readonly Func<int, IHouseGate> _gateOf;
+        private readonly Func<DateTime> _clock;
+        private readonly int _ttlMs;
+        private readonly Action<string> _log;
+        private readonly ConcurrentDictionary<string, DebugSession> _sessions = new ConcurrentDictionary<string, DebugSession>();
+
+        public DebugSessionManager(Func<int, IHouseGate> gateOf, Func<DateTime> clock, int ttlMs, Action<string> log)
+        {
+            _gateOf = gateOf; _clock = clock; _ttlMs = ttlMs; _log = log ?? (_ => { });
+        }
+
+        public DebugCommandResult Acquire(int houseSn)
+        {
+            var gate = _gateOf(houseSn);
+            if (gate == null) return DebugCommandResult.Fail("NO_HANDLE", $"舱{houseSn}无闸门");
+            var lease = gate.Acquire(HardwareUser.OperateDebug);
+            if (lease == null) return DebugCommandResult.Fail("BUSY", $"舱{houseSn}被占用,借用超时");
+            string sid = Guid.NewGuid().ToString("N");
+            _sessions[sid] = new DebugSession(sid, houseSn, lease, _clock());
+            _log($"[debug] acquire 舱{houseSn} sid={sid}");
+            return DebugCommandResult.Okay(sid);
+        }
+
+        public DebugCommandResult Heartbeat(string sid)
+        {
+            if (sid != null && _sessions.TryGetValue(sid, out var s)) { s.LastSeen = _clock(); return DebugCommandResult.Okay(); }
+            return DebugCommandResult.Fail("SESSION_EXPIRED", "会话不存在或已过期");
+        }
+
+        public DebugCommandResult Release(string sid)
+        {
+            // 幂等:不认识的 sid 也回 ok(spec §5.3)。
+            if (sid != null && _sessions.TryRemove(sid, out var s))
+            {
+                try { s.Lease.Dispose(); } catch { }
+                _log($"[debug] release sid={sid} 舱{s.HouseSn}");
+            }
+            return DebugCommandResult.Okay();
+        }
+
+        /// <summary>超时回收:LastSeen + ttl < now 的会话自动归还(spec §5.1)。看门狗线程周期调,也可单测直接调。</summary>
+        public int SweepExpired()
+        {
+            int n = 0; var now = _clock();
+            foreach (var kv in _sessions)
+            {
+                if ((now - kv.Value.LastSeen).TotalMilliseconds > _ttlMs)
+                {
+                    if (_sessions.TryRemove(kv.Key, out var s))
+                    {
+                        try { s.Lease.Dispose(); } catch { }
+                        _log($"[debug] 会话超时自动回收 sid={kv.Key} 舱{s.HouseSn}");
+                        n++;
+                    }
+                }
+            }
+            return n;
+        }
+    }
+}
+```
+
+- [ ] **Step 4: 跑验证通过**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter DebugSessionManagerTests`
+Expected: PASS(4 绿)。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugSessionManagerTests.cs
+git commit -m "feat(d2-02): DebugSessionManager acquire/release/heartbeat + 幂等单测"
+```
+
+---
+
+### Task 5: 超时自动回收(TDD) — 安全地基
+
+**Files:**
+- Modify Test: `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugSessionManagerTests.cs`
+
+- [ ] **Step 1: 加失败测试(可控时钟快进)**
+
+```csharp
+        [Fact]
+        public void Sweep_Reclaims_After_Ttl_And_Resumes()
+        {
+            var (mgr, gate) = New();
+            string sid = (string)mgr.Acquire(5).Result;
+            _now = _now.AddMilliseconds(11000); // 超过 ttl 10s
+            int reclaimed = mgr.SweepExpired();
+            Assert.Equal(1, reclaimed);
+            Assert.True(gate.LastLease.Disposed);
+            Assert.False(gate.IsCapturePaused); // 恢复采集
+            Assert.Equal("SESSION_EXPIRED", mgr.Heartbeat(sid).Code); // 会话已没
+        }
+
+        [Fact]
+        public void Heartbeat_Keeps_Session_Alive()
+        {
+            var (mgr, gate) = New();
+            string sid = (string)mgr.Acquire(5).Result;
+            _now = _now.AddMilliseconds(8000); mgr.Heartbeat(sid); // 续约
+            _now = _now.AddMilliseconds(8000); // 距上次心跳仅 8s < ttl
+            Assert.Equal(0, mgr.SweepExpired()); // 不回收
+            Assert.False(gate.LastLease.Disposed);
+        }
+```
+
+- [ ] **Step 2: 跑验证**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter DebugSessionManagerTests`
+Expected: PASS(6 绿)。Task4 的实现已满足;若 `IsCapturePaused` 未随 Dispose 复位,检查 FakeGate.FakeLease.Dispose 是否回调 gate.ResumeCapture——真实 `HardwareLeaseImpl.Dispose` 会(见 HouseGateImpl.Release),fake 需补:在 FakeLease.Dispose 里调 gate.ResumeCapture。
+
+- [ ] **Step 3: 若需要,修 FakeLease 让 Dispose 触发 Resume**
+
+```csharp
+    // FakeLease 改为持 gate 引用,Dispose 时恢复(对齐真实 HardwareLeaseImpl):
+    public class FakeLease : IHardwareLease
+    {
+        private readonly FakeGate _gate;
+        public HardwareUser Owner { get; }
+        public ISerialChannel Serial { get; }
+        public ICamera Camera => null;
+        public bool Disposed;
+        public FakeLease(FakeGate gate, ISerialChannel s, HardwareUser u) { _gate = gate; Serial = s; Owner = u; }
+        public void Dispose() { Disposed = true; _gate.ResumeCapture(); }
+    }
+    // FakeGate.Acquire 内改为 new FakeLease(this, _serial, u);
+```
+
+- [ ] **Step 4: 跑通**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter DebugSessionManagerTests`
+Expected: PASS(6 绿)。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests
+git commit -m "test(d2-02): 会话超时自动回收 + 心跳续约(安全地基)"
+```
+
+---
+
+### Task 6: 命令分发 Execute — 读数/握手/阀/LED(TDD)
+
+**Files:**
+- Modify: `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs`
+- Test: `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugExecuteTests.cs`
+
+- [ ] **Step 1: 写失败测试**
+
+```csharp
+using IvfTl.ControlHost.Debug;
+using IvfTl.ControlHost.Tests.Fakes;
+using Newtonsoft.Json.Linq;
+using System;
+using Xunit;
+namespace IvfTl.ControlHost.Tests
+{
+    public class DebugExecuteTests
+    {
+        private DateTime _now = new DateTime(2026, 6, 23);
+        private (DebugSessionManager mgr, FakeSerial serial, string sid) New()
+        {
+            var serial = new FakeSerial();
+            var gate = new FakeGate(5, serial);
+            var mgr = new DebugSessionManager(sn => gate, () => _now, 10000, _ => { });
+            string sid = (string)mgr.Acquire(5).Result;
+            return (mgr, serial, sid);
+        }
+
+        [Fact] public void Execute_ReadTemp_Dispatches()
+        {
+            var (mgr, serial, sid) = New();
+            var r = mgr.Execute(sid, "ReadTemp", null);
+            Assert.True(r.Ok);
+            Assert.Contains("Temp", serial.Calls);
+        }
+
+        [Fact] public void Execute_OpenLed_Dispatches()
+        {
+            var (mgr, serial, sid) = New();
+            Assert.True(mgr.Execute(sid, "OpenLed", null).Ok);
+            Assert.Contains("OpenLed", serial.Calls);
+        }
+
+        [Fact] public void Execute_Expired_Session_Rejected()
+        {
+            var (mgr, _, _) = New();
+            Assert.Equal("SESSION_EXPIRED", mgr.Execute("ghost", "ReadTemp", null).Code);
+        }
+
+        [Fact] public void Execute_Unknown_Op_Rejected()
+        {
+            var (mgr, _, sid) = New();
+            var r = mgr.Execute(sid, "NoSuchOp", null);
+            Assert.False(r.Ok);
+            Assert.Equal("BAD_OP", r.Code);
+        }
+    }
+}
+```
+
+- [ ] **Step 2: 跑验证失败**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter DebugExecuteTests`
+Expected: FAIL("Execute 不存在")。
+
+- [ ] **Step 3: 实现 Execute 分发(本 Task 含非电机部分;电机/EEPROM 在 Task7)**
+
+在 `DebugSessionManager` 内加(`using Newtonsoft.Json.Linq;`):
+```csharp
+        public DebugCommandResult Execute(string sid, string op, JObject args)
+        {
+            if (sid == null || !_sessions.TryGetValue(sid, out var s))
+                return DebugCommandResult.Fail("SESSION_EXPIRED", "会话不存在或已过期");
+            s.LastSeen = _clock(); // 命令本身也算活动,顺带续约
+            var ser = s.Lease.Serial;
+            if (ser == null) return DebugCommandResult.Fail("NO_HANDLE", "借用串口句柄为空");
+            try
+            {
+                switch (op)
+                {
+                    // 读数
+                    case "ReadTemp": return DebugCommandResult.Okay(ser.TemperatureWait());
+                    case "ReadPressure": return DebugCommandResult.Okay(ser.PressureWait());
+                    case "ReadDoor": return DebugCommandResult.Okay(ser.DoorStatusWait().ToString());
+                    case "ReadVentTime": return DebugCommandResult.Okay(ser.ReadOpenVentTimeWait());
+                    case "ShakeHands": return DebugCommandResult.Okay(ser.ShakeHandsWait());
+                    // 阀/LED/换气
+                    case "OpenLed": return DebugCommandResult.Okay(ser.OpenLedWait());
+                    case "CloseLed": return DebugCommandResult.Okay(ser.CloseLedWait());
+                    case "OpenIntake": return DebugCommandResult.Okay(ser.OpenIntakeValveWait());
+                    case "CloseIntake": return DebugCommandResult.Okay(ser.CloseIntakeValveWait());
+                    case "OpenExhaust": return DebugCommandResult.Okay(ser.OpenExhaustValveWait());
+                    case "CloseExhaust": return DebugCommandResult.Okay(ser.CloseExhaustValveWait());
+                    case "HouseAeration": return DebugCommandResult.Okay(ser.HouseAerationWait());
+                    case "HouseVent": return DebugCommandResult.Okay(ser.HouseVentWait());
+                    default:
+                        return ExecuteMotorOrEeprom(s, ser, op, args); // Task7 实现
+                }
+            }
+            catch (Exception ex) { return DebugCommandResult.Fail("HARDWARE_ERROR", ex.Message); }
+        }
+
+        // Task7 先占位:返回 BAD_OP,让"未知 op"测试通过;Task7 替换为真实电机/EEPROM 分发。
+        private DebugCommandResult ExecuteMotorOrEeprom(DebugSession s, IvfTl.Hardware.ISerialChannel ser, string op, JObject args)
+            => DebugCommandResult.Fail("BAD_OP", $"未知 op: {op}");
+```
+
+- [ ] **Step 4: 跑验证通过**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter DebugExecuteTests`
+Expected: PASS(4 绿)。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugExecuteTests.cs
+git commit -m "feat(d2-02): 命令分发 Execute(读数/阀/LED)+ 会话校验单测"
+```
+
+---
+
+### Task 7: 命令分发 — 电机(钳位)/EEPROM 写(TDD)
+
+**Files:**
+- Modify: `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs`(替换 `ExecuteMotorOrEeprom`)
+- Modify Test: `ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugExecuteTests.cs`
+
+- [ ] **Step 1: 加失败测试(钳位是红线重点)**
+
+```csharp
+        [Fact] public void Vertical_MoveTo_InRange_Dispatches()
+        {
+            var (mgr, serial, sid) = New();
+            var r = mgr.Execute(sid, "VerticalMoveTo", JObject.FromObject(new { pos = 90000 }));
+            Assert.True(r.Ok);
+            Assert.Contains("VMoveTo(90000)", serial.Calls);
+        }
+
+        [Fact] public void Vertical_MoveTo_OutOfRange_Rejected_NotDispatched()
+        {
+            var (mgr, serial, sid) = New();
+            var r = mgr.Execute(sid, "VerticalMoveTo", JObject.FromObject(new { pos = 130000 }));
+            Assert.Equal("OUT_OF_RANGE", r.Code);
+            Assert.DoesNotContain("VMoveTo(130000)", serial.Calls); // 越界绝不下发
+        }
+
+        [Fact] public void Horizontal_Forward_Relative_Clamped()
+        {
+            var (mgr, serial, sid) = New();
+            mgr.Execute(sid, "HorizontalMoveTo", JObject.FromObject(new { pos = 215000 })); // 当前位=215000
+            var r = mgr.Execute(sid, "HorizontalForward", JObject.FromObject(new { value = 10000 })); // 目标225000>220000
+            Assert.Equal("OUT_OF_RANGE", r.Code);
+        }
+
+        [Fact] public void WriteScanStep_Dispatches()
+        {
+            var (mgr, serial, sid) = New();
+            Assert.True(mgr.Execute(sid, "WriteScanStep", JObject.FromObject(new { value = 300 })).Ok);
+            Assert.Contains("WriteScanStep(300)", serial.Calls);
+        }
+```
+
+- [ ] **Step 2: 跑验证失败**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter DebugExecuteTests`
+Expected: FAIL(钳位/电机未实现,返回 BAD_OP)。
+
+- [ ] **Step 3: 替换 ExecuteMotorOrEeprom 为真实分发**
+
+```csharp
+        private DebugCommandResult ExecuteMotorOrEeprom(DebugSession s, IvfTl.Hardware.ISerialChannel ser, string op, JObject args)
+        {
+            int Arg(string k, int def = 0) => args?[k] != null ? args[k].Value<int>() : def;
+            int delay = -1; // 用 ISerialChannel 内部 MotorSettleMs 默认
+
+            switch (op)
+            {
+                // ── 垂直 Z(对焦轴,钳 [0,125000]) ──
+                case "VerticalReset": { bool ok = ser.VerticalResetWait(delay); s.CurrentVer = 0; return DebugCommandResult.Okay(ok); }
+                case "VerticalMoveTo":
+                {
+                    int pos = Arg("pos");
+                    if (!MotorClamp.IsVerticalInRange(pos)) return DebugCommandResult.Fail("OUT_OF_RANGE", $"垂直目标{pos}越界[0,{MotorClamp.VerMax}]");
+                    bool ok = ser.VerticalMoveToWait(pos, delay); if (ok) s.CurrentVer = pos; return DebugCommandResult.Okay(ok);
+                }
+                case "VerticalForward":
+                case "VerticalBackward":
+                {
+                    int delta = Arg("value") * (op == "VerticalBackward" ? -1 : 1);
+                    int basePos = s.CurrentVer < 0 ? 0 : s.CurrentVer;
+                    int target = MotorClamp.RelativeTarget(basePos, delta);
+                    if (!MotorClamp.IsVerticalInRange(target)) return DebugCommandResult.Fail("OUT_OF_RANGE", $"垂直目标{target}越界");
+                    bool ok = op == "VerticalBackward" ? ser.VerticalBackwardWait(Arg("value"), delay) : ser.VerticalForwardWait(Arg("value"), delay);
+                    if (ok) s.CurrentVer = target; return DebugCommandResult.Okay(ok);
+                }
+                // ── 水平(皿孔,钳 [0,220000]) ──
+                case "HorizontalReset": { bool ok = ser.HorizontalResetWait(delay); s.CurrentHor = 0; return DebugCommandResult.Okay(ok); }
+                case "HorizontalMoveTo":
+                {
+                    int pos = Arg("pos");
+                    if (!MotorClamp.IsHorizontalInRange(pos)) return DebugCommandResult.Fail("OUT_OF_RANGE", $"水平目标{pos}越界[0,{MotorClamp.HorMax}]");
+                    bool ok = ser.HorizontalMoveToWait(pos, delay); if (ok) s.CurrentHor = pos; return DebugCommandResult.Okay(ok);
+                }
+                case "HorizontalForward":
+                case "HorizontalBackward":
+                {
+                    int delta = Arg("value") * (op == "HorizontalBackward" ? -1 : 1);
+                    int basePos = s.CurrentHor < 0 ? 0 : s.CurrentHor;
+                    int target = MotorClamp.RelativeTarget(basePos, delta);
+                    if (!MotorClamp.IsHorizontalInRange(target)) return DebugCommandResult.Fail("OUT_OF_RANGE", $"水平目标{target}越界");
+                    bool ok = op == "HorizontalBackward" ? ser.HorizontalBackwardWait(Arg("value"), delay) : ser.HorizontalForwardWait(Arg("value"), delay);
+                    if (ok) s.CurrentHor = target; return DebugCommandResult.Okay(ok);
+                }
+                // ── EEPROM 写 ──
+                case "WriteScanStep": return DebugCommandResult.Okay(ser.WriteScanStepWait(Arg("value")));
+                case "WriteOpenIntakeTime": return DebugCommandResult.Okay(ser.WriteOpenIntakeTimeWait(Arg("value")));
+                case "WriteOpenVentTime": return DebugCommandResult.Okay(ser.WriteOpenVentTimeWait(Arg("value")));
+                case "WriteWellHorizontalPos": return DebugCommandResult.Okay(ser.WriteWellHorizontalPosWait(Arg("well"), Arg("hor")));
+                default: return DebugCommandResult.Fail("BAD_OP", $"未知 op: {op}");
+            }
+        }
+```
+
+- [ ] **Step 4: 跑验证通过**
+
+Run: `dotnet test ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests --filter DebugExecuteTests`
+Expected: PASS(8 绿)。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Debug/DebugSessionManager.cs ivf_tl_operate_2.0/control/IvfTl.ControlHost.Tests/DebugExecuteTests.cs
+git commit -m "feat(d2-02): 命令分发电机(红线钳位)/EEPROM写 + 越界拒绝单测"
+```
+
+---
+
+### Task 8: ControlHttpServer 加 /debug/* 路由
+
+**Files:**
+- Modify: `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ControlHttpServer.cs`
+
+> 现 `Handle()` 是"算 body 一次性写完"。本 Task 加 4 个 JSON 端点(acquire/command/release/heartbeat);`/debug/preview/stream`(流式)留第二阶段。新增一个可选构造参数 `DebugSessionManager debug`,避免再膨胀必填参数。
+
+- [ ] **Step 1: 构造函数加可选 manager 字段**
+
+在 `ControlHttpServer` 字段区加 `private readonly DebugSessionManager _debug;`,构造函数末尾加可选参 `DebugSessionManager debug = null` 并 `_debug = debug;`。
+
+- [ ] **Step 2: switch 加 /debug/* 分支**
+
+在 `Handle()` 的 `switch(path)` 内 `default` 之前插入:
+```csharp
+                case "/debug/acquire":
+                    if (method != "POST") { code = 405; body = Err("method not allowed"); break; }
+                    {
+                        int houseSn = ReadIntField(ctx, "houseSn");
+                        var r = _debug != null ? _debug.Acquire(houseSn) : DebugCommandResult.Fail("NO_HANDLE", "debug 未装配");
+                        code = r.Ok ? 200 : 409; body = JsonConvert.SerializeObject(r);
+                    }
+                    break;
+                case "/debug/heartbeat":
+                    if (method != "POST") { code = 405; body = Err("method not allowed"); break; }
+                    {
+                        var r = _debug != null ? _debug.Heartbeat(ReadField(ctx, "sessionId")) : DebugCommandResult.Fail("SESSION_EXPIRED", "debug 未装配");
+                        code = r.Ok ? 200 : 410; body = JsonConvert.SerializeObject(r);
+                    }
+                    break;
+                case "/debug/release":
+                    if (method != "POST") { code = 405; body = Err("method not allowed"); break; }
+                    {
+                        var r = _debug != null ? _debug.Release(ReadField(ctx, "sessionId")) : DebugCommandResult.Okay();
+                        code = 200; body = JsonConvert.SerializeObject(r);
+                    }
+                    break;
+                case "/debug/command":
+                    if (method != "POST") { code = 405; body = Err("method not allowed"); break; }
+                    {
+                        var jo = ReadBody(ctx); // 见 Step3:一次性读 body 成 JObject
+                        string sid = jo?["sessionId"]?.ToString();
+                        string op = jo?["op"]?.ToString();
+                        var argsObj = jo?["args"] as Newtonsoft.Json.Linq.JObject;
+                        var r = _debug != null ? _debug.Execute(sid, op, argsObj) : DebugCommandResult.Fail("SESSION_EXPIRED", "debug 未装配");
+                        code = r.Ok ? 200 : (r.Code == "SESSION_EXPIRED" ? 410 : (r.Code == "OUT_OF_RANGE" ? 400 : 200));
+                        body = JsonConvert.SerializeObject(r);
+                    }
+                    break;
+```
+
+- [ ] **Step 3: 加 ReadBody(整 body 解析一次,/command 多字段需要)**
+
+```csharp
+        /// <summary>把 POST body 整体解析为 JObject(失败返回 null)。/debug/command 多字段用。</summary>
+        private Newtonsoft.Json.Linq.JObject ReadBody(HttpListenerContext ctx)
+        {
+            try
+            {
+                using (var sr = new StreamReader(ctx.Request.InputStream, ctx.Request.ContentEncoding ?? Encoding.UTF8))
+                {
+                    string raw = sr.ReadToEnd();
+                    return string.IsNullOrEmpty(raw) ? null : Newtonsoft.Json.Linq.JObject.Parse(raw);
+                }
+            }
+            catch (Exception ex) { _log("解析 body 异常:" + ex.Message); return null; }
+        }
+```
+
+- [ ] **Step 4: 编译**
+
+Run: `dotnet build ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ivf_tl_ControlHost.csproj -c Debug`
+Expected: 0 错。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ControlHttpServer.cs
+git commit -m "feat(d2-02): ControlHttpServer 加 /debug/acquire|command|release|heartbeat 路由"
+```
+
+---
+
+### Task 9: Program.cs 装配 manager + TTL 看门狗线程
+
+**Files:**
+- Modify: `ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Program.cs`
+
+- [ ] **Step 1: 建 manager(用真实 HAL 的 GetHouseGate + 系统时钟)**
+
+在 `Main` 起 HTTP 之前加:
+```csharp
+                var debugMgr = new IvfTl.ControlHost.Debug.DebugSessionManager(
+                    houseSn => IvfTl.Hardware.Impl.HardwareAccessLayer.Instance.GetHouseGate(houseSn),
+                    () => DateTime.UtcNow,
+                    ttlMs: 10000,
+                    log: msg => Log4netHelper.WriteLog(msg));
+```
+
+- [ ] **Step 2: 注入 ControlHttpServer(末尾加 debugMgr 实参)**
+
+把 `new ControlHttpServer(...)` 调用末尾参数 `msg => Log4netHelper.WriteLog(msg)` 后补一个实参 `, debugMgr`(构造已加可选参)。
+
+- [ ] **Step 3: 起看门狗线程(周期 SweepExpired)**
+
+在 `_http.Start();` 之后加:
+```csharp
+                System.Threading.Tasks.Task.Run(async () =>
+                {
+                    while (!(_exitEvent?.IsSet ?? false))
+                    {
+                        try { debugMgr.SweepExpired(); } catch (Exception ex) { Log4netHelper.WriteLog("[debug] Sweep 异常:" + ex.Message); }
+                        await System.Threading.Tasks.Task.Delay(3000);
+                    }
+                });
+```
+(注:`_exitEvent` 在第 73 行才 new;把看门狗启动挪到 `_exitEvent = new ManualResetEventSlim(false);` 之后、`_exitEvent.Wait();` 之前。)
+
+- [ ] **Step 4: 编译**
+
+Run: `dotnet build ivf_tl_operate_2.0/control/ivf_tl_ControlHost/ivf_tl_ControlHost.csproj -c Release`
+Expected: 0 错(真机用 Release)。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add ivf_tl_operate_2.0/control/ivf_tl_ControlHost/Program.cs
+git commit -m "feat(d2-02): Program 装配 DebugSessionManager + TTL 看门狗线程"
+```
+
+---
+
+### Task 10: 第一阶段真机冒烟(curl,Claude 自主跑)
+
+> 真机门控:UAC 静默提权拉起 control,curl 验证端点。电机命令本 Task 只验**越界拒绝**(安全),真机走位放第三阶段 V-012。
+
+- [ ] **Step 1: Release 部署 control 到真机布局,提权拉起**(参考部署指南;先清僵尸 operate)。
+
+- [ ] **Step 2: 探活 + 借用**
+
+Run: `curl -s -X POST http://127.0.0.1:38080/debug/acquire -d '{"houseSn":5}'`
+Expected: `{"ok":true,"result":"<sid>"}`;control 日志 `[debug] acquire 舱5`。
+
+- [ ] **Step 3: 读数命令**
+
+Run: `curl -s -X POST http://127.0.0.1:38080/debug/command -d '{"sessionId":"<sid>","op":"ReadTemp"}'`
+Expected: `{"ok":true,"result":<温度>}`。
+
+- [ ] **Step 4: 越界电机被拒(红线)**
+
+Run: `curl -s -X POST http://127.0.0.1:38080/debug/command -d '{"sessionId":"<sid>","op":"VerticalMoveTo","args":{"pos":130000}}'`
+Expected: `{"ok":false,"code":"OUT_OF_RANGE",...}`;下位机无动作。
+
+- [ ] **Step 5: 归还 + 超时回收**
+
+归还:`curl -s -X POST http://127.0.0.1:38080/debug/release -d '{"sessionId":"<sid>"}'` → `{"ok":true}`。
+再借一次但不发心跳,等 >10s,control 日志应出 `会话超时自动回收`、该舱采集恢复。记录到 `待验证清单.md` D2-02 条目,提交。
+
+---
+
+## 后续阶段(待第一阶段真机验证后各自展开为独立计划)
+
+### 第二阶段 · MJPEG 预览(独立计划 `2026-06-xx-D2-02-MJPEG预览.md`)
+- control:`/debug/preview/stream?sessionId=` 在 `ControlHttpServer` 走**流式分支**(不进统一 body 写法):校验会话→起抓帧循环(`lease.Camera.GrabStable()`→JPEG→`multipart/x-mixed-replace` 持续写)→client 断开抛异常即停 + 标记会话可回收(快信号)。
+- operate:`MjpegStreamClient`(HttpClient 读流→切 boundary→BitmapImage→WPF Image)。
+- 关键:抓帧与 `SavePic` 同台相机经 `ICameraGate` 串行;长连接稳定性(spec §13.1)。
+- 验证:真机出图。
+
+### 第三阶段 · operate 接入 + 真机 V-012(独立计划 `2026-06-xx-D2-02-operate接入.md`)
+- operate 新建 `DebugSessionClient`(HttpClient 封 acquire/command/release + 心跳定时器 + 会话失效回调弹"请重新进入调试")。
+- `HouseDebugPageViewModel` + `BufferDebugViewModel`:把 `HardwareAccessLayer.Instance.GetHouseGate().Acquire` + `lease.Serial.*` 全换成 `client.Command(op,args)`;OperationLogger 埋点留 operate 侧。
+- `HouseDebugPageView.xaml.cs` + `BufferDebugView.xaml.cs`:Start/End/Return 改调 client;预览接 `MjpegStreamClient`。
+- `DebugCalibrationAdapter`:仅当引用死栈类型时换类型。
+- 真机 V-012:借串口让路时序、**电机真机走位(红线两轴守安全区间)**、operate 崩溃后 control 自动回收恢复采集。
+- 完成后解锁 **D3-04**(删 operate 死串口/相机栈)。
+
+---
+
+## 自查结论(对照 spec)
+
+- **spec §3/§4 形态与契约** → Task1/4/6/7/8(数据类/会话/分发/路由)。
+- **spec §5 安全地基(崩溃/丢消息/长时/失效)** → Task4(幂等)/Task5(超时回收+心跳续约)/Task6(失效拒绝);崩溃快信号在第二阶段(MJPEG 断流)。
+- **spec §7 操作清单** → Task6(读数/阀/LED)+Task7(电机/EEPROM);缓冲瓶 B 类 op 在第三阶段接入时按 `BufferDebugViewModel` 现有方法补登记(同 switch 模式)。
+- **spec §10 红线钳位** → Task2(纯函数)+Task7(分发处拦截+越界不下发)。
+- **spec §6 ComHouseInit 初始化序列** → 第三阶段接入时定 A/B 案;第一阶段 acquire 只借用、不跑序列(curl 验证够用)。
+- **类型一致**:`DebugCommandResult.Okay/Fail`、`code` 串(BUSY/SESSION_EXPIRED/OUT_OF_RANGE/NO_HANDLE/BAD_OP/HARDWARE_ERROR)、`Execute(sid,op,JObject)` 签名贯穿 Task6-9 一致。

+ 13 - 0
项目文档/进度/交接卡.md

@@ -388,3 +388,16 @@
 - **核实**:6 任务每个经 spec 合规 + 代码质量两阶段子代理审查(独立读码/独立 grep/实跑核对);真机 /status 实读、单点改动对称验证、回归各项实际数字;工作区干净(取证改的 bin 共享文件已恢复)。提交链:spec f547ac9 / plan c9f1c70 / Task2 ca30cb2+8f3d096 / Task3 d687a3f / Task4 c3866440+199db15 / Task5 9235e10。
 - **诚实边界**:operate WPF 真外壳端到端(经 file= 读 + 登录 + 拉 control)受僵尸 Mutex 门控未现场跑通;连接变更走"改文件→重启进程"流程(重启读合并配置正确),未单独验"统一配置 UI 保存后本进程 RefreshSection 即时生效"(对连接键非常用路径,重启是现实流程)。凭据组未并入(独立小决策)。
 - **下一步**:本分支待并 main。剩延后专项(D2-02 命令代理设计/D3-04 删死栈被其阻塞/D3-02 整机自启需重启)+ 凭据收敛(可选)。按用户优先级。
+
+---
+
+## 2026-06-23 · D2-02 调试页跨进程命令代理:brainstorm → spec → 第一阶段实现计划(代码未开工)
+
+- **背景**:用户从剩余延后专项里选「做 D2-02 设计」。D2-02 = operate/control 拆分后,调试页(工程师调下位机参数用)够不着 control 进程内的 HAL lease,全部硬件操作(电机/EEPROM/阀/LED/读数/抓图/实时预览)断了,需改跨进程。主设计 §8 阶段2 留的大改面,spec §160/§178 标"未细化"。
+- **开机先发现一处文档偏差并纠正**:旧断点写「feature/config-consolidation 待并 main」,但 git 实证那批 config 提交(d687a3f/c386644/9235e10/1020e22)**早已在 main**、该分支不存在 → 配置收敛已落地 main,"待并 main"系笔误,本轮已在断点纠正。
+- **流程(brainstorming skill)**:逐项澄清,与用户敲定 4 个关键决策——① 范围含相机实时预览;② 预览跨进程传输选 **HTTP MJPEG 流**(用户选,比轮询拉帧更流畅);③ 有活体培养时长时间调试**不加业务护栏**(与老系统一致,只靠监控页可见);④ 借用边界沿用现状(点【初始化】借、【卸载/返回】还)。
+- **用户两个关键追问,已写进 spec §5(安全地基)**:① operate 意外不还/还了没收到怎么办 → 租约+心跳+control端超时自动回收(走与正常归还同一路径),崩溃靠 MJPEG 断流快信号、丢消息靠幂等重试+TTL兜底,失效 sessionId 的命令一律拒(防抢串口);② 调试几小时 → TTL 只罚"失联"不罚"操作时长",心跳一直续就一直不回收。
+- **设计形态(spec)**:会话式借用(sessionId)+ 通用 `/debug/command`(op枚举)分发(非每操作一端点)+ MJPEG 预览 + **红线电机钳位放 control 端**(水平[0,220000]/垂直[0,125000]越界拒绝不下发)。是 **D3-04(删 operate 死串口栈)的前置**。
+- **产出**:spec `需求文档/specs/2026-06-23-D2-02-调试页命令代理-design.md`(14节,已提交 d5afcba) + 实现计划 `开发计划/2026-06-23-阶段2-D2-02-调试页命令代理实现计划.md`。计划按 Scope Check 拆 3 子阶段:①control后端(会话/分发/钳位/超时回收,纯 xUnit 单测 + curl 真机,**bite-sized 完整 Task0-10**)②MJPEG预览(提纲)③operate接入+真机V-012(提纲,解锁D3-04)。分支 `feature/d2-02-debug-command-proxy`。
+- **核实**:codegraph 实读改面(HouseDebugPageViewModel 全操作面/HouseGateImpl 借用闸门/CameraImpl.StartPreview=Usb2Start 贴HWND 跨进程做不到→改帧传输/ControlHttpServer switch路由/Program装配/ISerialChannel 54方法签名);spec 自查(无悬空TBD)+ 用户 review 通过;计划自查(对照 spec 覆盖、类型一致 DebugCommandResult/code串/Execute签名贯穿)。**代码一行未写**——本轮只到设计+计划。
+- **下一步**:执行第一阶段 control 后端(建议子代理驱动逐 Task 实现,真机 curl 冒烟由 Claude 自主跑)。spec+计划(本分支)待并 main。

+ 1 - 1
项目文档/进度/工作计划表.md

@@ -26,7 +26,7 @@
 | 阶段 | 内容 | 状态 | 出口验收 |
 |------|------|------|----------|
 | **阶段1** | control 独立进程骨架 | 🟢 代码完成·真机闭环打通(待并 main) | control 独立 exe 能起✓、HTTP探活/读状态✓、续命✓、单实例✓、硬件获取✓、**真机自控环运行✓**;阻塞闭环的 D1-08 串口握手死锁已修复 |
-| **阶段2** | 监控补全 + 调试借串口 + 受护栏停止 | 🟢 监控/受护栏停止/借串口让路 已实现+真机验;调试页完整驱动待设计 | 监控页跨进程 /status 显示完整✓;受护栏 /shutdown 安全停✓;/serial 让路✓(调试页完整借串口需命令代理设计+受监督真机) |
+| **阶段2** | 监控补全 + 调试借串口 + 受护栏停止 | 🟢 监控/受护栏停止/借串口让路 已实现+真机验;**D2-02 调试页完整借串口:spec + 第一阶段实现计划已出(2026-06-23),代码待执行** | 监控页跨进程 /status 显示完整✓;受护栏 /shutdown 安全停✓;/serial 让路✓;**D2-02(会话式借用+通用command分发+MJPEG预览+红线钳位+超时自动回收)spec `2026-06-23-D2-02-调试页命令代理-design.md` + 计划 `2026-06-23-阶段2-D2-02-...实现计划.md`(拆3阶段,①control后端 bite-sized 完整) 已出,分支 feature/d2-02-debug-command-proxy,待执行** |
 | **阶段3** | 清理老壳 + 装机收尾 | 🟢 退役删ControlTest+部署文档+开机自启 已做;**D1-10 control oplog审计埋点已迁移+真机验证**;**D3-05 control崩溃看门狗已实现+真机验证(2026-06-23)**;**HIL硬件在环回归套件已入库+真机验证(2026-06-23)**;**配置收敛 已完成+真机验证(2026-06-23)**;删operate死栈延后 | 退役删 ivf_tl_ControlTest✓;双进程部署指南✓;开机自启✓;**D1-10 oplog审计迁移到control活栈✓**;**D3-05 看门狗(崩溃重拉/DPAPI凭据/可暂停停止卸载)✓**;**HIL套件 IvfTl.Hardware.HilTests(守护M-05帧长/M-06按well焦点/M-01-03 EEPROM写,门控Skip+默认零写入)✓**;**配置收敛(operate↔control连接组7键单一数据源tl-shared.config经<appSettings file=>合并读+operate删12换气CCD死键,真机改一处对称生效)✓**;ComBin删operate死栈(D3-04,被D2-02阻塞)仍延后 |
 
 ---

+ 10 - 12
项目文档/进度/进度状态.yaml

@@ -1,18 +1,16 @@
 # 续接断点状态(机器可解析)。换会话/换电脑后首先读它定位。
 # 状态取值: 未开始 / 进行中 / 完成 / 代码完成待验证
 # 纪律:本字段只存【当前断点】,历史细节进 交接卡.md(见 CLAUDE.md 第三节)。
-更新时间: 2026-06-23 配置收敛(operate↔control 连接组单一数据源 + operate 死键清理)已完成并真机验证(= 昨日建议「配置收敛」)。当前无 control 在跑、无活体培养(僵尸 operate 20268 仍在但不占舱口、需重启清)。
+更新时间: 2026-06-23 D2-02(调试页跨进程命令代理)已出 spec + 第一阶段实现计划(bite-sized TDD),代码未开工。配置收敛此前已并入 main(那批 config 提交已在 main,旧断点"待并 main"系笔误、已纠正)。当前无 control 在跑、无活体培养(僵尸 operate 20268 仍在但不占舱口、需重启清)。
 当前任务: >
-  【配置收敛 ☑ 完成】(= 昨日建议「配置收敛」;分支 feature/config-consolidation)
-  · 7 个共享连接键(urlIp/urlPort/mqttIp/mqttPort/kfkaIP/kfkaPort/outInter)收敛为唯一数据源
-    新增文件 ivf_tl_Operate/tl-shared.config(放 operate 输出根);operate/control 各自 App.config
-    经 <appSettings file="(..\)tl-shared.config"> 只读合并 → 两进程读取 C# 零改动;换机只改这一份。
-  · 写入由 operate AppConfigHelper.Save 经新纯逻辑单元 SharedConfigStore(XDocument 直写)收口;
-    新建 operate 首个单测工程 ivf_tl_Operate.Tests(6 绿)。operate 删 12 个换气/CCD 死副本键(grep 坐实零消费)。
-  · 真机:部署布局下 control 经 ..\tl-shared.config 读到 ServerUrl=127.0.0.1:10010;改 urlPort 10010↔19999
-    重启 control 对称反映(单一数据源生效);回归 operate6/SerialHelper40/HIL 2过2跳/双编译0错。
-  · 下一步:剩工作计划延后专项(D2-02 命令代理需多会话设计→解锁 D3-04 / D3-02 整机自启需重启)。按用户优先级。
-    本分支待并 main。operate 真外壳 E2E 受僵尸 20268 Mutex 门控(需重启清),读取机制与 control 同源、据 control 实证判定成立。
+  【D2-02 调试页命令代理 · 设计+计划已出,待执行】(分支 feature/d2-02-debug-command-proxy)
+  · spec:需求文档/specs/2026-06-23-D2-02-调试页命令代理-design.md(已提交 d5afcba)
+    = 会话式借用(sessionId)+通用/debug/command(op枚举)分发+MJPEG预览;安全地基=租约+心跳+control端超时自动回收
+    (扛 operate 崩溃/丢消息/长时调试);红线电机钳位放 control;借用边界沿用现状(点初始化借、卸载/返回还);是 D3-04 前置。
+  · 计划:开发计划/2026-06-23-阶段2-D2-02-调试页命令代理实现计划.md
+    拆 3 阶段:①control后端(会话/分发/钳位/超时,纯单测+curl,bite-sized 完整 Task0-10)②MJPEG预览(提纲)③operate接入+真机V-012(提纲)。
+  · 下一步:执行第一阶段(control 后端),建议子代理驱动逐 Task 实现;真机冒烟 curl 由 Claude 自主跑。
+    本分支(spec+计划)待并 main。
 说明: >
   operate/control 双进程拆分三阶段主体早已完成;合并遗留 M 区 M-01~M-07 本轮全部闭合
   (M-01/02/03 builder去桩、M-04 存图代码定论、M-05 0x12帧长回归、M-06 按well焦点零点、M-07 网关)。
@@ -38,4 +36,4 @@
     名称: 清理老壳 + 装机收尾
     状态: 未开始
     备注: "退役删ivf_tl_ControlTest脏壳 + operate开机自启 + ComBin两套栈去重(G1-2) + 部署文档。待阶段2完成后拆计划"
-下一步: 配置收敛已完成并真机验证。剩工作计划延后专项(D2-02 命令代理设计→解锁 D3-04 删死栈 / D3-02 整机自启需重启)。feature/config-consolidation 待并 main。按用户优先级。
+下一步: D2-02 spec + 第一阶段实现计划已出(分支 feature/d2-02-debug-command-proxy)。执行第一阶段 control 后端(子代理驱动逐 Task,纯单测+curl 真机冒烟)。完成后第二阶段 MJPEG、第三阶段 operate 接入(解锁 D3-04)。D3-02 整机自启需重启。按用户优先级。