Módulo 7: Orchestrating The Full Reservo System

Un blackboard, compartido por los tres patrones a la vez

Descripción

Los tres Track de PLAN ya corren juntos, con run_tracks_parallel (lección 04) manejando el pipeline, el fan-out plano y el fan-out-con-handoff (lección 05) sin distinguir entre ellos. Esta lección agrega la última pieza: un Blackboard (Módulo 6) compartido por toda la corrida, con una restricción de diseño que no existía cuando el Módulo 6 lo construyó sobre una corrida completamente secuencial: ninguna escritura puede ocurrir mientras los tres tracks corren en paralelo — porque dos hilos escribiendo al mismo objeto mutable, al mismo tiempo, es exactamente el tipo de condición de carrera que ninguna lección de esta guía introduce a la ligera.

La solución es simple y ya la anticipaste, sin saberlo, en la lección 04: run_tracks_parallel devuelve sus resultados después de que el with ThreadPoolExecutor(...) as pool: termina —es decir, después de que los tres hilos ya cerraron—. Todas las escrituras al Blackboard de esta lección ocurren, sin excepción, fuera de esa sección paralela: antes de que empiece (el supervisor, que ya sabe quién es el socio) o después de que termina (booking_agent, escribiendo los hechos reales de la reserva).

Conexión con el módulo

Esta lección reusa, sin cambios, Blackboard y WriteLogEntry del Módulo 6, y la corrida completa de tres tracks de la lección 05. Lo único nuevo es cuándo se llama a bb.write — antes y después de la sección paralela, nunca durante—. La lección 07 toma exactamente esta misma corrida y le agrega la respuesta final compuesta para Ana, cerrando el módulo.


Analogía: la pizarra, consultada antes y después del turno más ocupado

Vuelve a la pizarra de la sala de guardia del Módulo 6. Durante el momento más ocupado del turno —tres médicos atendiendo a la vez, cada uno su propio caso— nadie corre hacia la pizarra a mitad de una consulta para anotar algo a las apuradas; eso sería un caos, con anotaciones a medio escribir cruzándose entre sí. Lo que sí ocurre es: antes de que empiece ese tramo ocupado, alguien anota lo único que ya se sabe con certeza (qué paciente llegó); y después, cuando cada médico terminó su parte y tiene un resultado real y completo en la mano, recién ahí lo anota en la pizarra, con calma, uno a la vez.


Ejemplo trabajado: el Blackboard antes y después de los tres tracks

import ast
import concurrent.futures
import itertools
from dataclasses import dataclass, field
import reservo_tools as rt


def dispatch_parallel(tool_use_blocks, tools):
    with concurrent.futures.ThreadPoolExecutor(max_workers=len(tool_use_blocks)) as pool:
        futures = [pool.submit(tools[b["name"]], **b["input"]) for b in tool_use_blocks]
        results = [f.result() for f in futures]
    return [
        {"type": "tool_result", "tool_use_id": b["id"], "content": str(r)}
        for b, r in zip(tool_use_blocks, results)
    ]


def run_agent_parallel(question, model_script, tools, max_iterations=10):
    messages = [{"role": "user", "content": question}]
    for step in range(max_iterations):
        turn = model_script[step]
        messages.append({"role": "assistant", "content": turn["content"]})
        if turn["stop_reason"] != "tool_use":
            return turn, messages
        tool_result_blocks = dispatch_parallel(turn["content"], tools)
        messages.append({"role": "user", "content": tool_result_blocks})
    raise RuntimeError(f"max_iterations alcanzado ({max_iterations})")


POLICY_DOCS = {
    "no-show-policy": (
        "Si un miembro no se presenta a una reserva confirmada y no cancela "
        "con al menos 2 horas de anticipación, Reservo cobra el 50% del "
        "precio cotizado como cargo por no-presentación."
    ),
    "cancellation-policy": (
        "Las reservas se pueden cancelar sin cargo hasta 2 horas antes del "
        "horario reservado. Cancelaciones dentro de esas 2 horas aplican "
        "el cargo de no-presentación."
    ),
}


def search_docs(query):
    q = query.lower()
    if "cancela" in q:
        return f"[cancellation-policy] {POLICY_DOCS['cancellation-policy']}"
    if "no" in q and ("present" in q or "show" in q):
        return f"[no-show-policy] {POLICY_DOCS['no-show-policy']}"
    return "No se encontró una política relevante para esa pregunta."


SPECIALISTS = {
    "booking_agent": {
        "tools": {
            "list_rooms": rt.list_rooms, "get_quote": rt.get_quote,
            "book_room": rt.book_room, "cancel_booking": rt.cancel_booking,
        },
    },
    "policy_agent": {"tools": {"search_docs": search_docs}},
    "pricing_agent": {"tools": {"get_quote": rt.get_quote}},
}


def run_specialist(name, task, model_script):
    tools = SPECIALISTS[name]["tools"]
    return run_agent_parallel(task, model_script, tools)


@dataclass
class PipelineStage:
    kind: str
    name: str
    label: str


def last_tool_result(history):
    for m in reversed(history):
        if isinstance(m["content"], list):
            for b in m["content"]:
                if b["type"] == "tool_result":
                    try:
                        return ast.literal_eval(b["content"])
                    except (ValueError, SyntaxError):
                        return b["content"]
    return None


def build_stage_task(stage, payload):
    if stage.kind == "quote":
        return f"Cotiza {payload['room']} {payload['tier']} {payload['hours']}h."
    if stage.kind == "validate_policy":
        return (f"¿Cuál es la política de cancelación para una reserva de "
                 f"{payload['room']} de {payload['hours']}h, antes de confirmarla?")
    if stage.kind == "confirm":
        if payload.get("cleared_to_book"):
            return (f"Reserva {payload['room']} {payload['tier']} {payload['hours']}h "
                     f"para {payload['member']} -- la política de cancelación ya se validó.")
        return (f"Reserva {payload['room']} {payload['tier']} {payload['hours']}h "
                 f"para {payload['member']}.")
    raise ValueError(f"no sé armar la tarea de la etapa {stage.kind!r}")


def extract_payload(stage, history, payload):
    new_payload = dict(payload)
    result = last_tool_result(history)
    if stage.kind == "quote":
        new_payload["price_cents"] = result["price_cents"]
    elif stage.kind == "validate_policy":
        new_payload["cancellation_policy"] = result
        new_payload["cleared_to_book"] = True
    elif stage.kind == "confirm":
        new_payload["booking_id"] = result["booking_id"]
        new_payload["confirmed"] = result["confirmed"]
    return new_payload


def run_pipeline(stages, model_scripts, initial_payload):
    payload = dict(initial_payload)
    trace = []
    for i, stage in enumerate(stages):
        task = build_stage_task(stage, payload)
        final, history = run_specialist(stage.name, task, model_scripts[i])
        payload = extract_payload(stage, history, payload)
        trace.append({
            "stage": i + 1, "kind": stage.kind, "agent": stage.name,
            "label": stage.label, "task": task, "history": history,
            "output": final["content"][0]["text"],
        })
    return payload, trace


def run_tracks_parallel(jobs):
    results = {}
    with concurrent.futures.ThreadPoolExecutor(max_workers=len(jobs)) as pool:
        future_to_key = {pool.submit(fn): key for key, fn in jobs.items()}
        for future in concurrent.futures.as_completed(future_to_key):
            key = future_to_key[future]
            results[key] = future.result()
    return results


HANDOFF_TOOL_NAME = "handoff_to_specialist"


@dataclass
class HandoffPackage:
    sender: str
    receiver: str
    reason: str
    task: str
    context: dict = field(default_factory=dict)


def run_agent_with_handoff(question, model_script, tools, self_name, max_iterations=10):
    messages = [{"role": "user", "content": question}]
    for step in range(max_iterations):
        turn = model_script[step]
        messages.append({"role": "assistant", "content": turn["content"]})
        if turn["stop_reason"] != "tool_use":
            return turn, messages, None
        block = turn["content"][0]
        if block["name"] == HANDOFF_TOOL_NAME:
            inp = block["input"]
            package = HandoffPackage(
                sender=self_name, receiver=inp["receiver"], reason=inp["reason"],
                task=inp["task"], context=inp.get("context", {}),
            )
            return None, messages, package
        tool_result_blocks = dispatch_parallel(turn["content"], tools)
        messages.append({"role": "user", "content": tool_result_blocks})
    raise RuntimeError(f"max_iterations alcanzado ({max_iterations})")


def run_specialist_with_handoff(name, task, model_script):
    tools = SPECIALISTS[name]["tools"]
    return run_agent_with_handoff(task, model_script, tools, self_name=name)


def run_with_handoff(name, task, model_scripts):
    final, history, package = run_specialist_with_handoff(name, task, model_scripts[name])
    trace = [{"agent": name, "history": history, "package": package}]
    if package is None:
        return final, trace
    receiver_final, receiver_history, receiver_package = run_specialist_with_handoff(
        package.receiver, package.task, model_scripts[package.receiver],
    )
    trace.append({"agent": package.receiver, "history": receiver_history, "package": receiver_package})
    return receiver_final, trace


# ---- M6: Blackboard (sin cambios) ----
WRITE_SEQ = itertools.count(1)


@dataclass
class WriteLogEntry:
    seq: int
    writer: str
    field: str
    value: object


@dataclass
class Blackboard:
    member: str | None = None
    room: str | None = None
    tier: str | None = None
    hours: int | None = None
    price_cents: int | None = None
    booking_id: int | None = None
    log: list = field(default_factory=list)

    def write(self, writer, **fields):
        for key, value in fields.items():
            setattr(self, key, value)
            self.log.append(
                WriteLogEntry(seq=next(WRITE_SEQ), writer=writer, field=key, value=value)
            )


PIPELINE_STAGES = [
    PipelineStage(kind="quote", name="booking_agent", label="cotizar"),
    PipelineStage(kind="validate_policy", name="policy_agent",
                  label="validar la política de cancelación"),
    PipelineStage(kind="confirm", name="booking_agent", label="confirmar la reserva"),
]
INITIAL_PAYLOAD = {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}
model_script_quote = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Focus pro 3h cuesta 6000 centavos."}]},
]
model_script_policy = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "search_docs",
         "input": {"query": "política de cancelación"}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": (
            "Puedes cancelar sin cargo hasta 2 horas antes del horario "
            "reservado. No hay ningún impedimento para confirmar."
        )}]},
]
model_script_confirm = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "book_room",
         "input": {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Reservé Focus pro 3h para Ana (confirmación #1)."}]},
]


def job_book_focus():
    return run_pipeline(
        PIPELINE_STAGES,
        [model_script_quote, model_script_policy, model_script_confirm],
        INITIAL_PAYLOAD,
    )


model_script_pricing = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Studio", "tier": "pro", "hours": 3}},
        {"type": "tool_use", "id": "toolu_02", "name": "get_quote",
         "input": {"room": "Boardroom", "tier": "pro", "hours": 3}},
    ]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": (
            "Studio pro 3h: 9600 centavos. Boardroom pro 3h: 19200 centavos. "
            "Studio es la opción más barata de las dos."
        )}]},
]


def job_compare_rooms():
    return run_specialist("pricing_agent", "Compara Studio y Boardroom pro 3h.", model_script_pricing)


model_script_boardroom_booking = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Boardroom", "tier": "pro", "hours": 2}}]},
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_02", "name": HANDOFF_TOOL_NAME,
         "input": {
             "receiver": "policy_agent",
             "reason": "la pregunta de no-presentación es política, fuera de mi expertise",
             "task": "¿qué pasa si un miembro no se presenta a una reserva confirmada?",
             "context": {"room": "Boardroom", "tier": "pro", "hours": 2, "price_cents": 12800},
         }}]},
]
model_script_boardroom_policy = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "search_docs",
         "input": {"query": "qué pasa si no me presento a mi reserva"}}]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": (
            "Si no te presentas a una reserva confirmada y no cancelas con al "
            "menos 2 horas de anticipación, Reservo cobra el 50% del precio "
            "cotizado como cargo por no-presentación."
        )}]},
]
model_scripts_boardroom = {
    "booking_agent": model_script_boardroom_booking,
    "policy_agent": model_script_boardroom_policy,
}


def job_boardroom_no_show():
    return run_with_handoff(
        "booking_agent", "Cotiza Boardroom pro 2h. ¿Qué pasa si no llego?", model_scripts_boardroom,
    )


bb = Blackboard()

print("=== antes de abrir los tracks: el supervisor escribe lo único que ya sabe ===")
bb.write("supervisor", member="Ana")
print(f"bb.member = {bb.member!r}")

print()
print("--- tres tracks a la vez: pipeline, fan-out plano, y un fan-out que ADENTRO hace handoff ---")
jobs = {
    "book_focus": job_book_focus,
    "compare_rooms": job_compare_rooms,
    "boardroom_no_show": job_boardroom_no_show,
}
results = run_tracks_parallel(jobs)
print("claves según llegaron (informativo):", list(results.keys()))

print()
print("=== salida SIEMPRE en el mismo orden (sorted por key del track) ===")
for key in sorted(results):
    print(f"--- {key} ---")
    if key == "book_focus":
        payload, trace = results[key]
        print("  payload final:", payload)
    elif key == "compare_rooms":
        final, history = results[key]
        print("  respuesta:", final["content"][0]["text"])
    elif key == "boardroom_no_show":
        final, trace = results[key]
        print(f"  {trace[0]['agent']} cedió el turno a {trace[0]['package'].receiver} "
              f"(razón: {trace[0]['package'].reason!r})")
        print("  respuesta final:", final["content"][0]["text"])

print()
print("=== después de cerrar el pool (sin escrituras concurrentes): booking_agent "
      "escribe los hechos REALES de la reserva ===")
payload_focus, _ = results["book_focus"]
bb.write("booking_agent", room=payload_focus["room"], tier=payload_focus["tier"],
         hours=payload_focus["hours"], price_cents=payload_focus["price_cents"])
bb.write("booking_agent", booking_id=payload_focus["booking_id"])
print(bb)

print()
print("--- el log completo: ¿refleja el orden real de los hilos? ---")
for entry in bb.log:
    print(f"  #{entry.seq} {entry.writer:<14} escribió {entry.field}={entry.value!r}")
print()
print("compare_rooms y boardroom_no_show no escribieron nada -- son cotizaciones de "
      "comparación, no la reserva de registro (misma regla de M6, lección 07).")

Qué esperar (corrida real; la línea de "claves según llegaron" puede variar entre ejecuciones — el resto, no):

=== antes de abrir los tracks: el supervisor escribe lo único que ya sabe ===
bb.member = 'Ana'

--- tres tracks a la vez: pipeline, fan-out plano, y un fan-out que ADENTRO hace handoff ---
claves según llegaron (informativo): ['compare_rooms', 'boardroom_no_show', 'book_focus']

=== salida SIEMPRE en el mismo orden (sorted por key del track) ===
--- boardroom_no_show ---
  booking_agent cedió el turno a policy_agent (razón: 'la pregunta de no-presentación es política, fuera de mi expertise')
  respuesta final: Si no te presentas a una reserva confirmada y no cancelas con al menos 2 horas de anticipación, Reservo cobra el 50% del precio cotizado como cargo por no-presentación.
--- book_focus ---
  payload final: {'room': 'Focus', 'tier': 'pro', 'hours': 3, 'member': 'Ana', 'price_cents': 6000, 'cancellation_policy': '[cancellation-policy] Las reservas se pueden cancelar sin cargo hasta 2 horas antes del horario reservado. Cancelaciones dentro de esas 2 horas aplican el cargo de no-presentación.', 'cleared_to_book': True, 'booking_id': 1, 'confirmed': True}
--- compare_rooms ---
  respuesta: Studio pro 3h: 9600 centavos. Boardroom pro 3h: 19200 centavos. Studio es la opción más barata de las dos.

=== después de cerrar el pool (sin escrituras concurrentes): booking_agent escribe los hechos REALES de la reserva ===
Blackboard(member='Ana', room='Focus', tier='pro', hours=3, price_cents=6000, booking_id=1, log=[WriteLogEntry(seq=1, writer='supervisor', field='member', value='Ana'), WriteLogEntry(seq=2, writer='booking_agent', field='room', value='Focus'), WriteLogEntry(seq=3, writer='booking_agent', field='tier', value='pro'), WriteLogEntry(seq=4, writer='booking_agent', field='hours', value=3), WriteLogEntry(seq=5, writer='booking_agent', field='price_cents', value=6000), WriteLogEntry(seq=6, writer='booking_agent', field='booking_id', value=1)])

--- el log completo: ¿refleja el orden real de los hilos? ---
  #1 supervisor     escribió member='Ana'
  #2 booking_agent  escribió room='Focus'
  #3 booking_agent  escribió tier='pro'
  #4 booking_agent  escribió hours=3
  #5 booking_agent  escribió price_cents=6000
  #6 booking_agent  escribió booking_id=1

compare_rooms y boardroom_no_show no escribieron nada -- son cotizaciones de comparación, no la reserva de registro (misma regla de M6, lección 07).

Ejecuta este ejemplo varias veces seguidas y compara: la línea de "claves según llegaron" cambia de corrida en corrida —confirmando que los tres hilos sí compiten de verdad—, pero el log del Blackboard, de la primera a la última entrada, sale idéntico cada vez: #1 siempre supervisor, #2 a #6 siempre booking_agent, en ese orden exacto. El log no está registrando "cuándo terminó cada hilo" — está registrando el orden en que el hilo principal, ya con los tres resultados en la mano, decidió llamar a bb.write. Y ese orden es, por diseño, siempre el mismo.


Por qué ninguna escritura ocurre "durante" la sección paralela

Vale la pena ser preciso sobre por qué esto es seguro, no solo afortunado. run_tracks_parallel devuelve su diccionario results recién después de que el bloque with concurrent.futures.ThreadPoolExecutor(...) as pool: termina — y ese with no termina hasta que los tres future.result() ya se resolvieron. En ningún punto del código de esta lección hay una llamada a bb.write dentro de job_book_focus, job_compare_rooms o job_boardroom_no_show — las tres funciones devuelven datos (un payload, un final, un trace), nunca tocan bb directamente. Todas las escrituras están, literalmente, fuera de la indentación de cualquier hilo: una antes de run_tracks_parallel(jobs), dos después. Esta es una decisión de diseño explícita de esta lección, no una casualidad — un sistema real que sí necesitara escribir al Blackboard desde dentro de un track paralelo tendría que resolver la sincronización entre hilos con cuidado (un Lock, por ejemplo), un problema que esta guía no construye porque el diseño de "escribir solo fuera de la sección paralela" lo evita de raíz.


Errores comunes

  1. Intentar llamar a bb.write dentro de uno de los job_* para "ahorrar una línea" al final. Rompe la garantía de esta lección: si dos jobs escribieran al mismo Blackboard desde hilos distintos, al mismo tiempo, el orden de las entradas del log dejaría de ser predecible —y, dependiendo de qué tan compleja fuera la escritura, podría incluso corromper el estado intermedio del objeto—. La disciplina de "escribe solo fuera del with" no es un detalle de estilo, es lo que hace segura toda esta lección.

  2. Pensar que el log "no sirve" porque no refleja el verdadero orden de finalización de los hilos. Sirve para exactamente lo que el Módulo 6 ya estableció: reconstruir QUIÉN escribió QUÉ y en qué SECUENCIA de decisiones del sistema — no para cronometrar qué tan rápido corrió cada hilo. Esas son dos preguntas distintas, y el log solo responde la primera.

  3. Olvidar que compare_rooms y boardroom_no_show no escriben nada. Igual que en el Módulo 6, lección 07, esta asimetría no es un accidente — solo booking_agent, en el track book_focus, produce un hecho de registro (una reserva real) que vale la pena que el resto del sistema pueda leer después.

  4. Ejecutar esta lección en un proceso que ya tenía reservas. Si booking_id no sale 1, el proceso ya corrió otro ejemplo de la guía antes — cada lección asume un Reservo desechable, recién iniciado.


Ejercicios

Ejercicio 1: Confirma tu propia ejecución, varias veces (Fácil)

Ejecuta el ejemplo trabajado tres veces seguidas, en el mismo proceso. Confirma que el log del Blackboard —de #1 a #6— sale idéntico en las tres corridas, aunque la línea de "claves según llegaron" varíe.

Ver solución

No hay una única "solución de código" para este ejercicio — es una verificación: si el log sale idéntico en las tres corridas (aunque el orden de llegada de los hilos varíe), confirmaste con tus propios ojos la garantía central de esta lección: las escrituras al Blackboard son deterministas porque ocurren fuera de la sección paralela, no porque los hilos terminen siempre en el mismo orden.

Ejercicio 2: ¿Qué leería pricing_agent si intentara consultar bb.member DURANTE el fan-out? (Medio)

job_compare_rooms no lee el Blackboard en el ejemplo trabajado. Modifícalo para que, antes de armar su tarea, lea bb.member (ya escrito por el supervisor ANTES de que empezara la sección paralela) y lo use para personalizar el saludo de su respuesta. Ejecuta la corrida completa y confirma que esa lectura, aunque ocurre DENTRO de un hilo, es segura.

Ver solución
def job_compare_rooms_personalized(bb):
    member_read = bb.member  # lectura DENTRO de un hilo, pero de un campo que
                              # ya se escribió ANTES de abrir la sección paralela
    final, history = run_specialist("pricing_agent", "Compara Studio y Boardroom pro 3h.", model_script_pricing)
    return final, history, member_read


bb2 = Blackboard()
bb2.write("supervisor", member="Ana")
jobs_ex2 = {
    "book_focus": job_book_focus,
    "compare_rooms": lambda: job_compare_rooms_personalized(bb2),
    "boardroom_no_show": job_boardroom_no_show,
}
results_ex2 = run_tracks_parallel(jobs_ex2)
_, _, member_leido = results_ex2["compare_rooms"]
print("member leído dentro del hilo de compare_rooms:", member_leido)

Salida esperada:

member leído dentro del hilo de compare_rooms: Ana

Explicación: esta lectura es segura porque bb2.member ya tenía su valor final ('Ana') desde ANTES de que run_tracks_parallel abriera el ThreadPoolExecutor — ningún hilo modifica bb2.member durante la sección paralela, así que leerlo desde dentro de un hilo no compite con ninguna escritura concurrente. La regla de esta lección no prohíbe TODAS las operaciones sobre el Blackboard dentro de un hilo — prohíbe las ESCRITURAS durante la sección paralela; las lecturas de campos que ya se estabilizaron antes de esa sección son seguras, exactamente como esta demuestra.

Ejercicio 3: Simula qué pasaría con una escritura DENTRO de la sección paralela (Difícil)

Sin necesidad de provocar una condición de carrera real y difícil de reproducir, razona: si job_book_focus y job_boardroom_no_show ambos intentaran escribir al mismo Blackboard —cada uno desde su propio hilo, al mismo tiempo, cada uno con su propio booking_id—, ¿qué campo del Blackboard terminaría reflejando el resultado final, y por qué eso sería un bug, aunque Python no lance ninguna excepción?

Ver solución

No hace falta ejecutar nada para razonar esto con el código de la lección 02 del Módulo 6 en mente: Blackboard.write hace setattr(self, key, value) — una asignación directa, sin ningún chequeo de "¿alguien más ya escribió esto en este mismo instante?". Si dos hilos llamaran a bb.write("booking_agent", booking_id=1) y bb.write("booking_agent", booking_id=99) "simultáneamente" (en la práctica, el GIL de Python serializa las operaciones individuales de setattr, así que una de las dos siempre se ejecuta estrictamente después de la otra, pero CUÁL de las dos es la última no está definido por el código — depende del scheduler del sistema operativo). El resultado sería que bb.booking_id terminaría con el valor de lo que sea que se haya escrito al final, sin ningún error, sin ninguna advertencia — un typo silencioso de un tipo distinto al que ya viste en el Módulo 6, lección 02 (Ejercicio 3), pero igual de peligroso: el Blackboard reportaría una reserva como la reserva "de registro" cuando, en una corrida distinta, podría haber sido la otra. Esto es exactamente lo que la disciplina de esta lección —escribir solo fuera de la sección paralela— evita de raíz, sin necesitar ningún Lock: si cada booking_id se escribe en su propio momento, secuencial, después de que su hilo ya terminó, nunca hay dos escrituras compitiendo por el mismo campo al mismo tiempo.


Resumen y siguiente paso

  • El Blackboard de esta lección solo se escribe fuera de la sección paralela: una vez antes (el supervisor, member), dos veces después (booking_agent, los hechos reales de la reserva).
  • Confirmado ejecutando varias veces: el orden de llegada de los hilos varía entre corridas, pero el log del Blackboard —de #1 a #6— sale idéntico siempre, porque refleja el orden de las escrituras del hilo principal, no el orden de finalización de los hilos.
  • Las LECTURAS de campos ya estabilizados (como bb.member, escrito antes de abrir el pool) son seguras incluso dentro de un hilo — lo que la disciplina de esta lección prohíbe son las ESCRITURAS durante la sección paralela.
  • Sin esta disciplina, dos escrituras concurrentes al mismo campo producirían un resultado sin ningún error visible, pero potencialmente incorrecto — un typo silencioso de una naturaleza distinta a los que ya viste en el Módulo 6.

Siguiente lección: 07 — La corrida completa, ejecutada de punta a punta. Juntamos las seis lecciones de este módulo en un solo script: la petición completa de Ana, resuelta con los tres patrones y el blackboard compartido, con la respuesta final compuesta para el socio.


Recursos adicionales

  1. Python — El Global Interpreter Lock (GIL) — El mecanismo que hace que operaciones individuales como setattr no se corrompan a mitad de camino bajo hilos concurrentes, aunque el ORDEN entre operaciones distintas siga sin estar definido.
  2. Python — dataclasses — El módulo detrás de Blackboard y WriteLogEntry, reusado sin cambios desde el Módulo 6.
  3. Anthropic — Multi-agent research system — Un sistema real donde el estado compartido se actualiza en puntos de sincronización explícitos, no en cualquier momento arbitrario de la ejecución paralela.
  4. Python — threading.Lock — El mecanismo que SÍ haría falta si un diseño real necesitara escribir al estado compartido desde dentro de hilos concurrentes — fuera del alcance de esta lección, que lo evita por diseño.