From 9029ac3e3350431403071774cb7046e0458b33cd Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Wed, 24 Jun 2026 12:51:01 +0200 Subject: [PATCH 01/20] HITL enabled and working, needs polish and test --- compute_worker/compute_worker.py | 50 ++++++++++++++++++- src/apps/api/serializers/competitions.py | 6 ++- src/apps/competitions/models.py | 1 + src/apps/competitions/tasks.py | 13 +++++ .../editor/_competition_details.tag | 18 +++++++ 5 files changed, 85 insertions(+), 3 deletions(-) diff --git a/compute_worker/compute_worker.py b/compute_worker/compute_worker.py index 243dcd16d..75a3540c3 100644 --- a/compute_worker/compute_worker.py +++ b/compute_worker/compute_worker.py @@ -314,6 +314,9 @@ def run_wrapper(run_args): run.prepare() run.start() if run.is_scoring: + if run.human_in_the_loop: + run.wait_for_human_validation() + run._update_status(SubmissionStatus.FINISHED) run.push_scores() run.push_output() except DockerImagePullException as e: @@ -470,6 +473,7 @@ def __init__(self, run_args): self.prediction_result = run_args["prediction_result"] self.scoring_result = run_args.get("scoring_result") self.execution_time_limit = run_args["execution_time_limit"] + self.human_in_the_loop = run_args.get("human_in_the_loop", False) # stdout and stderr self.stdout, self.stderr, self.ingestion_stdout, self.ingestion_stderr = ( self._get_stdout_stderr_file_names(run_args) @@ -1445,11 +1449,55 @@ def start(self): ) # Raise so upstream marks failed immediately raise SubmissionException("Child task failed or non-zero return code") - self._update_status(SubmissionStatus.FINISHED) + + if not self.human_in_the_loop: + self._update_status(SubmissionStatus.FINISHED) else: self._update_status(SubmissionStatus.SCORING) + def wait_for_human_validation(self): + container_output_dir = self.output_dir + host_output_dir = self._get_host_path(self.output_dir) + + scores_path = os.path.join(host_output_dir, "scores.json") + if not os.path.exists(os.path.join(container_output_dir, "scores.json")): + scores_path = os.path.join(host_output_dir, "scores.txt") + + approved_container = os.path.join(container_output_dir, "hitl_approved") + rejected_container = os.path.join(container_output_dir, "hitl_rejected") + approved_host = os.path.join(host_output_dir, "hitl_approved") + rejected_host = os.path.join(host_output_dir, "hitl_rejected") + + logger.info("=" * 60) + logger.info(f"HUMAN IN THE LOOP — submission {self.submission_id}") + logger.info("Inspect the scores file:") + logger.info(f" cat {scores_path}") + logger.info(f"To approve : touch {approved_host}") + logger.info(f"To reject : touch {rejected_host}") + logger.info("=" * 60) + + poll_interval = 3 + max_wait = 60 * 60 * 24 + + elapsed = 0 + while elapsed < max_wait: + if os.path.exists(approved_container): # ← poll le chemin conteneur + logger.info(f"HITL: submission {self.submission_id} approved, sending scores.") + return + if os.path.exists(rejected_container): # ← poll le chemin conteneur + raise SubmissionException( + f"HITL: scores rejected by the compute node operator " + f"(submission {self.submission_id})" + ) + time.sleep(poll_interval) + elapsed += poll_interval + + raise SubmissionException( + f"HITL: 24h timeout reached without validation " + f"(submission {self.submission_id})" + ) + def push_scores(self): """This is only ran at the end of the scoring step""" # POST to some endpoint: diff --git a/src/apps/api/serializers/competitions.py b/src/apps/api/serializers/competitions.py index a0fb14fda..3a566446e 100644 --- a/src/apps/api/serializers/competitions.py +++ b/src/apps/api/serializers/competitions.py @@ -272,7 +272,8 @@ class Meta: 'contact_email', 'report', 'whitelist_emails', - 'forum_enabled' + 'forum_enabled', + 'enable_human_in_the_loop' ) def validate_phases(self, phases): @@ -417,7 +418,8 @@ class Meta: 'contact_email', 'report', 'whitelist_emails', - 'forum_enabled' + 'forum_enabled', + 'enable_human_in_the_loop' ) def get_leaderboards(self, instance): diff --git a/src/apps/competitions/models.py b/src/apps/competitions/models.py index f919ae432..164e66536 100644 --- a/src/apps/competitions/models.py +++ b/src/apps/competitions/models.py @@ -89,6 +89,7 @@ class Competition(models.Model): # If true, forum is enabled (default=True) forum_enabled = models.BooleanField(default=True) + enable_human_in_the_loop = models.BooleanField(default=False) def __str__(self): return f"competition-{self.title}-{self.pk}-{self.competition_type}" diff --git a/src/apps/competitions/tasks.py b/src/apps/competitions/tasks.py index 3d7d8abcb..1f424b4ac 100644 --- a/src/apps/competitions/tasks.py +++ b/src/apps/competitions/tasks.py @@ -58,6 +58,7 @@ "contact_email", "fact_sheet", "forum_enabled", + "enable_human_in_the_loop" ] TASK_FIELDS = [ @@ -166,6 +167,7 @@ def _send_to_compute_worker(submission, is_scoring): ), "id": submission.pk, "is_scoring": is_scoring, + "human_in_the_loop": submission.phase.competition.enable_human_in_the_loop, } if ( @@ -260,6 +262,17 @@ def _send_to_compute_worker(submission, is_scoring): submission.queue = effective_queue submission.save(update_fields=["queue"]) + if is_scoring and submission.phase.competition.enable_human_in_the_loop: + time_limit = 60 * 60 * 25 + + if ( + submission.phase.competition.queue + ): # if the competition is running on a custom queue, not the default queue + submission.queue = submission.phase.competition.queue + run_args["execution_time_limit"] = ( + submission.phase.execution_time_limit + ) # use the competition time limit + submission.save(update_fields=["queue"]) if submission.status == Submission.SUBMITTING: submission.status = Submission.SUBMITTED submission.save(update_fields=["status"]) diff --git a/src/static/riot/competitions/editor/_competition_details.tag b/src/static/riot/competitions/editor/_competition_details.tag index 199edd404..da135fef0 100644 --- a/src/static/riot/competitions/editor/_competition_details.tag +++ b/src/static/riot/competitions/editor/_competition_details.tag @@ -211,6 +211,22 @@ + +
+ +
+ + +
+ + + + + +
+
@@ -314,6 +330,7 @@ self.data["auto_run_submissions"] = self.refs.auto_run_submissions.checked self.data["can_participants_make_submissions_public"] = self.refs.can_participants_make_submissions_public.checked self.data["forum_enabled"] = self.refs.forum_enabled.checked + self.data["enable_human_in_the_loop"] = self.refs.enable_human_in_the_loop.checked self.data["make_programs_available"] = self.refs.make_programs_available.checked self.data["make_input_data_available"] = self.refs.make_input_data_available.checked self.data["docker_image"] = $(self.refs.docker_image).val() @@ -452,6 +469,7 @@ self.refs.auto_run_submissions.checked = competition.auto_run_submissions self.refs.can_participants_make_submissions_public.checked = competition.can_participants_make_submissions_public self.refs.forum_enabled.checked = competition.forum_enabled + self.refs.enable_human_in_the_loop.checked = competition.enable_human_in_the_loop self.refs.make_programs_available.checked = competition.make_programs_available self.refs.make_input_data_available.checked = competition.make_input_data_available $(self.refs.docker_image).val(competition.docker_image) From 72a876354536841159ef1bb3daba8a9573a5003c Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Mon, 29 Jun 2026 15:23:32 +0200 Subject: [PATCH 02/20] disable HITL for public CWs --- compute_worker/compute_worker.py | 4 ++-- src/apps/competitions/tasks.py | 11 ++++++++--- .../competitions/editor/_competition_details.tag | 12 +++++++++++- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/compute_worker/compute_worker.py b/compute_worker/compute_worker.py index 75a3540c3..1d1a7ea11 100644 --- a/compute_worker/compute_worker.py +++ b/compute_worker/compute_worker.py @@ -1482,10 +1482,10 @@ def wait_for_human_validation(self): elapsed = 0 while elapsed < max_wait: - if os.path.exists(approved_container): # ← poll le chemin conteneur + if os.path.exists(approved_container): logger.info(f"HITL: submission {self.submission_id} approved, sending scores.") return - if os.path.exists(rejected_container): # ← poll le chemin conteneur + if os.path.exists(rejected_container): raise SubmissionException( f"HITL: scores rejected by the compute node operator " f"(submission {self.submission_id})" diff --git a/src/apps/competitions/tasks.py b/src/apps/competitions/tasks.py index 1f424b4ac..4251e2592 100644 --- a/src/apps/competitions/tasks.py +++ b/src/apps/competitions/tasks.py @@ -157,6 +157,11 @@ def _get_user_group_queues(user, competition): def _send_to_compute_worker(submission, is_scoring): + hitl_active = ( + submission.phase.competition.enable_human_in_the_loop + and submission.queue is not None + ) + run_args = { "user_pk": submission.owner.pk, "submissions_api_url": settings.SUBMISSIONS_API_URL, @@ -167,7 +172,7 @@ def _send_to_compute_worker(submission, is_scoring): ), "id": submission.pk, "is_scoring": is_scoring, - "human_in_the_loop": submission.phase.competition.enable_human_in_the_loop, + "human_in_the_loop": hitl_active, } if ( @@ -251,7 +256,7 @@ def _send_to_compute_worker(submission, is_scoring): logger.info(run_args) # Pad timelimit so worker has time to cleanup - time_padding = 60 * 20 # 20 minutes + time_padding = 60 * 20 time_limit = submission.phase.execution_time_limit + time_padding effective_queue = submission.queue or submission.phase.competition.queue @@ -262,7 +267,7 @@ def _send_to_compute_worker(submission, is_scoring): submission.queue = effective_queue submission.save(update_fields=["queue"]) - if is_scoring and submission.phase.competition.enable_human_in_the_loop: + if is_scoring and hitl_active: time_limit = 60 * 60 * 25 if ( diff --git a/src/static/riot/competitions/editor/_competition_details.tag b/src/static/riot/competitions/editor/_competition_details.tag index da135fef0..25afa4099 100644 --- a/src/static/riot/competitions/editor/_competition_details.tag +++ b/src/static/riot/competitions/editor/_competition_details.tag @@ -219,12 +219,17 @@
- + @@ -331,6 +336,11 @@ self.data["can_participants_make_submissions_public"] = self.refs.can_participants_make_submissions_public.checked self.data["forum_enabled"] = self.refs.forum_enabled.checked self.data["enable_human_in_the_loop"] = self.refs.enable_human_in_the_loop.checked + const hitlChecked = self.refs.enable_human_in_the_loop.checked + const hasPrivateQueue = !!(self.data["queue"] && self.data["queue"] !== "") + if (self.refs.hitl_warning) { + self.refs.hitl_warning.style.display = (hitlChecked && !hasPrivateQueue) ? 'block' : 'none' + } self.data["make_programs_available"] = self.refs.make_programs_available.checked self.data["make_input_data_available"] = self.refs.make_input_data_available.checked self.data["docker_image"] = $(self.refs.docker_image).val() From d8862a0f064cccd8b845131cb01b7839658a7117 Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Tue, 30 Jun 2026 10:56:34 +0200 Subject: [PATCH 03/20] HITL secute on public CW + UI improvement --- src/apps/competitions/tasks.py | 17 +++++++++++++++++ .../competitions/detail/submission_manager.tag | 8 ++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/apps/competitions/tasks.py b/src/apps/competitions/tasks.py index 4251e2592..35bbebd32 100644 --- a/src/apps/competitions/tasks.py +++ b/src/apps/competitions/tasks.py @@ -375,6 +375,23 @@ def _run_submission(submission_pk, task_pks=None, is_scoring=False): ) submission = qs.get(pk=submission_pk) + # ── HUMAN IN THE LOOP SECURE (private CW only) + if is_scoring and submission.phase.competition.enable_human_in_the_loop: + if submission.queue is None: + logger.error( + f"HITL: submission {submission_pk} rejected — " + f"Human in the Loop is enabled but no private queue is configured " + f"(competition or participant group)." + ) + submission.status = Submission.FAILED + submission.status_details = ( + "This competition requires Human in the Loop validation, but no " + "private compute queue is configured. Contact the organizer." + ) + submission.save(update_fields=["status", "status_details"]) + return + # ── HITL SECURE (private CW only) + if submission.is_specific_task_re_run: # Should only be one task for a specified task submission tasks = Task.objects.filter(pk__in=task_pks) diff --git a/src/static/riot/competitions/detail/submission_manager.tag b/src/static/riot/competitions/detail/submission_manager.tag index 3f41a69eb..37530345c 100644 --- a/src/static/riot/competitions/detail/submission_manager.tag +++ b/src/static/riot/competitions/detail/submission_manager.tag @@ -107,7 +107,8 @@ { submission.status } - + + Date: Wed, 8 Jul 2026 16:55:58 +0200 Subject: [PATCH 04/20] fix after rebase + detailed result + CW check of HITL enabled --- compute_worker/compute_worker.py | 84 +++++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 6 deletions(-) diff --git a/compute_worker/compute_worker.py b/compute_worker/compute_worker.py index 1d1a7ea11..54461b2d1 100644 --- a/compute_worker/compute_worker.py +++ b/compute_worker/compute_worker.py @@ -112,6 +112,9 @@ def to_bool(val): CODALAB_IGNORE_CLEANUP_STEP = to_bool(get("CODALAB_IGNORE_CLEANUP_STEP")) WORKER_BUNDLE_URL_REWRITE = get("WORKER_BUNDLE_URL_REWRITE", "").strip() + HUMAN_IN_THE_LOOP = ( + get("HUMAN_IN_THE_LOOP", "false").lower() == "true" + ) # ----------------------------------------------- @@ -313,12 +316,20 @@ def run_wrapper(run_args): try: run.prepare() run.start() + if run.is_scoring: if run.human_in_the_loop: run.wait_for_human_validation() - run._update_status(SubmissionStatus.FINISHED) - run.push_scores() - run.push_output() + run.send_final_detailed_results() + run.push_scores() + run.push_output() + else: + run.push_scores() + run.push_output() + else: + run.push_output() + + run._update_status(SubmissionStatus.FINISHED) except DockerImagePullException as e: msg = str(e).strip() if msg: @@ -473,7 +484,17 @@ def __init__(self, run_args): self.prediction_result = run_args["prediction_result"] self.scoring_result = run_args.get("scoring_result") self.execution_time_limit = run_args["execution_time_limit"] + # ----- HITL ------ self.human_in_the_loop = run_args.get("human_in_the_loop", False) + compute_hitl = Settings.HUMAN_IN_THE_LOOP + if self.human_in_the_loop != compute_hitl: + raise SubmissionException( + "Task rejected because the Site Worker and Compute Worker " + "do not have the same HUMAN_IN_THE_LOOP configuration " + f"(task={self.human_in_the_loop}, " + f"compute_worker={Settings.HUMAN_IN_THE_LOOP})." + ) + # stdout and stderr self.stdout, self.stderr, self.ingestion_stdout, self.ingestion_stderr = ( self._get_stdout_stderr_file_names(run_args) @@ -517,6 +538,13 @@ def __init__(self, run_args): async def watch_detailed_results(self): """Watches files alongside scoring + program containers, currently only used for detailed_results.html""" + + if self.human_in_the_loop: + logger.info( + "HITL enabled: skipping detailed_results streaming" + ) + return + if not self.detailed_results_url: return file_path = self.get_detailed_results_file_path() @@ -600,6 +628,49 @@ async def send_detailed_results(self, file_path): logger.exception(e) return + def send_final_detailed_results(self): + if not self.detailed_results_url: + return + + file_path = self.get_detailed_results_file_path() + + if not file_path: + logger.info("No detailed_results.html found") + return + + logger.info( + f"Uploading final detailed results {file_path} - {self.detailed_results_url}" + ) + + self._put_file( + self.detailed_results_url, + file=file_path, + content_type="text/html", + ) + + websocket_url = f"{self.websocket_url}?kind=detailed_results" + + try: + websocket = asyncio.run( + asyncio.wait_for( + websockets.connect(websocket_url), + timeout=30.0, + ) + ) + + asyncio.run( + websocket.send( + json.dumps( + { + "kind": "detailed_result_update", + } + ) + ) + ) + + except Exception as e: + logger.exception(e) + def _get_stdout_stderr_file_names(self, run_args): # run_args should be the run_args argument passed to __init__ from the run_wrapper. if not self.is_scoring: @@ -1312,9 +1383,10 @@ def start(self): ) # During scoring we watch for detailed results - tasks.append( - self.watch_detailed_results() - ) + if not self.human_in_the_loop: + tasks.append( + self.watch_detailed_results() + ) else: # During ingestion we run ingestion program directory and submission directory tasks.extend([ From 120bd96139713daff82028224c4e867fe50f04c1 Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Thu, 9 Jul 2026 15:32:32 +0200 Subject: [PATCH 05/20] HITL improvement + Websocket fixed + more logs --- compute_worker/compute_worker.py | 48 ++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/compute_worker/compute_worker.py b/compute_worker/compute_worker.py index 54461b2d1..05e4b1fd1 100644 --- a/compute_worker/compute_worker.py +++ b/compute_worker/compute_worker.py @@ -311,10 +311,19 @@ def rewrite_bundle_url_if_needed(url): def run_wrapper(run_args): # We need to convert the UUID given by celery into a byte like object otherwise things will break run_args.update(secret=str(run_args["secret"])) + logger.info(f"Received run arguments: \n {colorize_run_args(json.dumps(run_args))}") + logger.info( + "HITL configuration : " + f"task={run_args.get('human_in_the_loop', False)} " + f"compute_worker={Settings.HUMAN_IN_THE_LOOP}" + ) + run = Run(run_args) + try: run.prepare() + run.validate_hitl_configuration() run.start() if run.is_scoring: @@ -486,15 +495,6 @@ def __init__(self, run_args): self.execution_time_limit = run_args["execution_time_limit"] # ----- HITL ------ self.human_in_the_loop = run_args.get("human_in_the_loop", False) - compute_hitl = Settings.HUMAN_IN_THE_LOOP - if self.human_in_the_loop != compute_hitl: - raise SubmissionException( - "Task rejected because the Site Worker and Compute Worker " - "do not have the same HUMAN_IN_THE_LOOP configuration " - f"(task={self.human_in_the_loop}, " - f"compute_worker={Settings.HUMAN_IN_THE_LOOP})." - ) - # stdout and stderr self.stdout, self.stderr, self.ingestion_stdout, self.ingestion_stderr = ( self._get_stdout_stderr_file_names(run_args) @@ -628,6 +628,13 @@ async def send_detailed_results(self, file_path): logger.exception(e) return + finally: + if websocket is not None: + try: + await websocket.close() + except Exception as e: + logger.exception(e) + def send_final_detailed_results(self): if not self.detailed_results_url: return @@ -1357,6 +1364,15 @@ def prepare(self): self._get_container_image(self.container_image) self._update_status(SubmissionStatus.RUNNING) + def validate_hitl_configuration(self): + if self.human_in_the_loop != Settings.HUMAN_IN_THE_LOOP: + raise SubmissionException( + "Task rejected because the Site Worker and Compute Worker " + "do not have the same HUMAN_IN_THE_LOOP configuration " + f"(task={self.human_in_the_loop}, " + f"compute_worker={Settings.HUMAN_IN_THE_LOOP})." + ) + def start(self): logger.info(f"Preparing to run: {ProgramKind.SCORING_PROGRAM if self.is_scoring else ProgramKind.INGESTION_PROGRAM}") @@ -1505,9 +1521,15 @@ def start(self): # Check if scoring program failed # We have can have 2 or 3 gathered tasks: 3 gathered tasks in case when `ingestion_only_during_scoring` is True, 2 otherwise if self.ingestion_only_during_scoring: - program_results, _, _ = task_results + if self.human_in_the_loop: + program_results, _ = task_results + else: + program_results, _, _ = task_results else: - program_results, _ = task_results + if self.human_in_the_loop: + (program_results,) = task_results + else: + program_results, _ = task_results # Gather returns either normal values or exception instances when return_exceptions=True had_async_exc = isinstance( program_results, BaseException @@ -1553,6 +1575,10 @@ def wait_for_human_validation(self): max_wait = 60 * 60 * 24 elapsed = 0 + + logger.info( + "Waiting for human validation..." + ) while elapsed < max_wait: if os.path.exists(approved_container): logger.info(f"HITL: submission {self.submission_id} approved, sending scores.") From 1b5ac5038d2e3e965bf96051ca451c6b7c211bd4 Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Fri, 10 Jul 2026 16:04:38 +0200 Subject: [PATCH 06/20] submission status: awaiting validation --- compute_worker/compute_worker.py | 3 +++ src/apps/competitions/models.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/compute_worker/compute_worker.py b/compute_worker/compute_worker.py index 05e4b1fd1..84fcf1825 100644 --- a/compute_worker/compute_worker.py +++ b/compute_worker/compute_worker.py @@ -134,6 +134,7 @@ class SubmissionStatus: SUBMITTED = "Submitted" PREPARING = "Preparing" RUNNING = "Running" + AWAITING_VALIDATION = "Awaiting validation" SCORING = "Scoring" FINISHED = "Finished" FAILED = "Failed" @@ -144,6 +145,7 @@ class SubmissionStatus: SUBMITTED, PREPARING, RUNNING, + AWAITING_VALIDATION, SCORING, FINISHED, FAILED, @@ -328,6 +330,7 @@ def run_wrapper(run_args): if run.is_scoring: if run.human_in_the_loop: + run._update_status(SubmissionStatus.AWAITING_VALIDATION) run.wait_for_human_validation() run.send_final_detailed_results() run.push_scores() diff --git a/src/apps/competitions/models.py b/src/apps/competitions/models.py index 164e66536..97d7ff057 100644 --- a/src/apps/competitions/models.py +++ b/src/apps/competitions/models.py @@ -438,6 +438,7 @@ class Submission(models.Model): PREPARING = "Preparing" RUNNING = "Running" SCORING = "Scoring" + AWAITING_VALIDATION = "Awaiting validation" CANCELLED = "Cancelled" FINISHED = "Finished" FAILED = "Failed" @@ -449,6 +450,7 @@ class Submission(models.Model): (PREPARING, "Preparing"), (RUNNING, "Running"), (SCORING, "Scoring"), + (AWAITING_VALIDATION, "Awaiting validation"), (CANCELLED, "Cancelled"), (FINISHED, "Finished"), (FAILED, "Failed"), From d50bfb4daf80ebfbb92fcba3dbe5066e31c6ece2 Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Mon, 20 Jul 2026 16:23:26 +0200 Subject: [PATCH 07/20] adding HITL documentation --- .../Running_a_benchmark/Competition-HITL.md | 280 ++++++++++++++++++ .../_attachments/doc_HITL_1.png | Bin 0 -> 17180 bytes documentation/zensical.toml | 3 +- 3 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 documentation/docs/Organizers/Running_a_benchmark/Competition-HITL.md create mode 100644 documentation/docs/Organizers/Running_a_benchmark/_attachments/doc_HITL_1.png diff --git a/documentation/docs/Organizers/Running_a_benchmark/Competition-HITL.md b/documentation/docs/Organizers/Running_a_benchmark/Competition-HITL.md new file mode 100644 index 000000000..b45049032 --- /dev/null +++ b/documentation/docs/Organizers/Running_a_benchmark/Competition-HITL.md @@ -0,0 +1,280 @@ +# Human-in-the-Loop (HITL) + +## Introduction + +The **Human-in-the-Loop (HITL)** feature introduces a manual validation step into the Codabench submission workflow. + +When enabled, the Compute Worker executes the submission normally but **does not immediately publish the scoring results**. Instead, execution pauses after the scoring program has completed and waits for a human operator to explicitly approve or reject the submission. + +This mechanism is intended for competitions where the scoring results must be reviewed before being published, such as: + +* Medical AI competitions +* Security-sensitive evaluations +* Competitions involving confidential data +* Any evaluation requiring manual verification from compute worker side + +--- + +## Objectives + +The HITL feature guarantees: + +* the submission is fully executed before any result is published; +* no score appears on the leaderboard before validation; +* no detailed results are available before validation; +* no output archive is sent back to the Site Worker before validation; +* the operator can approve or reject the submission directly from the Compute Worker. + +--- + +## Scope + +The HITL feature applies **only to private Compute Workers**. + +Public Compute Workers **must never execute HITL jobs**, as waiting for manual validation would permanently block shared workers. + +--- + +## Activation + +### Competition configuration + +Each competition exposes the following option: + +``` +Enable Human-in-the-Loop +``` + +When enabled, every submission routed to a private Compute Worker is executed in HITL mode. +![image1](_attachments/doc_HITL_1.png) + +--- + +### Compute Worker configuration + +Each Compute Worker may enable HITL support using its `.env` file. + +``` title="Option" +HUMAN_IN_THE_LOOP=true +``` + +If the variable is omitted, the Compute Worker behaves as a standard worker. +The absence of the variable is interpreted as **False**, ensuring full backward compatibility with existing Compute Workers. + +--- + +## Safety checks + +To prevent configuration errors, the Compute Worker verifies that both sides agree on the HITL configuration. +The Site Worker includes the following field in every Celery task. + +Before starting the execution, the Compute Worker compares: + +* task `human_in_the_loop` Codabench side. +* local `.env` variable `HUMAN_IN_THE_LOOP` Compute worker side. + +--- + +### Mismatched configuration + +The submission is immediately rejected. +The Compute Worker updates the submission status to **Failed** and reports the configuration mismatch. + +``` title="Example logs:" +Task rejected because the Site Worker and Compute Worker +do not share the same HUMAN_IN_THE_LOOP configuration +(task=True, compute_worker=False) +``` + +This prevents accidental execution of HITL competitions on non-HITL workers. + +--- + +## Submission lifecycle + +### Standard workflow + +When HITL is disabled, the workflow remains unchanged. + +During scoring: + +* scores are uploaded immediately; +* detailed results are streamed continuously; +* output files are returned immediately. + +--- + +### HITL workflow + +When HITL is enabled: + +The scoring program executes normally. +After scoring completes, the Compute Worker pauses before publishing any result. + +--- + +## Awaiting validation status + +A new submission status is introduced: + +This status indicates: + +* scoring has completed successfully; +* all output files are available locally; +* publication is waiting for manual approval. + +This status allows users and organizers to distinguish between: + +* submissions that are still executing; +* submissions waiting for human validation. + +--- + +## Manual validation + +Once execution finishes, the Compute Worker logs instructions similar to: +``` title="Compute worker terminal command (exemple with docker)" +docker compose logs -f [container name] +``` + +``` title="HITL validation display on CW terminal" +============================================================ +HUMAN IN THE LOOP — submission 42 + +Inspect the scores file: + +cat /host/output/scores.json + +To approve: +touch /host/output/hitl_approved + +To reject: +touch /host/output/hitl_rejected +============================================================ +``` + +The operator inspects the generated files before deciding. +This needs to be done inside of the compute worker host machine (not inside of the CW terminal). + +--- + +### Approval + +The Compute Worker immediately resumes execution. +The following artifacts are published: + +* detailed results; +* scores; +* output archive; +* logs. + +The submission status becomes "Finished" + +--- + +### Rejection + +After rejection from operator, the compute worker immediately aborts the submission. + +The submission status becomes "Failed" + +No scoring results are published. + +--- + +### Timeout + +If no decision is received within 24 hours, the submission automatically fails. +The Compute Worker reports a timeout error. + +--- + +## Publication policy + +The fundamental principle of HITL is: + +> **Nothing leaves the Compute Worker before validation.** + +When HITL is enabled: + +| Artifact | Before validation | After approval | +| --------------------------------------- | ----------------- | -------------- | +| scores.json | ❌ | ✅ | +| leaderboard score | ❌ | ✅ | +| detailed_results.html | ❌ | ✅ | +| detailed results websocket notification | ❌ | ✅ | +| output archive | ❌ | ✅ | +| execution logs | Local only | ✅ | + +The Compute Worker keeps every generated artifact locally until approval. + +--- + +## Detailed Results behaviour + +### Without HITL + +```mermaid +flowchart TD + A[Scoring] --> B[Watch detailed results] + B --> C[Upload detailed_results.html] + C --> D[Notify frontend] +``` + +### With HITL + +```mermaid +flowchart TD + A[Scoring] --> B[Store detailed_results.html locally] + B --> C[Awaiting validation] + C --> D[Operator approval] + D --> E[Upload detailed_results.html] + E --> F[Notify frontend] +``` + +--- + +## Output behaviour + +Prediction files, scoring outputs and logs are generated normally. +However, publication is postponed until validation succeeds. +This guarantees that no evaluation result becomes visible before manual approval. + +--- + +## Error handling + +The following situations are handled explicitly. + +| Situation | Behaviour | +| ---------------------------------------------------- | ------------------------------- | +| HITL enabled on competition and Compute Worker | Execution pauses for validation | +| HITL disabled everywhere | Standard workflow | +| HITL mismatch between Site Worker and Compute Worker | Submission fails immediately | +| Operator rejects submission | Submission fails | +| Validation timeout | Submission fails | +| Approval received | Results are published normally | + +--- + +## Design principles + +The HITL implementation follows four principles: + +1. **Backward compatibility** + + * Existing deployments require no configuration changes. + +2. **Safety** + + * Configuration mismatches are detected before execution. + +3. **Isolation** + + * HITL is supported only on private Compute Workers. + +4. **Atomic publication** + + * No artifact is published before manual approval. + +These principles ensure that sensitive competitions can safely introduce manual validation without impacting the standard Codabench execution workflow. diff --git a/documentation/docs/Organizers/Running_a_benchmark/_attachments/doc_HITL_1.png b/documentation/docs/Organizers/Running_a_benchmark/_attachments/doc_HITL_1.png new file mode 100644 index 0000000000000000000000000000000000000000..866ec86ddf52c3cb50931a4d30dbb41420d3dd37 GIT binary patch literal 17180 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 literal 0 HcmV?d00001 diff --git a/documentation/zensical.toml b/documentation/zensical.toml index e69af2918..a2b056c92 100644 --- a/documentation/zensical.toml +++ b/documentation/zensical.toml @@ -36,7 +36,8 @@ nav = [ {"Queue Management" = "Organizers/Running_a_benchmark/Queue-Management.md"}, {"Compute Worker Management & Setup" = "Organizers/Running_a_benchmark/Compute-Worker-Management---Setup.md"}, {"Compute Worker Management with Podman" = "Organizers/Running_a_benchmark/Compute-worker-installation-with-Podman.md"}, - {"Server Status" = "Organizers/Running_a_benchmark/Server-status-page.md"} + {"Server Status" = "Organizers/Running_a_benchmark/Server-status-page.md"}, + {"Human in the loop" = "Organizers/Running_a_benchmark/Competition-HITL.md"} ]} ]}, {"Developers" = [ From 9ca592aee6c295348e3e5832976d12530afa0bbc Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Tue, 21 Jul 2026 13:30:27 +0200 Subject: [PATCH 08/20] HITL documentation reviwed --- .../Running_a_benchmark/Competition-HITL.md | 74 ++----------------- 1 file changed, 8 insertions(+), 66 deletions(-) diff --git a/documentation/docs/Organizers/Running_a_benchmark/Competition-HITL.md b/documentation/docs/Organizers/Running_a_benchmark/Competition-HITL.md index b45049032..5acdf4c07 100644 --- a/documentation/docs/Organizers/Running_a_benchmark/Competition-HITL.md +++ b/documentation/docs/Organizers/Running_a_benchmark/Competition-HITL.md @@ -19,10 +19,10 @@ This mechanism is intended for competitions where the scoring results must be re The HITL feature guarantees: -* the submission is fully executed before any result is published; -* no score appears on the leaderboard before validation; -* no detailed results are available before validation; -* no output archive is sent back to the Site Worker before validation; +* the submission is fully executed before any result is published. +* no score appears on the leaderboard before validation. +* no detailed results are available before validation. +* no output archive is sent back to instance. * the operator can approve or reject the submission directly from the Compute Worker. --- @@ -31,8 +31,6 @@ The HITL feature guarantees: The HITL feature applies **only to private Compute Workers**. -Public Compute Workers **must never execute HITL jobs**, as waiting for manual validation would permanently block shared workers. - --- ## Activation @@ -41,10 +39,6 @@ Public Compute Workers **must never execute HITL jobs**, as waiting for manual v Each competition exposes the following option: -``` -Enable Human-in-the-Loop -``` - When enabled, every submission routed to a private Compute Worker is executed in HITL mode. ![image1](_attachments/doc_HITL_1.png) @@ -163,9 +157,9 @@ This needs to be done inside of the compute worker host machine (not inside of t The Compute Worker immediately resumes execution. The following artifacts are published: -* detailed results; -* scores; -* output archive; +* detailed results. +* scores. +* output archive. * logs. The submission status becomes "Finished" @@ -186,27 +180,7 @@ No scoring results are published. If no decision is received within 24 hours, the submission automatically fails. The Compute Worker reports a timeout error. - ---- - -## Publication policy - -The fundamental principle of HITL is: - -> **Nothing leaves the Compute Worker before validation.** - -When HITL is enabled: - -| Artifact | Before validation | After approval | -| --------------------------------------- | ----------------- | -------------- | -| scores.json | ❌ | ✅ | -| leaderboard score | ❌ | ✅ | -| detailed_results.html | ❌ | ✅ | -| detailed results websocket notification | ❌ | ✅ | -| output archive | ❌ | ✅ | -| execution logs | Local only | ✅ | - -The Compute Worker keeps every generated artifact locally until approval. +The compute worker is enable to run another submission while waiting for HITL approval. --- @@ -234,14 +208,6 @@ flowchart TD --- -## Output behaviour - -Prediction files, scoring outputs and logs are generated normally. -However, publication is postponed until validation succeeds. -This guarantees that no evaluation result becomes visible before manual approval. - ---- - ## Error handling The following situations are handled explicitly. @@ -254,27 +220,3 @@ The following situations are handled explicitly. | Operator rejects submission | Submission fails | | Validation timeout | Submission fails | | Approval received | Results are published normally | - ---- - -## Design principles - -The HITL implementation follows four principles: - -1. **Backward compatibility** - - * Existing deployments require no configuration changes. - -2. **Safety** - - * Configuration mismatches are detected before execution. - -3. **Isolation** - - * HITL is supported only on private Compute Workers. - -4. **Atomic publication** - - * No artifact is published before manual approval. - -These principles ensure that sensitive competitions can safely introduce manual validation without impacting the standard Codabench execution workflow. From dbe05d5c37c35d0ce53a28e93d9f723a2a228ebd Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Tue, 21 Jul 2026 13:50:45 +0200 Subject: [PATCH 09/20] HITL in cw documentation --- .../Running_a_benchmark/Compute-Worker-Management---Setup.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/documentation/docs/Organizers/Running_a_benchmark/Compute-Worker-Management---Setup.md b/documentation/docs/Organizers/Running_a_benchmark/Compute-Worker-Management---Setup.md index 5735af914..fc0576c1c 100644 --- a/documentation/docs/Organizers/Running_a_benchmark/Compute-Worker-Management---Setup.md +++ b/documentation/docs/Organizers/Running_a_benchmark/Compute-Worker-Management---Setup.md @@ -59,7 +59,7 @@ HOST_DIRECTORY=/codabench CONTAINER_ENGINE_EXECUTABLE=docker #USE_GPU=True #GPU_DEVICE=nvidia.com/gpu=all - +#HUMAN_IN_THE_LOOP=False ####################################################################### # Network # ####################################################################### @@ -72,6 +72,8 @@ By default, the competition container created by the compute worker has access t If the VM hosting the compute worker is behind a proxy, and you want to allow the competition container to access internet, you will also need to set the proxy for the competition container to use, in which case you can use `COMPETITION_CONTAINER_HTTP_PROXY` +To control the scoring output before sending it to the instance, you can use the human in the loop feature the check the scoring file (json or txt) before sending anything to the instance, see: [HITL documentation](https://docs.codabench.org/latest/Organizers/Running_a_benchmark/Competition-HITL/) + !!! note - The broker URL is a unique identifier of the job queue that the worker should listen to. To create a queue or obtain the broker URL of an existing queue, you can refer to [Queue Management](Queue-Management.md) docs page. @@ -204,6 +206,7 @@ The folder `$HOST_DIRECTORY/data`, usually `/codabench/data`, is shared between !!! tip "If you simply wish to set up some compute workers to increase the computing power of your benchmark, you don't need to scroll this page any further." + --- ## Building compute worker From 38a0251a0233ba2dc524e0639e5b358cac6aef7a Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Thu, 23 Jul 2026 11:46:09 +0200 Subject: [PATCH 10/20] site worker modified to return submission status failed if hitl enabled on public CW --- src/apps/competitions/tasks.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/apps/competitions/tasks.py b/src/apps/competitions/tasks.py index 35bbebd32..3f43d69ea 100644 --- a/src/apps/competitions/tasks.py +++ b/src/apps/competitions/tasks.py @@ -162,6 +162,25 @@ def _send_to_compute_worker(submission, is_scoring): and submission.queue is not None ) + if ( + submission.phase.competition.enable_human_in_the_loop + and submission.queue is None + ): + submission.status = Submission.FAILED + submission.status_details = ( + "This competition requires Human-in-the-Loop (HITL), " + "but the submission was routed to a public compute worker. " + "HITL is only supported on private compute workers." + ) + submission.save(update_fields=["status", "status_details"]) + + logger.error( + "Submission %s rejected: HITL requires a private compute worker.", + submission.id, + ) + + return + run_args = { "user_pk": submission.owner.pk, "submissions_api_url": settings.SUBMISSIONS_API_URL, From 090487d33755e651109b5b4372611c5a5945ec7c Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Thu, 23 Jul 2026 17:33:38 +0200 Subject: [PATCH 11/20] in progress: detailed results check and validation --- compute_worker/compute_worker.py | 73 ++++++++++++-------------------- src/apps/competitions/tasks.py | 1 - 2 files changed, 26 insertions(+), 48 deletions(-) diff --git a/compute_worker/compute_worker.py b/compute_worker/compute_worker.py index 84fcf1825..d2f388a35 100644 --- a/compute_worker/compute_worker.py +++ b/compute_worker/compute_worker.py @@ -332,7 +332,12 @@ def run_wrapper(run_args): if run.human_in_the_loop: run._update_status(SubmissionStatus.AWAITING_VALIDATION) run.wait_for_human_validation() - run.send_final_detailed_results() + if run.pending_detailed_results: + asyncio.run( + run.send_detailed_results( + run.pending_detailed_results + ) + ) run.push_scores() run.push_output() else: @@ -515,6 +520,7 @@ def __init__(self, run_args): self.reference_data = run_args.get("reference_data") self.ingestion_only_during_scoring = run_args.get("ingestion_only_during_scoring") self.detailed_results_url = run_args.get("detailed_results_url") + self.pending_detailed_results = None self.ingestion_program_exit_code = None self.ingestion_program_elapsed_time = None @@ -563,7 +569,10 @@ async def watch_detailed_results(self): new_time = os.path.getmtime(file_path) if new_time != last_modified_time: last_modified_time = new_time - await self.send_detailed_results(file_path) + if self.human_in_the_loop: + self.pending_detailed_results = file_path + else: + await self.send_detailed_results(file_path) else: logger.info(time.time() - start) if time.time() - start > expiration_seconds: @@ -576,7 +585,10 @@ async def watch_detailed_results(self): else: # make sure we always send the final version of the file if file_path: - await self.send_detailed_results(file_path) + if self.human_in_the_loop: + self.pending_detailed_results = file_path + else: + await self.send_detailed_results(file_path) def push_logs(self): """Upload any collected logs, even in case of crash. @@ -638,49 +650,6 @@ async def send_detailed_results(self, file_path): except Exception as e: logger.exception(e) - def send_final_detailed_results(self): - if not self.detailed_results_url: - return - - file_path = self.get_detailed_results_file_path() - - if not file_path: - logger.info("No detailed_results.html found") - return - - logger.info( - f"Uploading final detailed results {file_path} - {self.detailed_results_url}" - ) - - self._put_file( - self.detailed_results_url, - file=file_path, - content_type="text/html", - ) - - websocket_url = f"{self.websocket_url}?kind=detailed_results" - - try: - websocket = asyncio.run( - asyncio.wait_for( - websockets.connect(websocket_url), - timeout=30.0, - ) - ) - - asyncio.run( - websocket.send( - json.dumps( - { - "kind": "detailed_result_update", - } - ) - ) - ) - - except Exception as e: - logger.exception(e) - def _get_stdout_stderr_file_names(self, run_args): # run_args should be the run_args argument passed to __init__ from the run_wrapper. if not self.is_scoring: @@ -1569,7 +1538,17 @@ def wait_for_human_validation(self): logger.info("=" * 60) logger.info(f"HUMAN IN THE LOOP — submission {self.submission_id}") logger.info("Inspect the scores file:") - logger.info(f" cat {scores_path}") + logger.info(f"cat {scores_path}") + + if self.detailed_results_url: + detailed_results = self.get_detailed_results_file_path() + + if detailed_results and os.path.exists(detailed_results): + logger.info("") + logger.info("Detailed results") + logger.info(f" {detailed_results}") + logger.info(" (Open this file in your browser to review the HTML report)") + logger.info(f"To approve : touch {approved_host}") logger.info(f"To reject : touch {rejected_host}") logger.info("=" * 60) diff --git a/src/apps/competitions/tasks.py b/src/apps/competitions/tasks.py index 3f43d69ea..220e93677 100644 --- a/src/apps/competitions/tasks.py +++ b/src/apps/competitions/tasks.py @@ -178,7 +178,6 @@ def _send_to_compute_worker(submission, is_scoring): "Submission %s rejected: HITL requires a private compute worker.", submission.id, ) - return run_args = { From 0ff24454f1a179f43fc2443ccd484490cfe9e82c Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Fri, 24 Jul 2026 16:45:01 +0200 Subject: [PATCH 12/20] feature OK / detailed result added to validation process --- compute_worker/compute_worker.py | 92 ++++++++++++++++++++++++-------- 1 file changed, 71 insertions(+), 21 deletions(-) diff --git a/compute_worker/compute_worker.py b/compute_worker/compute_worker.py index d2f388a35..e1eb54c29 100644 --- a/compute_worker/compute_worker.py +++ b/compute_worker/compute_worker.py @@ -3,6 +3,10 @@ import hashlib import json import os +import functools +import http.server +import socketserver +import threading import traceback import shutil import signal @@ -521,6 +525,8 @@ def __init__(self, run_args): self.ingestion_only_during_scoring = run_args.get("ingestion_only_during_scoring") self.detailed_results_url = run_args.get("detailed_results_url") self.pending_detailed_results = None + self.hitl_http_server = None + self.hitl_http_thread = None self.ingestion_program_exit_code = None self.ingestion_program_elapsed_time = None @@ -548,12 +554,6 @@ async def watch_detailed_results(self): """Watches files alongside scoring + program containers, currently only used for detailed_results.html""" - if self.human_in_the_loop: - logger.info( - "HITL enabled: skipping detailed_results streaming" - ) - return - if not self.detailed_results_url: return file_path = self.get_detailed_results_file_path() @@ -618,6 +618,40 @@ def get_detailed_results_file_path(self): if html_files: return html_files[0] + def start_hitl_http_server(self): + if not self.pending_detailed_results: + return + root = os.path.dirname(self.pending_detailed_results) + logger.info( + "Starting temporary HTTP server for HITL review (%s)", + root, + ) + handler = functools.partial( + http.server.SimpleHTTPRequestHandler, + directory=root, + ) + self.hitl_http_server = socketserver.TCPServer( + ("127.0.0.1", 8765), + handler, + ) + self.hitl_http_thread = threading.Thread( + target=self.hitl_http_server.serve_forever, + daemon=True, + ) + self.hitl_http_thread.start() + + def stop_hitl_http_server(self): + if self.hitl_http_server is None: + return + + logger.info("Stopping HITL HTTP server") + + self.hitl_http_server.shutdown() + self.hitl_http_server.server_close() + + self.hitl_http_server = None + self.hitl_http_thread = None + async def send_detailed_results(self, file_path): logger.info( f"Updating detailed results {file_path} - {self.detailed_results_url}" @@ -1535,44 +1569,59 @@ def wait_for_human_validation(self): approved_host = os.path.join(host_output_dir, "hitl_approved") rejected_host = os.path.join(host_output_dir, "hitl_rejected") + detailed_results = None + if self.detailed_results_url: + detailed_results = self.get_detailed_results_file_path() + if detailed_results: + self.pending_detailed_results = detailed_results + self.start_hitl_http_server() + logger.info("=" * 60) logger.info(f"HUMAN IN THE LOOP — submission {self.submission_id}") logger.info("Inspect the scores file:") logger.info(f"cat {scores_path}") - if self.detailed_results_url: - detailed_results = self.get_detailed_results_file_path() - - if detailed_results and os.path.exists(detailed_results): - logger.info("") - logger.info("Detailed results") - logger.info(f" {detailed_results}") - logger.info(" (Open this file in your browser to review the HTML report)") + if detailed_results and os.path.exists(detailed_results): + logger.info("") + logger.info("Inspect the detailed results:") + logger.info("") + logger.info("Create an SSH tunnel from your workstation:") + logger.info("ssh -L 8765:127.0.0.1:8765 operator@") + logger.info("") + logger.info("Then open in your browser:") + logger.info( + "http://127.0.0.1:8765/%s", + os.path.basename(detailed_results), + ) + logger.info("") logger.info(f"To approve : touch {approved_host}") logger.info(f"To reject : touch {rejected_host}") logger.info("=" * 60) + logger.info("Waiting for human validation...") poll_interval = 3 max_wait = 60 * 60 * 24 - elapsed = 0 - logger.info( - "Waiting for human validation..." - ) while elapsed < max_wait: if os.path.exists(approved_container): - logger.info(f"HITL: submission {self.submission_id} approved, sending scores.") + logger.info( + f"HITL: submission {self.submission_id} approved, sending results." + ) + self.stop_hitl_http_server() return + if os.path.exists(rejected_container): + self.stop_hitl_http_server() raise SubmissionException( - f"HITL: scores rejected by the compute node operator " - f"(submission {self.submission_id})" + f"HITL: submission {self.submission_id} rejected by the compute node operator." ) + time.sleep(poll_interval) elapsed += poll_interval + self.stop_hitl_http_server() raise SubmissionException( f"HITL: 24h timeout reached without validation " f"(submission {self.submission_id})" @@ -1650,6 +1699,7 @@ def push_output(self): self._put_dir(self.scoring_result, self.output_dir) def clean_up(self): + self.stop_hitl_http_server() if Settings.CODALAB_IGNORE_CLEANUP_STEP: logger.warning( f"CODALAB_IGNORE_CLEANUP_STEP mode enabled, ignoring clean up of: {self.root_dir}" From 40361389163791d0ef4a3fb2c701d59f321cfd41 Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Mon, 27 Jul 2026 14:43:00 +0200 Subject: [PATCH 13/20] hitl added to competiton admin --- src/apps/competitions/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apps/competitions/admin.py b/src/apps/competitions/admin.py index 69bc44229..f9a5c0e1e 100644 --- a/src/apps/competitions/admin.py +++ b/src/apps/competitions/admin.py @@ -212,6 +212,7 @@ class CompetitionExpansion(admin.ModelAdmin): "submissions_count", "participants_count", "created_when", + "enable_human_in_the_loop" ] }, ), From 18693f1bede19baae68850d7ec2259b757975fc4 Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Mon, 27 Jul 2026 14:45:06 +0200 Subject: [PATCH 14/20] admin: moving htil in checkbox section --- src/apps/competitions/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/competitions/admin.py b/src/apps/competitions/admin.py index f9a5c0e1e..6f2d32bde 100644 --- a/src/apps/competitions/admin.py +++ b/src/apps/competitions/admin.py @@ -212,7 +212,6 @@ class CompetitionExpansion(admin.ModelAdmin): "submissions_count", "participants_count", "created_when", - "enable_human_in_the_loop" ] }, ), @@ -234,6 +233,7 @@ class CompetitionExpansion(admin.ModelAdmin): "can_participants_make_submissions_public", "is_featured", "forum_enabled", + "enable_human_in_the_loop" ] }, ), From 529bb1f60ea88e46db34029e57ae418328e9d2eb Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Mon, 27 Jul 2026 15:41:31 +0200 Subject: [PATCH 15/20] options added for detailed result check and predictions not sent if sub rejected --- compute_worker/compute_worker.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/compute_worker/compute_worker.py b/compute_worker/compute_worker.py index e1eb54c29..22c637881 100644 --- a/compute_worker/compute_worker.py +++ b/compute_worker/compute_worker.py @@ -335,13 +335,18 @@ def run_wrapper(run_args): if run.is_scoring: if run.human_in_the_loop: run._update_status(SubmissionStatus.AWAITING_VALIDATION) - run.wait_for_human_validation() + if not run.wait_for_human_validation(): + raise SubmissionException( + f"HITL: submission {run.submission_id} rejected by the compute node operator." + ) + if run.pending_detailed_results: asyncio.run( run.send_detailed_results( run.pending_detailed_results ) ) + run.push_scores() run.push_output() else: @@ -1582,17 +1587,26 @@ def wait_for_human_validation(self): logger.info(f"cat {scores_path}") if detailed_results and os.path.exists(detailed_results): - logger.info("") logger.info("Inspect the detailed results:") - logger.info("") + logger.info("HTML report location:") + logger.info(f"{detailed_results}") + logger.info("Option 1 (recommended): Preview without copying the file") logger.info("Create an SSH tunnel from your workstation:") logger.info("ssh -L 8765:127.0.0.1:8765 operator@") - logger.info("") logger.info("Then open in your browser:") logger.info( "http://127.0.0.1:8765/%s", os.path.basename(detailed_results), ) + host_detailed_results = self._get_host_path(detailed_results) + logger.info("HTML report location:") + logger.info("%s", host_detailed_results) + logger.info("") + logger.info("Option 2: Copy the HTML report") + logger.info( + "cat %s", + host_detailed_results, + ) logger.info("") logger.info(f"To approve : touch {approved_host}") @@ -1610,20 +1624,18 @@ def wait_for_human_validation(self): f"HITL: submission {self.submission_id} approved, sending results." ) self.stop_hitl_http_server() - return + return True if os.path.exists(rejected_container): self.stop_hitl_http_server() - raise SubmissionException( - f"HITL: submission {self.submission_id} rejected by the compute node operator." - ) + return False time.sleep(poll_interval) elapsed += poll_interval self.stop_hitl_http_server() raise SubmissionException( - f"HITL: 24h timeout reached without validation " + f"HITL: 24h timeout reached without validation" f"(submission {self.submission_id})" ) From 1f5f02eaa815f82573bdac90c89041ea72d3f07a Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Tue, 28 Jul 2026 10:11:33 +0200 Subject: [PATCH 16/20] cw logs conf modified to display colors (private cw compatible) --- compute_worker/compute_worker.py | 114 +++++++++++++++++++++++-------- 1 file changed, 85 insertions(+), 29 deletions(-) diff --git a/compute_worker/compute_worker.py b/compute_worker/compute_worker.py index 22c637881..fbff4ace7 100644 --- a/compute_worker/compute_worker.py +++ b/compute_worker/compute_worker.py @@ -34,11 +34,41 @@ from billiard.exceptions import SoftTimeLimitExceeded from logs_loguru import configure_logging, colorize_run_args - logger = logging.getLogger(__name__) sys.path.append("/app/src/settings/") +# Colors for logs (method below) +RESET = "\033[0m" +BOLD = "\033[1m" + +RED = "\033[31m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +CYAN = "\033[36m" + + +def colorize(text, color="", bold=False): + """ + Colorize any log message using ANSI escape sequences. + Compatible with logging, Loguru, Docker and SSH terminals, public and private CWs. + """ + prefix = "" + + if bold: + prefix += BOLD + + if color == "red": + prefix += RED + elif color == "green": + prefix += GREEN + elif color == "yellow": + prefix += YELLOW + elif color == "cyan": + prefix += CYAN + + return f"{prefix}{text}{RESET}" + # ----------------------------------------------- # Settings @@ -1581,38 +1611,59 @@ def wait_for_human_validation(self): self.pending_detailed_results = detailed_results self.start_hitl_http_server() - logger.info("=" * 60) - logger.info(f"HUMAN IN THE LOOP — submission {self.submission_id}") - logger.info("Inspect the scores file:") - logger.info(f"cat {scores_path}") + logger.info(colorize("=" * 60, "cyan", bold=True)) + logger.info(colorize(f"HUMAN IN THE LOOP — submission {self.submission_id}", "cyan", bold=True)) + + logger.info(colorize("Inspect the scores file:", bold=True)) + logger.info(colorize(f"cat {scores_path}", "green")) if detailed_results and os.path.exists(detailed_results): - logger.info("Inspect the detailed results:") - logger.info("HTML report location:") - logger.info(f"{detailed_results}") - logger.info("Option 1 (recommended): Preview without copying the file") + host_detailed_results = self._get_host_path(detailed_results) + + logger.info("") + logger.info( + colorize( + "Option 1 (recommended): Preview without copying the file", + "yellow", + ) + ) + logger.info("Create an SSH tunnel from your workstation:") - logger.info("ssh -L 8765:127.0.0.1:8765 operator@") + logger.info( + colorize( + "ssh -L 8765:127.0.0.1:8765 operator@", + "green", + ) + ) + logger.info("Then open in your browser:") logger.info( - "http://127.0.0.1:8765/%s", - os.path.basename(detailed_results), + colorize( + f"http://127.0.0.1:8765/{os.path.basename(detailed_results)}", + "green", + ) ) - host_detailed_results = self._get_host_path(detailed_results) + + logger.info("") logger.info("HTML report location:") - logger.info("%s", host_detailed_results) + logger.info(colorize(host_detailed_results, "green")) + logger.info("") - logger.info("Option 2: Copy the HTML report") logger.info( - "cat %s", - host_detailed_results, + colorize( + "Option 2: Display the HTML directly in the terminal", + "yellow", + ) ) + logger.info(colorize(f"cat {host_detailed_results}", "green")) logger.info("") - logger.info(f"To approve : touch {approved_host}") - logger.info(f"To reject : touch {rejected_host}") - logger.info("=" * 60) - logger.info("Waiting for human validation...") + logger.info(colorize("Validation commands:", bold=True)) + logger.info(colorize(f"Approve : touch {approved_host}", "green")) + logger.info(colorize(f"Reject : touch {rejected_host}", "red")) + + logger.info(colorize("=" * 60, "cyan", bold=True)) + logger.info(colorize("Waiting for human validation...", "yellow")) poll_interval = 3 max_wait = 60 * 60 * 24 @@ -1621,21 +1672,26 @@ def wait_for_human_validation(self): while elapsed < max_wait: if os.path.exists(approved_container): logger.info( - f"HITL: submission {self.submission_id} approved, sending results." + colorize( + f"HITL: submission {self.submission_id} approved, sending results.", + "green", + bold=True, + ) ) - self.stop_hitl_http_server() - return True - - if os.path.exists(rejected_container): - self.stop_hitl_http_server() - return False + logger.info( + colorize( + f"HITL: submission {self.submission_id} rejected by the compute node operator.", + "red", + bold=True, + ) + ) time.sleep(poll_interval) elapsed += poll_interval self.stop_hitl_http_server() raise SubmissionException( - f"HITL: 24h timeout reached without validation" + f"HITL: 24h timeout reached without validation " f"(submission {self.submission_id})" ) From 3b677cbc365bea09bceeb91bbf87dc53359d9cd9 Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Tue, 28 Jul 2026 11:12:31 +0200 Subject: [PATCH 17/20] cw code for hitl clean --- compute_worker/compute_worker.py | 116 ++++++++----------------------- 1 file changed, 28 insertions(+), 88 deletions(-) diff --git a/compute_worker/compute_worker.py b/compute_worker/compute_worker.py index fbff4ace7..b3a1590dc 100644 --- a/compute_worker/compute_worker.py +++ b/compute_worker/compute_worker.py @@ -34,41 +34,11 @@ from billiard.exceptions import SoftTimeLimitExceeded from logs_loguru import configure_logging, colorize_run_args + logger = logging.getLogger(__name__) sys.path.append("/app/src/settings/") -# Colors for logs (method below) -RESET = "\033[0m" -BOLD = "\033[1m" - -RED = "\033[31m" -GREEN = "\033[32m" -YELLOW = "\033[33m" -CYAN = "\033[36m" - - -def colorize(text, color="", bold=False): - """ - Colorize any log message using ANSI escape sequences. - Compatible with logging, Loguru, Docker and SSH terminals, public and private CWs. - """ - prefix = "" - - if bold: - prefix += BOLD - - if color == "red": - prefix += RED - elif color == "green": - prefix += GREEN - elif color == "yellow": - prefix += YELLOW - elif color == "cyan": - prefix += CYAN - - return f"{prefix}{text}{RESET}" - # ----------------------------------------------- # Settings @@ -1611,59 +1581,34 @@ def wait_for_human_validation(self): self.pending_detailed_results = detailed_results self.start_hitl_http_server() - logger.info(colorize("=" * 60, "cyan", bold=True)) - logger.info(colorize(f"HUMAN IN THE LOOP — submission {self.submission_id}", "cyan", bold=True)) - - logger.info(colorize("Inspect the scores file:", bold=True)) - logger.info(colorize(f"cat {scores_path}", "green")) + logger.info("=" * 60) + logger.info(f"HUMAN IN THE LOOP — submission {self.submission_id}") + logger.info("Inspect scoring:") + logger.info(f"cat {scores_path}") + logger.info("") if detailed_results and os.path.exists(detailed_results): - host_detailed_results = self._get_host_path(detailed_results) - - logger.info("") - logger.info( - colorize( - "Option 1 (recommended): Preview without copying the file", - "yellow", - ) - ) - + logger.info("Inspect detailed results:") + logger.info("Option 1: Preview without copying the file") logger.info("Create an SSH tunnel from your workstation:") + logger.info("ssh -L 8765:127.0.0.1:8765 operator@") + logger.info("Then open in your browser:",) logger.info( - colorize( - "ssh -L 8765:127.0.0.1:8765 operator@", - "green", - ) - ) - - logger.info("Then open in your browser:") - logger.info( - colorize( - f"http://127.0.0.1:8765/{os.path.basename(detailed_results)}", - "green", - ) + "http://127.0.0.1:8765/%s", + os.path.basename(detailed_results), ) - - logger.info("") - logger.info("HTML report location:") - logger.info(colorize(host_detailed_results, "green")) - - logger.info("") + host_detailed_results = self._get_host_path(detailed_results) + logger.info("Option 2: Copy the HTML report") logger.info( - colorize( - "Option 2: Display the HTML directly in the terminal", - "yellow", - ) + "cat %s", + host_detailed_results, ) - logger.info(colorize(f"cat {host_detailed_results}", "green")) logger.info("") - logger.info(colorize("Validation commands:", bold=True)) - logger.info(colorize(f"Approve : touch {approved_host}", "green")) - logger.info(colorize(f"Reject : touch {rejected_host}", "red")) - - logger.info(colorize("=" * 60, "cyan", bold=True)) - logger.info(colorize("Waiting for human validation...", "yellow")) + logger.info(f"To approve : touch {approved_host}") + logger.info(f"To reject : touch {rejected_host}") + logger.info("=" * 60) + logger.info("Waiting for human validation...") poll_interval = 3 max_wait = 60 * 60 * 24 @@ -1672,26 +1617,21 @@ def wait_for_human_validation(self): while elapsed < max_wait: if os.path.exists(approved_container): logger.info( - colorize( - f"HITL: submission {self.submission_id} approved, sending results.", - "green", - bold=True, - ) + f"HITL: submission {self.submission_id} approved, sending results." ) + self.stop_hitl_http_server() + return True + + if os.path.exists(rejected_container): + self.stop_hitl_http_server() + return False - logger.info( - colorize( - f"HITL: submission {self.submission_id} rejected by the compute node operator.", - "red", - bold=True, - ) - ) time.sleep(poll_interval) elapsed += poll_interval self.stop_hitl_http_server() raise SubmissionException( - f"HITL: 24h timeout reached without validation " + f"HITL: 24h timeout reached without validation" f"(submission {self.submission_id})" ) From 0a9cea20abb2b3fc5a7091b23a307f58d38be250 Mon Sep 17 00:00:00 2001 From: didayolo Date: Tue, 4 Aug 2026 15:37:05 +0200 Subject: [PATCH 18/20] Add migration files --- ...ition_enable_human_in_the_loop_and_more.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/apps/competitions/migrations/0062_competition_enable_human_in_the_loop_and_more.py diff --git a/src/apps/competitions/migrations/0062_competition_enable_human_in_the_loop_and_more.py b/src/apps/competitions/migrations/0062_competition_enable_human_in_the_loop_and_more.py new file mode 100644 index 000000000..fcb4779d3 --- /dev/null +++ b/src/apps/competitions/migrations/0062_competition_enable_human_in_the_loop_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.15 on 2026-08-04 13:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("competitions", "0061_competition_participant_groups"), + ] + + operations = [ + migrations.AddField( + model_name="competition", + name="enable_human_in_the_loop", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="submission", + name="status", + field=models.CharField( + choices=[ + ("None", "None"), + ("Submitting", "Submitting"), + ("Submitted", "Submitted"), + ("Preparing", "Preparing"), + ("Running", "Running"), + ("Scoring", "Scoring"), + ("Awaiting validation", "Awaiting validation"), + ("Cancelled", "Cancelled"), + ("Finished", "Finished"), + ("Failed", "Failed"), + ], + default="Submitting", + max_length=128, + ), + ), + ] From fd301d1ecd59001d1e9d29098deb1a136f5907bb Mon Sep 17 00:00:00 2001 From: didayolo Date: Fri, 7 Aug 2026 14:26:20 +0200 Subject: [PATCH 19/20] Simplify code --- compute_worker/compute_worker.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/compute_worker/compute_worker.py b/compute_worker/compute_worker.py index b3a1590dc..19b8288e9 100644 --- a/compute_worker/compute_worker.py +++ b/compute_worker/compute_worker.py @@ -346,16 +346,10 @@ def run_wrapper(run_args): run.pending_detailed_results ) ) - - run.push_scores() - run.push_output() - else: - run.push_scores() - run.push_output() - else: - run.push_output() - + run.push_scores() + run.push_output() run._update_status(SubmissionStatus.FINISHED) + except DockerImagePullException as e: msg = str(e).strip() if msg: From c31df9889b230870a2e582b2bf508adb3d6a3ddf Mon Sep 17 00:00:00 2001 From: Idir Chikhoune Date: Fri, 7 Aug 2026 16:47:30 +0200 Subject: [PATCH 20/20] test fix detailed result path --- compute_worker/compute_worker.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/compute_worker/compute_worker.py b/compute_worker/compute_worker.py index 19b8288e9..769c31643 100644 --- a/compute_worker/compute_worker.py +++ b/compute_worker/compute_worker.py @@ -1559,6 +1559,21 @@ def wait_for_human_validation(self): container_output_dir = self.output_dir host_output_dir = self._get_host_path(self.output_dir) + detailed_results = None + host_detailed_results = None + + if self.detailed_results_url: + detailed_results = self.get_detailed_results_file_path() + + if detailed_results: + self.pending_detailed_results = detailed_results + self.start_hitl_http_server() + + host_detailed_results = os.path.join( + host_output_dir, + os.path.basename(detailed_results), + ) + scores_path = os.path.join(host_output_dir, "scores.json") if not os.path.exists(os.path.join(container_output_dir, "scores.json")): scores_path = os.path.join(host_output_dir, "scores.txt") @@ -1593,10 +1608,7 @@ def wait_for_human_validation(self): ) host_detailed_results = self._get_host_path(detailed_results) logger.info("Option 2: Copy the HTML report") - logger.info( - "cat %s", - host_detailed_results, - ) + logger.info("cat %s", host_detailed_results) logger.info("") logger.info(f"To approve : touch {approved_host}")