Skip to content

Commit 2d57126

Browse files
feature(api, robot-server): Allow fixit commands to recover from an error (#14908)
1 parent d4f7f17 commit 2d57126

File tree

18 files changed

+551
-73
lines changed

18 files changed

+551
-73
lines changed

api/src/opentrons/protocol_engine/actions/actions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ class QueueCommandAction:
116116
created_at: datetime
117117
request: CommandCreate
118118
request_hash: Optional[str]
119+
failed_command_id: Optional[str] = None
119120

120121

121122
@dataclass(frozen=True)

api/src/opentrons/protocol_engine/commands/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from . import thermocycler
2020
from . import calibration
2121

22-
from .hash_command_params import hash_command_params
22+
from .hash_command_params import hash_protocol_command_params
2323
from .generate_command_schema import generate_command_schema
2424

2525
from .command import (
@@ -333,7 +333,7 @@
333333
"CommandStatus",
334334
"CommandIntent",
335335
# command parameter hashing
336-
"hash_command_params",
336+
"hash_protocol_command_params",
337337
# command schema generation
338338
"generate_command_schema",
339339
# aspirate command models

api/src/opentrons/protocol_engine/commands/command.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class CommandIntent(str, Enum):
5555

5656
PROTOCOL = "protocol"
5757
SETUP = "setup"
58+
FIXIT = "fixit"
5859

5960

6061
class BaseCommandCreate(GenericModel, Generic[CommandParamsT]):
@@ -159,6 +160,12 @@ class BaseCommand(GenericModel, Generic[CommandParamsT, CommandResultT]):
159160
" the command's execution or the command's generation."
160161
),
161162
)
163+
failedCommandId: Optional[str] = Field(
164+
None,
165+
description=(
166+
"FIXIT command use only. Reference of the failed command id we are trying to fix."
167+
),
168+
)
162169

163170

164171
class AbstractCommandImpl(

api/src/opentrons/protocol_engine/commands/hash_command_params.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
# TODO(mm, 2023-04-28):
1010
# This implementation will not notice that commands are different if they have different params
1111
# but share the same commandType. We should also hash command params. (Jira RCORE-326.)
12-
def hash_command_params(
12+
def hash_protocol_command_params(
1313
create: CommandCreate, last_hash: Optional[str]
1414
) -> Optional[str]:
1515
"""Given a command create object, return a hash.
@@ -28,12 +28,11 @@ def hash_command_params(
2828
The command hash, if the command is a protocol command.
2929
`None` if the command is a setup command.
3030
"""
31-
if create.intent == CommandIntent.SETUP:
31+
if create.intent != CommandIntent.PROTOCOL:
3232
return None
33-
else:
34-
# We avoid Python's built-in hash() function because it's not stable across
35-
# runs of the Python interpreter. (Jira RSS-215.)
36-
last_contribution = b"" if last_hash is None else last_hash.encode("ascii")
37-
this_contribution = md5(create.commandType.encode("ascii")).digest()
38-
to_hash = last_contribution + this_contribution
39-
return md5(to_hash).hexdigest()
33+
# We avoid Python's built-in hash() function because it's not stable across
34+
# runs of the Python interpreter. (Jira RSS-215.)
35+
last_contribution = b"" if last_hash is None else last_hash.encode("ascii")
36+
this_contribution = md5(create.commandType.encode("ascii")).digest()
37+
to_hash = last_contribution + this_contribution
38+
return md5(to_hash).hexdigest()

api/src/opentrons/protocol_engine/errors/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
MustHomeError,
4040
RunStoppedError,
4141
SetupCommandNotAllowedError,
42+
FixitCommandNotAllowedError,
4243
ModuleNotAttachedError,
4344
ModuleAlreadyPresentError,
4445
WrongModuleTypeError,
@@ -55,6 +56,7 @@
5556
InvalidHoldTimeError,
5657
CannotPerformModuleAction,
5758
PauseNotAllowedError,
59+
ResumeFromRecoveryNotAllowedError,
5860
GripperNotAttachedError,
5961
CannotPerformGripperAction,
6062
HardwareNotSupportedError,
@@ -65,6 +67,7 @@
6567
LocationIsStagingSlotError,
6668
InvalidAxisForRobotType,
6769
NotSupportedOnRobotType,
70+
CommandNotAllowedError,
6871
)
6972

7073
from .error_occurrence import ErrorOccurrence, ProtocolCommandFailedError
@@ -109,6 +112,7 @@
109112
"MustHomeError",
110113
"RunStoppedError",
111114
"SetupCommandNotAllowedError",
115+
"FixitCommandNotAllowedError",
112116
"ModuleNotAttachedError",
113117
"ModuleAlreadyPresentError",
114118
"WrongModuleTypeError",
@@ -124,6 +128,7 @@
124128
"InvalidBlockVolumeError",
125129
"InvalidHoldTimeError",
126130
"CannotPerformModuleAction",
131+
"ResumeFromRecoveryNotAllowedError",
127132
"PauseNotAllowedError",
128133
"ProtocolCommandFailedError",
129134
"GripperNotAttachedError",
@@ -138,5 +143,5 @@
138143
"NotSupportedOnRobotType",
139144
# error occurrence models
140145
"ErrorOccurrence",
141-
"FailedGripperPickupError",
146+
"CommandNotAllowedError",
142147
]

api/src/opentrons/protocol_engine/errors/exceptions.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,32 @@ def __init__(
505505
super().__init__(ErrorCodes.POSITION_UNKNOWN, message, details, wrapping)
506506

507507

508+
class CommandNotAllowedError(ProtocolEngineError):
509+
"""Raised when adding a command with bad data."""
510+
511+
def __init__(
512+
self,
513+
message: Optional[str] = None,
514+
details: Optional[Dict[str, Any]] = None,
515+
wrapping: Optional[Sequence[EnumeratedError]] = None,
516+
) -> None:
517+
"""Build a CommandNotAllowedError."""
518+
super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
519+
520+
521+
class FixitCommandNotAllowedError(ProtocolEngineError):
522+
"""Raised when adding a fixit command to a non-recoverable engine."""
523+
524+
def __init__(
525+
self,
526+
message: Optional[str] = None,
527+
details: Optional[Dict[str, Any]] = None,
528+
wrapping: Optional[Sequence[EnumeratedError]] = None,
529+
) -> None:
530+
"""Build a SetupCommandNotAllowedError."""
531+
super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
532+
533+
508534
class SetupCommandNotAllowedError(ProtocolEngineError):
509535
"""Raised when adding a setup command to a non-idle/non-paused engine."""
510536

@@ -518,6 +544,19 @@ def __init__(
518544
super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
519545

520546

547+
class ResumeFromRecoveryNotAllowedError(ProtocolEngineError):
548+
"""Raised when attempting to resume a run from recovery that has a fixit command in the queue."""
549+
550+
def __init__(
551+
self,
552+
message: Optional[str] = None,
553+
details: Optional[Dict[str, Any]] = None,
554+
wrapping: Optional[Sequence[EnumeratedError]] = None,
555+
) -> None:
556+
"""Build a ResumeFromRecoveryNotAllowedError."""
557+
super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
558+
559+
521560
class PauseNotAllowedError(ProtocolEngineError):
522561
"""Raised when attempting to pause a run that is not running."""
523562

api/src/opentrons/protocol_engine/protocol_engine.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
EnumeratedError,
1818
)
1919

20-
from .errors import ProtocolCommandFailedError, ErrorOccurrence
20+
from .errors import ProtocolCommandFailedError, ErrorOccurrence, CommandNotAllowedError
2121
from .errors.exceptions import EStopActivatedError
2222
from . import commands, slot_standardization
2323
from .resources import ModelUtils, ModuleDataProvider
@@ -176,7 +176,9 @@ def resume_from_recovery(self) -> None:
176176
)
177177
self._action_dispatcher.dispatch(action)
178178

179-
def add_command(self, request: commands.CommandCreate) -> commands.Command:
179+
def add_command(
180+
self, request: commands.CommandCreate, failed_command_id: Optional[str] = None
181+
) -> commands.Command:
180182
"""Add a command to the `ProtocolEngine`'s queue.
181183
182184
Arguments:
@@ -191,23 +193,37 @@ def add_command(self, request: commands.CommandCreate) -> commands.Command:
191193
but the engine was not idle or paused.
192194
RunStoppedError: the run has been stopped, so no new commands
193195
may be added.
196+
CommandNotAllowedError: the request specified a failed command id
197+
with a non fixit command.
194198
"""
195199
request = slot_standardization.standardize_command(
196200
request, self.state_view.config.robot_type
197201
)
198202

203+
if failed_command_id and request.intent != commands.CommandIntent.FIXIT:
204+
raise CommandNotAllowedError(
205+
"failed command id should be supplied with a FIXIT command."
206+
)
207+
199208
command_id = self._model_utils.generate_id()
200-
request_hash = commands.hash_command_params(
201-
create=request,
202-
last_hash=self._state_store.commands.get_latest_command_hash(),
203-
)
209+
if request.intent in (
210+
commands.CommandIntent.SETUP,
211+
commands.CommandIntent.FIXIT,
212+
):
213+
request_hash = None
214+
else:
215+
request_hash = commands.hash_protocol_command_params(
216+
create=request,
217+
last_hash=self._state_store.commands.get_latest_protocol_command_hash(),
218+
)
204219

205220
action = self.state_view.commands.validate_action_allowed(
206221
QueueCommandAction(
207222
request=request,
208223
request_hash=request_hash,
209224
command_id=command_id,
210225
created_at=self._model_utils.get_timestamp(),
226+
failed_command_id=failed_command_id,
211227
)
212228
)
213229
self._action_dispatcher.dispatch(action)

api/src/opentrons/protocol_engine/state/command_history.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ class CommandHistory:
3333
_queued_setup_command_ids: OrderedSet[str]
3434
"""The IDs of queued setup commands, in FIFO order"""
3535

36+
_queued_fixit_command_ids: OrderedSet[str]
37+
"""The IDs of queued fixit commands, in FIFO order"""
38+
3639
_running_command_id: Optional[str]
3740
"""The ID of the currently running command, if any"""
3841

@@ -43,6 +46,7 @@ def __init__(self) -> None:
4346
self._all_command_ids = []
4447
self._queued_command_ids = OrderedSet()
4548
self._queued_setup_command_ids = OrderedSet()
49+
self._queued_fixit_command_ids = OrderedSet()
4650
self._commands_by_id = OrderedDict()
4751
self._running_command_id = None
4852
self._terminal_command_id = None
@@ -135,6 +139,10 @@ def get_setup_queue_ids(self) -> OrderedSet[str]:
135139
"""Get the IDs of all queued setup commands, in FIFO order."""
136140
return self._queued_setup_command_ids
137141

142+
def get_fixit_queue_ids(self) -> OrderedSet[str]:
143+
"""Get the IDs of all queued fixit commands, in FIFO order."""
144+
return self._queued_fixit_command_ids
145+
138146
def clear_queue(self) -> None:
139147
"""Clears all commands within the queued command ids structure."""
140148
self._queued_command_ids.clear()
@@ -143,6 +151,10 @@ def clear_setup_queue(self) -> None:
143151
"""Clears all commands within the queued setup command ids structure."""
144152
self._queued_setup_command_ids.clear()
145153

154+
def clear_fixit_queue(self) -> None:
155+
"""Clears all commands within the queued setup command ids structure."""
156+
self._queued_fixit_command_ids.clear()
157+
146158
def set_command_queued(self, command: Command) -> None:
147159
"""Validate and mark a command as queued in the command history."""
148160
assert command.status == CommandStatus.QUEUED
@@ -157,6 +169,8 @@ def set_command_queued(self, command: Command) -> None:
157169

158170
if command.intent == CommandIntent.SETUP:
159171
self._add_to_setup_queue(command.id)
172+
elif command.intent == CommandIntent.FIXIT:
173+
self._add_to_fixit_queue(command.id)
160174
else:
161175
self._add_to_queue(command.id)
162176

@@ -177,6 +191,7 @@ def set_command_running(self, command: Command) -> None:
177191

178192
self._remove_queue_id(command.id)
179193
self._remove_setup_queue_id(command.id)
194+
self._remove_fixit_queue_id(command.id)
180195

181196
def set_command_succeeded(self, command: Command) -> None:
182197
"""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:
239254
"""Add a new ID to the queued setup."""
240255
self._queued_setup_command_ids.add(command_id)
241256

257+
def _add_to_fixit_queue(self, command_id: str) -> None:
258+
"""Add a new ID to the queued fixit."""
259+
self._queued_fixit_command_ids.add(command_id)
260+
242261
def _remove_queue_id(self, command_id: str) -> None:
243262
"""Remove a specific command from the queued command ids structure."""
244263
self._queued_command_ids.discard(command_id)
@@ -247,6 +266,10 @@ def _remove_setup_queue_id(self, command_id: str) -> None:
247266
"""Remove a specific command from the queued setup command ids structure."""
248267
self._queued_setup_command_ids.discard(command_id)
249268

269+
def _remove_fixit_queue_id(self, command_id: str) -> None:
270+
"""Remove a specific command from the queued fixit command ids structure."""
271+
self._queued_fixit_command_ids.discard(command_id)
272+
250273
def _set_terminal_command_id(self, command_id: str) -> None:
251274
"""Set the ID of the most recently dequeued command."""
252275
self._terminal_command_id = command_id

0 commit comments

Comments
 (0)