diff --git a/src/k1link/device_plugins/xgrids_k1/facade.py b/src/k1link/device_plugins/xgrids_k1/facade.py index a8dd04b..abe2cfd 100644 --- a/src/k1link/device_plugins/xgrids_k1/facade.py +++ b/src/k1link/device_plugins/xgrids_k1/facade.py @@ -8477,6 +8477,7 @@ class XgridsK1CompatibilityService: acquisition_id=( acquisition.acquisition_id if acquisition is not None else None ), + allow_terminal_failure_retirement=True, ) ) self._supersede_control_bootstrap_continuation() @@ -27415,6 +27416,7 @@ class XgridsK1CompatibilityService: self, *, acquisition_id: str | None, + allow_terminal_failure_retirement: bool = False, ) -> tuple[_LocalStopRetirementDisposition, str | None]: """Cancel the local worker under the exact publish fence. @@ -27450,6 +27452,24 @@ class XgridsK1CompatibilityService: ) self._application_control_session.close() retired_control = dict(self._application_control_session.snapshot()) + if ( + allow_terminal_failure_retirement + and retired_control.get("state") == "failed" + ): + # ``can_open`` deliberately remains false after an ambiguous + # START/STOP failure even when the socket worker has already + # exited. That is the correct admission policy for an + # ordinary retry, but an explicit scenario reset is a + # different operation: retire only the dead local owner while + # the durable physical ledger below preserves the ambiguous + # device outcome. ``retire_for_network_change`` still checks + # that the thread and transport are actually gone, so a live + # publisher cannot be detached by this path. + with suppress(AttributeError, RuntimeError): + self._retire_application_control_for_network_change( + allow_terminal_failure=True, + ) + retired_control = dict(self._application_control_session.snapshot()) if ( retired_control.get("state") not in {"idle", "completed", "closed", "failed"} diff --git a/tests/test_xgrids_connection_scenario_reset.py b/tests/test_xgrids_connection_scenario_reset.py index 0fd387d..84b3dc3 100644 --- a/tests/test_xgrids_connection_scenario_reset.py +++ b/tests/test_xgrids_connection_scenario_reset.py @@ -1722,6 +1722,72 @@ def test_terminal_cleanup_pending_is_locally_sealed_by_mode_reset( assert reset["connection_scenario_reset"]["network_write_performed"] is False +def test_terminal_ambiguous_start_failure_can_be_locally_reset_after_worker_exit( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + service = _service(monkeypatch, tmp_path) + _seed_resettable_physical_record(service) + acquisition = AcquisitionRecord( + acquisition_id="reset-preflight-acquisition-0001", + device_id="device-reset-terminal-start-fault", + device_session_id="device-session-reset-terminal-start-fault", + compatibility_profile_id=("xgrids.lixelkity-k1.fw-3.0.2.local-network.v2"), + control_mode="plugin-commanded", + requested_streams=("points",), + target_host="192.168.68.51", + duration_seconds=None, + evidence_policy="required", + ) + acquisition.transition( + "failed", + message_code="acquisition.start.device_outcome_unknown", + result={ + "receiver_stopped": False, + "device_start": "outcome-unknown", + }, + ) + control = service._application_control_session # noqa: SLF001 + with control._lock: # noqa: SLF001 + control._set_phase_locked("failed") # noqa: SLF001 + control._failure = { # noqa: SLF001 + "reason_code": "device_status_fault", + "safe_to_retry": False, + "modeling_command_attempted": True, + "outcome_unknown": True, + } + control._thread = None # noqa: SLF001 + control._transport = None # noqa: SLF001 + with service._lock: # noqa: SLF001 + service._acquisition = acquisition # noqa: SLF001 + + before = control.snapshot() + assert before["state"] == "failed" + assert before["can_open"] is False + + reset = service.select_connection_mode( + _reset( + mode="bridge", + revision=0, + reset_id="op-reset-terminal-ambiguous-start-01", + ) + ) + + assert reset["desired_connection_mode_revision"] == 1 + assert reset["connection_scenario_reset"]["local_session_closed"] is True + assert reset["connection_scenario_reset"]["physical_disposition"] == ( + "operator-retired-outcome-unknown" + ) + assert reset["connection_scenario_reset"]["device_command_performed"] is False + assert reset["connection_scenario_reset"]["network_write_performed"] is False + assert reset["application_control_session"]["state"] == "idle" + assert reset["acquisition"]["state"] == "failed" + assert reset["acquisition"]["cleanup_pending"] is False + physical = service._physical_command_ledger.snapshot().record # noqa: SLF001 + assert physical is not None + assert physical.resolution == "operator-retired-outcome-unknown" + + def test_terminal_cleanup_failure_stays_visible_and_exact_reset_can_retry( monkeypatch: pytest.MonkeyPatch, tmp_path: Path,