06 — Self-Driving Lab

The capstone that combines the earlier abilities into one workflow: an on-cluster LLM planner + judge (03), a Dragon Batch experiment ensemble whose live progress and bulk output ride a DDict (05), and real-time mid-flight abort via a DDict flag (04) — with the agents placed across two nodes by Policy.

What you’ll learn:

  • How a LangGraph loop drives an optimization campaign end-to-end

  • How the supervisor runs an experiment ensemble via Dragon Batch

  • How a shared DDict acts as a two-way message board for live supervision

  • How a diverging run is aborted mid-flight and its compute reclaimed

Needs: 1 node (GPU optional — the LLM judge is optional).

The Flow (orchestration, not the science)

This is a self-driving optimization campaign: an AI loop that repeatedly proposes experiments, runs them, watches them live, kills the bad ones early, and refines toward the best result. The scientific kernel is a deliberately simple stand-in — the point is the orchestration.

It is an ordinary LangGraph StateGraph with three nodes wired into a cycle:

START -> planner -> supervisor -> analyzer --(refine?)--> planner
                                      |
                                   (converged?) --> END
  • planner — proposes N candidate experiments (each is one parameter).

  • supervisor — runs those N experiments and babysits them live.

  • analyzer — ranks the finished ones, updates the “best so far”, and takes the conditional edge: refine (loop) or converged (END).

Three Dragon pieces sit underneath the graph:

Piece

Role

DragonExecutor

Launches the AgentHost processes and runs each agent as a thread inside one (the LangGraph integration). The planner + analyzer are two threads in one host process; the supervisor is a thread in a second host process on another node.

Batch

A separate Dragon scheduler that runs the experiment tasks across the allocation.

DDict

A shared distributed dictionary — the “blackboard” every party reads and writes.

Placement. The planner + analyzer share one host process (head node); the supervisor runs in a second host process (compute node); Batch schedules the experiments wherever there is room. On a single-node allocation everything resolves to the one node.

One round in detail — what the supervisor actually does:

receive the candidate list from the planner
  |
  +- for each candidate: submit it to Batch as a task  --> Batch runs the
  |    (submission returns immediately; tasks run async)    experiments
  |
  +- enter a POLL LOOP (every POLL_INTERVAL seconds):
        for each still-running experiment:
           read  progress:{id}  from the DDict   <-- experiments write here
           |
           +- if it looks like it is diverging:
           |     write  control:{id} = "abort"   --> experiment reads this
           |                                          flag and stops itself
           |
           +- if the Batch task finished:
                 record its outcome; drop it from the pending set
  |
  +- when all experiments are done/aborted -> return outcomes to the analyzer

The key mechanism is the DDict used as a two-way message board:

  • experiments write their live progress → progress:{id}

  • the supervisor reads that progress and, if a run goes bad, writes an abort flag → control:{id}

  • the experiment reads its own abort flag each step and, if set, stops itself and reports aborted.

That is the whole “real-time supervision” idea: nobody waits for an experiment to finish before deciding it is a dud — a diverging run is stopped mid-flight, freeing its compute for the next round.

Note

The LLM judge is optional. With USE_LLM_JUDGE=1 (and a GPU) an on-cluster vLLM model makes the “is this diverging?” call; otherwise a simple threshold rule decides. Either way the flow above is identical.

Main Code

Listing 39 06_self_driving_lab.py
  1"""
  206 - Self-driving lab: the full agentic-AI-on-HPC workflow.
  3=============================================================================
  4A SELF-DRIVING LAB on HPC -- the capstone, built on **Dragon + LangGraph**.
  5
  6This combines the abilities from earlier examples into one workflow: an LLM
  7planner served on-cluster (03) proposes experiments, the data-plane discipline
  8from the crossover example (05) keeps bulk fields out of the coordinator, and a
  9supervisor watches runs live and aborts diverging ones mid-flight (04).
 10Experiments must be watched *while they run*, and a diverging run must be killed
 11*immediately* so its compute can be reclaimed for promising candidates.
 12
 13This example shows all three pillars of why Dragon is powerful for agentic
 14AI on HPC, in ONE workflow:
 15
 16  (A) LOCAL INFERENCE.  A planner agent calls an LLM served *on the cluster*
 17      by Dragon Inference (vLLM behind a queue -- no HTTP, no external API)
 18      to propose the next batch of experiments.  [Optional; rule-based
 19      fallback so the demo runs without GPUs.]
 20
 21  (B) SCIENTIFIC WORKFLOW.  Each experiment is a small sinusoidal solver
 22      submitted as a Dragon **Batch** task, scheduled across the allocation.
 23      It writes both its live progress and its bulk output into a user-managed
 24      DDict (zero-copy, never through the coordinator).
 25
 26  (C) REAL-TIME ACTION -- the headline.  Experiments STREAM progress into the
 27      DDict as they run.  A supervisor agent polls those DDict entries live
 28      and, the instant a run diverges, sets its abort flag in the DDict.  The
 29      experiment sees the flag and stops within one reporting interval.
 30      No waiting for completion.  No reading data after the fact.  The agent
 31      steers the lab in real time.
 32
 33Why pure LangGraph cannot do this
 34---------------------------------
 35LangGraph nodes are opaque function calls: a node blocks until it returns,
 36and the framework has NO handle to the computation running inside it.  You
 37cannot observe a half-finished experiment, and you cannot stop it.  Dragon
 38gives the agent first-class handles -- Batch tasks and a shared DDict -- so the
 39agent can supervise and intervene mid-flight.  That is
 40an architectural capability, not a feature you can bolt on.
 41
 42The flow
 43-----------------------------------------
 44This is a self-driving optimization campaign: an AI loop repeatedly (1) proposes
 45a batch of experiments, (2) runs them in parallel, (3) watches them live and
 46kills the bad ones early, and (4) inspects the survivors, keeps the best, and
 47decides whether to stop or iterate with better guesses.
 48
 49It is an ordinary LangGraph ``StateGraph`` with three nodes wired into a cycle::
 50
 51    START -> planner -> supervisor -> analyzer --(refine?)--> planner
 52                                          |
 53                                       (converged?) --> END
 54
 55  * planner    -- proposes N candidate experiments (each is one parameter).
 56  * supervisor -- runs those N experiments and babysits them LIVE.
 57  * analyzer   -- ranks the finished ones, updates the "best so far", and takes
 58                  the conditional edge: refine (loop) or converged (END).
 59
 60Three Dragon pieces sit underneath the graph:
 61
 62  * DragonExecutor -- launches the AgentHost processes and runs each agent
 63                      inside one (this integration).  planner + analyzer run in
 64                      one host process; the supervisor runs in a second host
 65                      process on another node.
 66  * Batch          -- a separate Dragon scheduler that runs the experiment tasks
 67                      across the allocation.
 68  * DDict          -- a shared distributed dictionary; the "blackboard" that
 69                      every party reads and writes.
 70
 71Placement: planner + analyzer share one host process (head node); the supervisor
 72runs in a second host process (compute node); Batch schedules the experiments
 73wherever there is room.
 74
 75One round in detail -- what the supervisor actually does::
 76
 77    receive the candidate list from the planner
 78      |
 79      +- for each candidate: submit it to Batch as a task  --> Batch runs the
 80      |    (submission returns immediately; tasks run async)    experiments
 81      |
 82      +- enter a POLL LOOP (every POLL_INTERVAL seconds):
 83            for each still-running experiment:
 84               read  progress:{id}  from the DDict   <-- experiments write here
 85               |
 86               +- if it looks like it is diverging:
 87               |     write  control:{id} = "abort"   --> experiment reads this
 88               |                                          flag and stops itself
 89               |
 90               +- if the Batch task finished:
 91                     record its outcome; drop it from the pending set
 92      |
 93      +- when all experiments are done/aborted -> return outcomes to the analyzer
 94
 95The key mechanism is the DDict used as a two-way message board:
 96
 97  * experiments WRITE their live progress   -> ``progress:{id}``
 98  * the supervisor READS that progress and, if a run goes bad, WRITES an abort
 99    flag                                     -> ``control:{id}``
100  * the experiment READS its own abort flag each step and, if set, STOPS itself
101    and reports "aborted".
102
103That is the whole "real-time supervision" idea: nobody waits for an experiment
104to finish before deciding it is a dud -- a diverging run is stopped mid-flight,
105freeing its compute for the next round.
106
107Run
108---
109    dragon examples/dragon_ai/langgraph/06_self_driving_lab.py            # rule-based judge
110    USE_LLM_JUDGE=1 dragon examples/dragon_ai/langgraph/06_self_driving_lab.py   # + local LLM
111
112Agents are placed across two hosts via ``Policy``: the control plane (planner +
113analyzer) on the head node, the supervisor on a compute node. On a single-node
114allocation both policies resolve to the same node and the demo still runs.
115=============================================================================
116"""
117
118from __future__ import annotations
119
120import dragon
121import multiprocessing as mp
122import operator
123import os
124import time
125from functools import partial
126from typing import Annotated, Any, TypedDict
127
128import numpy as np
129
130# -- Standard LangGraph imports -----------------------------------------------
131from langgraph.graph import END, START, StateGraph
132
133# -- The Dragon + LangGraph integration ---------------------------------------
134from dragon.ai.langgraph import DragonExecutor
135
136# -- Dragon Batch (schedules the experiment tasks across the allocation) -------
137from dragon import Batch
138from dragon.workflows.batch import TaskNotReadyError
139
140# -- Dragon Inference (on-cluster vLLM behind a queue) ------------------------
141from dragon.ai.inference.config import (
142    BatchingConfig,
143    HardwareConfig,
144    InferenceConfig,
145    ModelConfig,
146)
147from dragon.ai.inference.inference_utils import Inference
148from dragon.ai.inference.llm_proxy import DragonQueueLLMProxy
149from dragon.native.queue import Queue
150
151# -- Dragon placement ---------------------------------------------------------
152from dragon import (
153    Policy,
154    System
155)
156from dragon.native.machine import Node
157from dragon.native.process import current as current_process
158
159# =============================================================================
160# Lab configuration
161# =============================================================================
162
163N_CANDIDATES = 6           # experiments launched per round
164MAX_ITERATIONS = 3         # outer optimization rounds
165TARGET_FREQ = 1.0          # frequency the campaign is optimizing toward
166STABLE_FREQ = 1.5          # experiments with freq above this resonate & diverge
167DIVERGE_THRESHOLD = 50.0   # supervisor aborts a run once its amplitude exceeds this
168TOTAL_STEPS = 60           # iterations per experiment
169REPORT_EVERY = 5           # write a progress event every N steps
170POLL_INTERVAL = 0.2        # how often the supervisor polls DDict progress (s)
171DDICT_MEM = 1 * 1024**3    # 1 GiB managed heap (waveforms are small)
172USE_LLM_JUDGE = os.environ.get("USE_LLM_JUDGE") == "1"
173
174
175# =============================================================================
176# Graph state -- only lightweight handles cross the graph, never bulk data.
177# =============================================================================
178
179class LabState(TypedDict):
180    iteration: int
181    candidates: list[dict]                              # params proposed this round
182    outcomes: list[dict]                               # per-experiment summaries
183    best_score: float
184    best_params: dict
185    history: Annotated[list[float], operator.add]
186    aborted_count: int                                # interventions this round
187    done: bool
188
189
190# =============================================================================
191# DDict attach helper (attach-once-and-cache inside long-lived host threads).
192# =============================================================================
193
194_DDICT_CACHE: dict[bytes, Any] = {}
195
196
197def _get_ddict(serialized: bytes) -> Any:
198    d = _DDICT_CACHE.get(serialized)
199    if d is None:
200        from dragon import DDict
201        d = DDict.attach(serialized)
202        _DDICT_CACHE[serialized] = d
203    return d
204
205
206# =============================================================================
207# Placement helper -- resolve the caller's host process into a readable label.
208# ``current().node`` is only a numeric index (0..#nodes-1); ``h_uid`` maps to
209# the actual hostname via ``Node``, so we print both to make placement obvious.
210# =============================================================================
211
212def _where() -> str:
213    me = current_process()
214    hostname = Node(me.h_uid).hostname
215    return f"puid={me.puid} node={me.node} host={hostname}"
216
217
218# =============================================================================
219# THE EXPERIMENT  (runs as a Dragon Batch task, scheduled onto the allocation).
220#
221# A tiny sinusoidal solver: value(step) = amp * sin(freq * step).  A stable run
222# is a clean sine wave (amp stays 1); a run with freq > STABLE_FREQ resonates
223# and its amplitude blows up.  It is iterative, STREAMS its progress into the
224# DDict, and is INTERRUPTIBLE: it checks its abort flag (also in the DDict)
225# every reporting interval.  Replace the body with a real PDE/MD/CFD step; the
226# DDict streaming + abort contract is identical.
227# =============================================================================
228
229def _run_experiment(exp_id: str, params: dict, ddict_ser: bytes,
230                    total_steps: int) -> dict:
231    """One sinusoidal experiment, run as a Dragon Batch task. Streams progress
232    into the DDict and obeys a mid-flight abort flag read from the DDict."""
233    print(f"  [experiment {exp_id}] {_where()} started")
234    ddict = _get_ddict(ddict_ser)
235    ddict[f"control:{exp_id}"] = "run"
236
237    freq = params["freq"]
238    unstable = freq > STABLE_FREQ
239    waveform = np.zeros(total_steps, dtype=np.float32)
240
241    amp = 1.0
242    for step in range(1, total_steps + 1):
243        if unstable:
244            amp *= 1.15                          # resonance blow-up
245        waveform[step - 1] = amp * float(np.sin(freq * step))
246        time.sleep(0.02)                         # stand-in for real solver flops
247
248        if step % REPORT_EVERY == 0 or step == total_steps:
249            # Stream a live progress event INTO the DDict.
250            ddict[f"progress:{exp_id}"] = {"id": exp_id, "step": step,
251                                           "metric": amp, "status": "running"}
252            # Read our abort flag FROM the DDict.
253            if ddict.get(f"control:{exp_id}") == "abort":
254                ddict[f"progress:{exp_id}"] = {"id": exp_id, "step": step,
255                                               "metric": amp, "status": "aborted"}
256                return {"id": exp_id, "status": "aborted"}
257
258    # --- completed: store the full waveform (bulk) and score vs target freq --
259    ddict[f"wave:{exp_id}"] = waveform            # zero-copy bulk artifact
260    score = -abs(freq - TARGET_FREQ)              # closeness to the target frequency
261    ddict[f"progress:{exp_id}"] = {"id": exp_id, "step": total_steps,
262                                   "metric": amp, "status": "done",
263                                   "score": float(score),
264                                   "wave_key": f"wave:{exp_id}"}
265    return {"id": exp_id, "status": "done", "score": float(score)}
266
267
268# =============================================================================
269# Divergence judgment -- the "decision" the supervisor makes in real time.
270# Rule-based by default; optional local-LLM judge to show inference-in-the-loop.
271# =============================================================================
272
273def judge_divergence(event: dict, llm: Any | None) -> bool:
274    """Return True if this *running* experiment should be aborted now."""
275    metric = event["metric"]
276    if not np.isfinite(metric) or metric > DIVERGE_THRESHOLD:
277        if llm is not None:
278            # Inference-in-the-loop: let a cluster-served LLM make the call.
279            # A real prompt would include recent metric history / physics context.
280            import asyncio
281            verdict = asyncio.run(llm.chat([
282                {"role": "system", "content": "You are an experiment supervisor."},
283                {"role": "user", "content":
284                    f"Experiment {event['id']} metric={metric:.2f} at step "
285                    f"{event['step']}. Diverging? Answer ABORT or CONTINUE."},
286            ]))
287            return "ABORT" in str(verdict).upper()
288        return True            # rule-based: clear divergence
289    return False
290
291
292# =============================================================================
293# Agent 1 -- PLANNER (optionally LLM-driven, on cluster-local inference)
294# =============================================================================
295
296def planner_node(state: LabState, *, llm: Any | None = None) -> dict:
297    """Propose the next batch of experiments (one frequency each). Deliberately
298    seeds some unstable (freq > STABLE_FREQ) candidates so aborts are visible."""
299    it = state.get("iteration", 0)
300    rng = np.random.default_rng(seed=100 + it)
301
302    if it == 0:
303        center, spread = TARGET_FREQ, 0.8       # straddle the stability edge
304    else:
305        center = state["best_params"]["freq"]
306        spread = 0.5 / (it + 1)
307
308    # (Optional) ask a cluster-served LLM to nudge the search -- here we just
309    # show the call site; the numeric proposal below is the executable default.
310    if llm is not None:
311        import asyncio
312        _ = asyncio.run(llm.chat([
313            {"role": "system", "content": "You are an experiment design AI."},
314            {"role": "user", "content":
315                f"Round {it}; best params {state.get('best_params', {})}. "
316                "Suggest exploration spread."},
317        ]))
318
319    candidates = [{
320        "id": f"it{it}-e{i}",
321        "freq": float(center + rng.normal(0, spread)),
322    } for i in range(N_CANDIDATES)]
323
324    print(f"[planner] {_where()} round {it}: "
325          f"proposed {len(candidates)} experiments")
326    return {"iteration": it, "candidates": candidates}
327
328
329# =============================================================================
330# Agent 2 -- LAB SUPERVISOR: submit to Batch, MONITOR LIVE, and INTERVENE.
331#
332# This is the real-time core.  It submits each experiment as a Dragon Batch
333# task, then polls the DDict for the progress each task streams into it.  The
334# moment a progress entry shows divergence, it flips that experiment's abort
335# flag in the DDict -- reclaiming the slot without waiting for the run to finish.
336# =============================================================================
337
338def supervisor_node(state: LabState, *, ddict_ser: bytes, batch: Any,
339                    llm: Any | None = None) -> dict:
340    """Submit each experiment as a Dragon Batch task, then supervise LIVE by
341    polling the DDict. The instant a run diverges, flip its abort flag in the
342    DDict; the task sees it and self-terminates within one reporting interval."""
343    ddict = _get_ddict(ddict_ser)
344    candidates = state["candidates"]
345
346    # -- Submit every experiment as an independent Batch task (non-blocking) --
347    # Batch schedules these across the allocation; submission returns at once.
348    tasks: dict[str, Any] = {}
349    for c in candidates:
350        eid = c["id"]
351        ddict[f"control:{eid}"] = "run"
352        ddict[f"progress:{eid}"] = {"id": eid, "step": 0, "metric": 0.0,
353                                    "status": "running"}
354        tasks[eid] = batch.function(_run_experiment, eid, c, ddict_ser, TOTAL_STEPS)
355    print(f"[supervisor] {_where()} submitted "
356          f"{len(tasks)} Batch experiments; supervising via DDict...")
357
358    # -- Poll the DDict for live progress and intervene in real time ---------
359    pending = set(tasks)
360    aborting: set[str] = set()
361    outcomes: list[dict] = []
362    while pending:
363        time.sleep(POLL_INTERVAL)
364        for eid in list(pending):
365            prog = ddict.get(f"progress:{eid}")
366
367            # (1) live intervention while the task is still running
368            if prog and prog["status"] == "running" and eid not in aborting:
369                if judge_divergence(prog, llm):
370                    ddict[f"control:{eid}"] = "abort"     # mid-flight, via DDict
371                    aborting.add(eid)
372                    print(f"[supervisor]  ! ABORT {eid} at step {prog['step']} "
373                          f"(metric={prog['metric']:.1f} diverging) -> reclaiming slot")
374
375            # (2) has the Batch task finished? (authoritative completion signal)
376            try:
377                tasks[eid].get(block=False)               # non-blocking reap
378            except TaskNotReadyError:
379                continue                                  # still running
380            except Exception:                             # worker raised
381                outcomes.append({"id": eid, "status": "error", "score": None})
382                pending.discard(eid)
383                continue
384
385            # (3) task done -- read its final outcome from the DDict
386            final = ddict.get(f"progress:{eid}") or {"status": "done"}
387            if final["status"] == "aborted":
388                outcomes.append({"id": eid, "status": "aborted", "score": None})
389            else:
390                outcomes.append({"id": eid, "status": "done",
391                                 "score": final.get("score"),
392                                 "wave_key": final.get("wave_key")})
393                print(f"[supervisor]  + DONE  {eid} score={final.get('score'):.4f}")
394            pending.discard(eid)
395
396    batch.fence()   # ensure this round's Batch DAG is fully drained
397    n_done = sum(1 for o in outcomes if o["status"] == "done")
398    print(f"[supervisor] round complete: {n_done} finished, "
399          f"{len(aborting)} aborted early")
400    return {"outcomes": outcomes, "aborted_count": len(aborting)}
401
402
403# =============================================================================
404# Agent 3 -- ANALYZER: rank survivors (zero-copy reads), update incumbent,
405# decide convergence, and free spent artifacts.
406# =============================================================================
407
408def analyzer_node(state: LabState, *, ddict_ser: bytes) -> dict:
409    ddict = _get_ddict(ddict_ser)
410
411    survivors = [o for o in state["outcomes"] if o["status"] == "done"]
412    if not survivors:
413        # Everything diverged -- widen the search next round.
414        print(f"[analyzer] {_where()} no survivors this round")
415        it = state["iteration"]
416        return {"iteration": it + 1, "history": [state.get("best_score", -1e9)],
417                "done": it + 1 >= MAX_ITERATIONS}
418
419    # Rank by the figure of merit; the winner needs its full waveform read back.
420    survivors.sort(key=lambda o: o["score"], reverse=True)
421    champ = survivors[0]
422    champ_wave = ddict[champ["wave_key"]]              # zero-copy bulk read
423    rms = float(np.sqrt(np.mean(champ_wave ** 2)))     # a "needs whole array" metric
424    best_params = next(c for c in state["candidates"] if c["id"] == champ["id"])
425
426    prev_best = state.get("best_score", float(-np.inf))
427    improved = champ["score"] - prev_best
428
429    # User-managed cleanup: keep the champion, free the rest.
430    for o in survivors[1:]:
431        if o["wave_key"] in ddict:
432            del ddict[o["wave_key"]]
433
434    it = state["iteration"]
435    converged = (it + 1 >= MAX_ITERATIONS) or (it > 0 and abs(improved) < 1e-3)
436    print(f"[analyzer] {_where()} round {it}: "
437          f"champion={champ['id']} score={champ['score']:.4f} "
438          f"(wave rms={rms:.3f}) -> {'CONVERGED' if converged else 'refine'}")
439
440    return {
441        "best_score": max(prev_best, champ["score"]),
442        "best_params": best_params,
443        "history": [champ["score"]],
444        "iteration": it + 1,
445        "done": converged,
446    }
447
448
449# =============================================================================
450# Routing
451# =============================================================================
452
453def route_after_analysis(state: LabState) -> str:
454    return END if state["done"] else "planner"
455
456
457# =============================================================================
458# Optional: build a cluster-local LLM proxy (Dragon Inference / vLLM).
459# Returns (llm_proxy, inference_handle). The handle is kept alive for the
460# campaign and destroyed in main()'s finally block; it is None when the
461# rule-based judge is used.
462# =============================================================================
463
464def _maybe_build_llm() -> tuple[Any | None, Any | None]:
465    if not USE_LLM_JUDGE:
466        return None, None
467
468    # Configure a small on-cluster vLLM pipeline. Adjust the model and GPU
469    # counts to match your allocation; set HF_TOKEN in the environment for
470    # gated models (e.g. Llama).
471    config = InferenceConfig(
472        model=ModelConfig(
473            model_name="meta-llama/Llama-3.1-8B-Instruct",
474            hf_token=os.environ.get("HF_TOKEN", ""),
475            tp_size=1,               # GPUs per inference worker (tensor parallel)
476            max_tokens=64,           # the judge only needs ABORT / CONTINUE
477            max_model_len=4096,
478        ),
479        hardware=HardwareConfig(
480            num_nodes=1,             # inference on one node
481            num_gpus=1,              # one GPU on that node
482        ),
483        batching=BatchingConfig(
484            enabled=True,
485            batch_wait_seconds=0.05,
486            max_batch_size=32,
487        ),
488    )
489
490    # Launch the pipeline and hand the agents a queue-backed proxy.
491    input_queue = Queue(maxsize=256)
492    inference = Inference(config=config, input_queue=input_queue)
493    inference.initialize()
494    print("[setup] on-cluster vLLM inference pipeline ready")
495
496    llm = DragonQueueLLMProxy(input_queue, max_concurrent_requests=16)
497    return llm, inference
498
499
500# =============================================================================
501# Main
502# =============================================================================
503
504def main() -> None:
505    from dragon import DDict
506
507    print(f"[orchestrator] {_where()}")
508
509    ddict = DDict(managers_per_node=1, n_nodes=1, total_mem=DDICT_MEM)
510    ddict_ser = ddict.serialize()
511
512    llm, inference = _maybe_build_llm()
513
514    # A Batch runtime schedules the experiment tasks across the allocation. The
515    # supervisor submits into it; the experiments stream progress via the DDict.
516    # It is created with managed_lifecycle=True because the supervisor runs in a
517    # *different* process (its agent host) and receives its own Batch client via
518    # cloudpickle -- that copy is torn down with the host, so we shut the shared
519    # runtime down explicitly from here with a forced grace period.
520    batch = Batch(managed_lifecycle=True)
521    print(f"[setup] {batch.topology()}")
522
523    # -- Discover the allocation and build one Policy per host ----------------
524    # Two hosts are placed on two nodes: the control plane (planner + analyzer)
525    # on the head node, and the supervisor -- which submits and monitors the
526    # experiment tasks -- on a compute node. If the allocation has only one
527    # node, both policies resolve to it (the demo still runs).
528    nodes = System().nodes
529    head_hostname = Node(nodes[0]).hostname
530    compute_hostname = Node(nodes[1 % len(nodes)]).hostname
531    head_policy = Policy(placement=Policy.Placement.HOST_NAME,
532                         host_name=head_hostname)
533    compute_policy = Policy(placement=Policy.Placement.HOST_NAME,
534                            host_name=compute_hostname)
535    print(f"[setup] head node={head_hostname}, compute node={compute_hostname}")
536
537    try:
538        with DragonExecutor() as executor:
539            # Host 0 (head node): control-plane agents -- planning and ranking.
540            executor.launch_host(
541                agents={
542                    "planner": partial(planner_node, llm=llm),
543                    "analyzer": partial(analyzer_node, ddict_ser=ddict_ser),
544                },
545                policy=head_policy,
546            )
547            # Host 1 (compute node): the supervisor, which submits the experiment
548            # tasks to Batch and polls the DDict live. It needs extra threads to
549            # run its monitoring loop alongside the submitted work.
550            executor.launch_host(
551                agents={
552                    "supervisor": partial(supervisor_node, ddict_ser=ddict_ser,
553                                          batch=batch, llm=llm),
554                },
555                max_threads=8,
556                policy=compute_policy,
557            )
558
559            # Equivalent one-call form via launch_hosts(): pass a list of agent
560            # dicts and a matching list of policies (applied in order). Note that
561            # max_threads here would apply to every host, so the two-call form
562            # above is used when hosts need different max_threads.
563            #
564            #   executor.launch_hosts(
565            #       hosts=[
566            #           {"planner": partial(planner_node, llm=llm),
567            #            "analyzer": partial(analyzer_node, ddict_ser=ddict_ser)},
568            #           {"supervisor": partial(supervisor_node, ddict_ser=ddict_ser,
569            #                                  batch=batch, llm=llm)},
570            #       ],
571            #       policies=[head_policy, compute_policy],
572            #   )
573
574            builder = StateGraph(LabState)
575            builder.add_node("planner", executor.node("planner"))
576            builder.add_node("supervisor", executor.node("supervisor"))
577            builder.add_node("analyzer", executor.node("analyzer"))
578
579            builder.add_edge(START, "planner")
580            builder.add_edge("planner", "supervisor")
581            builder.add_edge("supervisor", "analyzer")
582            builder.add_conditional_edges("analyzer", route_after_analysis,
583                                          ["planner", END])
584
585            graph = builder.compile()
586
587            config = {"recursion_limit": 100}
588            initial = {"iteration": 0, "outcomes": [], "best_score": float(-np.inf),
589                       "history": [], "aborted_count": 0, "done": False}
590
591            print("\n=== Self-driving lab (Dragon + LangGraph) ===")
592            final = graph.invoke(initial, config)
593
594            print("\n=== Lab campaign complete ===")
595            print(f"  rounds run    : {final['iteration']}")
596            print(f"  best params   : {final.get('best_params')}")
597            print(f"  best score    : {final['best_score']:.4f}")
598            print(f"  score history : {[round(h, 4) for h in final['history']]}")
599    finally:
600        # Force the shared Batch runtime down after a short grace period: the
601        # supervisor's cloudpickled Batch client lived in the (now stopped) agent
602        # host and can never detach on its own, so we don't wait on it forever.
603        batch.destroy(force_timeout=30)
604        if inference is not None:
605            inference.destroy()
606        ddict.destroy()
607
608
609if __name__ == "__main__":
610    mp.set_start_method("dragon", force=True)
611    main()

See Also