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
ProcessHow 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
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
LangGraph Integration — internals and code paths.
LangGraph Integration — API reference.