From 4b1f33bf6b85aab0e6034e99dfce0de0ece070e0 Mon Sep 17 00:00:00 2001 From: Su Yang Date: Mon, 9 Nov 2026 02:24:05 +0800 Subject: [PATCH 1/4] Release Python-policy L20 dispatch-only reply reservations --- .github/workflows/release-packages.yml | 2 +- CHANGELOG.md | 6 + CHANGELOG.zh-CN.md | 6 + CMakeLists.txt | 2 +- README.en.md | 2 +- README.md | 2 +- RELEASE_NOTES.md | 10 + docs/en/guides/python-behavior-baseline.md | 6 + docs/en/guides/releases.md | 2 +- docs/zh/guides/python-behavior-baseline.md | 6 + docs/zh/guides/releases.md | 2 +- src/hand/runtime/device_runtime.cpp | 9 + .../unified/detail/python_transactions.hpp | 12 + tests/hand/device/test_device.cpp | 206 +++++++++++++++++- .../hand/device/test_python_transactions.cpp | 31 +++ 15 files changed, 292 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index 8b3f779..58f8887 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -26,7 +26,7 @@ name: Release packages expected_version: description: 'Hand SDK version (transport may use a compatible newer patch)' required: true - default: '0.3.10' + default: '0.3.11' permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index f9f7fc7..db580a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ [English](CHANGELOG.md) | [简体中文](CHANGELOG.zh-CN.md) +## 0.3.11 + +- Release L20 dispatch-only write reply reservations under the explicit Python policy. +- Reject Python-policy response frames received before physical send claim; cover late replies, lifecycle transitions and shared Session isolation. +- Preserve Native behavior, public APIs and transport v0.3.4 minimum. Rebuild consumers; hardware validation remains separate. See [release notes](RELEASE_NOTES.md). + ## 0.3.10 - Correct Python-policy missing-command-feedback health without changing physical dispatch outcomes or Native defaults. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 20af11d..fac9249 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -2,6 +2,12 @@ [English](CHANGELOG.md) | [简体中文](CHANGELOG.zh-CN.md) +## 0.3.11 + +- 显式 Python 策略下,L20 仅发送写入不再保留可选回复及读取匹配资源。 +- Python 策略拒绝读取实际开始发送前收到的回复,增加迟到回复、生命周期及共享 Session 隔离回归测试。 +- 保持 Native 行为、公开 API 和 transport v0.3.4 最低依赖。消费者需重新编译,实机验证仍单独进行,详见 [发行说明](RELEASE_NOTES.md)。 + ## 0.3.10 - 修正 Python 策略缺少命令反馈时的健康统计,保持实际发送结果及 Native 默认行为。 diff --git a/CMakeLists.txt b/CMakeLists.txt index 55f30c4..f60942b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.16) project(linkerbot-hand-cpp - VERSION 0.3.10 + VERSION 0.3.11 DESCRIPTION "LinkerBot dexterous hand C++ SDK" LANGUAGES CXX ) diff --git a/README.en.md b/README.en.md index aed1214..5e1b1f3 100644 --- a/README.en.md +++ b/README.en.md @@ -60,7 +60,7 @@ This installs the SDK and communication library into the local `install/` direct Catch2 and vendor development packages are not required. Run the remaining commands from this repository's root directory. -Hand SDK 0.3.10 requires transport 0.3.4 or newer in the 0.3 minor line, +Hand SDK 0.3.11 requires transport 0.3.4 or newer in the 0.3 minor line, for source builds, prebuilt packages and installed consumers. To reuse a prebuilt transport v0.3.4 SDK, configure with diff --git a/README.md b/README.md index aed1214..5e1b1f3 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ This installs the SDK and communication library into the local `install/` direct Catch2 and vendor development packages are not required. Run the remaining commands from this repository's root directory. -Hand SDK 0.3.10 requires transport 0.3.4 or newer in the 0.3 minor line, +Hand SDK 0.3.11 requires transport 0.3.4 or newer in the 0.3 minor line, for source builds, prebuilt packages and installed consumers. To reuse a prebuilt transport v0.3.4 SDK, configure with diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 34267b5..f87eb7d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,15 @@ # Release notes +## 0.3.11 + +- Under the explicit Python runtime policy, L20 speed, current-limit and fault-clear writes end at physical dispatch without retaining Native optional reply decoders or read-key reservations. Subsequent reads do not wait for an unused reply window. +- Python-policy response matching starts at physical send claim. Replies received before a queued read is claimed cannot complete that read. Idle L20 write replies are not published as telemetry. Classic L20 replies have no transaction identifier: a same-opcode late write reply received after a read is claimed remains indistinguishable from a read response, as in the Python reference. +- Keep Native optional feedback, existing public C++ APIs, frame contents and inter-frame delays unchanged. No additional sender or scheduling queue is introduced. + +The minimum and paired transport remain v0.3.4 (`0e190032d65ed0b315337b45b3779cf4f650ed68`). Rebuild the SDK and downstream consumers to use the fix; no public structures or signatures are added in this patch. + +Validation is offline and covers missing and late replies, repeated write/read sequences, close/reopen, explicit recovery, queued-read claim boundaries and device isolation on a shared Session. No new physical-device acceptance is claimed. Deployed firmware reply timing, adapters/drivers, physical timestamps and multi-device sustained operation require separate hardware validation. O20, Python bindings and hand/arm integration are outside this patch. + ## 0.3.10 - Treat missing verified angle-command feedback as a logical timeout under the Python policy. Preserve the command's physical `Dispatched` outcome, exclude it from successful transaction latency and retain its error count until a complete logical success. Native optional-feedback accounting is unchanged. diff --git a/docs/en/guides/python-behavior-baseline.md b/docs/en/guides/python-behavior-baseline.md index cbe53ac..f949243 100644 --- a/docs/en/guides/python-behavior-baseline.md +++ b/docs/en/guides/python-behavior-baseline.md @@ -163,3 +163,9 @@ Global transaction latency records one successful logical operation, including a Offline tests cover counter boundaries, logical success/failure, shared-device isolation, force projection and an independent Python percentile comparison over 6144 rolling-window updates. These tests do not establish hardware timing or throughput. O20, Python bindings, hand/arm integration, tactile-stage angle scheduling and remaining identity/reconnect differences remain outside this batch. Missing verified angle-command feedback preserves physical `Dispatched` status while recording one logical response timeout. It does not add successful transaction latency or advance the last successful transaction timestamp. Repeated missing feedback increments consecutive errors; a subsequent complete logical success clears that counter. This applies with or without an active polling plan. Native optional-feedback behavior is unchanged. + +## Dispatch-only L20 compatibility patch (0.3.11) + +The explicit Python policy removes Native optional reply decoders, response windows and correlation-key reservations from L20 speed, current-limit and fault-clear writes. Physical dispatch, command receipts, frames and inter-frame gaps are retained. The Native policy and verified angle feedback are unchanged. Python-policy response matching accepts only frames received at or after the transaction's physical claim, rather than queue admission. This prevents a late write reply received while a later read is still queued from completing that read. + +Offline regressions cover no replies, idle late/malformed replies, repeated write/read pairs, close/reopen, explicit recovery and independent devices on one Session. No second sender is added. L20 has no wire transaction identifier: a matching late write reply received after read claim cannot be distinguished reliably from the read response. This is also a limitation of the pinned Python implementation; the patch does not claim to remove that protocol ambiguity or establish hardware timing. diff --git a/docs/en/guides/releases.md b/docs/en/guides/releases.md index 9faedf2..3cfb85b 100644 --- a/docs/en/guides/releases.md +++ b/docs/en/guides/releases.md @@ -16,7 +16,7 @@ availability of proprietary ARM64 drivers. Hand SDK packages contain both hand and paired transport static libraries, headers, CMake packages, documentation and read-only command-line examples. Test executables and proprietary drivers/libraries -are excluded. Versions remain independent. Hand 0.3.10 requires transport 0.3.4 or newer in +are excluded. Versions remain independent. Hand 0.3.11 requires transport 0.3.4 or newer in the 0.3 minor line in source, prebuilt and installed-consumer builds. diff --git a/docs/zh/guides/python-behavior-baseline.md b/docs/zh/guides/python-behavior-baseline.md index 37f7a38..0c782bc 100644 --- a/docs/zh/guides/python-behavior-baseline.md +++ b/docs/zh/guides/python-behavior-baseline.md @@ -134,3 +134,9 @@ Python 策略公开健康状态、通道采样年龄和最近错误上下文的 离线回归覆盖计数边界、逻辑事务成功与失败、共享 Session 的设备隔离、力字段投影,以及 6144 次窗口更新的独立 Python 百分位对照。这些验证不代表硬件时序或吞吐验收。本批不包括 O20、Python 绑定、hand/arm 接入、触觉阶段角度调度以及剩余身份字段和重连差异。 缺少已声明的角度命令反馈时,保留实际发送的 `Dispatched` 状态,同时记录一次逻辑响应超时;不增加成功事务延迟,也不推进最近成功事务时间。连续缺失反馈增加连续错误数,后续完整逻辑成功清零该计数。是否启用轮询不影响这一规则。Native 可选反馈行为保持不变。 + +## L20 仅发送写入兼容补丁(0.3.11) + +显式 Python 策略移除 L20 速度、电流限制和清故障写入继承的 Native 可选回复解码器、回复窗口和读取匹配占用,保留实际发送、命令回执、帧内容和帧间隔。Native 策略及角度反馈保持原有行为。Python 策略按实际发送认领时刻过滤回复,避免后续读取尚在发送队列等待时收到的旧写入回复完成该读取。 + +离线回归覆盖无回复、空闲期迟到及畸形回复、连续写后读、关闭重开、显式恢复和共享 Session 的设备隔离,不增加第二套发送调度。L20 协议没有事务序号:读取实际开始发送后到达的同类旧回复无法可靠区分,固定版本 Python 实现也有这一限制。本补丁不声称消除了该协议歧义,也不代表完成实机时序验证。 diff --git a/docs/zh/guides/releases.md b/docs/zh/guides/releases.md index c1563e7..59a04d4 100644 --- a/docs/zh/guides/releases.md +++ b/docs/zh/guides/releases.md @@ -15,7 +15,7 @@ libstdc++ ABI1 和 C++17。Windows 使用原生架构 runner 与 v143 14.44 工 产物是开发 SDK,Hand 包包含 hand 与配套 transport 静态库、头文件、CMake 包和文档, 保留只读命令行示例;不包含测试程序、厂商驱动或厂商库。 -两个项目版本独立。hand 0.3.10 的源码、预构建及安装消费者均要求 transport 0.3.x 且不低于 0.3.4。 +两个项目版本独立。hand 0.3.11 的源码、预构建及安装消费者均要求 transport 0.3.x 且不低于 0.3.4。 配套源码固定提交和预构建下载统一使用 v0.3.4 发行版。transport 公共结构有新增字段, diff --git a/src/hand/runtime/device_runtime.cpp b/src/hand/runtime/device_runtime.cpp index 31aa0cc..aac5e68 100644 --- a/src/hand/runtime/device_runtime.cpp +++ b/src/hand/runtime/device_runtime.cpp @@ -1116,6 +1116,15 @@ namespace linkerbot::hand::runtime { input.received_at < state->receipt->result().accepted_at) { continue; } + if (state->scoped_response_policy) { + const auto send = state->receipt->result(); + // Python accepts response frames from physical claim, not + // queue admission. A queued read must not consume an older + // dispatch-only write echo while another batch owns TX. + if (!send.claimed_at || input.received_at < *send.claimed_at) { + continue; + } + } if (state->response || state->buffered_error) { if (state->plan.defer_errors_until_send) { // This request still owns its response domain while the diff --git a/src/hand/unified/detail/python_transactions.hpp b/src/hand/unified/detail/python_transactions.hpp index 702c1d7..4ceee69 100644 --- a/src/hand/unified/detail/python_transactions.hpp +++ b/src/hand/unified/detail/python_transactions.hpp @@ -58,6 +58,18 @@ namespace linkerbot::hand::unified_detail { if (!plan.consume) { return plan; } + // Python classic multi-axis writes only collect angle feedback. Keep + // the physical batch/receipt, but do not reserve a read correlation key + // or publish native optional observations for dispatch-only controls. + if (config.model == Model::kL20 && plan.optional_response_window && !plan.command_family.empty() && + plan.command_family != "angle") { + plan.consume = {}; + plan.keys.clear(); + plan.optional_response_window.reset(); + plan.response_window.reset(); + plan.supersede_optional_feedback = false; + return plan; + } using namespace std::chrono_literals; auto window = 100ms; auto guard = 5ms; diff --git a/tests/hand/device/test_device.cpp b/tests/hand/device/test_device.cpp index 8cde25d..85aeea3 100644 --- a/tests/hand/device/test_device.cpp +++ b/tests/hand/device/test_device.cpp @@ -4243,14 +4243,14 @@ TEST_CASE("Python O30 force fields project one completed shared polling query", device.close(); } -TEST_CASE("Python L20 non-angle writes do not require native optional observations", "[comm][python-phase5]") { +TEST_CASE("Python L20 non-angle writes release reads without optional replies", "[comm][python-phase5][l20-dispatch]") { using namespace std::chrono_literals; for (const auto semantic : {CommandSemantic::kSpeeds, CommandSemantic::kCurrentLimits}) { CAPTURE(semantic); auto config = config_for(Model::kL20); config.runtime_behavior = RuntimeBehavior::kPython; config.identity_timeout = 300ms; - config.response_timeout = 20ms; + config.response_timeout = 5s; FakeTransport *transport = nullptr; std::shared_ptr hop; HandDevice device(config, [&] { @@ -4268,12 +4268,12 @@ TEST_CASE("Python L20 non-angle writes do not require native optional observatio auto command = semantic == CommandSemantic::kSpeeds ? device.set_speeds(values) : device.set_current_limits(values); completed(command); - // A read with the same wire key drains the optional observation before - // succeeding. Its success must not conceal a spurious recent timeout. + // The caller budget is shorter than the old optional reply window. + // A same-key read must succeed without waiting for that window. if (semantic == CommandSemantic::kSpeeds) { - REQUIRE(device.get_speeds(1s).meta.valid); + REQUIRE(device.get_speeds(750ms).meta.valid); } else { - REQUIRE(device.get_currents(1s).meta.valid); + REQUIRE(device.get_currents(750ms).meta.valid); } const auto health = device.get_health(); REQUIRE(health.transaction_latency.count == before + 2); @@ -4284,3 +4284,197 @@ TEST_CASE("Python L20 non-angle writes do not require native optional observatio device.close(); } } + +namespace { + HandDeviceConfig python_l20_dispatch_config() { + auto config = config_for(Model::kL20); + config.runtime_behavior = RuntimeBehavior::kPython; + config.identity_timeout = std::chrono::milliseconds{750}; + config.response_timeout = std::chrono::seconds{5}; + return config; + } + + void install_l20_dispatch_only(FakeTransport *transport, uint32_t address = 0x28) { + transport->on_request(address, [](const Message &request) { + if (request.dlc > 1) { + return std::vector{}; + } + return classic_angle_reply(Model::kL20, request); + }); + } + + void verify_l20_write_read(HandDevice &device) { + const std::vector values(16, 10); + completed(device.set_speeds(values)); + REQUIRE(device.get_speeds(std::chrono::milliseconds{750}).meta.valid); + completed(device.set_current_limits(values)); + REQUIRE(device.get_currents(std::chrono::milliseconds{750}).raw == std::vector(16, 64)); + completed(device.clear_faults()); + REQUIRE(device.get_faults(std::chrono::milliseconds{750}).meta.valid); + REQUIRE(device.get_health().consecutive_errors == 0); + REQUIRE_FALSE(device.get_health().recent_error); + } +} + +TEST_CASE("Python L20 repeated dispatch-only controls do not hold read keys", "[comm][python-phase5][l20-dispatch]") { + FakeTransport *transport = nullptr; + HandDevice device(python_l20_dispatch_config(), [&] { + auto next = std::make_unique(); + transport = next.get(); + install_classic_identity(transport, Model::kL20); + return next; + }); + device.open(); + install_l20_dispatch_only(transport); + const auto baseline = device.get_health().transaction_latency.count; + for (int round = 0; round < 3; ++round) { + verify_l20_write_read(device); + } + REQUIRE(device.get_health().transaction_latency.count == baseline + 18); + device.close(); +} + +TEST_CASE("Python L20 idle late write replies do not publish or complete future reads", + "[comm][python-phase5][l20-dispatch]") { + using namespace std::chrono_literals; + FakeTransport *transport = nullptr; + HandDevice device(python_l20_dispatch_config(), [&] { + auto next = std::make_unique(); + transport = next.get(); + install_classic_identity(transport, Model::kL20); + return next; + }); + device.open(); + install_l20_dispatch_only(transport); + const auto before = device.get_snapshot(); + completed(device.set_speeds(std::vector(16, 10))); + completed(device.set_current_limits(std::vector(16, 10))); + const auto latency = device.get_health().transaction_latency.count; + const auto sent = transport->sent(); + for (const auto &request : sent) { + if (request.dlc > 1) { + for (const auto &reply : classic_angle_reply(Model::kL20, request)) { + transport->inject(reply); + } + auto malformed = request; + malformed.dlc = 2; + transport->inject(malformed); + } + } + // Complete an unrelated angle query behind the injected backlog. It is a + // receive-worker barrier, rather than a sleep used to guess processing time. + REQUIRE(device.get_angles(750ms).meta.valid); + const auto after = device.get_snapshot(); + REQUIRE(after.currents.has_value() == before.currents.has_value()); + if (before.currents) { + REQUIRE(after.currents->meta.sequence == before.currents->meta.sequence); + } + REQUIRE(after.measured_speeds.has_value() == before.measured_speeds.has_value()); + REQUIRE(device.get_health().transaction_latency.count == latency + 1); + REQUIRE(device.get_speeds(750ms).raw != std::vector(16, 10)); + REQUIRE(device.get_currents(750ms).raw == std::vector(16, 64)); + REQUIRE(device.get_health().consecutive_errors == 0); + REQUIRE_FALSE(device.get_health().recent_error); + device.close(); +} + +TEST_CASE("Python L20 dispatch-only writes leave no reply state across close reopen and recovery", + "[comm][python-phase5][l20-dispatch]") { + using namespace std::chrono_literals; + FakeTransport *transport = nullptr; + HandDevice device(python_l20_dispatch_config(), [&] { + auto next = std::make_unique(); + transport = next.get(); + install_classic_identity(transport, Model::kL20); + return next; + }); + device.open(); + install_l20_dispatch_only(transport); + completed(device.set_speeds(std::vector(16, 10))); + device.close(); + REQUIRE_THROWS_AS(device.get_speeds(750ms), DeviceNotReadyError); + device.open(); + install_l20_dispatch_only(transport); + verify_l20_write_read(device); + completed(device.set_current_limits(std::vector(16, 10))); + // Recovery performs identity and baseline reads, so restore the device's + // normal identity responder before entering the recovery state machine. + install_classic_identity(transport, Model::kL20); + device.recover(); + install_l20_dispatch_only(transport); + verify_l20_write_read(device); + device.close(); +} + +TEST_CASE("Python L20 dispatch-only state remains device-local on a shared Session", + "[comm][python-phase5][l20-dispatch]") { + using namespace std::chrono_literals; + auto transport = std::make_unique(); + auto *raw = transport.get(); + install_classic_identity(raw, Model::kL20, "LHT20-1-123-L", 0x28); + install_classic_identity(raw, Model::kL20, "LHT20-1-456-R", 0x27); + auto session = linkerbot::transport::Session::own(std::move(transport)); + auto left_config = python_l20_dispatch_config(); + auto right_config = left_config; + right_config.side = HandSide::kRight; + HandDevice left(left_config, session); + HandDevice right(right_config, session); + left.open(); + right.open(); + install_l20_dispatch_only(raw, 0x28); + install_l20_dispatch_only(raw, 0x27); + completed(left.set_speeds(std::vector(16, 10))); + const auto right_before = right.get_health().transaction_latency.count; + REQUIRE(right.get_speeds(750ms).meta.valid); + REQUIRE(right.get_health().transaction_latency.count == right_before + 1); + REQUIRE(left.get_speeds(750ms).meta.valid); + completed(left.set_current_limits(std::vector(16, 10))); + left.close(); + REQUIRE(session->health().open); + verify_l20_write_read(right); + install_classic_identity(raw, Model::kL20, "LHT20-1-123-L", 0x28); + left.open(); + install_l20_dispatch_only(raw, 0x28); + verify_l20_write_read(left); + verify_l20_write_read(right); + left.close(); + right.close(); + session->close(); +} + +TEST_CASE("Python L20 queued reads reject write replies received before physical claim", + "[comm][python-phase5][l20-dispatch]") { + using namespace std::chrono_literals; + auto transport = std::make_unique(); + auto *raw = transport.get(); + install_classic_identity(raw, Model::kL20); + auto session = linkerbot::transport::Session::own(std::move(transport)); + HandDevice device(python_l20_dispatch_config(), session); + device.open(); + install_l20_dispatch_only(raw); + const auto expected = device.get_speeds(750ms).raw; + completed(device.set_speeds(std::vector(16, 10))); + const auto sent = raw->sent(); + + Message unrelated{}; + unrelated.id = 0x123; + unrelated.dlc = 1; + linkerbot::transport::MessageBatch blocker{{unrelated}}; + blocker.initial_delay = 800ms; + auto receipt = session->submit(std::move(blocker)); + REQUIRE(wait_until([&] { return receipt.result().claimed_at.has_value(); })); + auto read = std::async(std::launch::async, [&] { return device.get_speeds(2s); }); + REQUIRE(wait_until([&] { return session->health().pending_fifo > 0; })); + for (const auto &request : sent) { + if (request.dlc > 1 && request.data[0] >= 0x49 && request.data[0] <= 0x4D) { + for (const auto &reply : classic_angle_reply(Model::kL20, request)) { + raw->inject(reply); + } + } + } + REQUIRE(read.get().raw == expected); + REQUIRE(receipt.completed()); + REQUIRE(device.get_health().consecutive_errors == 0); + device.close(); + session->close(); +} diff --git a/tests/hand/device/test_python_transactions.cpp b/tests/hand/device/test_python_transactions.cpp index e2e8600..92be081 100644 --- a/tests/hand/device/test_python_transactions.cpp +++ b/tests/hand/device/test_python_transactions.cpp @@ -106,3 +106,34 @@ TEST_CASE("Python L30 response validation distinguishes protocol errors from dev REQUIRE((tactile.response_window == 50ms)); REQUIRE((tactile.late_guard == 20ms)); } + +TEST_CASE("Python L20 non-angle controls release native reply reservations", "[comm][python-phase5][l20-dispatch]") { + HandDeviceConfig config; + config.model = Model::kL20; + config.response_timeout = 5s; + l20::protocol::Backend backend(0x28); + for (const auto *operation : {"speed", "current_limit", "fault"}) { + CAPTURE(operation); + auto native = backend.make_command(operation, std::array{}); + REQUIRE(native.consume); + REQUIRE(native.optional_response_window == 200ms); + REQUIRE(native.supersede_optional_feedback); + const auto profile = unified_detail::python_transaction(native, config); + REQUIRE_FALSE(profile.consume); + REQUIRE(profile.keys.empty()); + REQUIRE_FALSE(profile.response_window); + REQUIRE_FALSE(profile.optional_response_window); + REQUIRE_FALSE(profile.supersede_optional_feedback); + REQUIRE(profile.frames.size() == native.frames.size()); + REQUIRE(profile.gap == native.gap); + REQUIRE(profile.command_family == native.command_family); + } + auto angle = unified_detail::python_transaction( + backend.make_command("angle", std::array{}), config); + REQUIRE(angle.consume); + REQUIRE(angle.optional_response_window == 5s); + REQUIRE(angle.supersede_optional_feedback); + auto read = unified_detail::python_transaction(backend.make_read("speed"), config); + REQUIRE(read.consume); + REQUIRE(read.response_window == 5s); +} From 0c90eb21242bfeb529c58f7c53ebbf32f500a0d0 Mon Sep 17 00:00:00 2001 From: Su Yang Date: Mon, 9 Nov 2026 02:26:46 +0800 Subject: [PATCH 2/4] Wait for logical write metrics before late-reply assertions --- tests/hand/device/test_device.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/hand/device/test_device.cpp b/tests/hand/device/test_device.cpp index 85aeea3..53f2ddf 100644 --- a/tests/hand/device/test_device.cpp +++ b/tests/hand/device/test_device.cpp @@ -4347,8 +4347,12 @@ TEST_CASE("Python L20 idle late write replies do not publish or complete future device.open(); install_l20_dispatch_only(transport); const auto before = device.get_snapshot(); + const auto baseline = device.get_health().transaction_latency.count; completed(device.set_speeds(std::vector(16, 10))); completed(device.set_current_limits(std::vector(16, 10))); + // Physical dispatch may precede logical metric finalization. Observe both + // writes before measuring whether unsolicited frames create extra samples. + REQUIRE(wait_until([&] { return device.get_health().transaction_latency.count == baseline + 2; })); const auto latency = device.get_health().transaction_latency.count; const auto sent = transport->sent(); for (const auto &request : sent) { @@ -4370,7 +4374,7 @@ TEST_CASE("Python L20 idle late write replies do not publish or complete future REQUIRE(after.currents->meta.sequence == before.currents->meta.sequence); } REQUIRE(after.measured_speeds.has_value() == before.measured_speeds.has_value()); - REQUIRE(device.get_health().transaction_latency.count == latency + 1); + REQUIRE(wait_until([&] { return device.get_health().transaction_latency.count == latency + 1; })); REQUIRE(device.get_speeds(750ms).raw != std::vector(16, 10)); REQUIRE(device.get_currents(750ms).raw == std::vector(16, 64)); REQUIRE(device.get_health().consecutive_errors == 0); From c7cadfccb95943436fadcdb0167b6ecc2e251c3c Mon Sep 17 00:00:00 2001 From: Su Yang Date: Mon, 9 Nov 2026 02:37:40 +0800 Subject: [PATCH 3/4] Use Session event order for Python response claim boundaries --- RELEASE_NOTES.md | 2 +- docs/en/guides/python-behavior-baseline.md | 2 +- docs/zh/guides/python-behavior-baseline.md | 2 +- src/hand/runtime/device_runtime.cpp | 8 +++-- .../detail/python_response_boundary.hpp | 14 ++++++++ .../hand/device/test_python_transactions.cpp | 35 +++++++++++++++++++ 6 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 src/hand/unified/detail/python_response_boundary.hpp diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f87eb7d..eac5610 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -3,7 +3,7 @@ ## 0.3.11 - Under the explicit Python runtime policy, L20 speed, current-limit and fault-clear writes end at physical dispatch without retaining Native optional reply decoders or read-key reservations. Subsequent reads do not wait for an unused reply window. -- Python-policy response matching starts at physical send claim. Replies received before a queued read is claimed cannot complete that read. Idle L20 write replies are not published as telemetry. Classic L20 replies have no transaction identifier: a same-opcode late write reply received after a read is claimed remains indistinguishable from a read response, as in the Python reference. +- Python-policy response matching starts at physical send claim, using Session event order when available and timestamps as a compatibility fallback. Replies received before a queued read is claimed cannot complete that read. Idle L20 write replies are not published as telemetry. Classic L20 replies have no transaction identifier: a same-opcode late write reply received after a read is claimed remains indistinguishable from a read response, as in the Python reference. - Keep Native optional feedback, existing public C++ APIs, frame contents and inter-frame delays unchanged. No additional sender or scheduling queue is introduced. The minimum and paired transport remain v0.3.4 (`0e190032d65ed0b315337b45b3779cf4f650ed68`). Rebuild the SDK and downstream consumers to use the fix; no public structures or signatures are added in this patch. diff --git a/docs/en/guides/python-behavior-baseline.md b/docs/en/guides/python-behavior-baseline.md index f949243..d505089 100644 --- a/docs/en/guides/python-behavior-baseline.md +++ b/docs/en/guides/python-behavior-baseline.md @@ -166,6 +166,6 @@ Missing verified angle-command feedback preserves physical `Dispatched` status w ## Dispatch-only L20 compatibility patch (0.3.11) -The explicit Python policy removes Native optional reply decoders, response windows and correlation-key reservations from L20 speed, current-limit and fault-clear writes. Physical dispatch, command receipts, frames and inter-frame gaps are retained. The Native policy and verified angle feedback are unchanged. Python-policy response matching accepts only frames received at or after the transaction's physical claim, rather than queue admission. This prevents a late write reply received while a later read is still queued from completing that read. +The explicit Python policy removes Native optional reply decoders, response windows and correlation-key reservations from L20 speed, current-limit and fault-clear writes. Physical dispatch, command receipts, frames and inter-frame gaps are retained. The Native policy and verified angle feedback are unchanged. Python-policy response matching uses Session event order after physical claim, with a timestamp fallback when order metadata is unavailable, rather than queue admission. This prevents a late write reply received while a later read is still queued from completing that read. Offline regressions cover no replies, idle late/malformed replies, repeated write/read pairs, close/reopen, explicit recovery and independent devices on one Session. No second sender is added. L20 has no wire transaction identifier: a matching late write reply received after read claim cannot be distinguished reliably from the read response. This is also a limitation of the pinned Python implementation; the patch does not claim to remove that protocol ambiguity or establish hardware timing. diff --git a/docs/zh/guides/python-behavior-baseline.md b/docs/zh/guides/python-behavior-baseline.md index 0c782bc..80c5bd6 100644 --- a/docs/zh/guides/python-behavior-baseline.md +++ b/docs/zh/guides/python-behavior-baseline.md @@ -137,6 +137,6 @@ Python 策略公开健康状态、通道采样年龄和最近错误上下文的 ## L20 仅发送写入兼容补丁(0.3.11) -显式 Python 策略移除 L20 速度、电流限制和清故障写入继承的 Native 可选回复解码器、回复窗口和读取匹配占用,保留实际发送、命令回执、帧内容和帧间隔。Native 策略及角度反馈保持原有行为。Python 策略按实际发送认领时刻过滤回复,避免后续读取尚在发送队列等待时收到的旧写入回复完成该读取。 +显式 Python 策略移除 L20 速度、电流限制和清故障写入继承的 Native 可选回复解码器、回复窗口和读取匹配占用,保留实际发送、命令回执、帧内容和帧间隔。Native 策略及角度反馈保持原有行为。Python 策略优先使用 Session 事件顺序判断回复是否在实际发送认领之后,缺少顺序信息时按时间戳回退,避免后续读取尚在发送队列等待时收到的旧写入回复完成该读取。 离线回归覆盖无回复、空闲期迟到及畸形回复、连续写后读、关闭重开、显式恢复和共享 Session 的设备隔离,不增加第二套发送调度。L20 协议没有事务序号:读取实际开始发送后到达的同类旧回复无法可靠区分,固定版本 Python 实现也有这一限制。本补丁不声称消除了该协议歧义,也不代表完成实机时序验证。 diff --git a/src/hand/runtime/device_runtime.cpp b/src/hand/runtime/device_runtime.cpp index aac5e68..15e007a 100644 --- a/src/hand/runtime/device_runtime.cpp +++ b/src/hand/runtime/device_runtime.cpp @@ -1,5 +1,6 @@ #include "../detail/checked_optional.hpp" #include "../detail/failure_kind.hpp" +#include "../unified/detail/python_response_boundary.hpp" #include "../unified/detail/runtime_access.hpp" #include #include @@ -1112,8 +1113,7 @@ namespace linkerbot::hand::runtime { bool matched = false; const auto requests = pending; for (const auto &state : requests) { - if (!state->plan.consume || input.received_at >= state->deadline || - input.received_at < state->receipt->result().accepted_at) { + if (!state->plan.consume || input.received_at >= state->deadline) { continue; } if (state->scoped_response_policy) { @@ -1121,9 +1121,11 @@ namespace linkerbot::hand::runtime { // Python accepts response frames from physical claim, not // queue admission. A queued read must not consume an older // dispatch-only write echo while another batch owns TX. - if (!send.claimed_at || input.received_at < *send.claimed_at) { + if (!unified_detail::python_response_after_claim(input, send)) { continue; } + } else if (input.received_at < state->receipt->result().accepted_at) { + continue; } if (state->response || state->buffered_error) { if (state->plan.defer_errors_until_send) { diff --git a/src/hand/unified/detail/python_response_boundary.hpp b/src/hand/unified/detail/python_response_boundary.hpp new file mode 100644 index 0000000..1a50822 --- /dev/null +++ b/src/hand/unified/detail/python_response_boundary.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace linkerbot::hand::unified_detail { + inline bool python_response_after_claim(const transport::RoutedMessage &input, const transport::SendResult &send) { + // Session event order is authoritative when both sides provide it. + // Timestamp fallback matches the Python runner's legacy transport path. + if (input.received_order != 0 && send.claimed_order) { + return input.received_order > *send.claimed_order; + } + return send.claimed_at && input.received_at >= *send.claimed_at; + } +} diff --git a/tests/hand/device/test_python_transactions.cpp b/tests/hand/device/test_python_transactions.cpp index 92be081..687456e 100644 --- a/tests/hand/device/test_python_transactions.cpp +++ b/tests/hand/device/test_python_transactions.cpp @@ -1,4 +1,5 @@ #include "../../../src/hand/unified/detail/python_identity.hpp" +#include "../../../src/hand/unified/detail/python_response_boundary.hpp" #include "../../../src/hand/unified/detail/python_transactions.hpp" #include #include @@ -137,3 +138,37 @@ TEST_CASE("Python L20 non-angle controls release native reply reservations", "[c REQUIRE(read.consume); REQUIRE(read.response_window == 5s); } + +TEST_CASE("Python response claim boundaries prefer Session order with timestamp fallback", + "[comm][python-phase5][l20-dispatch]") { + linkerbot::transport::RoutedMessage input; + linkerbot::transport::SendResult send; + const auto claim = std::chrono::steady_clock::now(); + input.received_at = claim; + REQUIRE_FALSE(unified_detail::python_response_after_claim(input, send)); + send.claimed_at = claim; + REQUIRE(unified_detail::python_response_after_claim(input, send)); + input.received_at = claim - 1ns; + REQUIRE_FALSE(unified_detail::python_response_after_claim(input, send)); + input.received_at = claim + 1ns; + REQUIRE(unified_detail::python_response_after_claim(input, send)); + send.claimed_order = 10; + input.received_at = claim; + input.received_order = 9; + REQUIRE_FALSE(unified_detail::python_response_after_claim(input, send)); + input.received_order = 10; + REQUIRE_FALSE(unified_detail::python_response_after_claim(input, send)); + input.received_order = 11; + REQUIRE(unified_detail::python_response_after_claim(input, send)); + input.received_at = claim - 1ns; + REQUIRE(unified_detail::python_response_after_claim(input, send)); + input.received_order = 0; + REQUIRE_FALSE(unified_detail::python_response_after_claim(input, send)); + input.received_at = claim; + REQUIRE(unified_detail::python_response_after_claim(input, send)); + send.claimed_order.reset(); + input.received_order = 1; + REQUIRE(unified_detail::python_response_after_claim(input, send)); + send.claimed_at.reset(); + REQUIRE_FALSE(unified_detail::python_response_after_claim(input, send)); +} From c276afce183c29fdbd36aae32cb9ac97d7cf5ec4 Mon Sep 17 00:00:00 2001 From: Su Yang Date: Mon, 9 Nov 2026 03:08:07 +0800 Subject: [PATCH 4/4] Check optional snapshots explicitly in the late-reply regression --- tests/hand/device/test_device.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/hand/device/test_device.cpp b/tests/hand/device/test_device.cpp index 53f2ddf..af69e96 100644 --- a/tests/hand/device/test_device.cpp +++ b/tests/hand/device/test_device.cpp @@ -4371,7 +4371,8 @@ TEST_CASE("Python L20 idle late write replies do not publish or complete future const auto after = device.get_snapshot(); REQUIRE(after.currents.has_value() == before.currents.has_value()); if (before.currents) { - REQUIRE(after.currents->meta.sequence == before.currents->meta.sequence); + REQUIRE(test_support::require_value(after.currents).meta.sequence == + test_support::require_value(before.currents).meta.sequence); } REQUIRE(after.measured_speeds.has_value() == before.measured_speeds.has_value()); REQUIRE(wait_until([&] { return device.get_health().transaction_latency.count == latency + 1; }));