gabriel / muse public
dag.py python
486 lines 16.8 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """``muse coord dag`` — inspect the coordination dependency DAG.
2
3 Shows which reservations are waiting for others to complete, reports blocked
4 status, detects cycles, and computes the correct execution order via
5 topological sort.
6
7 Overview
8 --------
9 When multiple agents reserve addresses in parallel, some agents may declare
10 that their work depends on another agent's reservation being released first.
11 The dependency DAG captures these ordering constraints.
12
13 ``muse coord dag`` reads all dependency records from
14 ``.muse/coordination/dependencies/``, cross-references the currently active
15 reservations, and answers three questions:
16
17 1. **Who is blocked?** — which reservations cannot start because a dependency
18 is still active.
19 2. **What order should work proceed?** — topological sort respecting all
20 declared dependencies.
21 3. **Are there any cycles?** — flag immediately; cycles mean no agent can ever
22 become unblocked.
23
24 Output examples
25 ---------------
26 Text (full DAG, default)::
27
28 Dependency DAG — 4 node(s), 3 edge(s)
29 1 blocked 3 unblocked 0 cycles
30
31 TOPO STATUS ID DEPENDS-ON
32 ────────────────────────────────────────────────────────────────────
33 1 unblocked a1b2c3d4 (no deps)
34 2 unblocked e5f6a7b8 (no deps)
35 3 BLOCKED c9d0e1f2 a1b2c3d4
36 4 unblocked 01234567 e5f6a7b8
37
38 Text (flat, no topo column, default without --topo)::
39
40 STATUS ID DEPENDS-ON
41 ────────────────────────────────────────────────────────────────────
42 unblocked a1b2c3d4 (no deps)
43 BLOCKED c9d0e1f2 a1b2c3d4
44
45 JSON::
46
47 {
48 "schema_version": "...",
49 "total_nodes": 4,
50 "total_edges": 3,
51 "blocked_count": 1,
52 "active_only": false,
53 "cycle": null,
54 "nodes": [
55 {
56 "reservation_id": "c9d0e1f2-...",
57 "depends_on": ["a1b2c3d4-..."],
58 "active": true,
59 "blocked": true,
60 "blocking": ["a1b2c3d4-..."],
61 "topo_index": 3
62 },
63 ...
64 ]
65 }
66
67 Exit codes::
68
69 0 — success (even when blocked nodes exist)
70 1 — cycle detected, bad arguments, or unexpected error
71 """
72
73 from __future__ import annotations
74
75 import argparse
76 import json
77 import sys
78
79 from collections.abc import Callable
80 from typing import TypedDict
81
82 from muse.core.coordination import _validate_reservation_id, active_reservations
83 from muse.core.dag import (
84 DependencyRecord,
85 detect_cycle,
86 get_blocking,
87 is_blocked,
88 load_all_dependencies,
89 load_dag,
90 topological_sort,
91 )
92 from muse.core._types import short_id
93 from muse.core.envelope import EnvelopeJson, make_envelope
94 from muse.core.errors import ExitCode
95 from muse.core.repo import require_repo
96 from muse.core.timing import start_timer
97 from muse.core.validation import sanitize_display
98
99
100
101 type _Graph = dict[str, set[str]]
102
103
104 class _DagErrorJson(EnvelopeJson):
105 """JSON output for dag error paths."""
106
107 error: str
108 status: str
109
110
111 class _DagNodeJson(TypedDict):
112 """One node in the DAG output."""
113
114 reservation_id: str
115 depends_on: list[str]
116 active: bool
117 blocked: bool
118 blocking: list[str]
119 topo_index: int | None
120
121
122 class _DagSingleJson(EnvelopeJson):
123 """JSON output for ``muse coord dag --reservation-id <id> --json``."""
124
125 reservation_id: str
126 depends_on: list[str]
127 active: bool
128 blocked: bool
129 blocking: list[str]
130 cycle: list[str] | None
131
132
133 class _DagFullJson(EnvelopeJson):
134 """JSON output for ``muse coord dag --json`` (full DAG)."""
135
136 total_nodes: int
137 total_edges: int
138 blocked_count: int
139 active_only: bool
140 cycle: list[str] | None
141 nodes: list[_DagNodeJson]
142
143
144 def register(
145 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
146 ) -> None:
147 """Register ``dag`` on *subparsers* (under ``muse coord``).
148
149 Wires all flags with their defaults, choices, and help text so that
150 ``--help`` output is accurate. Sets ``func`` to :func:`run`.
151
152 Flags registered
153 ----------------
154 ``--reservation-id UUID``
155 Show dependency details for a single reservation. Must be a valid
156 UUID when provided.
157 ``--topo``
158 Print nodes in topological execution order with an explicit TOPO
159 column. Without this flag, a simpler flat table (no TOPO column)
160 is shown.
161 ``--active-only``
162 Restrict the graph to nodes that correspond to currently active
163 reservations. Nodes whose reservation has already expired or been
164 released are hidden.
165 ``--format`` / ``--json``
166 Emit compact JSON to stdout; default is human-readable text.
167 """
168 parser = subparsers.add_parser(
169 "dag",
170 help="Inspect the coordination dependency DAG.",
171 formatter_class=argparse.RawDescriptionHelpFormatter,
172 description=__doc__,
173 )
174 parser.add_argument(
175 "--reservation-id",
176 dest="reservation_id",
177 default=None,
178 metavar="UUID",
179 help=(
180 "Show dependency details for a single reservation ID. "
181 "Must be a valid UUID. When omitted the full DAG is shown."
182 ),
183 )
184 parser.add_argument(
185 "--topo",
186 action="store_true",
187 default=False,
188 help=(
189 "Print nodes in topological execution order with a TOPO column. "
190 "Without this flag, a simpler flat table is shown."
191 ),
192 )
193 parser.add_argument(
194 "--active-only",
195 action="store_true",
196 default=False,
197 dest="active_only",
198 help=(
199 "Restrict output to nodes whose reservation is currently active. "
200 "Expired and released reservations are hidden."
201 ),
202 )
203 parser.add_argument(
204 "--format", "-f",
205 default="text",
206 dest="fmt",
207 choices=("text", "json"),
208 help="Output format: text (default) or json.",
209 )
210 parser.add_argument(
211 "--json",
212 action="store_const",
213 const="json",
214 dest="fmt",
215 help="Shorthand for --format json.",
216 )
217 parser.set_defaults(func=run)
218
219
220 def run(args: argparse.Namespace) -> None:
221 """Inspect the coordination dependency DAG.
222
223 Loads all dependency records and active reservations from
224 ``.muse/coordination/``, derives blocked status for each node, and
225 optionally computes topological order. Use ``--reservation-id`` to focus
226 on a single node; ``--active-only`` to prune expired reservations.
227
228 Agent quickstart
229 ----------------
230 ::
231
232 muse coord dag --format json
233 muse coord dag --active-only --format json
234 muse coord dag --reservation-id <uuid> --format json
235 muse coord dag --topo --format json
236
237 JSON fields (full graph)
238 ------------------------
239 total_nodes Number of nodes in the graph.
240 total_edges Total number of dependency edges.
241 blocked_count Number of nodes with unsatisfied dependencies.
242 active_only ``true`` if ``--active-only`` was passed.
243 cycle List of node IDs forming a cycle, or ``null``.
244 nodes List of node objects: ``reservation_id``, ``depends_on``,
245 ``active``, ``blocked``, ``blocking``.
246
247 JSON fields (single node, with ``--reservation-id``)
248 -----------------------------------------------------
249 reservation_id UUID of the requested node.
250 depends_on List of reservation IDs this node depends on.
251 active ``true`` if the reservation is currently active.
252 blocked ``true`` if any dependency is unresolved.
253 blocking List of reservation IDs blocked by this node.
254 cycle Cycle path if a cycle is detected, else ``null``.
255
256 Exit codes
257 ----------
258 0 Success (blocked nodes or empty graph are still success).
259 1 Cycle detected, bad arguments, or error loading state.
260 2 Not inside a Muse repository.
261 """
262 elapsed = start_timer()
263 as_json = args.fmt == "json"
264 reservation_id: str | None = args.reservation_id
265 active_only: bool = args.active_only
266
267 # ── Input validation (before any file I/O) ────────────────────────────────
268
269 if reservation_id is not None:
270 try:
271 _validate_reservation_id(reservation_id)
272 except ValueError as exc:
273 msg = str(exc)
274 if as_json:
275 print(json.dumps({**make_envelope(elapsed, exit_code=ExitCode.USER_ERROR), **{"error": msg, "status": "bad_reservation_id"}}))
276 else:
277 print(f"❌ --reservation-id: {msg}", file=sys.stderr)
278 raise SystemExit(ExitCode.USER_ERROR)
279
280 root = require_repo()
281
282 all_deps = load_all_dependencies(root)
283 graph = load_dag(root)
284
285 # Derive the set of currently active reservation IDs.
286 try:
287 active = active_reservations(root)
288 except Exception as exc: # noqa: BLE001
289 if as_json:
290 print(json.dumps({**make_envelope(elapsed, exit_code=ExitCode.USER_ERROR), **{"error": str(exc), "status": "error"}}))
291 else:
292 print(f"❌ {exc}", file=sys.stderr)
293 raise SystemExit(ExitCode.USER_ERROR)
294
295 active_ids: frozenset[str] = frozenset(r.reservation_id for r in active)
296
297 # ── Active-only filter ────────────────────────────────────────────────────
298 if active_only:
299 graph = {k: v for k, v in graph.items() if k in active_ids}
300
301 # ── Cycle detection ───────────────────────────────────────────────────────
302 cycle = detect_cycle(graph)
303
304 # ── Single-reservation mode ───────────────────────────────────────────────
305 if reservation_id is not None:
306 _run_single(
307 reservation_id, graph, active_ids, cycle, as_json, elapsed
308 )
309 return
310
311 # ── Full DAG mode ─────────────────────────────────────────────────────────
312 _run_full(graph, active_ids, cycle, all_deps, as_json, args.topo, active_only, elapsed)
313
314 if cycle is not None:
315 raise SystemExit(ExitCode.USER_ERROR)
316
317
318 # ── Output helpers ─────────────────────────────────────────────────────────────
319
320
321 def _run_single(
322 reservation_id: str,
323 graph: _Graph,
324 active_ids: frozenset[str],
325 cycle: list[str] | None,
326 as_json: bool,
327 elapsed: Callable[[], float],
328 ) -> None:
329 """Emit dependency details for a single reservation.
330
331 Args:
332 reservation_id: The UUID to look up in the graph.
333 graph: Adjacency map — node → set of nodes it depends on.
334 active_ids: Reservation IDs whose reservation file is still active.
335 cycle: Cycle path returned by :func:`~muse.core.dag.detect_cycle`,
336 or ``None`` if the graph is acyclic.
337 as_json: Emit compact JSON when ``True``; human-readable text otherwise.
338 """
339 deps = sorted(graph.get(reservation_id, set()))
340 blocked = is_blocked(reservation_id, graph, active_ids)
341 blocking = get_blocking(reservation_id, graph, active_ids)
342
343 if as_json:
344 print(json.dumps({**make_envelope(elapsed), **{
345 "reservation_id": reservation_id,
346 "depends_on": deps,
347 "active": reservation_id in active_ids,
348 "blocked": blocked,
349 "blocking": blocking,
350 "cycle": cycle,
351 }}))
352 return
353
354 rid_short = sanitize_display(short_id(reservation_id))
355 status = "BLOCKED" if blocked else "unblocked"
356 print(f"\nReservation {rid_short}… [{status}]")
357 if deps:
358 print(f" depends_on ({len(deps)}):")
359 for dep in deps:
360 active_marker = " ← ACTIVE" if dep in active_ids else ""
361 print(f" {sanitize_display(dep[:8])}…{active_marker}")
362 else:
363 print(" no declared dependencies")
364 if blocking:
365 print(f"\n Blocking ({len(blocking)}):")
366 for b in blocking:
367 print(f" {sanitize_display(b[:8])}… (still active)")
368 if cycle:
369 print(f"\n ⚠️ Cycle detected: {' → '.join(sanitize_display(c[:8]) + '…' for c in cycle)}")
370
371
372 def _run_full(
373 graph: _Graph,
374 active_ids: frozenset[str],
375 cycle: list[str] | None,
376 all_deps: list[DependencyRecord],
377 as_json: bool,
378 topo_mode: bool,
379 active_only: bool,
380 elapsed: Callable[[], float],
381 ) -> None:
382 """Emit the full DAG listing.
383
384 Args:
385 graph: Adjacency map — node → set of nodes it depends on. May be
386 pre-filtered to active-only nodes by the caller.
387 active_ids: Reservation IDs whose reservation file is still active.
388 cycle: Cycle path from :func:`~muse.core.dag.detect_cycle`, or
389 ``None`` if acyclic.
390 all_deps: Raw dependency records loaded from disk (used for metadata
391 in future extensions; not currently used directly in output).
392 as_json: Emit compact JSON when ``True``; human-readable text otherwise.
393 topo_mode: When ``True``, include a TOPO column in text output and
394 order rows by dependency-first execution order.
395 active_only: Reflected in JSON output as ``active_only`` field so
396 consumers know whether the graph was filtered.
397 """
398 total_nodes = len(graph)
399 total_edges = sum(len(v) for v in graph.values())
400
401 # Compute topological order (or handle cycle gracefully).
402 if cycle is None:
403 try:
404 topo_order = topological_sort(graph)
405 except ValueError:
406 topo_order = sorted(graph)
407 else:
408 topo_order = sorted(graph)
409
410 topo_index = {node: i + 1 for i, node in enumerate(topo_order)}
411
412 # Build per-node records.
413 nodes = []
414 for node in topo_order:
415 deps = sorted(graph.get(node, set()))
416 blocked = is_blocked(node, graph, active_ids)
417 blocking = get_blocking(node, graph, active_ids)
418 nodes.append({
419 "reservation_id": node,
420 "depends_on": deps,
421 "active": node in active_ids,
422 "blocked": blocked,
423 "blocking": blocking,
424 "topo_index": topo_index.get(node),
425 })
426
427 blocked_count = sum(1 for n in nodes if n["blocked"])
428
429 if as_json:
430 print(json.dumps({**make_envelope(elapsed), **{
431 "total_nodes": total_nodes,
432 "total_edges": total_edges,
433 "blocked_count": blocked_count,
434 "active_only": active_only,
435 "cycle": cycle,
436 "nodes": nodes,
437 }}))
438 return
439
440 # Text output.
441 print(f"\nDependency DAG — {total_nodes} node(s), {total_edges} edge(s)")
442 if cycle:
443 cycle_str = " → ".join(sanitize_display(c[:8]) + "…" for c in cycle)
444 print(f" ⚠️ CYCLE DETECTED: {cycle_str}")
445 print(" No topological order is possible until the cycle is resolved.")
446 else:
447 print(
448 f" {blocked_count} blocked "
449 f"{total_nodes - blocked_count} unblocked"
450 )
451
452 if not nodes:
453 print("\n (no dependency records)")
454 return
455
456 print()
457 if topo_mode:
458 print(f"{'TOPO':>4} {'STATUS':<12} {'ID':8} DEPENDS-ON")
459 print("─" * 72)
460 for node_rec in nodes:
461 idx = node_rec["topo_index"] or 0
462 status = "BLOCKED" if node_rec["blocked"] else "unblocked"
463 rid = sanitize_display(node_rec["reservation_id"][:8]) + "…"
464 if node_rec["depends_on"]:
465 deps_str = ", ".join(
466 sanitize_display(d[:8]) + "…" for d in node_rec["depends_on"]
467 )
468 else:
469 deps_str = "(no deps)"
470 print(f"{idx:>4} {status:<12} {rid:<9} {deps_str}")
471 else:
472 print(f"{'STATUS':<12} {'ID':8} DEPENDS-ON")
473 print("─" * 56)
474 for node_rec in nodes:
475 status = "BLOCKED" if node_rec["blocked"] else "unblocked"
476 rid = sanitize_display(node_rec["reservation_id"][:8]) + "…"
477 if node_rec["depends_on"]:
478 deps_str = ", ".join(
479 sanitize_display(d[:8]) + "…" for d in node_rec["depends_on"]
480 )
481 else:
482 deps_str = "(no deps)"
483 print(f"{status:<12} {rid:<9} {deps_str}")
484
485 if cycle:
486 print(f"\n ⚠️ Resolve the cycle before any blocked agent can proceed.")
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago