feat(k1): complete canonical control lifecycle
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
@@ -37,6 +38,11 @@ class FakeTransportSnapshot:
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"publish_attempts": 0,
|
||||
"qos2_completions": 0,
|
||||
"correlated_responses": 0,
|
||||
"ignored_known_responses": 0,
|
||||
"late_known_responses": 0,
|
||||
"latest_device_session_state": "ready" if self.ready else "scanning",
|
||||
"latest_device_project_bound": not self.ready,
|
||||
"automatic_retry": False,
|
||||
@@ -71,8 +77,8 @@ class FakeExecutor:
|
||||
device_serial="serial-id",
|
||||
software_version="3.0.2",
|
||||
system_version="3.0.2",
|
||||
device_model="K1",
|
||||
device_type="scanner",
|
||||
device_model="LixelKity K1",
|
||||
device_type="A4",
|
||||
is_activated=True,
|
||||
)
|
||||
|
||||
@@ -124,13 +130,17 @@ class FakeExecutor:
|
||||
self.transport.ready = True
|
||||
return object()
|
||||
|
||||
def maintain_post_stop_until_standby_confirmed(self, observed: Any) -> None:
|
||||
self.records.append("wait:steady-green")
|
||||
while not observed():
|
||||
threading.Event().wait(0.005)
|
||||
def maintain_post_stop_until_standby(self) -> None:
|
||||
self.records.append("wait:device-standby")
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {"records": tuple(self.records), "automatic_retry": False}
|
||||
return {
|
||||
"records": list(self.records),
|
||||
"dialogue_stage": "test-stage",
|
||||
"start_attempted": False,
|
||||
"stop_attempted": False,
|
||||
"automatic_retry": False,
|
||||
}
|
||||
|
||||
|
||||
def _confirmation() -> OperatorPresenceConfirmation:
|
||||
@@ -156,7 +166,7 @@ def _wait_phase(
|
||||
raise AssertionError(f"session did not reach {expected}: {session.snapshot()}")
|
||||
|
||||
|
||||
def test_every_canonical_stage_requires_a_separate_operator_event(
|
||||
def test_canonical_stages_require_operator_events_but_device_standby_does_not(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
FakeExecutor.records = []
|
||||
@@ -195,12 +205,9 @@ def test_every_canonical_stage_requires_a_separate_operator_event(
|
||||
assert FakeExecutor.records[-1] == "wait:stop"
|
||||
|
||||
session.request_stop(confirmation=_confirmation())
|
||||
standby = _wait_phase(session, "awaiting-standby-confirmation")
|
||||
assert standby["can_confirm_standby"] is True
|
||||
assert FakeExecutor.records[-1] == "wait:steady-green"
|
||||
|
||||
session.confirm_standby()
|
||||
completed = _wait_phase(session, "completed")
|
||||
assert completed["can_confirm_standby"] is False
|
||||
assert completed["pending_operator_action"] is None
|
||||
assert loader.calls == 1
|
||||
assert completed["automatic_retry"] is False
|
||||
assert completed["scripted_transitions"] is False
|
||||
@@ -214,5 +221,508 @@ def test_every_canonical_stage_requires_a_separate_operator_event(
|
||||
"start:11-14",
|
||||
"wait:stop",
|
||||
"stop",
|
||||
"wait:steady-green",
|
||||
"wait:device-standby",
|
||||
]
|
||||
|
||||
|
||||
def test_prestart_failure_is_reported_and_requires_a_new_operator_click(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
caplog.set_level(logging.ERROR)
|
||||
|
||||
class BootstrapFailureExecutor(FakeExecutor):
|
||||
def run_connection_stage(
|
||||
self,
|
||||
_orchestrator: object,
|
||||
) -> LiveDeviceControlBinding:
|
||||
raise RuntimeError("bootstrap correlation failed")
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {
|
||||
"dialogue_stage": "connection",
|
||||
"start_attempted": False,
|
||||
"stop_attempted": False,
|
||||
"response_evidence": [],
|
||||
"automatic_retry": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
BootstrapFailureExecutor,
|
||||
)
|
||||
session = InteractiveApplicationControlSession(
|
||||
FakeAuthorityLoader(),
|
||||
transport_factory=lambda host: FakeTransport(host), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
_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["outcome_unknown"] is False
|
||||
assert failed["can_open"] is True
|
||||
assert failed["automatic_retry"] is False
|
||||
assert failed["failure"] == {
|
||||
"code": "RuntimeError",
|
||||
"reason_code": "unexpected_runtime_error",
|
||||
"message": "bootstrap correlation failed",
|
||||
"failed_phase": "connecting",
|
||||
"dialogue_stage": "connection",
|
||||
"transport_state": "ready",
|
||||
"publish_attempts": 0,
|
||||
"qos2_completions": 0,
|
||||
"correlated_responses": 0,
|
||||
"ignored_known_responses": 0,
|
||||
"late_known_responses": 0,
|
||||
"modeling_command_attempted": False,
|
||||
"diagnostic_snapshot_unavailable": [],
|
||||
"diagnostic_evidence_unavailable": [],
|
||||
"correlation_failure": None,
|
||||
"compatibility_failure": None,
|
||||
"safe_to_retry": True,
|
||||
}
|
||||
assert "reason_code=unexpected_runtime_error" in caplog.text
|
||||
|
||||
|
||||
def test_correlated_read_only_profile_mismatch_allows_only_a_fresh_explicit_attempt(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class ProfileMismatchError(RuntimeError):
|
||||
reason_code = "compatibility_profile_mismatch"
|
||||
|
||||
@dataclass
|
||||
class CorrelatedReadSnapshot:
|
||||
state: str
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"publish_attempts": 1,
|
||||
"qos2_completions": 1,
|
||||
"correlated_responses": 1,
|
||||
"ignored_known_responses": 0,
|
||||
"late_known_responses": 0,
|
||||
"automatic_retry": False,
|
||||
"automatic_reconnect": False,
|
||||
}
|
||||
|
||||
class CorrelatedReadTransport(FakeTransport):
|
||||
def snapshot(self) -> CorrelatedReadSnapshot:
|
||||
return CorrelatedReadSnapshot(self.state)
|
||||
|
||||
class ProfileMismatchExecutor(FakeExecutor):
|
||||
def run_connection_stage(
|
||||
self,
|
||||
_orchestrator: object,
|
||||
) -> LiveDeviceControlBinding:
|
||||
raise ProfileMismatchError("live DeviceInfo profile mismatch")
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {
|
||||
"dialogue_stage": "new",
|
||||
"start_attempted": False,
|
||||
"stop_attempted": False,
|
||||
"response_evidence": [],
|
||||
"correlation_failure": None,
|
||||
"compatibility_failure": {
|
||||
"operation_key": "bootstrap:1:DeviceInfoRequest",
|
||||
"reason_code": "compatibility_profile_mismatch",
|
||||
},
|
||||
"automatic_retry": False,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
ProfileMismatchExecutor,
|
||||
)
|
||||
session = InteractiveApplicationControlSession(
|
||||
FakeAuthorityLoader(),
|
||||
transport_factory=lambda host: CorrelatedReadTransport(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 False
|
||||
assert failed["automatic_retry"] is False
|
||||
assert failed["can_open"] is True
|
||||
failure = failed["failure"]
|
||||
assert isinstance(failure, dict)
|
||||
assert failure["reason_code"] == "compatibility_profile_mismatch"
|
||||
assert failure["modeling_command_attempted"] is False
|
||||
assert failure["safe_to_retry"] is True
|
||||
|
||||
|
||||
def test_new_explicit_session_waits_for_old_worker_transport_retirement(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
old_close_entered = threading.Event()
|
||||
release_old_close = threading.Event()
|
||||
transports: list[TaggedTransport] = []
|
||||
|
||||
@dataclass
|
||||
class TaggedTransportSnapshot:
|
||||
state: str
|
||||
ready: bool
|
||||
tag: str
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"tag": self.tag,
|
||||
"publish_attempts": 0,
|
||||
"qos2_completions": 0,
|
||||
"correlated_responses": 0,
|
||||
"ignored_known_responses": 0,
|
||||
"late_known_responses": 0,
|
||||
"latest_device_session_state": "ready" if self.ready else "scanning",
|
||||
"latest_device_project_bound": not self.ready,
|
||||
"automatic_retry": False,
|
||||
"automatic_reconnect": False,
|
||||
}
|
||||
|
||||
class TaggedTransport(FakeTransport):
|
||||
def __init__(self, host: str, tag: str) -> None:
|
||||
super().__init__(host)
|
||||
self.tag = tag
|
||||
self.close_calls = 0
|
||||
|
||||
def close(self) -> None:
|
||||
if self.tag == "generation-1":
|
||||
old_close_entered.set()
|
||||
assert release_old_close.wait(timeout=2.0)
|
||||
self.close_calls += 1
|
||||
super().close()
|
||||
|
||||
def snapshot(self) -> TaggedTransportSnapshot:
|
||||
return TaggedTransportSnapshot(self.state, self.ready, self.tag)
|
||||
|
||||
class RacingExecutor(FakeExecutor):
|
||||
instances = 0
|
||||
|
||||
def __init__(self, transport: TaggedTransport) -> None:
|
||||
super().__init__(transport)
|
||||
type(self).instances += 1
|
||||
self.instance = type(self).instances
|
||||
|
||||
def run_connection_stage(
|
||||
self,
|
||||
orchestrator: object,
|
||||
) -> LiveDeviceControlBinding:
|
||||
if self.instance == 1:
|
||||
raise RuntimeError("first generation failed before publish")
|
||||
return super().run_connection_stage(orchestrator)
|
||||
|
||||
def transport_factory(host: str) -> TaggedTransport:
|
||||
transport = TaggedTransport(host, f"generation-{len(transports) + 1}")
|
||||
transports.append(transport)
|
||||
return transport
|
||||
|
||||
FakeExecutor.records = []
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
RacingExecutor,
|
||||
)
|
||||
session = InteractiveApplicationControlSession(
|
||||
FakeAuthorityLoader(),
|
||||
transport_factory=transport_factory, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
_wait_phase(session, "failed")
|
||||
assert old_close_entered.wait(timeout=2.0)
|
||||
old_thread = session._thread # noqa: SLF001
|
||||
assert old_thread is not None
|
||||
|
||||
# The failure is safe to retry at the protocol level, but the old worker
|
||||
# still owns its socket. A new explicit click must not construct or open a
|
||||
# second transport until close() completes and the worker exits.
|
||||
retiring = session.snapshot()
|
||||
assert retiring["failure"]["safe_to_retry"] is True # type: ignore[index]
|
||||
assert retiring["can_open"] is False
|
||||
with pytest.raises(
|
||||
session_module.ApplicationAcceptanceError,
|
||||
match="already open or requires manual recovery",
|
||||
):
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
assert len(transports) == 1
|
||||
assert transports[0].close_calls == 0
|
||||
|
||||
release_old_close.set()
|
||||
old_thread.join(timeout=2.0)
|
||||
assert not old_thread.is_alive()
|
||||
retired = session.snapshot()
|
||||
assert retired["state"] == "failed"
|
||||
assert retired["can_open"] is True
|
||||
assert transports[0].close_calls == 1
|
||||
|
||||
# Only a fresh explicit action after retirement creates generation 2.
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
new_thread = session._thread # noqa: SLF001
|
||||
assert new_thread is not None
|
||||
current = _wait_phase(session, "connection-ready")
|
||||
assert current["transport"]["tag"] == "generation-2" # type: ignore[index]
|
||||
assert session._transport is transports[1] # noqa: SLF001
|
||||
assert transports[1].close_calls == 0
|
||||
|
||||
# Complete the second synthetic session so no background waiter remains.
|
||||
session.enter_workspace()
|
||||
_wait_phase(session, "workspace-ready")
|
||||
session.open_project_prompt()
|
||||
_wait_phase(session, "project-ready")
|
||||
session.request_start(project_name="TEST002", confirmation=_confirmation())
|
||||
_wait_phase(session, "scanning")
|
||||
session.request_stop(confirmation=_confirmation())
|
||||
_wait_phase(session, "completed")
|
||||
new_thread.join(timeout=2.0)
|
||||
assert not new_thread.is_alive()
|
||||
assert transports[1].close_calls == 1
|
||||
|
||||
|
||||
def test_start_outcome_unknown_blocks_reopen_even_when_transport_is_closed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class StartFailureExecutor(FakeExecutor):
|
||||
start_attempted = False
|
||||
|
||||
def execute_canonical_start(self, *_args: object, **_kwargs: object) -> object:
|
||||
type(self).start_attempted = True
|
||||
raise session_module.ApplicationCommandOutcomeUnknown(
|
||||
"START response was not correlated"
|
||||
)
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {
|
||||
"dialogue_stage": "start-attempted",
|
||||
"start_attempted": type(self).start_attempted,
|
||||
"stop_attempted": False,
|
||||
"response_evidence": [],
|
||||
"automatic_retry": False,
|
||||
}
|
||||
|
||||
FakeExecutor.records = []
|
||||
StartFailureExecutor.start_attempted = False
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
StartFailureExecutor,
|
||||
)
|
||||
session = InteractiveApplicationControlSession(
|
||||
FakeAuthorityLoader(),
|
||||
transport_factory=lambda host: FakeTransport(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())
|
||||
failed = _wait_phase(session, "failed")
|
||||
|
||||
assert failed["outcome_unknown"] is True
|
||||
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]
|
||||
|
||||
|
||||
def test_unavailable_transport_snapshot_after_publish_blocks_reopen(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
executor_entered = threading.Event()
|
||||
release_failure = threading.Event()
|
||||
|
||||
class SnapshotFailureTransport(FakeTransport):
|
||||
def __init__(self, host: str) -> None:
|
||||
super().__init__(host)
|
||||
self.simulated_publish_attempts = 0
|
||||
self.snapshot_unavailable = False
|
||||
|
||||
def snapshot(self) -> FakeTransportSnapshot:
|
||||
if self.snapshot_unavailable:
|
||||
raise RuntimeError("simulated transport snapshot failure")
|
||||
return super().snapshot()
|
||||
|
||||
class PublishedThenFailedExecutor(FakeExecutor):
|
||||
def run_connection_stage(
|
||||
self,
|
||||
_orchestrator: object,
|
||||
) -> LiveDeviceControlBinding:
|
||||
executor_entered.set()
|
||||
assert release_failure.wait(timeout=2.0)
|
||||
assert isinstance(self.transport, SnapshotFailureTransport)
|
||||
self.transport.simulated_publish_attempts = 1
|
||||
self.transport.snapshot_unavailable = True
|
||||
raise RuntimeError("bootstrap failed after simulated publish")
|
||||
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
PublishedThenFailedExecutor,
|
||||
)
|
||||
transport = SnapshotFailureTransport("192.168.1.20")
|
||||
session = InteractiveApplicationControlSession(
|
||||
FakeAuthorityLoader(),
|
||||
transport_factory=lambda _host: transport, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
assert executor_entered.wait(timeout=2.0)
|
||||
failed_thread = session._thread # noqa: SLF001
|
||||
assert failed_thread is not None
|
||||
release_failure.set()
|
||||
failed_thread.join(timeout=2.0)
|
||||
assert not failed_thread.is_alive()
|
||||
failed = session.snapshot()
|
||||
|
||||
assert transport.simulated_publish_attempts == 1
|
||||
assert failed["state"] == "failed"
|
||||
assert failed["outcome_unknown"] is True
|
||||
assert failed["can_open"] is False
|
||||
failure = failed["failure"]
|
||||
assert isinstance(failure, dict)
|
||||
assert failure["transport_state"] is None
|
||||
assert failure["publish_attempts"] is None
|
||||
assert failure["diagnostic_snapshot_unavailable"] == ["transport"]
|
||||
assert failure["diagnostic_evidence_unavailable"] == ["transport.publish_attempts"]
|
||||
assert failure["modeling_command_attempted"] is False
|
||||
assert failure["safe_to_retry"] is False
|
||||
with pytest.raises(
|
||||
session_module.ApplicationAcceptanceError,
|
||||
match="already open or requires manual recovery",
|
||||
):
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
|
||||
|
||||
def test_unavailable_dialogue_snapshot_is_outcome_unknown(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class DialogueSnapshotFailureExecutor(FakeExecutor):
|
||||
def run_connection_stage(
|
||||
self,
|
||||
_orchestrator: object,
|
||||
) -> LiveDeviceControlBinding:
|
||||
raise RuntimeError("bootstrap failed with unavailable dialogue evidence")
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
raise RuntimeError("simulated dialogue snapshot failure")
|
||||
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
DialogueSnapshotFailureExecutor,
|
||||
)
|
||||
session = InteractiveApplicationControlSession(
|
||||
FakeAuthorityLoader(),
|
||||
transport_factory=lambda host: FakeTransport(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["can_open"] is False
|
||||
failure = failed["failure"]
|
||||
assert isinstance(failure, dict)
|
||||
assert failure["publish_attempts"] == 0
|
||||
assert failure["modeling_command_attempted"] is None
|
||||
assert failure["diagnostic_snapshot_unavailable"] == ["dialogue"]
|
||||
assert failure["diagnostic_evidence_unavailable"] == ["dialogue.modeling_command_attempted"]
|
||||
assert failure["safe_to_retry"] is False
|
||||
|
||||
|
||||
def test_pretransport_authority_failure_remains_safe_after_worker_retirement() -> None:
|
||||
class FailingAuthorityLoader(FakeAuthorityLoader):
|
||||
def load(self) -> ApplicationControlAuthority:
|
||||
self.calls += 1
|
||||
raise RuntimeError("authority unavailable before transport creation")
|
||||
|
||||
transport_factory_calls = 0
|
||||
|
||||
def transport_factory(host: str) -> FakeTransport:
|
||||
nonlocal transport_factory_calls
|
||||
transport_factory_calls += 1
|
||||
return FakeTransport(host)
|
||||
|
||||
session = InteractiveApplicationControlSession(
|
||||
FailingAuthorityLoader(),
|
||||
transport_factory=transport_factory, # 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 transport_factory_calls == 0
|
||||
assert failed["state"] == "failed"
|
||||
assert failed["outcome_unknown"] is False
|
||||
assert failed["can_open"] is True
|
||||
failure = failed["failure"]
|
||||
assert isinstance(failure, dict)
|
||||
assert failure["publish_attempts"] is None
|
||||
assert failure["modeling_command_attempted"] is False
|
||||
assert failure["diagnostic_snapshot_unavailable"] == []
|
||||
assert failure["diagnostic_evidence_unavailable"] == []
|
||||
assert failure["safe_to_retry"] is True
|
||||
|
||||
Reference in New Issue
Block a user