diff --git a/api/src/opentrons/protocol_engine/actions/actions.py b/api/src/opentrons/protocol_engine/actions/actions.py index 2d46f614ec3..adcf4f9e40b 100644 --- a/api/src/opentrons/protocol_engine/actions/actions.py +++ b/api/src/opentrons/protocol_engine/actions/actions.py @@ -116,6 +116,7 @@ class QueueCommandAction: created_at: datetime request: CommandCreate request_hash: Optional[str] + failed_command_id: Optional[str] = None @dataclass(frozen=True) diff --git a/api/src/opentrons/protocol_engine/commands/__init__.py b/api/src/opentrons/protocol_engine/commands/__init__.py index 3dfe6eaf51f..7ce6e07eb68 100644 --- a/api/src/opentrons/protocol_engine/commands/__init__.py +++ b/api/src/opentrons/protocol_engine/commands/__init__.py @@ -19,7 +19,7 @@ from . import thermocycler from . import calibration -from .hash_command_params import hash_command_params +from .hash_command_params import hash_protocol_command_params from .generate_command_schema import generate_command_schema from .command import ( @@ -333,7 +333,7 @@ "CommandStatus", "CommandIntent", # command parameter hashing - "hash_command_params", + "hash_protocol_command_params", # command schema generation "generate_command_schema", # aspirate command models diff --git a/api/src/opentrons/protocol_engine/commands/command.py b/api/src/opentrons/protocol_engine/commands/command.py index 5c2ab46b06f..ad43128236d 100644 --- a/api/src/opentrons/protocol_engine/commands/command.py +++ b/api/src/opentrons/protocol_engine/commands/command.py @@ -55,6 +55,7 @@ class CommandIntent(str, Enum): PROTOCOL = "protocol" SETUP = "setup" + FIXIT = "fixit" class BaseCommandCreate(GenericModel, Generic[CommandParamsT]): @@ -159,6 +160,12 @@ class BaseCommand(GenericModel, Generic[CommandParamsT, CommandResultT]): " the command's execution or the command's generation." ), ) + failedCommandId: Optional[str] = Field( + None, + description=( + "FIXIT command use only. Reference of the failed command id we are trying to fix." + ), + ) class AbstractCommandImpl( diff --git a/api/src/opentrons/protocol_engine/commands/hash_command_params.py b/api/src/opentrons/protocol_engine/commands/hash_command_params.py index 39a042e55dd..9b927aab014 100644 --- a/api/src/opentrons/protocol_engine/commands/hash_command_params.py +++ b/api/src/opentrons/protocol_engine/commands/hash_command_params.py @@ -9,7 +9,7 @@ # TODO(mm, 2023-04-28): # This implementation will not notice that commands are different if they have different params # but share the same commandType. We should also hash command params. (Jira RCORE-326.) -def hash_command_params( +def hash_protocol_command_params( create: CommandCreate, last_hash: Optional[str] ) -> Optional[str]: """Given a command create object, return a hash. @@ -28,12 +28,11 @@ def hash_command_params( The command hash, if the command is a protocol command. `None` if the command is a setup command. """ - if create.intent == CommandIntent.SETUP: + if create.intent != CommandIntent.PROTOCOL: return None - else: - # We avoid Python's built-in hash() function because it's not stable across - # runs of the Python interpreter. (Jira RSS-215.) - last_contribution = b"" if last_hash is None else last_hash.encode("ascii") - this_contribution = md5(create.commandType.encode("ascii")).digest() - to_hash = last_contribution + this_contribution - return md5(to_hash).hexdigest() + # We avoid Python's built-in hash() function because it's not stable across + # runs of the Python interpreter. (Jira RSS-215.) + last_contribution = b"" if last_hash is None else last_hash.encode("ascii") + this_contribution = md5(create.commandType.encode("ascii")).digest() + to_hash = last_contribution + this_contribution + return md5(to_hash).hexdigest() diff --git a/api/src/opentrons/protocol_engine/errors/__init__.py b/api/src/opentrons/protocol_engine/errors/__init__.py index d3c3bb6d79e..994e4cc9ed3 100644 --- a/api/src/opentrons/protocol_engine/errors/__init__.py +++ b/api/src/opentrons/protocol_engine/errors/__init__.py @@ -39,6 +39,7 @@ MustHomeError, RunStoppedError, SetupCommandNotAllowedError, + FixitCommandNotAllowedError, ModuleNotAttachedError, ModuleAlreadyPresentError, WrongModuleTypeError, @@ -55,6 +56,7 @@ InvalidHoldTimeError, CannotPerformModuleAction, PauseNotAllowedError, + ResumeFromRecoveryNotAllowedError, GripperNotAttachedError, CannotPerformGripperAction, HardwareNotSupportedError, @@ -65,6 +67,7 @@ LocationIsStagingSlotError, InvalidAxisForRobotType, NotSupportedOnRobotType, + CommandNotAllowedError, ) from .error_occurrence import ErrorOccurrence, ProtocolCommandFailedError @@ -109,6 +112,7 @@ "MustHomeError", "RunStoppedError", "SetupCommandNotAllowedError", + "FixitCommandNotAllowedError", "ModuleNotAttachedError", "ModuleAlreadyPresentError", "WrongModuleTypeError", @@ -124,6 +128,7 @@ "InvalidBlockVolumeError", "InvalidHoldTimeError", "CannotPerformModuleAction", + "ResumeFromRecoveryNotAllowedError", "PauseNotAllowedError", "ProtocolCommandFailedError", "GripperNotAttachedError", @@ -138,5 +143,5 @@ "NotSupportedOnRobotType", # error occurrence models "ErrorOccurrence", - "FailedGripperPickupError", + "CommandNotAllowedError", ] diff --git a/api/src/opentrons/protocol_engine/errors/exceptions.py b/api/src/opentrons/protocol_engine/errors/exceptions.py index 0e27a270c94..7f022652d71 100644 --- a/api/src/opentrons/protocol_engine/errors/exceptions.py +++ b/api/src/opentrons/protocol_engine/errors/exceptions.py @@ -505,6 +505,32 @@ def __init__( super().__init__(ErrorCodes.POSITION_UNKNOWN, message, details, wrapping) +class CommandNotAllowedError(ProtocolEngineError): + """Raised when adding a command with bad data.""" + + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a CommandNotAllowedError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + + +class FixitCommandNotAllowedError(ProtocolEngineError): + """Raised when adding a fixit command to a non-recoverable engine.""" + + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a SetupCommandNotAllowedError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + + class SetupCommandNotAllowedError(ProtocolEngineError): """Raised when adding a setup command to a non-idle/non-paused engine.""" @@ -518,6 +544,19 @@ def __init__( super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) +class ResumeFromRecoveryNotAllowedError(ProtocolEngineError): + """Raised when attempting to resume a run from recovery that has a fixit command in the queue.""" + + def __init__( + self, + message: Optional[str] = None, + details: Optional[Dict[str, Any]] = None, + wrapping: Optional[Sequence[EnumeratedError]] = None, + ) -> None: + """Build a ResumeFromRecoveryNotAllowedError.""" + super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping) + + class PauseNotAllowedError(ProtocolEngineError): """Raised when attempting to pause a run that is not running.""" diff --git a/api/src/opentrons/protocol_engine/protocol_engine.py b/api/src/opentrons/protocol_engine/protocol_engine.py index 8bb4c91dda3..0c4f2c4b670 100644 --- a/api/src/opentrons/protocol_engine/protocol_engine.py +++ b/api/src/opentrons/protocol_engine/protocol_engine.py @@ -17,7 +17,7 @@ EnumeratedError, ) -from .errors import ProtocolCommandFailedError, ErrorOccurrence +from .errors import ProtocolCommandFailedError, ErrorOccurrence, CommandNotAllowedError from .errors.exceptions import EStopActivatedError from . import commands, slot_standardization from .resources import ModelUtils, ModuleDataProvider @@ -176,7 +176,9 @@ def resume_from_recovery(self) -> None: ) self._action_dispatcher.dispatch(action) - def add_command(self, request: commands.CommandCreate) -> commands.Command: + def add_command( + self, request: commands.CommandCreate, failed_command_id: Optional[str] = None + ) -> commands.Command: """Add a command to the `ProtocolEngine`'s queue. Arguments: @@ -191,16 +193,29 @@ def add_command(self, request: commands.CommandCreate) -> commands.Command: but the engine was not idle or paused. RunStoppedError: the run has been stopped, so no new commands may be added. + CommandNotAllowedError: the request specified a failed command id + with a non fixit command. """ request = slot_standardization.standardize_command( request, self.state_view.config.robot_type ) + if failed_command_id and request.intent != commands.CommandIntent.FIXIT: + raise CommandNotAllowedError( + "failed command id should be supplied with a FIXIT command." + ) + command_id = self._model_utils.generate_id() - request_hash = commands.hash_command_params( - create=request, - last_hash=self._state_store.commands.get_latest_command_hash(), - ) + if request.intent in ( + commands.CommandIntent.SETUP, + commands.CommandIntent.FIXIT, + ): + request_hash = None + else: + request_hash = commands.hash_protocol_command_params( + create=request, + last_hash=self._state_store.commands.get_latest_protocol_command_hash(), + ) action = self.state_view.commands.validate_action_allowed( QueueCommandAction( @@ -208,6 +223,7 @@ def add_command(self, request: commands.CommandCreate) -> commands.Command: request_hash=request_hash, command_id=command_id, created_at=self._model_utils.get_timestamp(), + failed_command_id=failed_command_id, ) ) self._action_dispatcher.dispatch(action) diff --git a/api/src/opentrons/protocol_engine/state/command_history.py b/api/src/opentrons/protocol_engine/state/command_history.py index 6a66a2b8209..b21fca030ae 100644 --- a/api/src/opentrons/protocol_engine/state/command_history.py +++ b/api/src/opentrons/protocol_engine/state/command_history.py @@ -33,6 +33,9 @@ class CommandHistory: _queued_setup_command_ids: OrderedSet[str] """The IDs of queued setup commands, in FIFO order""" + _queued_fixit_command_ids: OrderedSet[str] + """The IDs of queued fixit commands, in FIFO order""" + _running_command_id: Optional[str] """The ID of the currently running command, if any""" @@ -43,6 +46,7 @@ def __init__(self) -> None: self._all_command_ids = [] self._queued_command_ids = OrderedSet() self._queued_setup_command_ids = OrderedSet() + self._queued_fixit_command_ids = OrderedSet() self._commands_by_id = OrderedDict() self._running_command_id = None self._terminal_command_id = None @@ -135,6 +139,10 @@ def get_setup_queue_ids(self) -> OrderedSet[str]: """Get the IDs of all queued setup commands, in FIFO order.""" return self._queued_setup_command_ids + def get_fixit_queue_ids(self) -> OrderedSet[str]: + """Get the IDs of all queued fixit commands, in FIFO order.""" + return self._queued_fixit_command_ids + def clear_queue(self) -> None: """Clears all commands within the queued command ids structure.""" self._queued_command_ids.clear() @@ -143,6 +151,10 @@ def clear_setup_queue(self) -> None: """Clears all commands within the queued setup command ids structure.""" self._queued_setup_command_ids.clear() + def clear_fixit_queue(self) -> None: + """Clears all commands within the queued setup command ids structure.""" + self._queued_fixit_command_ids.clear() + def set_command_queued(self, command: Command) -> None: """Validate and mark a command as queued in the command history.""" assert command.status == CommandStatus.QUEUED @@ -157,6 +169,8 @@ def set_command_queued(self, command: Command) -> None: if command.intent == CommandIntent.SETUP: self._add_to_setup_queue(command.id) + elif command.intent == CommandIntent.FIXIT: + self._add_to_fixit_queue(command.id) else: self._add_to_queue(command.id) @@ -177,6 +191,7 @@ def set_command_running(self, command: Command) -> None: self._remove_queue_id(command.id) self._remove_setup_queue_id(command.id) + self._remove_fixit_queue_id(command.id) def set_command_succeeded(self, command: Command) -> None: """Validate and mark a command as succeeded in the command history.""" @@ -239,6 +254,10 @@ def _add_to_setup_queue(self, command_id: str) -> None: """Add a new ID to the queued setup.""" self._queued_setup_command_ids.add(command_id) + def _add_to_fixit_queue(self, command_id: str) -> None: + """Add a new ID to the queued fixit.""" + self._queued_fixit_command_ids.add(command_id) + def _remove_queue_id(self, command_id: str) -> None: """Remove a specific command from the queued command ids structure.""" self._queued_command_ids.discard(command_id) @@ -247,6 +266,10 @@ def _remove_setup_queue_id(self, command_id: str) -> None: """Remove a specific command from the queued setup command ids structure.""" self._queued_setup_command_ids.discard(command_id) + def _remove_fixit_queue_id(self, command_id: str) -> None: + """Remove a specific command from the queued fixit command ids structure.""" + self._queued_fixit_command_ids.discard(command_id) + def _set_terminal_command_id(self, command_id: str) -> None: """Set the ID of the most recently dequeued command.""" self._terminal_command_id = command_id diff --git a/api/src/opentrons/protocol_engine/state/commands.py b/api/src/opentrons/protocol_engine/state/commands.py index b5805251046..f9d7643b728 100644 --- a/api/src/opentrons/protocol_engine/state/commands.py +++ b/api/src/opentrons/protocol_engine/state/commands.py @@ -38,6 +38,8 @@ ErrorOccurrence, RobotDoorOpenError, SetupCommandNotAllowedError, + FixitCommandNotAllowedError, + ResumeFromRecoveryNotAllowedError, PauseNotAllowedError, UnexpectedProtocolError, ProtocolCommandFailedError, @@ -184,8 +186,8 @@ class CommandState: finish_error: Optional[ErrorOccurrence] """The error that happened during the post-run finish steps (homing & dropping tips), if any.""" - latest_command_hash: Optional[str] - """The latest hash value received in a QueueCommandAction. + latest_protocol_command_hash: Optional[str] + """The latest PROTOCOL command hash value received in a QueueCommandAction. This value can be used to generate future hashes. """ @@ -219,7 +221,7 @@ def __init__( recovery_target_command_id=None, run_completed_at=None, run_started_at=None, - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) @@ -241,12 +243,13 @@ def handle_action(self, action: Action) -> None: # noqa: C901 params=action.request.params, # type: ignore[arg-type] intent=action.request.intent, status=CommandStatus.QUEUED, + failedCommandId=action.failed_command_id, ) self._state.command_history.set_command_queued(queued_command) if action.request_hash is not None: - self._state.latest_command_hash = action.request_hash + self._state.latest_protocol_command_hash = action.request_hash elif isinstance(action, RunCommandAction): prev_entry = self._state.command_history.get(action.command_id) @@ -321,6 +324,20 @@ def handle_action(self, action: Action) -> None: # noqa: C901 self._state.command_history.clear_queue() else: assert_never(action.type) + elif prev_entry.command.intent == CommandIntent.FIXIT: + other_command_ids_to_fail = ( + self._state.command_history.get_fixit_queue_ids() + ) + for command_id in other_command_ids_to_fail: + # TODO(mc, 2022-06-06): add new "cancelled" status or similar + self._update_to_failed( + command_id=command_id, + failed_at=action.failed_at, + error_occurrence=None, + error_recovery_type=None, + notes=None, + ) + self._state.command_history.clear_fixit_queue() else: assert_never(prev_entry.command.intent) @@ -339,6 +356,7 @@ def handle_action(self, action: Action) -> None: # noqa: C901 self._state.queue_status = QueueStatus.PAUSED elif isinstance(action, ResumeFromRecoveryAction): + self._state.command_history.clear_fixit_queue() self._state.queue_status = QueueStatus.RUNNING self._state.recovery_target_command_id = None @@ -606,9 +624,18 @@ def get_next_to_execute(self) -> Optional[str]: if self._state.run_result: raise RunStoppedError("Engine was stopped") + # if queue is in recovery mode, return the next fixit command + next_fixit_cmd = self._state.command_history.get_fixit_queue_ids().head(None) + if next_fixit_cmd and self._state.queue_status == QueueStatus.AWAITING_RECOVERY: + return next_fixit_cmd + # if there is a setup command queued, prioritize it next_setup_cmd = self._state.command_history.get_setup_queue_ids().head(None) - if self._state.queue_status != QueueStatus.PAUSED and next_setup_cmd: + if ( + self._state.queue_status + not in [QueueStatus.PAUSED, QueueStatus.AWAITING_RECOVERY] + and next_setup_cmd + ): return next_setup_cmd # if the queue is running, return the next protocol command @@ -816,12 +843,28 @@ def validate_action_allowed( # noqa: C901 raise SetupCommandNotAllowedError( "Setup commands are not allowed after run has started." ) + elif action.request.intent == CommandIntent.FIXIT: + if self._state.queue_status != QueueStatus.AWAITING_RECOVERY: + raise FixitCommandNotAllowedError( + "Fixit commands are not allowed when the run is not in a recoverable state." + ) + else: + return action else: return action elif isinstance(action, ResumeFromRecoveryAction): if self.get_status() != EngineStatus.AWAITING_RECOVERY: - raise NotImplementedError() + raise ResumeFromRecoveryNotAllowedError( + "Cannot resume from recovery if the run is not in recovery mode." + ) + elif ( + self.get_status() == EngineStatus.AWAITING_RECOVERY + and len(self._state.command_history.get_fixit_queue_ids()) > 0 + ): + raise ResumeFromRecoveryNotAllowedError( + "Cannot resume from recovery while there are fixit commands in the queue." + ) else: return action @@ -873,6 +916,6 @@ def get_status(self) -> EngineStatus: # SETUP and we're currently a setup command? return EngineStatus.IDLE - def get_latest_command_hash(self) -> Optional[str]: + def get_latest_protocol_command_hash(self) -> Optional[str]: """Get the command hash of the last queued command, if any.""" - return self._state.latest_command_hash + return self._state.latest_protocol_command_hash diff --git a/api/tests/opentrons/protocol_engine/commands/test_hash_command_params.py b/api/tests/opentrons/protocol_engine/commands/test_hash_command_params.py index 098ce53c321..9988854a9d4 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_hash_command_params.py +++ b/api/tests/opentrons/protocol_engine/commands/test_hash_command_params.py @@ -2,7 +2,9 @@ from opentrons.protocol_engine import CommandIntent from opentrons.protocol_engine import commands -from opentrons.protocol_engine.commands.hash_command_params import hash_command_params +from opentrons.protocol_engine.commands.hash_command_params import ( + hash_protocol_command_params, +) def test_equivalent_commands() -> None: @@ -20,10 +22,14 @@ def test_equivalent_commands() -> None: params=commands.WaitForDurationParams(seconds=123) ) - assert hash_command_params(b, None) == hash_command_params(c, None) + assert hash_protocol_command_params(b, None) == hash_protocol_command_params( + c, None + ) - a_hash = hash_command_params(a, None) - assert hash_command_params(b, a_hash) == hash_command_params(c, a_hash) + a_hash = hash_protocol_command_params(a, None) + assert hash_protocol_command_params(b, a_hash) == hash_protocol_command_params( + c, a_hash + ) def test_nonequivalent_commands() -> None: @@ -32,26 +38,31 @@ def test_nonequivalent_commands() -> None: params=commands.BlowOutInPlaceParams( pipetteId="abc123", flowRate=123, - ) + ), + intent=CommandIntent.PROTOCOL, ) b = commands.WaitForDurationCreate( params=commands.WaitForDurationParams(seconds=123) ) - assert hash_command_params(a, None) != hash_command_params(b, None) + assert hash_protocol_command_params(a, None) != hash_protocol_command_params( + b, None + ) def test_repeated_commands() -> None: """Repeated commands should hash differently, even though they're equivalent in isolation.""" a = commands.WaitForDurationCreate( - params=commands.WaitForDurationParams(seconds=123) + params=commands.WaitForDurationParams(seconds=123), + intent=CommandIntent.PROTOCOL, ) b = commands.WaitForDurationCreate( - params=commands.WaitForDurationParams(seconds=123) + params=commands.WaitForDurationParams(seconds=123), + intent=CommandIntent.PROTOCOL, ) - a_hash = hash_command_params(a, None) - b_hash = hash_command_params(b, a_hash) + a_hash = hash_protocol_command_params(a, None) + b_hash = hash_protocol_command_params(b, a_hash) assert a_hash != b_hash @@ -61,4 +72,4 @@ def test_setup_command() -> None: params=commands.WaitForDurationParams(seconds=123), intent=CommandIntent.SETUP, ) - assert hash_command_params(setup_command, None) is None + assert hash_protocol_command_params(setup_command, None) is None diff --git a/api/tests/opentrons/protocol_engine/state/command_fixtures.py b/api/tests/opentrons/protocol_engine/state/command_fixtures.py index 191dd49bd48..b8b47648b3a 100644 --- a/api/tests/opentrons/protocol_engine/state/command_fixtures.py +++ b/api/tests/opentrons/protocol_engine/state/command_fixtures.py @@ -24,6 +24,7 @@ def create_queued_command( command_id: str = "command-id", command_key: str = "command-key", command_type: str = "command-type", + intent: cmd.CommandIntent = cmd.CommandIntent.PROTOCOL, params: Optional[BaseModel] = None, ) -> cmd.Command: """Given command data, build a pending command model.""" @@ -36,6 +37,7 @@ def create_queued_command( createdAt=datetime(year=2021, month=1, day=1), status=cmd.CommandStatus.QUEUED, params=params or BaseModel(), + intent=intent, ), ) diff --git a/api/tests/opentrons/protocol_engine/state/test_command_history.py b/api/tests/opentrons/protocol_engine/state/test_command_history.py index c6344141281..3c84b86e07f 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_history.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_history.py @@ -5,6 +5,7 @@ from opentrons.protocol_engine.errors.exceptions import CommandDoesNotExistError from opentrons.protocol_engine.state.command_history import CommandHistory, CommandEntry +from opentrons.protocol_engine.commands import CommandIntent, CommandStatus from .command_fixtures import ( create_queued_command, @@ -18,6 +19,15 @@ def create_queued_command_entry( return CommandEntry(create_queued_command(command_id=command_id), index) +def create_fixit_command_entry( + command_id: str = "command-id", index: int = 0 +) -> CommandEntry: + """Create a command entry for a fixit command.""" + return CommandEntry( + create_queued_command(command_id=command_id, intent=CommandIntent.FIXIT), index + ) + + @pytest.fixture def command_history() -> CommandHistory: """Instantiates a CommandHistory instance.""" @@ -161,6 +171,14 @@ def test_get_setup_queue_ids(command_history: CommandHistory) -> None: assert command_history.get_setup_queue_ids() == OrderedSet(["0", "1"]) +def test_get_fixit_queue_ids(command_history: CommandHistory) -> None: + """It should return the IDs of all commands in the setup queue.""" + assert command_history.get_fixit_queue_ids() == OrderedSet() + command_history._add_to_fixit_queue("0") + command_history._add_to_fixit_queue("1") + assert command_history.get_fixit_queue_ids() == OrderedSet(["0", "1"]) + + def test_set_command_entry(command_history: CommandHistory) -> None: """It should set the command entry for the given ID.""" command_entry = create_queued_command_entry() @@ -184,6 +202,41 @@ def test_set_running_command_id(command_history: CommandHistory) -> None: assert command_history.get_running_command() == command_entry +def test_set_fixit_running_command_id(command_history: CommandHistory) -> None: + """It should set the ID of the currently running fixit command.""" + command_entry = create_queued_command() + command_history.set_command_queued(command_entry) + running_command = command_entry.copy( + update={ + "status": CommandStatus.RUNNING, + } + ) + command_history.set_command_running(running_command) + finished_command = command_entry.copy( + update={ + "status": CommandStatus.SUCCEEDED, + } + ) + command_history.set_command_succeeded(finished_command) + fixit_command_entry = create_queued_command( + command_id="fixit-id", intent=CommandIntent.FIXIT + ) + command_history.set_command_queued(fixit_command_entry) + fixit_running_command = fixit_command_entry.copy( + update={ + "status": CommandStatus.RUNNING, + } + ) + command_history.set_command_running(fixit_running_command) + current_running_command = command_history.get_running_command() + assert current_running_command is not None + assert current_running_command.command == fixit_running_command + assert command_history.get_all_commands() == [ + finished_command, + fixit_running_command, + ] + + def test_add_to_queue(command_history: CommandHistory) -> None: """It should add the given ID to the queue.""" command_history._add_to_queue("0") @@ -196,6 +249,13 @@ def test_add_to_setup_queue(command_history: CommandHistory) -> None: assert command_history.get_setup_queue_ids() == OrderedSet(["0"]) +def test_add_to_fixit_queue(command_history: CommandHistory) -> None: + """It should add the given ID to the setup queue.""" + fixit_command = create_queued_command(intent=CommandIntent.FIXIT) + command_history.set_command_queued(fixit_command) + assert command_history.get_fixit_queue_ids() == OrderedSet(["command-id"]) + + def test_clear_queue(command_history: CommandHistory) -> None: """It should clear all commands in the queue.""" command_history._add_to_queue("0") @@ -212,6 +272,19 @@ def test_clear_setup_queue(command_history: CommandHistory) -> None: assert command_history.get_setup_queue_ids() == OrderedSet() +def test_clear_fixit_queue(command_history: CommandHistory) -> None: + """It should clear all commands in the setup queue.""" + command_history.set_command_queued( + create_queued_command(command_id="0", intent=CommandIntent.FIXIT) + ) + command_history.set_command_queued( + create_queued_command(command_id="1", intent=CommandIntent.FIXIT) + ) + assert command_history.get_fixit_queue_ids() == OrderedSet(["0", "1"]) + command_history.clear_fixit_queue() + assert command_history.get_fixit_queue_ids() == OrderedSet() + + def test_remove_id_from_queue(command_history: CommandHistory) -> None: """It should remove the given ID from the queue.""" command_history._add_to_queue("0") diff --git a/api/tests/opentrons/protocol_engine/state/test_command_store_old.py b/api/tests/opentrons/protocol_engine/state/test_command_store_old.py index 52d5aa961ce..60cdf27838f 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_store_old.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_store_old.py @@ -84,7 +84,7 @@ def test_initial_state( failed_command=None, command_error_recovery_types={}, recovery_target_command_id=None, - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) @@ -254,7 +254,7 @@ def test_command_queue_with_hash() -> None: ) assert subject.state.command_history.get("command-id-1").command.key == "abc123" - assert subject.state.latest_command_hash == "abc123" + assert subject.state.latest_protocol_command_hash == "abc123" subject.handle_action( QueueCommandAction( @@ -265,7 +265,7 @@ def test_command_queue_with_hash() -> None: ) ) - assert subject.state.latest_command_hash == "def456" + assert subject.state.latest_protocol_command_hash == "def456" def test_command_queue_and_unqueue() -> None: @@ -518,7 +518,7 @@ def test_command_store_handles_pause_action(pause_source: PauseSource) -> None: failed_command=None, command_error_recovery_types={}, recovery_target_command_id=None, - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) @@ -545,7 +545,7 @@ def test_command_store_handles_play_action(pause_source: PauseSource) -> None: command_error_recovery_types={}, recovery_target_command_id=None, run_started_at=datetime(year=2021, month=1, day=1), - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) assert subject.state.command_history.get_running_command() is None @@ -577,7 +577,7 @@ def test_command_store_handles_finish_action() -> None: command_error_recovery_types={}, recovery_target_command_id=None, run_started_at=datetime(year=2021, month=1, day=1), - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) assert subject.state.command_history.get_running_command() is None @@ -629,7 +629,7 @@ def test_command_store_handles_stop_action( command_error_recovery_types={}, recovery_target_command_id=None, run_started_at=datetime(year=2021, month=1, day=1), - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=from_estop, ) assert subject.state.command_history.get_running_command() is None @@ -660,7 +660,7 @@ def test_command_store_cannot_restart_after_should_stop() -> None: command_error_recovery_types={}, recovery_target_command_id=None, run_started_at=None, - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) assert subject.state.command_history.get_running_command() is None @@ -792,7 +792,7 @@ def test_command_store_wraps_unknown_errors() -> None: failed_command=None, command_error_recovery_types={}, recovery_target_command_id=None, - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) assert subject.state.command_history.get_running_command() is None @@ -855,7 +855,7 @@ def __init__(self, message: str) -> None: command_error_recovery_types={}, recovery_target_command_id=None, run_started_at=None, - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) assert subject.state.command_history.get_running_command() is None @@ -888,7 +888,7 @@ def test_command_store_ignores_stop_after_graceful_finish() -> None: command_error_recovery_types={}, recovery_target_command_id=None, run_started_at=datetime(year=2021, month=1, day=1), - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) assert subject.state.command_history.get_running_command() is None @@ -921,7 +921,7 @@ def test_command_store_ignores_finish_after_non_graceful_stop() -> None: command_error_recovery_types={}, recovery_target_command_id=None, run_started_at=datetime(year=2021, month=1, day=1), - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) assert subject.state.command_history.get_running_command() is None @@ -950,7 +950,7 @@ def test_handles_hardware_stopped() -> None: command_error_recovery_types={}, recovery_target_command_id=None, run_started_at=None, - latest_command_hash=None, + latest_protocol_command_hash=None, stopped_by_estop=False, ) assert subject.state.command_history.get_running_command() is None diff --git a/api/tests/opentrons/protocol_engine/state/test_command_view_old.py b/api/tests/opentrons/protocol_engine/state/test_command_view_old.py index a9b5fc92cc3..19a2515a3e6 100644 --- a/api/tests/opentrons/protocol_engine/state/test_command_view_old.py +++ b/api/tests/opentrons/protocol_engine/state/test_command_view_old.py @@ -46,7 +46,7 @@ ) -def get_command_view( +def get_command_view( # noqa: C901 queue_status: QueueStatus = QueueStatus.SETUP, run_completed_at: Optional[datetime] = None, run_started_at: Optional[datetime] = None, @@ -55,6 +55,7 @@ def get_command_view( running_command_id: Optional[str] = None, queued_command_ids: Sequence[str] = (), queued_setup_command_ids: Sequence[str] = (), + queued_fixit_command_ids: Sequence[str] = (), run_error: Optional[errors.ErrorOccurrence] = None, failed_command: Optional[CommandEntry] = None, command_error_recovery_types: Optional[Dict[str, ErrorRecoveryType]] = None, @@ -74,6 +75,9 @@ def get_command_view( if queued_setup_command_ids: for command_id in queued_setup_command_ids: command_history._add_to_setup_queue(command_id) + if queued_fixit_command_ids: + for command_id in queued_fixit_command_ids: + command_history._add_to_fixit_queue(command_id) if commands: for index, command in enumerate(commands): command_history._add( @@ -93,7 +97,7 @@ def get_command_view( command_error_recovery_types=command_error_recovery_types or {}, recovery_target_command_id=recovery_target_command_id, run_started_at=run_started_at, - latest_command_hash=latest_command_hash, + latest_protocol_command_hash=latest_command_hash, stopped_by_estop=False, ) @@ -133,6 +137,7 @@ def test_get_next_to_execute_returns_first_queued() -> None: subject = get_command_view( queue_status=QueueStatus.RUNNING, queued_command_ids=["command-id-1", "command-id-2"], + queued_fixit_command_ids=["fixit-id-1", "fixit-id-2"], ) assert subject.get_next_to_execute() == "command-id-1" @@ -155,6 +160,24 @@ def test_get_next_to_execute_prioritizes_setup_command_queue( assert subject.get_next_to_execute() == "setup-command-id" +@pytest.mark.parametrize( + "queue_status", + [QueueStatus.AWAITING_RECOVERY], +) +def test_get_next_to_execute_prioritizes_fixit_command_queue( + queue_status: QueueStatus, +) -> None: + """It should prioritize fixit command queue over protocol command queue.""" + subject = get_command_view( + queue_status=queue_status, + queued_command_ids=["command-id-1", "command-id-2"], + queued_setup_command_ids=["setup-command-id"], + queued_fixit_command_ids=["fixit-1", "fixit-2"], + ) + + assert subject.get_next_to_execute() == "fixit-1" + + def test_get_next_to_execute_returns_none_when_no_queued() -> None: """It should return None if there are no queued commands.""" subject = get_command_view( @@ -186,6 +209,20 @@ def test_get_next_to_execute_returns_no_commands_if_paused() -> None: queue_status=QueueStatus.PAUSED, queued_setup_command_ids=["setup-id-1", "setup-id-2"], queued_command_ids=["command-id-1", "command-id-2"], + queued_fixit_command_ids=["fixit-id-1", "fixit-id-2"], + ) + result = subject.get_next_to_execute() + + assert result is None + + +def test_get_next_to_execute_returns_no_commands_if_awaiting_recovery_no_fixit() -> None: + """It should not return any type of command if the engine is awaiting-recovery.""" + subject = get_command_view( + queue_status=QueueStatus.AWAITING_RECOVERY, + queued_setup_command_ids=["setup-id-1", "setup-id-2"], + queued_command_ids=["command-id-1", "command-id-2"], + queued_fixit_command_ids=[], ) result = subject.get_next_to_execute() @@ -486,12 +523,69 @@ class ActionAllowedSpec(NamedTuple): ), expected_error=errors.SetupCommandNotAllowedError, ), - # Resuming from error recovery is not implemented yet. - # https://opentrons.atlassian.net/browse/EXEC-301 + # fixit command is disallowed if not in recovery mode ActionAllowedSpec( - subject=get_command_view(), + subject=get_command_view(queue_status=QueueStatus.RUNNING), + action=QueueCommandAction( + request=cmd.HomeCreate( + params=cmd.HomeParams(), + intent=cmd.CommandIntent.FIXIT, + ), + request_hash=None, + command_id="command-id", + created_at=datetime(year=2021, month=1, day=1), + ), + expected_error=errors.FixitCommandNotAllowedError, + ), + ActionAllowedSpec( + subject=get_command_view( + queue_status=QueueStatus.AWAITING_RECOVERY, + failed_command=CommandEntry( + index=2, + command=create_failed_command( + command_id="command-id-3", + error=ErrorOccurrence( + id="error-id", + errorType="ProtocolEngineError", + createdAt=datetime(year=2022, month=2, day=2), + detail="oh no", + errorCode=ErrorCodes.GENERAL_ERROR.value.code, + ), + ), + ), + ), + action=QueueCommandAction( + request=cmd.HomeCreate( + params=cmd.HomeParams(), + intent=cmd.CommandIntent.FIXIT, + ), + request_hash=None, + command_id="command-id", + created_at=datetime(year=2021, month=1, day=1), + ), + expected_error=None, + ), + # resume from recovery not allowed if fixit commands in queue + ActionAllowedSpec( + subject=get_command_view( + queue_status=QueueStatus.AWAITING_RECOVERY, + queued_fixit_command_ids=["fixit-id-1", "fixit-id-2"], + failed_command=CommandEntry( + index=2, + command=create_failed_command( + command_id="command-id-3", + error=ErrorOccurrence( + id="error-id", + errorType="ProtocolEngineError", + createdAt=datetime(year=2022, month=2, day=2), + detail="oh no", + errorCode=ErrorCodes.GENERAL_ERROR.value.code, + ), + ), + ), + ), action=ResumeFromRecoveryAction(), - expected_error=NotImplementedError, + expected_error=errors.ResumeFromRecoveryNotAllowedError, ), ] @@ -931,4 +1025,4 @@ def test_get_slice_default_cursor_queued() -> None: def test_get_latest_command_hash() -> None: """It should get the latest command hash from state, if set.""" subject = get_command_view(latest_command_hash="abc123") - assert subject.get_latest_command_hash() == "abc123" + assert subject.get_latest_protocol_command_hash() == "abc123" diff --git a/api/tests/opentrons/protocol_engine/test_protocol_engine.py b/api/tests/opentrons/protocol_engine/test_protocol_engine.py index e3f7b315e4d..4816708fa57 100644 --- a/api/tests/opentrons/protocol_engine/test_protocol_engine.py +++ b/api/tests/opentrons/protocol_engine/test_protocol_engine.py @@ -17,6 +17,9 @@ from opentrons.protocols.models import LabwareDefinition from opentrons.protocol_engine import ProtocolEngine, commands, slot_standardization +from opentrons.protocol_engine.errors.exceptions import ( + CommandNotAllowedError, +) from opentrons.protocol_engine.types import ( DeckType, LabwareOffset, @@ -126,9 +129,9 @@ def _mock_slot_standardization_module( def _mock_hash_command_params_module( decoy: Decoy, monkeypatch: pytest.MonkeyPatch ) -> None: - hash_command_params = commands.hash_command_params + hash_command_params = commands.hash_protocol_command_params monkeypatch.setattr( - commands, "hash_command_params", decoy.mock(func=hash_command_params) + commands, "hash_protocol_command_params", decoy.mock(func=hash_command_params) ) @@ -180,7 +183,9 @@ def test_add_command( original_request = commands.WaitForResumeCreate( params=commands.WaitForResumeParams() ) - standardized_request = commands.HomeCreate(params=commands.HomeParams()) + standardized_request = commands.HomeCreate( + params=commands.HomeParams(), intent=commands.CommandIntent.PROTOCOL + ) queued = commands.Home( id="command-id", key="command-key", @@ -200,9 +205,13 @@ def test_add_command( decoy.when(model_utils.generate_id()).then_return("command-id") decoy.when(model_utils.get_timestamp()).then_return(created_at) - decoy.when(state_store.commands.get_latest_command_hash()).then_return("abc") + decoy.when(state_store.commands.get_latest_protocol_command_hash()).then_return( + "abc" + ) decoy.when( - commands.hash_command_params(create=standardized_request, last_hash="abc") + commands.hash_protocol_command_params( + create=standardized_request, last_hash="abc" + ) ).then_return("123") def _stub_queued(*_a: object, **_k: object) -> None: @@ -242,6 +251,105 @@ def _stub_queued(*_a: object, **_k: object) -> None: assert result == queued +def test_add_fixit_command( + decoy: Decoy, + state_store: StateStore, + action_dispatcher: ActionDispatcher, + model_utils: ModelUtils, + subject: ProtocolEngine, +) -> None: + """It should add a fixit command to the state from a request.""" + created_at = datetime(year=2021, month=1, day=1) + original_request = commands.WaitForResumeCreate( + params=commands.WaitForResumeParams() + ) + standardized_request = commands.HomeCreate( + params=commands.HomeParams(), intent=commands.CommandIntent.FIXIT + ) + queued = commands.Home( + id="command-id", + key="command-key", + status=commands.CommandStatus.QUEUED, + createdAt=created_at, + params=commands.HomeParams(), + ) + + robot_type: RobotType = "OT-3 Standard" + decoy.when(state_store.config).then_return( + Config(robot_type=robot_type, deck_type=DeckType.OT3_STANDARD) + ) + + decoy.when( + slot_standardization.standardize_command(original_request, robot_type) + ).then_return(standardized_request) + + decoy.when(model_utils.generate_id()).then_return("command-id") + decoy.when(model_utils.get_timestamp()).then_return(created_at) + + def _stub_queued(*_a: object, **_k: object) -> None: + decoy.when(state_store.commands.get("command-id")).then_return(queued) + + decoy.when( + state_store.commands.validate_action_allowed( + QueueCommandAction( + command_id="command-id", + created_at=created_at, + request=standardized_request, + request_hash=None, + ) + ) + ).then_return( + QueueCommandAction( + command_id="command-id-validated", + created_at=created_at, + request=standardized_request, + request_hash=None, + ) + ) + + decoy.when( + action_dispatcher.dispatch( + QueueCommandAction( + command_id="command-id-validated", + created_at=created_at, + request=standardized_request, + request_hash=None, + ) + ), + ).then_do(_stub_queued) + + result = subject.add_command(original_request) + assert result == queued + + +def test_add_fixit_command_raises( + decoy: Decoy, + state_store: StateStore, + action_dispatcher: ActionDispatcher, + model_utils: ModelUtils, + subject: ProtocolEngine, +) -> None: + """It should raise if a failedCommandId is supplied without a fixit command.""" + original_request = commands.WaitForResumeCreate( + params=commands.WaitForResumeParams() + ) + standardized_request = commands.HomeCreate( + params=commands.HomeParams(), intent=commands.CommandIntent.PROTOCOL + ) + + robot_type: RobotType = "OT-3 Standard" + decoy.when(state_store.config).then_return( + Config(robot_type=robot_type, deck_type=DeckType.OT3_STANDARD) + ) + + decoy.when( + slot_standardization.standardize_command(original_request, robot_type) + ).then_return(standardized_request) + + with pytest.raises(CommandNotAllowedError): + subject.add_command(original_request, "id-123") + + async def test_add_and_execute_command( decoy: Decoy, state_store: StateStore, diff --git a/robot-server/robot_server/runs/router/commands_router.py b/robot-server/robot_server/runs/router/commands_router.py index 734d1a26066..47a64c5d800 100644 --- a/robot-server/robot_server/runs/router/commands_router.py +++ b/robot-server/robot_server/runs/router/commands_router.py @@ -56,11 +56,18 @@ class CommandNotFound(ErrorDetails): title: str = "Run Command Not Found" +class SetupCommandNotAllowed(ErrorDetails): + """An error if a given run setup command is not allowed.""" + + id: Literal["SetupCommandNotAllowed"] = "SetupCommandNotAllowed" + title: str = "Setup Command Not Allowed" + + class CommandNotAllowed(ErrorDetails): """An error if a given run command is not allowed.""" id: Literal["CommandNotAllowed"] = "CommandNotAllowed" - title: str = "Setup Command Not Allowed" + title: str = "Command Not Allowed" class CommandLinkMeta(BaseModel): @@ -128,6 +135,7 @@ async def get_current_run_engine_from_url( - Setup commands (`data.source == "setup"`) - Protocol commands (`data.source == "protocol"`) + - Fixit commands (`data.source == "fixit"`) Setup commands may be enqueued before the run has been started. You could use setup commands to prepare a module or @@ -138,6 +146,11 @@ async def get_current_run_engine_from_url( If you are running a protocol from a file(s), then you will likely not need to enqueue protocol commands using this endpoint. + Fixit commands may be enqueued while the run is `awaiting-recovery` state. + These commands are intended to fix a failed command. + They will be executed right after the failed command + and only if the run is in a `awaiting-recovery` state. + Once enqueued, setup commands will execute immediately with priority, while protocol commands will wait until a `play` action is issued. A play action may be issued while setup commands are still queued, @@ -153,8 +166,9 @@ async def get_current_run_engine_from_url( status.HTTP_201_CREATED: {"model": SimpleBody[pe_commands.Command]}, status.HTTP_404_NOT_FOUND: {"model": ErrorBody[RunNotFound]}, status.HTTP_409_CONFLICT: { - "model": ErrorBody[Union[RunStopped, CommandNotAllowed]] + "model": ErrorBody[Union[RunStopped, SetupCommandNotAllowed]] }, + status.HTTP_400_BAD_REQUEST: {"model": ErrorBody[CommandNotAllowed]}, }, ) async def create_run_command( @@ -187,6 +201,12 @@ async def create_run_command( " the default was 30 seconds, not infinite." ), ), + failedCommandId: Optional[str] = Query( + default=None, + description=( + "FIXIT command use only. Reference of the failed command id we are trying to fix." + ), + ), protocol_engine: ProtocolEngine = Depends(get_current_run_engine_from_url), check_estop: bool = Depends(require_estop_in_good_state), ) -> PydanticResponse[SimpleBody[pe_commands.Command]]: @@ -199,6 +219,8 @@ async def create_run_command( Else, return immediately. Comes from a query parameter in the URL. timeout: The maximum time, in seconds, to wait before returning. Comes from a query parameter in the URL. + failedCommandId: FIXIT command use only. + Reference of the failed command id we are trying to fix. protocol_engine: The run's `ProtocolEngine` on which the new command will be enqueued. check_estop: Dependency to verify the estop is in a valid state. @@ -207,14 +229,17 @@ async def create_run_command( # behavior is to pass through `command_intent` without overriding it command_intent = request_body.data.intent or pe_commands.CommandIntent.SETUP command_create = request_body.data.copy(update={"intent": command_intent}) - try: - command = protocol_engine.add_command(command_create) + command = protocol_engine.add_command( + request=command_create, failed_command_id=failedCommandId + ) except pe_errors.SetupCommandNotAllowedError as e: - raise CommandNotAllowed.from_exc(e).as_error(status.HTTP_409_CONFLICT) + raise SetupCommandNotAllowed.from_exc(e).as_error(status.HTTP_409_CONFLICT) except pe_errors.RunStoppedError as e: raise RunStopped.from_exc(e).as_error(status.HTTP_409_CONFLICT) + except pe_errors.CommandNotAllowedError as e: + raise CommandNotAllowed.from_exc(e).as_error(status.HTTP_400_BAD_REQUEST) if waitUntilComplete: timeout_sec = None if timeout is None else timeout / 1000.0 diff --git a/robot-server/tests/runs/router/test_commands_router.py b/robot-server/tests/runs/router/test_commands_router.py index fa5e47ada9a..93adb46fa53 100644 --- a/robot-server/tests/runs/router/test_commands_router.py +++ b/robot-server/tests/runs/router/test_commands_router.py @@ -114,10 +114,11 @@ def _stub_queued_command_state(*_a: object, **_k: object) -> pe_commands.Command decoy.when( mock_protocol_engine.add_command( - pe_commands.WaitForResumeCreate( + request=pe_commands.WaitForResumeCreate( params=pe_commands.WaitForResumeParams(message="Hello"), intent=pe_commands.CommandIntent.SETUP, - ) + ), + failed_command_id=None, ) ).then_do(_stub_queued_command_state) @@ -125,6 +126,7 @@ def _stub_queued_command_state(*_a: object, **_k: object) -> pe_commands.Command request_body=RequestModelWithCommandCreate(data=command_request), waitUntilComplete=False, protocol_engine=mock_protocol_engine, + failedCommandId=None, ) assert result.content.data == command_once_added @@ -132,6 +134,33 @@ def _stub_queued_command_state(*_a: object, **_k: object) -> pe_commands.Command decoy.verify(await mock_protocol_engine.wait_for_command("command-id"), times=0) +async def test_create_command_with_failed_command_raises( + decoy: Decoy, + mock_protocol_engine: ProtocolEngine, +) -> None: + """It should return 400 bad request.""" + command_create = pe_commands.HomeCreate(params=pe_commands.HomeParams()) + + decoy.when( + mock_protocol_engine.add_command( + pe_commands.HomeCreate( + params=pe_commands.HomeParams(), + intent=pe_commands.CommandIntent.SETUP, + ), + failed_command_id="123", + ) + ).then_raise(pe_errors.CommandNotAllowedError()) + + with pytest.raises(ApiError): + await create_run_command( + RequestModelWithCommandCreate(data=command_create), + waitUntilComplete=False, + timeout=42, + protocol_engine=mock_protocol_engine, + failedCommandId="123", + ) + + async def test_create_run_command_blocking_completion( decoy: Decoy, mock_protocol_engine: ProtocolEngine, @@ -171,7 +200,7 @@ def _stub_completed_command_state(*_a: object, **_k: object) -> None: mock_protocol_engine.state_view.commands.get("command-id") ).then_return(command_once_completed) - decoy.when(mock_protocol_engine.add_command(command_request)).then_do( + decoy.when(mock_protocol_engine.add_command(command_request, None)).then_do( _stub_queued_command_state ) @@ -184,6 +213,7 @@ def _stub_completed_command_state(*_a: object, **_k: object) -> None: waitUntilComplete=True, timeout=999, protocol_engine=mock_protocol_engine, + failedCommandId=None, ) assert result.content.data == command_once_completed @@ -200,7 +230,7 @@ async def test_add_conflicting_setup_command( intent=pe_commands.CommandIntent.SETUP, ) - decoy.when(mock_protocol_engine.add_command(command_request)).then_raise( + decoy.when(mock_protocol_engine.add_command(command_request, None)).then_raise( pe_errors.SetupCommandNotAllowedError("oh no") ) @@ -209,6 +239,7 @@ async def test_add_conflicting_setup_command( request_body=RequestModelWithCommandCreate(data=command_request), waitUntilComplete=False, protocol_engine=mock_protocol_engine, + failedCommandId=None, ) assert exc_info.value.status_code == 409 @@ -228,7 +259,7 @@ async def test_add_command_to_stopped_engine( intent=pe_commands.CommandIntent.SETUP, ) - decoy.when(mock_protocol_engine.add_command(command_request)).then_raise( + decoy.when(mock_protocol_engine.add_command(command_request, None)).then_raise( pe_errors.RunStoppedError("oh no") ) @@ -237,6 +268,7 @@ async def test_add_command_to_stopped_engine( request_body=RequestModelWithCommandCreate(data=command_request), waitUntilComplete=False, protocol_engine=mock_protocol_engine, + failedCommandId=None, ) assert exc_info.value.status_code == 409 diff --git a/shared-data/command/schemas/8.json b/shared-data/command/schemas/8.json index a17be9ee690..f3c5bb38b27 100644 --- a/shared-data/command/schemas/8.json +++ b/shared-data/command/schemas/8.json @@ -339,7 +339,7 @@ "CommandIntent": { "title": "CommandIntent", "description": "Run intent for a given command.\n\nProps:\n PROTOCOL: the command is part of the protocol run itself.\n SETUP: the command is part of the setup phase of a run.", - "enum": ["protocol", "setup"], + "enum": ["protocol", "setup", "fixit"], "type": "string" }, "AspirateCreate": {