fix(k1): stabilize repeated acquisition and live viewer recovery

This commit is contained in:
DCCONSTRUCTIONS
2026-07-29 01:55:12 +03:00
parent 37b8930527
commit 2a97cf28c0
28 changed files with 2776 additions and 286 deletions
+208
View File
@@ -170,6 +170,7 @@ def test_canonical_stages_require_operator_events_but_device_standby_does_not(
monkeypatch: pytest.MonkeyPatch,
) -> None:
FakeExecutor.records = []
scanning_observed = threading.Event()
monkeypatch.setattr(
session_module,
"PhysicalAcceptanceDialogueExecutor",
@@ -181,6 +182,7 @@ def test_canonical_stages_require_operator_events_but_device_standby_does_not(
loader,
transport_factory=lambda _host: transport, # type: ignore[arg-type]
epoch_seconds=lambda: 1_752_680_000,
scanning_observer=scanning_observed.set,
)
session.open(
@@ -202,6 +204,7 @@ def test_canonical_stages_require_operator_events_but_device_standby_does_not(
session.request_start(project_name="TEST001", confirmation=_confirmation())
_wait_phase(session, "scanning")
assert scanning_observed.wait(timeout=1.0)
assert FakeExecutor.records[-1] == "wait:stop"
session.request_stop(confirmation=_confirmation())
@@ -210,6 +213,7 @@ def test_canonical_stages_require_operator_events_but_device_standby_does_not(
assert completed["pending_operator_action"] is None
assert loader.calls == 1
assert completed["automatic_retry"] is False
assert completed["scanning_observer_errors"] == 0
assert completed["scripted_transitions"] is False
assert FakeExecutor.records == [
"connection:1-6",
@@ -562,6 +566,210 @@ def test_start_outcome_unknown_blocks_reopen_even_when_transport_is_closed(
assert failed["can_open"] is False
assert failed["failure"]["modeling_command_attempted"] is True # type: ignore[index]
assert failed["failure"]["safe_to_retry"] is False # type: ignore[index]
with pytest.raises(
session_module.ApplicationAcceptanceError,
match="cannot be retired",
):
session.retire_for_network_change()
def test_acknowledged_stop_disconnect_allows_only_explicit_network_change(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@dataclass
class StopDisconnectTransportSnapshot:
state: str
def as_dict(self) -> dict[str, object]:
return {
"state": self.state,
"publish_attempts": 15,
"qos2_completions": 15,
"correlated_responses": 13,
"ignored_known_responses": 304,
"late_known_responses": 0,
"device_status_reports": 52,
"latest_device_session_state": "scan_stopping",
"latest_device_project_bound": True,
"latest_system_error_code": None,
"last_loop_result_code": 7,
"last_loop_result_name": "The connection was lost.",
"last_loop_phase": "maintain-open",
"automatic_retry": False,
"automatic_reconnect": False,
}
class StopDisconnectTransport(FakeTransport):
def snapshot(self) -> StopDisconnectTransportSnapshot:
return StopDisconnectTransportSnapshot(self.state)
class StopDisconnectExecutor(FakeExecutor):
def __init__(self, transport: StopDisconnectTransport) -> None:
super().__init__(transport)
self.stop_attempted = False
self.stop_complete = False
def execute_canonical_start(self, *_args: object, **_kwargs: object) -> object:
self.records.append("start:11-14")
return object()
def execute_canonical_stop(self, *_args: object, **_kwargs: object) -> object:
self.records.append("stop")
self.stop_attempted = True
self.stop_complete = True
return object()
def maintain_post_stop_until_standby(self) -> None:
self.records.append("wait:device-standby")
self.transport.state = "poisoned"
raise session_module.ApplicationCommandOutcomeUnknown(
"control MQTT network loop returned an error",
reason_code="mqtt_network_loop_failed",
)
def snapshot(self) -> dict[str, object]:
return {
"dialogue_stage": (
"stop-acknowledged" if self.stop_complete else "test-stage"
),
"start_attempted": True,
"start_complete": True,
"stop_attempted": self.stop_attempted,
"stop_complete": self.stop_complete,
"response_evidence": [],
"automatic_retry": False,
}
FakeExecutor.records = []
monkeypatch.setattr(
session_module,
"PhysicalAcceptanceDialogueExecutor",
StopDisconnectExecutor,
)
session = InteractiveApplicationControlSession(
FakeAuthorityLoader(),
transport_factory=lambda host: StopDisconnectTransport(host), # type: ignore[arg-type]
)
session.open(
host="192.168.1.20",
timezone_name="Europe/Moscow",
confirmation=_confirmation(),
)
_wait_phase(session, "connection-ready")
session.enter_workspace()
_wait_phase(session, "workspace-ready")
session.open_project_prompt()
_wait_phase(session, "project-ready")
session.request_start(project_name="TEST001", confirmation=_confirmation())
_wait_phase(session, "scanning")
session.request_stop(confirmation=_confirmation())
failed = _wait_phase(session, "failed")
failed_thread = session._thread # noqa: SLF001
assert failed_thread is not None
failed_thread.join(timeout=2.0)
assert not failed_thread.is_alive()
failed = session.snapshot()
assert failed["can_open"] is False
assert failed["outcome_unknown"] is True
failure = failed["failure"]
assert isinstance(failure, dict)
assert failure["safe_to_retry"] is False
assert failure["network_change_admissible"] is True
assert failure["network_change_reconciliation"] == {
"device_session_state": "scan_stopping",
"device_project_bound": True,
"system_error_code": None,
"stop_complete": True,
"standby_confirmed": False,
"decision": "explicit-network-change-only-after-acknowledged-stop",
"automatic_retry": False,
}
retired = session.retire_for_network_change()
assert retired["state"] == "idle"
assert retired["failure"] is None
assert retired["can_open"] is True
def test_prestart_loop_failure_allows_only_fresh_explicit_retry_after_ready_status(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@dataclass
class ReconciledTransportSnapshot:
state: str
def as_dict(self) -> dict[str, object]:
return {
"state": self.state,
"publish_attempts": 4,
"qos2_completions": 4,
"correlated_responses": 3,
"ignored_known_responses": 0,
"late_known_responses": 0,
"device_status_reports": 1,
"latest_device_session_state": "ready",
"latest_device_project_bound": False,
"latest_system_error_code": None,
"last_loop_result_code": 7,
"last_loop_result_name": "The connection was lost.",
"last_loop_phase": "post-publish-drain",
"automatic_retry": False,
"automatic_reconnect": False,
}
class ReconciledTransport(FakeTransport):
def snapshot(self) -> ReconciledTransportSnapshot:
return ReconciledTransportSnapshot(self.state)
class PrestartLoopFailureExecutor(FakeExecutor):
def run_connection_stage(
self,
_orchestrator: object,
) -> LiveDeviceControlBinding:
raise session_module.ApplicationCommandOutcomeUnknown(
"control MQTT network loop returned an error",
reason_code="mqtt_network_loop_failed",
)
monkeypatch.setattr(
session_module,
"PhysicalAcceptanceDialogueExecutor",
PrestartLoopFailureExecutor,
)
session = InteractiveApplicationControlSession(
FakeAuthorityLoader(),
transport_factory=lambda host: ReconciledTransport(host), # type: ignore[arg-type]
)
session.open(
host="192.168.1.20",
timezone_name="Europe/Moscow",
confirmation=_confirmation(),
)
failed_thread = session._thread # noqa: SLF001
assert failed_thread is not None
failed_thread.join(timeout=2.0)
assert not failed_thread.is_alive()
failed = session.snapshot()
assert failed["state"] == "failed"
assert failed["outcome_unknown"] is True
assert failed["automatic_retry"] is False
assert failed["can_open"] is True
failure = failed["failure"]
assert isinstance(failure, dict)
assert failure["reason_code"] == "mqtt_network_loop_failed"
assert failure["modeling_command_attempted"] is False
assert failure["safe_to_retry"] is True
assert failure["status_reconciliation"] == {
"device_session_state": "ready",
"device_project_bound": False,
"system_error_code": None,
"decision": "safe-explicit-prestart-retry",
"automatic_retry": False,
}
def test_unavailable_transport_snapshot_after_publish_blocks_reopen(