@@ -273,14 +273,16 @@ def StartInstance(self, request: pb.CreateInstanceRequest, context: grpc.Service
273273 )
274274 self ._next_completion_token += 1
275275
276- # Add initial events to start the orchestration
277- orchestrator_started = helpers .new_orchestrator_started_event (start_time )
276+ # Add initial events to start the orchestration.
277+ # orchestratorStarted bookends each replay batch and is
278+ # always the very first event, followed by executionStarted.
278279 execution_started = helpers .new_execution_started_event (
279280 request .name , instance_id ,
280281 request .input .value if request .input else None ,
281282 dict (request .tags ) if request .tags else None ,
282283 version = version ,
283284 )
285+ orchestrator_started = helpers .new_orchestrator_started_event (start_time )
284286
285287 instance .pending_events .append (orchestrator_started )
286288 instance .pending_events .append (execution_started )
@@ -611,8 +613,12 @@ def GetWorkItems(self, request: pb.GetWorkItemsRequest, context: grpc.ServicerCo
611613 instance .pending_events .clear ()
612614
613615 # Add OrchestratorStarted for re-dispatches so that
614- # ctx.current_utc_datetime advances correctly
615- if instance .history :
616+ # ctx.current_utc_datetime advances correctly, unless
617+ # the pending batch already begins with one (e.g. a
618+ # post-rewind replay supplies its own).
619+ if instance .history and not (
620+ instance .dispatched_events
621+ and instance .dispatched_events [0 ].HasField ("orchestratorStarted" )):
616622 now = datetime .now (timezone .utc )
617623 orch_started = helpers .new_orchestrator_started_event (now )
618624 instance .dispatched_events .insert (0 , orch_started )
@@ -802,6 +808,22 @@ def CompleteOrchestratorTask(self, request: pb.OrchestratorResponse, context: gr
802808 instance .completion_token = self ._next_completion_token
803809 self ._next_completion_token += 1
804810
811+ # Bookend the replay with orchestratorCompleted.
812+ # Skip for continue-as-new (status is PENDING after reset).
813+ if instance .status != pb .ORCHESTRATION_STATUS_PENDING :
814+ instance .history .append (helpers .new_orchestrator_completed_event ())
815+
816+ # executionCompleted is the very last event when the
817+ # orchestration reaches a terminal state.
818+ if self ._is_terminal_status (instance .status ):
819+ instance .history .append (
820+ helpers .new_execution_completed_event (
821+ instance .status ,
822+ instance .output ,
823+ instance .failure_details ,
824+ )
825+ )
826+
805827 # Remove from in-flight before notifying or re-enqueuing
806828 self ._orchestration_in_flight .discard (request .instanceId )
807829
@@ -1193,8 +1215,33 @@ def DeleteTaskHub(self, request: pb.DeleteTaskHubRequest, context: grpc.Servicer
11931215 return pb .DeleteTaskHubResponse ()
11941216
11951217 def RewindInstance (self , request : pb .RewindInstanceRequest , context : grpc .ServicerContext ) -> pb .RewindInstanceResponse :
1196- """Rewinds an orchestration instance (not implemented)."""
1197- context .abort (grpc .StatusCode .UNIMPLEMENTED , "RewindInstance not implemented" )
1218+ """Rewinds a failed orchestration instance.
1219+
1220+ The backend validates the instance is in a failed state, appends
1221+ an ``ExecutionRewoundEvent`` to the pending events, resets the
1222+ instance status to RUNNING, and re-enqueues the orchestration
1223+ so the worker can replay it and produce a
1224+ ``RewindOrchestrationAction`` with the corrected history.
1225+ """
1226+ with self ._lock :
1227+ instance = self ._instances .get (request .instanceId )
1228+ if not instance :
1229+ context .abort (
1230+ grpc .StatusCode .NOT_FOUND ,
1231+ f"Orchestration instance '{ request .instanceId } ' not found" )
1232+ return pb .RewindInstanceResponse ()
1233+
1234+ if instance .status != pb .ORCHESTRATION_STATUS_FAILED :
1235+ context .abort (
1236+ grpc .StatusCode .FAILED_PRECONDITION ,
1237+ f"Orchestration instance '{ request .instanceId } ' is not in a failed state" )
1238+ return pb .RewindInstanceResponse ()
1239+
1240+ reason = request .reason .value if request .HasField ("reason" ) else None
1241+ self ._prepare_rewind (instance , reason )
1242+
1243+ self ._logger .info (f"Rewound instance '{ request .instanceId } '" )
1244+ return pb .RewindInstanceResponse ()
11981245
11991246 def AbandonTaskActivityWorkItem (self , request : pb .AbandonActivityTaskRequest , context : grpc .ServicerContext ) -> pb .AbandonActivityTaskResponse :
12001247 """Abandons an activity work item."""
@@ -1280,9 +1327,9 @@ def _create_instance_internal(self, instance_id: str, name: str,
12801327 )
12811328 self ._next_completion_token += 1
12821329
1283- orchestrator_started = helpers .new_orchestrator_started_event (now )
12841330 execution_started = helpers .new_execution_started_event (
12851331 name , instance_id , encoded_input , version = version )
1332+ orchestrator_started = helpers .new_orchestrator_started_event (now )
12861333 instance .pending_events .append (orchestrator_started )
12871334 instance .pending_events .append (execution_started )
12881335
@@ -1410,14 +1457,25 @@ def _process_action(self, instance: OrchestrationInstance, action: pb.Orchestrat
14101457 self ._process_send_event_action (action .sendEvent )
14111458 elif action .HasField ("sendEntityMessage" ):
14121459 self ._process_send_entity_message_action (instance , action )
1460+ elif action .HasField ("rewindOrchestration" ):
1461+ self ._process_rewind_orchestration_action (instance , action .rewindOrchestration )
14131462
14141463 def _process_complete_orchestration_action (self , instance : OrchestrationInstance ,
14151464 complete_action : pb .CompleteOrchestrationAction ):
14161465 """Processes a complete orchestration action."""
14171466 status = complete_action .orchestrationStatus
14181467 instance .status = status
14191468 instance .output = complete_action .result .value if complete_action .result else None
1420- instance .failure_details = complete_action .failureDetails if complete_action .failureDetails else None
1469+ # NOTE: a protobuf singular message field is always truthy, so
1470+ # ``if complete_action.failureDetails`` would set failure_details
1471+ # even for successful completions. Use HasField for a correct
1472+ # presence check so failure_details is None unless the
1473+ # orchestration actually failed.
1474+ instance .failure_details = (
1475+ complete_action .failureDetails
1476+ if complete_action .HasField ("failureDetails" )
1477+ else None
1478+ )
14211479 instance .completed_at = datetime .now (timezone .utc ) if self ._is_terminal_status (status ) else None
14221480
14231481 if status == pb .ORCHESTRATION_STATUS_CONTINUED_AS_NEW :
@@ -1446,11 +1504,11 @@ def _process_complete_orchestration_action(self, instance: OrchestrationInstance
14461504 # Build the new pending events in the correct order:
14471505 # OrchestratorStarted, ExecutionStarted, carryover, new arrivals
14481506 now = datetime .now (timezone .utc )
1449- orchestrator_started = helpers .new_orchestrator_started_event (now )
14501507 execution_started = helpers .new_execution_started_event (
14511508 instance .name , instance .instance_id , new_input ,
14521509 version = new_version ,
14531510 )
1511+ orchestrator_started = helpers .new_orchestrator_started_event (now )
14541512 instance .pending_events .append (orchestrator_started )
14551513 instance .pending_events .append (execution_started )
14561514 instance .pending_events .extend (carryover_events )
@@ -1822,6 +1880,129 @@ def fire() -> None:
18221880 timer_thread = threading .Thread (target = fire , daemon = True )
18231881 timer_thread .start ()
18241882
1883+ def _prepare_rewind (self , instance : OrchestrationInstance ,
1884+ reason : str | None = None ):
1885+ """Prepares an orchestration instance for rewind.
1886+
1887+ Appends an ``ExecutionRewoundEvent`` to the pending events, resets
1888+ the instance status to RUNNING, and re-enqueues it so the worker
1889+ can replay it. The actual history rewriting is done by the SDK
1890+ worker when it processes the rewind event.
1891+
1892+ Args:
1893+ instance: The orchestration instance to rewind.
1894+ reason: Optional reason string for the rewind.
1895+
1896+ Note:
1897+ Must be called while holding ``self._lock``.
1898+ """
1899+ # Reset instance state so it can be re-processed.
1900+ instance .status = pb .ORCHESTRATION_STATUS_RUNNING
1901+ instance .output = None
1902+ instance .failure_details = None
1903+ instance .last_updated_at = datetime .now (timezone .utc )
1904+
1905+ # Clear any stale dispatched events.
1906+ instance .dispatched_events .clear ()
1907+
1908+ # Add the ExecutionRewound event as a new pending event. Stamp it
1909+ # with the current time (rather than the default epoch) so the
1910+ # audit event carries a meaningful timestamp, consistent with
1911+ # other backend-generated events.
1912+ rewind_timestamp = timestamp_pb2 .Timestamp ()
1913+ rewind_timestamp .FromDatetime (datetime .now (timezone .utc ))
1914+ rewind_event = pb .HistoryEvent (
1915+ eventId = - 1 ,
1916+ timestamp = rewind_timestamp ,
1917+ executionRewound = pb .ExecutionRewoundEvent (
1918+ reason = wrappers_pb2 .StringValue (value = reason ) if reason else None ,
1919+ ),
1920+ )
1921+ instance .pending_events .append (rewind_event )
1922+
1923+ # Refresh the completion token and enqueue.
1924+ instance .completion_token = self ._next_completion_token
1925+ self ._next_completion_token += 1
1926+ self ._orchestration_in_flight .discard (instance .instance_id )
1927+ self ._enqueue_orchestration (instance .instance_id )
1928+
1929+ def _process_rewind_orchestration_action (
1930+ self , instance : OrchestrationInstance ,
1931+ rewind_action : pb .RewindOrchestrationAction ):
1932+ """Processes a RewindOrchestrationAction returned by the SDK.
1933+
1934+ The action contains a ``newHistory`` field with the rewritten
1935+ history computed by the SDK (failed tasks and sub-orchestration
1936+ failures removed). The backend replaces the instance's history
1937+ with this new history, recursively rewinds any failed
1938+ sub-orchestrations, and re-enqueues the orchestration.
1939+ """
1940+ new_history = list (rewind_action .newHistory )
1941+
1942+ # Replace history with the rewritten version.
1943+ instance .history = new_history
1944+ instance .status = pb .ORCHESTRATION_STATUS_RUNNING
1945+ instance .output = None
1946+ instance .failure_details = None
1947+ instance .last_updated_at = datetime .now (timezone .utc )
1948+
1949+ # Identify sub-orchestrations that were created but did not
1950+ # complete successfully — they need to be recursively rewound.
1951+ completed_sub_orch_task_ids : set [int ] = set ()
1952+ created_sub_orch_events : dict [int , pb .HistoryEvent ] = {}
1953+ for event in new_history :
1954+ if event .HasField ("subOrchestrationInstanceCreated" ):
1955+ created_sub_orch_events [event .eventId ] = event
1956+ elif event .HasField ("subOrchestrationInstanceCompleted" ):
1957+ completed_sub_orch_task_ids .add (
1958+ event .subOrchestrationInstanceCompleted .taskScheduledId )
1959+
1960+ # Extract the rewind reason from the last ExecutionRewound event.
1961+ reason : str | None = None
1962+ for event in reversed (new_history ):
1963+ if event .HasField ("executionRewound" ):
1964+ if event .executionRewound .HasField ("reason" ):
1965+ reason = event .executionRewound .reason .value
1966+ break
1967+
1968+ # Recursively rewind failed sub-orchestrations. If the sub was
1969+ # purged (no longer in _instances), re-create it from the
1970+ # subOrchestrationInstanceCreated event so it runs fresh.
1971+ for task_id , event in created_sub_orch_events .items ():
1972+ if task_id not in completed_sub_orch_task_ids :
1973+ sub_info = event .subOrchestrationInstanceCreated
1974+ sub_instance_id = sub_info .instanceId
1975+ sub_instance = self ._instances .get (sub_instance_id )
1976+ if sub_instance is None :
1977+ # Sub-orchestration was purged — re-create it.
1978+ sub_name = sub_info .name
1979+ sub_input = sub_info .input .value if sub_info .HasField ("input" ) else None
1980+ sub_version = sub_info .version .value if sub_info .HasField ("version" ) else None
1981+ self ._create_instance_internal (
1982+ sub_instance_id , sub_name , sub_input , version = sub_version )
1983+ elif sub_instance .status == pb .ORCHESTRATION_STATUS_FAILED :
1984+ self ._prepare_rewind (sub_instance , reason )
1985+ self ._watch_sub_orchestration (
1986+ instance .instance_id , sub_instance_id , task_id )
1987+
1988+ # Re-enqueue so the orchestration replays with the clean history.
1989+ # The executionRewound event is already present in the clean
1990+ # history (it was kept by the SDK's rewind rewrite), so it must
1991+ # NOT be re-sent as a pending event — doing so would duplicate it
1992+ # in the instance history once the dispatched events are
1993+ # committed. A lone orchestratorStarted is enough to make the
1994+ # instance dispatchable for the post-rewind replay; the worker
1995+ # replays normally because executionCompleted is no longer in the
1996+ # history.
1997+ instance .pending_events .clear ()
1998+ instance .dispatched_events .clear ()
1999+ instance .pending_events .append (
2000+ helpers .new_orchestrator_started_event (datetime .now (timezone .utc )))
2001+ instance .completion_token = self ._next_completion_token
2002+ self ._next_completion_token += 1
2003+ self ._orchestration_in_flight .discard (instance .instance_id )
2004+ self ._enqueue_orchestration (instance .instance_id )
2005+
18252006 def _enqueue_entity (self , entity_id : str ):
18262007 """Enqueues an entity for processing."""
18272008 if entity_id not in self ._entity_queue_set :
0 commit comments