@@ -177,6 +177,20 @@ def setUpClass(cls):
177177 cls ._task_handler .start_processes ()
178178 time .sleep (3 ) # let workers start (once, not per test)
179179
180+ # Confirm every worker this module needs was actually started with a
181+ # live process. If one silently fails to start, its task sits in
182+ # SCHEDULED forever and surfaces later as an opaque "workflow still
183+ # RUNNING" assertion; fail loudly here naming the exact worker instead.
184+ started = {}
185+ for worker , process in zip (cls ._task_handler .workers ,
186+ cls ._task_handler .task_runner_processes ):
187+ started [worker .get_task_definition_name ()] = process .is_alive ()
188+ logger .info ("Started workers (name -> alive): %s" , started )
189+ missing = [n for n in cls .WORKER_TASK_NAMES
190+ if not started .get (n , False )]
191+ assert not missing , (
192+ f"workers not started/alive: { missing } ; started={ started } " )
193+
180194 @classmethod
181195 def tearDownClass (cls ):
182196 handler = getattr (cls , '_task_handler' , None )
@@ -219,6 +233,37 @@ def _wait_for_workflow(self, wf_id, timeout_seconds=90):
219233 time .sleep (1 )
220234 return self .workflow_client .get_workflow (wf_id , include_tasks = True )
221235
236+ def _dump_workflow_diagnostics (self , wf ):
237+ """Print task statuses plus server-side poll data / queue size so a
238+ non-terminal workflow shows *why* (e.g. a task stuck in SCHEDULED with
239+ no poller = the worker isn't consuming it), rather than only a bare
240+ status assertion. Poll data is server-side, so it survives regardless
241+ of worker child-process log capture.
242+ """
243+ from conductor .client .orkes .orkes_task_client import OrkesTaskClient
244+ task_client = OrkesTaskClient (self .config )
245+
246+ # Worker processes can die *after* setUpClass; report current liveness
247+ # so we can tell "worker crashed mid-suite" apart from "worker alive but
248+ # not polling / server not timing out".
249+ handler = getattr (self , '_task_handler' , None )
250+ if handler is not None :
251+ alive = {}
252+ for worker , process in zip (handler .workers , handler .task_runner_processes ):
253+ alive [worker .get_task_definition_name ()] = process .is_alive ()
254+ print (f" [diag] worker liveness: { alive } " )
255+
256+ for task in (getattr (wf , 'tasks' , None ) or []):
257+ print (f" [diag] task { task .task_def_name } : { task .status } " )
258+ try :
259+ queue_size = task_client .get_queue_size_for_task (task .task_def_name )
260+ poll_data = task_client .get_task_poll_data (task .task_def_name ) or []
261+ pollers = [(p .worker_id , p .domain , p .last_poll_time ) for p in poll_data ]
262+ print (f" [diag] { task .task_def_name } : "
263+ f"queue_size={ queue_size } pollers={ pollers } " )
264+ except Exception as e : # diagnostics must never mask the real failure
265+ print (f" [diag] { task .task_def_name } : failed to fetch poll data: { e !r} " )
266+
222267 # -- Tests ----------------------------------------------------------------
223268
224269 def test_01_async_with_heartbeat_completes (self ):
@@ -268,6 +313,9 @@ def test_02_async_without_heartbeat_times_out(self):
268313 for task in (wf .tasks or []):
269314 print (f" Task { task .task_def_name } : { task .status } " )
270315
316+ if wf .status not in ('FAILED' , 'TIMED_OUT' ):
317+ self ._dump_workflow_diagnostics (wf )
318+
271319 self .assertIn (wf .status , ('FAILED' , 'TIMED_OUT' ),
272320 f"Workflow should FAIL/TIMEOUT without heartbeat, got { wf .status } " )
273321
0 commit comments