Repository: PrefectHQ/prefect

Issue:
returning `Cancelled` from within a task raises `TypeError`
### First check

- [X] I added a descriptive title to this issue.
- [X] I used the GitHub search to find a similar issue and didn't find it.
- [X] I searched the Prefect documentation for this issue.
- [X] I checked that this issue is related to Prefect and not one of its dependencies.

### Bug summary

Returning a `Cancelled` state from a task raises a `TypeError` after the task finishes in `Cancelled` state.


### Reproduction

```python
from prefect import flow, task
from prefect.orion.schemas.states import Cancelled

@task
def bar(x: bool = False):
    if not x:
        return Cancelled(message="I want to skip this")
    
@flow
def foo():
    bar()

if __name__ == "__main__":
    foo()
```


### Error

```
❯ python skip-2.py
09:54:32.421 | INFO    | prefect.engine - Created flow run 'fortunate-cuscus' for flow 'foo'
09:54:34.564 | INFO    | Flow run 'fortunate-cuscus' - Created task run 'bar-8897b405-0' for task 'bar'
09:54:34.565 | INFO    | Flow run 'fortunate-cuscus' - Executing 'bar-8897b405-0' immediately...
09:54:36.376 | ERROR   | Task run 'bar-8897b405-0' - Finished in state Cancelled('I want to skip this')
09:54:37.022 | ERROR   | Flow run 'fortunate-cuscus' - Finished in state Failed('0/1 states are not final.')
Traceback (most recent call last):
  File "skip-2.py", line 14, in <module>
    foo()
  File "/Users/nate/opt/miniconda3/envs/prefect-2/lib/python3.8/site-packages/prefect/flows.py", line 384, in __call__
    return enter_flow_run_engine_from_flow_call(
  File "/Users/nate/opt/miniconda3/envs/prefect-2/lib/python3.8/site-packages/prefect/engine.py", line 158, in enter_flow_run_engine_from_flow_call
    return anyio.run(begin_run)
  File "/Users/nate/opt/miniconda3/envs/prefect-2/lib/python3.8/site-packages/anyio/_core/_eventloop.py", line 70, in run
    return asynclib.run(func, *args, **backend_options)
  File "/Users/nate/opt/miniconda3/envs/prefect-2/lib/python3.8/site-packages/anyio/_backends/_asyncio.py", line 292, in run
    return native_run(wrapper(), debug=debug)
  File "/Users/nate/opt/miniconda3/envs/prefect-2/lib/python3.8/asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "/Users/nate/opt/miniconda3/envs/prefect-2/lib/python3.8/asyncio/base_events.py", line 616, in run_until_complete
    return future.result()
  File "/Users/nate/opt/miniconda3/envs/prefect-2/lib/python3.8/site-packages/anyio/_backends/_asyncio.py", line 287, in wrapper
    return await func(*args)
  File "/Users/nate/opt/miniconda3/envs/prefect-2/lib/python3.8/site-packages/prefect/client.py", line 103, in with_injected_client
    return await fn(*args, **kwargs)
  File "/Users/nate/opt/miniconda3/envs/prefect-2/lib/python3.8/site-packages/prefect/engine.py", line 238, in create_then_begin_flow_run
    return state.result()
  File "/Users/nate/opt/miniconda3/envs/prefect-2/lib/python3.8/site-packages/prefect/orion/schemas/states.py", line 160, in result
    raise TypeError(
TypeError: Unexpected result for failure state: [Cancelled(message='I want to skip this', type=CANCELLED, result=None)] —— list cannot be resolved into an exception
```


### Versions

```
Version:             2.3.2
API version:         0.8.0
Python version:      3.8.13
Git commit:          6e931ee9
Built:               Tue, Sep 6, 2022 12:36 PM
OS/Arch:             darwin/x86_64
Profile:             pong
Server type:         cloud
```


### Additional context

_No response_


Discussion:
I have the same problem with this behavior. I think the Prefect user expectation is that if:

* A task returns `Cancelled`
* Other flows/tasks are dependent on this
* THEN: The flow should be assigned the state `Cancelled`.

[The behavior is part of the States aggregation rules](https://docs.prefect.io/api-ref/prefect/states/#prefect.states.get_state_exception), `get_state_exception` raises an exception if the task is not Failed/Crashed.

IMO those rules about combinations of states deserve more attention and probably a way for users to set a simple set of rules.

[I started a slack discussion when I ran into this, good context in usage there](https://prefect-community.slack.com/archives/CL09KU1K7/p1668055610988379), basically I don't want to control flow by raising exceptions. What happened to 'negative engineering' and 'dont panic'?  

If my flow handles an exception that I expect could happen, I'd like to control the state the overall flow ends up in.

I suggest it might be good to allow Prefect code to define how those multi-state aggregation rules behave. For example I would like:
```
If [flow tasks have multiple states]:
    If task is Cancelled:
        If said task is a dependency:
             Assign Cancelled to the flow
```

The guiding principle here is:

>Stack traces are for when something unexpected happened that needs to be troubleshooted.

>I don't want to read a stack trace if the flow encountered a problem I was aware of and coded into my error handling. I don't want to read stack traces, if a task returns Failed I will have logged the important info, just let the flow be failed and that is enough.


Resolving patch:
diff --git a/src/prefect/states.py b/src/prefect/states.py
--- a/src/prefect/states.py
+++ b/src/prefect/states.py
@@ -228,13 +228,18 @@ async def return_value_to_state(retval: R, result_factory: ResultFactory) -> Sta
         states = StateGroup(ensure_iterable(retval))
 
         # Determine the new state type
-        new_state_type = (
-            StateType.COMPLETED if states.all_completed() else StateType.FAILED
-        )
+        if states.all_completed():
+            new_state_type = StateType.COMPLETED
+        elif states.any_cancelled():
+            new_state_type = StateType.CANCELLED
+        else:
+            new_state_type = StateType.FAILED
 
         # Generate a nice message for the aggregate
         if states.all_completed():
             message = "All states completed."
+        elif states.any_cancelled():
+            message = f"{states.cancelled_count}/{states.total_count} states cancelled."
         elif states.any_failed():
             message = f"{states.fail_count}/{states.total_count} states failed."
         elif not states.all_final():
@@ -371,6 +376,7 @@ def __init__(self, states: Iterable[State]) -> None:
         self.states = states
         self.type_counts = self._get_type_counts(states)
         self.total_count = len(states)
+        self.cancelled_count = self.type_counts[StateType.CANCELLED]
         self.final_count = sum(state.is_final() for state in states)
         self.not_final_count = self.total_count - self.final_count
 
@@ -381,6 +387,9 @@ def fail_count(self):
     def all_completed(self) -> bool:
         return self.type_counts[StateType.COMPLETED] == self.total_count
 
+    def any_cancelled(self) -> bool:
+        return self.cancelled_count > 0
+
     def any_failed(self) -> bool:
         return (
             self.type_counts[StateType.FAILED] > 0


Test patch:
diff --git a/tests/test_flows.py b/tests/test_flows.py
--- a/tests/test_flows.py
+++ b/tests/test_flows.py
@@ -28,7 +28,7 @@
 from prefect.orion.schemas.states import StateType
 from prefect.results import PersistedResult
 from prefect.settings import PREFECT_LOCAL_STORAGE_PATH, temporary_settings
-from prefect.states import State, StateType, raise_state_exception
+from prefect.states import Cancelled, State, StateType, raise_state_exception
 from prefect.task_runners import ConcurrentTaskRunner, SequentialTaskRunner
 from prefect.testing.utilities import (
     exceptions_equal,
@@ -517,6 +517,58 @@ def foo(x, y=3, z=3):
             state = foo(1, 2)
             assert state is None
 
+    def test_flow_can_end_in_cancelled_state(self):
+        @flow
+        def my_flow():
+            return Cancelled()
+
+        flow_state = my_flow(return_state=True)
+        assert flow_state.is_cancelled()
+
+    def test_flow_state_with_cancelled_tasks_has_cancelled_state(self):
+        @task
+        def cancel():
+            return Cancelled()
+
+        @task
+        def fail():
+            raise ValueError("Fail")
+
+        @task
+        def succeed():
+            return True
+
+        @flow(version="test")
+        def my_flow():
+            return cancel.submit(), succeed.submit(), fail.submit()
+
+        flow_state = my_flow(return_state=True)
+        assert flow_state.is_cancelled()
+        assert flow_state.message == "1/3 states cancelled."
+
+        # The task run states are attached as a tuple
+        first, second, third = flow_state.result(raise_on_failure=False)
+        assert first.is_cancelled()
+        assert second.is_completed()
+        assert third.is_failed()
+
+    def test_flow_with_cancelled_subflow_has_cancelled_state(self):
+        @task
+        def cancel():
+            return Cancelled()
+
+        @flow(version="test")
+        def subflow():
+            return cancel.submit()
+
+        @flow
+        def my_flow():
+            return subflow(return_state=True)
+
+        flow_state = my_flow(return_state=True)
+        assert flow_state.is_cancelled()
+        assert flow_state.message == "1/1 states cancelled."
+
 
 class TestSubflowCalls:
     async def test_subflow_call_with_no_tasks(self):
diff --git a/tests/test_states.py b/tests/test_states.py
--- a/tests/test_states.py
+++ b/tests/test_states.py
@@ -233,6 +233,21 @@ def test_all_completed(self):
 
         assert not StateGroup(states).all_completed()
 
+    def test_any_cancelled(self):
+        states = [
+            Cancelled(),
+            Failed(data=ValueError("1")),
+        ]
+
+        assert StateGroup(states).any_cancelled()
+
+        states = [
+            Completed(data="test"),
+            Failed(data=ValueError("1")),
+        ]
+
+        assert not StateGroup(states).any_cancelled()
+
     def test_any_failed(self):
         states = [
             Completed(data="test"),