04 — Real-Time Supervision

One of the two hero examples. Experiments run as first-class Dragon Process es streaming progress over a Queue; a supervisor aborts diverging runs mid-flight via a DDict flag and reclaims their compute. This is a capability plain LangGraph structurally lacks — its nodes are atomic, so a running experiment cannot be observed or interrupted from within the graph.

What you’ll learn:

  • How to run an experiment as a supervised Dragon Process

  • How a supervisor streams live metrics and trips an abort flag

  • How aborting frees the node for the next unit of work

Needs: 1 node.

Main Code

Listing 37 04_realtime_supervision.py
  1"""
  204 - Real-time supervision: watch and abort live experiments.
  3=============================================================================
  4The new Dragon ability
  5----------------------
  6A LangGraph node is normally an opaque, blocking call: the framework has no
  7handle to the computation inside it, so it cannot observe a half-finished
  8experiment or stop it.  Dragon gives the agent first-class handles - a
  9``Process`` per experiment, a streaming ``Queue``, and a shared ``DDict`` -
 10so the agent can supervise and intervene *while the experiment runs*.
 11
 12This self-driving lab (planner -> supervise -> analyze -> loop) launches each
 13experiment as its own Dragon ``Process``.  Experiments STREAM progress over a
 14``Queue``; the supervisor consumes those events live and, the instant a run
 15diverges, sets an abort flag in a shared ``DDict``.  The experiment sees the
 16flag and stops within one reporting interval - its remaining compute is
 17RECLAIMED instead of wasted.  That mid-flight steering is impossible in plain
 18LangGraph; it is an architectural capability, not a tunable.
 19
 20Notes
 21-----
 22* ``launch_host`` runs its agents in one AgentHost process; call it once per
 23  node (each with its own ``Policy``) to spread agents across the cluster.
 24  The experiments it launches are independent Dragon ``Process``es regardless.
 25* The abort path uses Dragon runtime primitives (Process / Queue / DDict);
 26  the integration's role is letting you drive them from an idiomatic
 27  LangGraph graph.
 28
 29Run
 30---
 31    dragon examples/dragon_ai/langgraph/04_realtime_supervision.py
 32
 33For a true multi-node run, uncomment the Policy blocks in main().
 34=============================================================================
 35"""
 36
 37from __future__ import annotations
 38
 39import dragon
 40import multiprocessing as mp
 41import operator
 42import time
 43from functools import partial
 44from typing import Annotated, Any, TypedDict
 45
 46from langgraph.graph import END, START, StateGraph
 47
 48# -- The Dragon + LangGraph integration (the only new import) -----------------
 49from dragon.ai.langgraph import DragonExecutor
 50
 51# -- Dragon primitives the agents use as first-class handles ------------------
 52from dragon.data.ddict import DDict
 53from dragon.native.process import Process
 54from dragon.native.queue import Queue
 55
 56# -- Multi-node placement (uncomment on a real allocation) --------------------
 57# from dragon.infrastructure.policy import Policy
 58# from dragon.native.machine import Node, System
 59
 60
 61# =============================================================================
 62# Lab configuration  (identical to the "before")
 63# =============================================================================
 64
 65N_CANDIDATES = 4
 66MAX_ITERATIONS = 2
 67STABLE_TEMP = 1.2
 68DIVERGE_THRESHOLD = 50.0
 69TOTAL_STEPS = 20
 70STEP_SECONDS = 0.03
 71REPORT_EVERY = 2            # stream a progress event every N steps
 72DDICT_MEM = 256 * 1024**2  # 256 MiB managed heap
 73
 74
 75# =============================================================================
 76# Graph state  (identical to the "before", plus reclaimed_steps)
 77# =============================================================================
 78
 79class LabState(TypedDict):
 80    iteration: int
 81    candidates: list[dict]
 82    outcomes: list[dict]
 83    reclaimed_steps: Annotated[int, operator.add]
 84    abort_latency_ms: list[float]
 85    done: bool
 86
 87
 88# =============================================================================
 89# DDict attach helper (attach-once-and-cache inside long-lived host threads).
 90# =============================================================================
 91
 92_DDICT_CACHE: dict[bytes, Any] = {}
 93
 94
 95def _get_ddict(serialized: bytes) -> Any:
 96    d = _DDICT_CACHE.get(serialized)
 97    if d is None:
 98        d = DDict.attach(serialized)
 99        _DDICT_CACHE[serialized] = d
100    return d
101
102
103# =============================================================================
104# THE EXPERIMENT — now a standalone Dragon Process.
105# It STREAMS progress as it runs and OBEYS a mid-flight abort flag.
106# (Same solver math as the "before"; the contract is what's different.)
107# =============================================================================
108
109def _experiment_process(exp_id: str, params: dict, ddict_ser: bytes,
110                        progress_q: Any) -> None:
111    ddict = _get_ddict(ddict_ser)
112    ddict[f"control:{exp_id}"] = "run"
113
114    temp = params["temperature"]
115    x = 0.1
116    for step in range(1, TOTAL_STEPS + 1):
117        x = x * (2.71828 ** ((temp - STABLE_TEMP) * 0.25))
118        time.sleep(STEP_SECONDS)
119
120        if step % REPORT_EVERY == 0 or step == TOTAL_STEPS:
121            progress_q.put({"id": exp_id, "step": step, "metric": float(x),
122                            "status": "running"})
123            # Cooperative cancellation: stop if the supervisor said so.
124            if ddict.get(f"control:{exp_id}") == "abort":
125                progress_q.put({"id": exp_id, "step": step, "metric": float(x),
126                                "status": "aborted"})
127                return
128
129    score = -abs(x - 1.0)
130    progress_q.put({"id": exp_id, "step": TOTAL_STEPS, "metric": float(x),
131                    "status": "done", "score": float(score)})
132
133
134# =============================================================================
135# Agents (graph nodes)
136# =============================================================================
137
138def planner_node(state: LabState) -> dict:
139    """Identical proposal logic to the 'before'."""
140    it = state.get("iteration", 0)
141    temps = [0.8, 1.0, 1.6, 2.0] if it == 0 else [0.6, 0.9, 1.1, 1.8]
142    candidates = [
143        {"id": f"exp-{it}-{i}", "temperature": t} for i, t in enumerate(temps)
144    ]
145    print(f"\n[planner] round {it}: proposing {len(candidates)} experiments "
146          f"(temps={temps})")
147    return {"candidates": candidates, "iteration": it}
148
149
150def supervise_node(state: LabState, *, ddict_ser: bytes) -> dict:
151    """Launch experiments as Dragon Processes and WATCH them live.
152
153    This is what 'before' cannot do: each experiment is a real handle, progress
154    streams over a Queue, and a diverging run is aborted mid-flight by setting
155    its DDict flag. Remaining steps are reclaimed instead of wasted.
156    """
157    ddict = _get_ddict(ddict_ser)
158    candidates = state["candidates"]
159    progress_q = Queue()
160
161    # --- launch each experiment as its own Dragon Process --------------------
162    procs: dict[str, Any] = {}
163    for c in candidates:
164        p = Process(target=_experiment_process,
165                    args=(c["id"], c, ddict_ser, progress_q))
166        p.start()
167        procs[c["id"]] = p
168    print(f"[supervise] launched {len(procs)} experiments as Dragon Processes "
169          f"— watching live")
170
171    # --- consume the live event stream and intervene -------------------------
172    outcomes: list[dict] = []
173    reclaimed = 0
174    abort_latencies: list[float] = []
175    finished: set[str] = set()
176    abort_t0: dict[str, float] = {}
177
178    while len(finished) < len(procs):
179        ev = progress_q.get()                # blocks until the next live event
180        eid, status = ev["id"], ev["status"]
181
182        if status == "running":
183            # The "decision": is this running experiment diverging right now?
184            if eid not in abort_t0 and ev["metric"] > DIVERGE_THRESHOLD:
185                abort_t0[eid] = time.perf_counter()
186                ddict[f"control:{eid}"] = "abort"     # <-- mid-flight kill
187                print(f"  ! ABORT {eid} at step {ev['step']} "
188                      f"(metric={ev['metric']:.1f}) — reclaiming its compute")
189
190        elif status == "aborted":
191            latency = (time.perf_counter() - abort_t0.get(eid, time.perf_counter())) * 1e3
192            abort_latencies.append(latency)
193            reclaimed += (TOTAL_STEPS - ev["step"])
194            outcomes.append({"id": eid, "final_metric": ev["metric"],
195                             "score": -abs(ev["metric"] - 1.0), "aborted": True})
196            finished.add(eid)
197            print(f"  + {eid} stopped at step {ev['step']} "
198                  f"({latency:.0f} ms after abort signal)")
199
200        elif status == "done":
201            outcomes.append({"id": eid, "final_metric": ev["metric"],
202                             "score": ev["score"], "aborted": False})
203            finished.add(eid)
204            print(f"  + {eid} finished score={ev['score']:.3f}")
205
206    for p in procs.values():
207        p.join()
208
209    return {"outcomes": outcomes, "reclaimed_steps": reclaimed,
210            "abort_latency_ms": abort_latencies}
211
212
213def analyze_node(state: LabState) -> dict:
214    """Identical decision logic to the 'before'."""
215    it = state["iteration"]
216    best = max(state["outcomes"], key=lambda o: o["score"])
217    converged = best["score"] > -0.2
218    done = converged or (it + 1) >= MAX_ITERATIONS
219    print(f"[analyze] round {it}: best={best['id']} "
220          f"score={best['score']:.3f} -> {'DONE' if done else 'refine'}")
221    return {"iteration": it + 1, "done": done}
222
223
224def route(state: LabState) -> str:
225    return END if state["done"] else "planner"
226
227
228# =============================================================================
229# Build and run — same graph shape; only the executor + DDict are new.
230# =============================================================================
231
232def main() -> None:
233    # -- shared DDict the agents and experiments use --------------------------
234    ddict = DDict(managers_per_node=1, n_nodes=1, total_mem=DDICT_MEM)
235    ddict_ser = ddict.serialize()
236
237    # -- multi-node placement (uncomment on a real allocation) ----------------
238    # system = System()
239    # policy = Policy(placement=Policy.Placement.HOST_NAME,
240    #                 host_name=Node(system.nodes[0]).hostname)
241
242    with DragonExecutor() as executor:
243        # One AgentHost process holds these agents. To spread agents across
244        # nodes, call launch_host once per node with its own Policy. The
245        # experiments each agent launches are already separate Dragon Processes
246        # regardless (see supervise_node).
247        executor.launch_host(agents={
248            "planner": planner_node,
249            "supervise": partial(supervise_node, ddict_ser=ddict_ser),
250            "analyze": analyze_node,
251        })  # , policy=policy)
252
253        # -- the graph is IDENTICAL in shape to the plain-LangGraph version ---
254        builder = StateGraph(LabState)
255        builder.add_node("planner", executor.node("planner"))
256        builder.add_node("supervise", executor.node("supervise"))
257        builder.add_node("analyze", executor.node("analyze"))
258
259        builder.add_edge(START, "planner")
260        builder.add_edge("planner", "supervise")
261        builder.add_edge("supervise", "analyze")
262        builder.add_conditional_edges("analyze", route,
263                                      {"planner": "planner", END: END})
264
265        graph = builder.compile()
266
267        t0 = time.perf_counter()
268        final = graph.invoke(
269            {"iteration": 0, "candidates": [], "outcomes": [],
270             "reclaimed_steps": 0, "abort_latency_ms": [], "done": False},
271            config={"recursion_limit": 50},
272        )
273        elapsed = time.perf_counter() - t0
274
275    ddict.destroy()
276
277    avg_latency = (sum(final["abort_latency_ms"]) / len(final["abort_latency_ms"])
278                   if final["abort_latency_ms"] else 0.0)
279    print("\n" + "=" * 60)
280    print("AFTER (Dragon + LangGraph) summary")
281    print("=" * 60)
282    print(f"  wall-time            : {elapsed:6.1f} s")
283    print(f"  reclaimed solver steps: {final['reclaimed_steps']}  "
284          f"(diverging runs killed mid-flight instead of wasted)")
285    print(f"  avg abort latency    : {avg_latency:.0f} ms "
286          f"(divergence detected -> experiment stopped)")
287    print("  why: experiments are Dragon Processes streaming over a Queue; the")
288    print("       supervisor sets a DDict abort flag the instant a run diverges.")
289    print("       Same LangGraph graph — only the executor changed.")
290
291
292if __name__ == "__main__":
293    mp.set_start_method("dragon", force=True)
294    main()

See Also