@@ -121,6 +121,18 @@ def failing_task(should_fail: bool) -> dict:
121121# Main test class
122122class TestComprehensiveE2E (unittest .TestCase ):
123123
124+ # Annotated workers this suite relies on. Every one must be discovered by
125+ # the scan AND have a live process, otherwise its task silently stays
126+ # SCHEDULED forever (which is exactly how a non-starting task_in_progress
127+ # worker manifested as a "4 != 5 tasks" failure in CI).
128+ EXPECTED_WORKERS = (
129+ 'sync_basic' ,
130+ 'async_basic' ,
131+ 'complex_schema' ,
132+ 'task_in_progress' ,
133+ 'failing_task' ,
134+ )
135+
124136 @classmethod
125137 def setUpClass (cls ):
126138 from tests .integration .conftest import skip_if_server_unavailable
@@ -191,12 +203,55 @@ def test_02_start_workers(self):
191203 self .__class__ .task_handler = handler
192204 time .sleep (5 ) # Wait for startup
193205
206+ # Confirm every expected annotated worker was discovered by the scan AND
207+ # its child process is actually alive. Assert each one individually so a
208+ # failure names the exact worker that didn't come up, instead of the
209+ # failure surfacing later (and opaquely) as a task stuck in SCHEDULED.
210+ started = {}
211+ for worker , process in zip (handler .workers , handler .task_runner_processes ):
212+ started [worker .get_task_definition_name ()] = process .is_alive ()
213+ print (f"Discovered { len (started )} annotated worker(s): { sorted (started )} " )
214+
215+ for name in self .EXPECTED_WORKERS :
216+ self .assertIn (
217+ name , started ,
218+ f"worker '{ name } ' was not discovered by the annotated-worker scan; "
219+ f"discovered={ sorted (started )} " )
220+ self .assertTrue (
221+ started [name ],
222+ f"worker '{ name } ' was discovered but its process is not alive" )
223+
194224 print ("✓ Workers started" )
195225 self .__class__ .workers_started = True
196226 self .assertTrue (self .workers_started )
197227
198228 EXPECTED_TASK_COUNT = 5
199229
230+ def _dump_stuck_task_diagnostics (self , wf ):
231+ """When a run fails to complete, print server-side poll data and queue
232+ sizes so CI shows *why* a task is stuck (e.g. no worker polling its
233+ queue) rather than just a bare task-count assertion. Poll data is
234+ server-side, so it survives regardless of worker child-process log
235+ capture.
236+ """
237+ from conductor .client .orkes .orkes_task_client import OrkesTaskClient
238+ task_client = OrkesTaskClient (self .config )
239+
240+ stuck = [t .task_def_name for t in (getattr (wf , 'tasks' , None ) or [])
241+ if t .status not in ('COMPLETED' , 'FAILED' , 'FAILED_WITH_TERMINAL_ERROR' )]
242+ print (f" [diag] non-terminal tasks: { stuck } " )
243+
244+ # Include the expected workers so we can compare a stuck queue against a
245+ # known-good one (e.g. task_in_progress vs sync_basic).
246+ for task_type in sorted (set (stuck ) | set (self .EXPECTED_WORKERS )):
247+ try :
248+ queue_size = task_client .get_queue_size_for_task (task_type )
249+ poll_data = task_client .get_task_poll_data (task_type ) or []
250+ pollers = [(p .worker_id , p .domain , p .last_poll_time ) for p in poll_data ]
251+ print (f" [diag] { task_type } : queue_size={ queue_size } pollers={ pollers } " )
252+ except Exception as e : # diagnostics must never mask the real failure
253+ print (f" [diag] { task_type } : failed to fetch poll data: { e !r} " )
254+
200255 def _run_workflow_to_terminal (self , workflow_client , timeout_s = 90 ):
201256 """Start the e2e workflow and wait until it is genuinely terminal with
202257 all expected tasks present. Returns (wf_id, workflow_or_None). A None
@@ -247,6 +302,7 @@ def test_03_execute_workflow(self):
247302 print (f"attempt { attempt + 1 } : wf={ wf_id } "
248303 f"status={ getattr (wf , 'status' , None )} "
249304 f"tasks={ len (getattr (wf , 'tasks' , []) or [])} ; retrying" )
305+ self ._dump_stuck_task_diagnostics (wf )
250306
251307 # Assertions
252308 self .assertIsNotNone (
0 commit comments