Skip to content

โ† Back to index

4. Assignment modes

All four modes are implemented as branches of TaskAssignmentService.assign() (exchange-db/core/task_assignment.py), called once, synchronously, from inside the POST /tasks handler (exchange-db/core/apis.py). Every mode ends the same way โ€” a call to the private _submit_and_save() helper, which does the actual delegate round-trip and writes task_output / task_assignment_status back to Mongo:

def _submit_and_save(self, task, subject_id):
    try:
        result = self._subject_task.submit_and_wait(
            subject_id=subject_id,
            session_id=task.task_id,
            task_id=task.task_id,
            task_data=task.task_data,
        )
        self._tasks_crud.update(task.task_id, {"task_output": result, "task_assignment_status": "completed"})
    except Exception as e:
        self._tasks_crud.update(task.task_id, {"task_assignment_status": "failed", "task_output": {"error": str(e)}})

The four modes differ only in how subject_id gets decided and which parts of that decision happen synchronously vs. on a background thread. See ยง6 for what submit_and_wait actually sends.

4.1 direct mode

Config (task_metadata.task_assignment):

Field Type Required
subject_id string yes โ€” KeyError (surfaces as the API's 400) if missing

What happens:

def _handle_direct(self, task):
    subject_id = task.task_metadata["task_assignment"]["subject_id"]
    threading.Thread(target=self._submit_and_save, args=(task, subject_id), daemon=True).start()

No selection logic at all โ€” the caller already knows who should do the work. The only asynchronous part is the delegate round-trip itself; POST /tasks returns immediately after the thread is started.

4.2 function mode

Config (task_metadata.task_assignment):

Field Type Required
function_id string yes โ€” KeyError if missing
parameters object no ({}) โ€” forwarded as FunctionCaller.call(..., parameters=...)

What happens:

def _handle_function(self, task):
    assignment = task.task_metadata["task_assignment"]
    subjects = self._subjects_crud.list()                       # every subject in this exchange
    result = self._function_caller.call(
        function_id=assignment["function_id"],
        input_data={"task_data": task.task_data, "subjects": [s.to_dict() for s in subjects]},
        parameters=assignment.get("parameters", {}),
    )
    selected_subject_id = result["selected_subject_id"]          # raises if falsy/missing
    self._tasks_crud.update(task.task_id, {"task_assignment_status": "assigned"})
    threading.Thread(target=self._submit_and_save, args=(task, selected_subject_id), daemon=True).start()

This is the only mode with a synchronous step: the selector-function call blocks the POST /tasks request. If it raises, or its response has no truthy selected_subject_id, the exception propagates all the way up to the API handler โ†’ 400, and the task is marked failed. See ยง5.1 for the function's input/output contract.

Once a subject is selected, submission to that subject is still backgrounded โ€” same as direct.

4.3 open mode

Config (task_metadata.task_assignment): none โ€” open mode ignores this key entirely; every subject in the exchange is a candidate.

What happens:

def _run_open_assignment(self, task):          # runs on a background thread from the start
    for subject in self._subjects_crud.list():
        if not subject.task_evaluation_function:
            continue                            # subjects with no evaluation function never participate
        try:
            result = self._function_caller.call(
                function_id=subject.task_evaluation_function,
                input_data={"task_data": task.task_data, "subject": subject.to_dict()},
            )
        except Exception:
            continue                            # a failing evaluator is treated as "did not accept"
        if result.get("status") == "accepted":
            self._tasks_crud.update(task.task_id, {"task_assignment_status": "assigned"})
            self._submit_and_save(task, subject.subject_id)     # inline โ€” already on the background thread
            return
    self._tasks_crud.update(task.task_id, {"task_assignment_status": "rejected", "task_output": {"error": "no subject accepted the task"}})

Unlike function mode, the entire flow โ€” evaluating every subject and submitting to the winner โ€” runs on one background thread, started immediately when POST /tasks is handled. Nothing here is parallelized: subjects are asked one at a time, in whatever order MongoDB returns them, and the loop stops at the first "status": "accepted" response. If no subject accepts (or the exchange has no subjects with a task_evaluation_function set), the task ends in task_assignment_status="rejected" with an explanatory task_output.error โ€” this is a normal terminal state, not a bug.

See ยง5.2 for the per-subject evaluation function's input/output contract.

4.4 bidding mode

Config (task_metadata.task_assignment):

Field Type Required Notes
bid_job_evaluator_id string yes โ€” KeyError if missing Forwarded straight to Orcade
bid_job_pqt_id string no ("") Forwarded straight to Orcade
bid_job_tie_id string no ("") Forwarded straight to Orcade (inert on Orcade's side too)
bid_job_metadata object no ({}) Forwarded straight to Orcade
bid_job_description object no ({}) Forwarded straight to Orcade
bid_job_name object no ({}) Forwarded straight to Orcade
bid_job_creator_id string no ("") Forwarded straight to Orcade
bid_job_subject_ids array<string> no Explicit subject list.
topics array<string> no Resolved to every ExchangeSubjects whose own topics list intersects this one ({"topics": {"$in": topics}}).
bid_job_mode string no ("closed") "closed" (default), "open", or "mixed". Open and mixed modes are described below.
bid_job_max_subjects integer open mode Forwarded to Orcade; job closes once this many subjects have applied.
bid_job_max_time integer (seconds) open mode Forwarded to Orcade; job closes this many seconds after creation.
bid_job_tags array<string> no ([]) Forwarded to Orcade. Subjects discover open jobs by these tags.

Open mode (bid_job_mode: "open"). Subjects are not pre-selected: they find the job on Orcade by bid_job_tags and apply themselves. Orcade closes the job on bid_job_max_subjects or bid_job_max_time (or whichever comes first) and evaluates the applicants. Consequently, open mode rejects bid_job_subject_ids and topics synchronously (400 from POST /tasks), since neither has meaning there. Xchange's poller also stops early with task_assignment_status="rejected" if the job closes with no applicants, instead of waiting forever.

Mixed mode (bid_job_mode: "mixed"). A fixed, required roster like closed mode โ€” Orcade rejects a mixed-mode job with an empty bid_job_subject_ids โ€” but Orcade also auto-tags each named subject with subject::<subject_id> on top of any bid_job_tags you supply, so a subject can discover the job by tag (matching its own ID) as a fallback or alternative to receiving the bid_request push notification. Both are still sent in mixed mode โ€” see Orcade's schema doc for the tagging mechanics. From Xchange's side, mixed behaves identically to closed: only bid_job_mode == "open" changes how the subject pool and the polling loop are handled (see the code below), so bid_job_subject_ids / topics resolution, bid_job_max_subjects / bid_job_max_time (which do not apply here โ€” Orcade rejects them for anything but open), and the no-early-exit polling behavior are all exactly as described for closed below.

For both closed and mixed: if both bid_job_subject_ids and topics are given, the final subject pool is their union (deduplicated, order-preserving) โ€” not an intersection and not one taking priority over the other. If only one is given, that one alone determines the pool. If neither is given, the pool defaults to every subject in this exchange (ExchangeSubjectsCRUD.list()) โ€” for mixed mode this also satisfies Orcade's "at least one subject" requirement as long as the exchange isn't empty; if it is, job creation fails on Orcade's side and the task is marked failed, the same way any other Orcade-side validation failure is handled (e.g. a subject ID containing a comma).

What happens: the entire thing โ€” job creation and polling for a result โ€” runs on one background thread, started immediately when POST /tasks is handled:

def _run_bidding(self, task):
    assignment = task.task_metadata["task_assignment"]
    explicit_subject_ids = assignment.get("bid_job_subject_ids") or []
    topics = assignment.get("topics")
    topic_subject_ids = (
        [s.subject_id for s in self._subjects_crud.list({"topics": {"$in": topics}})]
        if topics else []
    )

    if explicit_subject_ids or topic_subject_ids:
        subject_ids = list(dict.fromkeys(explicit_subject_ids + topic_subject_ids))
    else:
        subject_ids = [s.subject_id for s in self._subjects_crud.list()]

    bid_job = self._bidding_client.create_bid_job(
        bid_job_evaluator_id=assignment["bid_job_evaluator_id"],
        bid_job_subject_ids=subject_ids,
        # ... other bid_job_* fields, all with defaults
    )
    self._tasks_crud.update(task.task_id, {
        "task_metadata.bid_job_id": bid_job["bid_job_id"],
        "task_assignment_status": "bidding",
    })

    while True:
        task_results = self._bidding_client.get_task_results(bid_job["bid_job_id"])
        winner = next((r for r in task_results if r.get("task_type") == "bid_winner"), None)
        if winner is not None:
            self._tasks_crud.update(task.task_id, {"task_output": winner["task_result"], "task_assignment_status": "completed"})
            return
        time.sleep(self._bid_poll_interval)   # default 5s, configurable on TaskAssignmentService

Xchange never calls submit_and_wait itself in this mode โ€” Orcade does the actual subject delivery (its own bid_request and bid_winner notifications, see Orcade's own delegate-system doc). Xchange's role is purely: open the job, then poll until Orcade records a bid_winner result, then copy that result into its own task_output.

If bid-job creation itself fails (e.g. Orcade unreachable, or bid_job_evaluator_id missing), the task is marked failed immediately and no polling loop starts. If creation succeeds but Orcade never produces a bid_winner (e.g. a required subject never bids โ€” see Orcade's own docs), this task's task_assignment_status stays "bidding" forever; there is no timeout. See ยง7 for the full request/response detail and how to inspect an in-flight bid job directly on Orcade.


โ† Previous: APIs ยท Back to index ยท Next: Functions to write โ†’