forecast.py
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 1 | """``muse coord forecast`` β predict merge conflicts before they happen. |
| 2 | |
| 3 | Reads all active reservations and intents across branches, then uses the |
| 4 | reverse call graph to compute *likely* conflicts before any code is written. |
| 5 | |
| 6 | This turns merge conflict resolution from a reactive ("it broke") problem into |
| 7 | a proactive ("we predicted it") workflow β essential when many agents operate |
| 8 | on a codebase simultaneously. |
| 9 | |
| 10 | Conflict types detected |
| 11 | ----------------------- |
| 12 | ``address_overlap`` |
| 13 | Two agents have reserved the same symbol address. Direct collision. |
| 14 | Confidence: **1.00** β this will definitely conflict. |
| 15 | |
| 16 | ``blast_radius_overlap`` |
| 17 | Agent A's reserved symbol is in the call chain of Agent B's target, or |
| 18 | vice versa. A change to A's symbol will affect B's symbol. |
| 19 | Requires the code index to be built (``muse code index``). |
| 20 | Confidence: **0.75** β likely to conflict. |
| 21 | |
| 22 | ``operation_conflict`` |
| 23 | Agent A intends to delete/rename a symbol that Agent B intends to modify. |
| 24 | Classic use-after-free / use-after-rename semantic conflict. |
| 25 | Confidence: **0.90** β will almost certainly conflict. |
| 26 | |
| 27 | Incomplete forecasts |
| 28 | -------------------- |
| 29 | When the code index has not been built, or when the index cannot be read |
| 30 | (e.g. the repo has no commits yet), blast-radius analysis is skipped. The |
| 31 | output includes a ``warnings`` field (JSON) or a visible notice (text) so you |
| 32 | know the forecast is partial. **A partial forecast is still reported** β it |
| 33 | is never silently presented as complete. Agents should check |
| 34 | ``partial_forecast`` (JSON) to gate on whether the full analysis ran. |
| 35 | |
| 36 | Usage:: |
| 37 | |
| 38 | muse coord forecast |
| 39 | muse coord forecast --branch feature-x |
| 40 | muse coord forecast --run-id agent-42 |
| 41 | muse coord forecast --min-confidence 0.9 |
| 42 | muse coord forecast --format json |
| 43 | muse coord forecast --json |
| 44 | |
| 45 | JSON output schema:: |
| 46 | |
| 47 | { |
| 48 | "schema_version": str, |
| 49 | "current_branch": str, |
| 50 | "branch_filter": str | null, |
| 51 | "run_id_filter": str | null, |
| 52 | "min_confidence": float, |
| 53 | "active_reservations": int, |
| 54 | "intents_count": int, |
| 55 | "call_graph_available": bool, |
| 56 | "partial_forecast": bool, |
| 57 | "warnings": [str, ...], |
| 58 | "conflicts": [ |
| 59 | { |
| 60 | "conflict_type": "address_overlap" | "blast_radius_overlap" | "operation_conflict", |
| 61 | "addresses": [str, ...], |
| 62 | "agents": [str, ...], |
| 63 | "confidence": float, |
| 64 | "description": str |
| 65 | }, |
| 66 | ... |
| 67 | ], |
| 68 | "high_risk": int, |
| 69 | "medium_risk": int, |
| 70 | "low_risk": int, |
| 71 | "duration_ms": float |
| 72 | } |
| 73 | |
| 74 | Text output:: |
| 75 | |
| 76 | Conflict forecast β 3 active reservation(s), 1 intent(s) |
| 77 | ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 78 | |
| 79 | β οΈ blast_radius_overlap (confidence 0.75) |
| 80 | src/billing.py::compute_total |
| 81 | agent-41 (branch: main) β agent-42 (branch: feature/billing) |
| 82 | β compute_total is in the transitive call chain of process_payment |
| 83 | |
| 84 | π΄ operation_conflict (confidence 0.90) |
| 85 | ... |
| 86 | |
| 87 | 1 high-risk, 1 medium-risk, 0 low-risk conflict(s) predicted |
| 88 | |
| 89 | Flags:: |
| 90 | |
| 91 | --branch BRANCH Restrict to reservations/intents on this branch. |
| 92 | --run-id ID Show only conflicts involving a specific agent. |
| 93 | Maximum 256 characters. |
| 94 | --min-confidence FLOAT Hide conflicts below this confidence threshold |
| 95 | (default: 0.0 β show all). Range: [0.0, 1.0]. |
| 96 | --format / -f Output format: text (default) or json. |
| 97 | --json Shorthand for --format json. |
| 98 | |
| 99 | Exit codes:: |
| 100 | |
| 101 | 0 β success (zero conflicts is still success) |
| 102 | 1 β bad arguments (--min-confidence out of range, --run-id too long) |
| 103 | 2 β unexpected error during analysis |
| 104 | """ |
| 105 | |
| 106 | import argparse |
| 107 | import json |
| 108 | import logging |
| 109 | from typing import TypedDict |
| 110 | import sys |
| 111 | |
| 112 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 113 | from muse.core.coordination import active_reservations, load_all_intents |
| 114 | from muse.core.errors import ExitCode |
| 115 | from muse.core.repo import require_repo |
| 116 | from muse.core.refs import read_current_branch |
| 117 | from muse.core.commits import resolve_commit_ref |
| 118 | from muse.core.snapshots import get_commit_snapshot_manifest |
| 119 | from muse.core.validation import sanitize_display |
| 120 | from muse.core.timing import start_timer |
| 121 | from muse.plugins.code._callgraph import build_reverse_graph, transitive_callers |
| 122 | |
| 123 | type _ConflictDict = dict[str, str | float | list[str]] |
| 124 | type _AgentListMap = dict[str, list[str]] |
| 125 | |
| 126 | class _ForecastJson(EnvelopeJson): |
| 127 | """JSON output for ``muse coord forecast --json``. |
| 128 | |
| 129 | Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`. |
| 130 | |
| 131 | Fields |
| 132 | ------ |
| 133 | current_branch Branch from which the forecast was computed. |
| 134 | branch_filter ``--branch`` filter value used, or ``None`` when all |
| 135 | branches were considered. |
| 136 | run_id_filter ``--run-id`` filter value used, or ``None`` when not set. |
| 137 | min_confidence Minimum confidence threshold applied to predictions |
| 138 | (range 0.0β1.0; predictions below this were suppressed). |
| 139 | active_reservations Number of active agent reservations visible at forecast time. |
| 140 | intents_count Number of declared intents that fed the conflict model. |
| 141 | call_graph_available True when a full call graph was available β False means |
| 142 | blast-radius predictions are approximate. |
| 143 | partial_forecast True when the forecast is based on incomplete data |
| 144 | (missing call graph or sparse intent coverage). |
| 145 | conflicts List of predicted conflict dicts; each has type, paths, |
| 146 | agents, and risk_level fields. |
| 147 | high_risk Count of predicted conflicts classified as high risk. |
| 148 | medium_risk Count of predicted conflicts classified as medium risk. |
| 149 | low_risk Count of predicted conflicts classified as low risk. |
| 150 | """ |
| 151 | |
| 152 | current_branch: str |
| 153 | branch_filter: str | None |
| 154 | run_id_filter: str | None |
| 155 | min_confidence: float |
| 156 | active_reservations: int |
| 157 | intents_count: int |
| 158 | call_graph_available: bool |
| 159 | partial_forecast: bool |
| 160 | conflicts: list[_ConflictDict] |
| 161 | high_risk: int |
| 162 | medium_risk: int |
| 163 | low_risk: int |
| 164 | |
| 165 | logger = logging.getLogger(__name__) |
| 166 | |
| 167 | # ββ Input constraints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 168 | |
| 169 | #: Maximum byte-length of the ``--run-id`` filter value. Matches the cap |
| 170 | #: applied to run-id in all other coordination commands. |
| 171 | _MAX_RUN_ID_LEN: int = 256 |
| 172 | |
| 173 | # ββ Data model ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 174 | |
| 175 | class _ConflictPrediction: |
| 176 | """A single predicted merge conflict between two or more agents. |
| 177 | |
| 178 | Attributes |
| 179 | ---------- |
| 180 | conflict_type: |
| 181 | One of ``"address_overlap"``, ``"blast_radius_overlap"``, or |
| 182 | ``"operation_conflict"``. |
| 183 | addresses: |
| 184 | The symbol address(es) at the centre of the conflict. |
| 185 | agents: |
| 186 | List of ``"run_id@branch"`` strings identifying the conflicting agents. |
| 187 | confidence: |
| 188 | Float in ``[0, 1]``. 1.0 = certain conflict; < 0.5 = speculative. |
| 189 | description: |
| 190 | Human-readable explanation of why this is a conflict. |
| 191 | """ |
| 192 | |
| 193 | def __init__( |
| 194 | self, |
| 195 | conflict_type: str, |
| 196 | addresses: list[str], |
| 197 | agents: list[str], |
| 198 | confidence: float, |
| 199 | description: str, |
| 200 | ) -> None: |
| 201 | self.conflict_type = conflict_type |
| 202 | self.addresses = addresses |
| 203 | self.agents = agents |
| 204 | self.confidence = confidence |
| 205 | self.description = description |
| 206 | |
| 207 | def to_dict(self) -> _ConflictDict: |
| 208 | """Serialise to a JSON-safe dict. |
| 209 | |
| 210 | Returns |
| 211 | ------- |
| 212 | dict |
| 213 | Keys: ``conflict_type``, ``addresses``, ``agents``, |
| 214 | ``confidence`` (rounded to 3 d.p.), ``description``. |
| 215 | """ |
| 216 | return { |
| 217 | "conflict_type": self.conflict_type, |
| 218 | "addresses": self.addresses, |
| 219 | "agents": self.agents, |
| 220 | "confidence": round(self.confidence, 3), |
| 221 | "description": self.description, |
| 222 | } |
| 223 | |
| 224 | # ββ CLI registration ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 225 | |
| 226 | def register( |
| 227 | subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", |
| 228 | ) -> None: |
| 229 | """Register the ``forecast`` subcommand on *subparsers* (under ``muse coord``). |
| 230 | |
| 231 | Wires all flags with their defaults, choices, and help text so that |
| 232 | ``--help`` output is accurate. Sets ``func`` to :func:`run`. |
| 233 | """ |
| 234 | parser = subparsers.add_parser( |
| 235 | "forecast", |
| 236 | help="Predict merge conflicts from active reservations and intents.", |
| 237 | description=__doc__, |
| 238 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 239 | ) |
| 240 | parser.add_argument( |
| 241 | "--branch", "-b", |
| 242 | dest="branch_filter", |
| 243 | default=None, |
| 244 | metavar="BRANCH", |
| 245 | help="Restrict to reservations/intents on this branch.", |
| 246 | ) |
| 247 | parser.add_argument( |
| 248 | "--run-id", |
| 249 | dest="run_id_filter", |
| 250 | default=None, |
| 251 | metavar="ID", |
| 252 | help=( |
| 253 | "Show only conflicts involving this agent run-id. " |
| 254 | f"Maximum {_MAX_RUN_ID_LEN} characters." |
| 255 | ), |
| 256 | ) |
| 257 | parser.add_argument( |
| 258 | "--min-confidence", |
| 259 | dest="min_confidence", |
| 260 | type=float, |
| 261 | default=0.0, |
| 262 | metavar="FLOAT", |
| 263 | help=( |
| 264 | "Hide conflicts below this confidence threshold " |
| 265 | "(default: 0.0 β show all). Range: [0.0, 1.0]." |
| 266 | ), |
| 267 | ) |
| 268 | parser.add_argument( |
| 269 | "--json", "-j", |
| 270 | action="store_true", |
| 271 | dest="json_out", |
| 272 | help="Emit machine-readable JSON.", |
| 273 | ) |
| 274 | parser.set_defaults(func=run) |
| 275 | |
| 276 | # ββ Command implementation ββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 277 | |
| 278 | def run(args: argparse.Namespace) -> None: |
| 279 | """Predict merge conflicts from active reservations and intents. |
| 280 | |
| 281 | Runs three analysis passes against active coordination state: direct |
| 282 | address overlap (confidence 1.0), blast-radius overlap via the reverse |
| 283 | call graph (confidence 0.75), and operation conflicts between delete/modify |
| 284 | intents (confidence 0.9). ``partial_forecast`` is ``true`` when the call |
| 285 | graph was unavailable and Pass 2 was skipped β always check this before |
| 286 | treating an empty ``conflicts`` list as authoritative. |
| 287 | |
| 288 | Agent quickstart |
| 289 | ---------------- |
| 290 | :: |
| 291 | |
| 292 | muse coord forecast --format json |
| 293 | muse coord forecast --branch feat/billing --format json |
| 294 | muse coord forecast --run-id agent-42 --format json |
| 295 | muse coord forecast --min-confidence 0.8 --format json |
| 296 | |
| 297 | JSON fields |
| 298 | ----------- |
| 299 | current_branch Branch being analysed. |
| 300 | branch_filter ``--branch`` value, or ``null``. |
| 301 | run_id_filter ``--run-id`` value, or ``null``. |
| 302 | active_reservations Number of reservations loaded. |
| 303 | intents_count Number of intents loaded. |
| 304 | call_graph_available ``false`` if the blast-radius pass was skipped. |
| 305 | partial_forecast ``true`` when any analysis pass was skipped. |
| 306 | conflicts List of conflict objects: ``addresses``, ``agents``, |
| 307 | ``confidence``, ``reason``. |
| 308 | high_risk Conflicts with confidence β₯ 0.9. |
| 309 | medium_risk Conflicts with 0.5 β€ confidence < 0.9. |
| 310 | |
| 311 | Exit codes |
| 312 | ---------- |
| 313 | 0 Success (zero conflicts is still success). |
| 314 | 1 Invalid arguments. |
| 315 | 2 Not inside a Muse repository. |
| 316 | """ |
| 317 | elapsed = start_timer() |
| 318 | |
| 319 | branch_filter: str | None = args.branch_filter |
| 320 | run_id_filter: str | None = args.run_id_filter |
| 321 | min_confidence: float = args.min_confidence |
| 322 | json_out: bool = args.json_out |
| 323 | |
| 324 | # ββ Input validation (before any file I/O) ββββββββββββββββββββββββββββββββ |
| 325 | |
| 326 | if run_id_filter is not None and len(run_id_filter) > _MAX_RUN_ID_LEN: |
| 327 | msg = f"--run-id is too long ({len(run_id_filter)} chars; max {_MAX_RUN_ID_LEN})" |
| 328 | if json_out: |
| 329 | print(json.dumps({"error": msg, "status": "bad_args"})) |
| 330 | else: |
| 331 | print(f"β {msg}", file=sys.stderr) |
| 332 | raise SystemExit(ExitCode.USER_ERROR) |
| 333 | |
| 334 | if not (0.0 <= min_confidence <= 1.0): |
| 335 | msg = f"--min-confidence must be in [0.0, 1.0], got {min_confidence}" |
| 336 | if json_out: |
| 337 | print(json.dumps({"error": msg, "status": "bad_args"})) |
| 338 | else: |
| 339 | print(f"β {msg}", file=sys.stderr) |
| 340 | raise SystemExit(ExitCode.USER_ERROR) |
| 341 | |
| 342 | root = require_repo() |
| 343 | branch = read_current_branch(root) |
| 344 | |
| 345 | reservations = active_reservations(root) |
| 346 | intents = load_all_intents(root) |
| 347 | |
| 348 | if branch_filter: |
| 349 | reservations = [r for r in reservations if r.branch == branch_filter] |
| 350 | intents = [i for i in intents if i.branch == branch_filter] |
| 351 | |
| 352 | conflicts: list[_ConflictPrediction] = [] |
| 353 | warnings: list[str] = [] |
| 354 | call_graph_available = False |
| 355 | |
| 356 | # ββ Pass 1: Direct address overlap ββββββββββββββββββββββββββββββββββββββββ |
| 357 | # Build: address β list of "run_id@branch" labels. |
| 358 | addr_agents: _AgentListMap = {} |
| 359 | for res in reservations: |
| 360 | for addr in res.addresses: |
| 361 | addr_agents.setdefault(addr, []).append(f"{res.run_id}@{res.branch}") |
| 362 | |
| 363 | for addr, agents in sorted(addr_agents.items()): |
| 364 | unique_agents = list(dict.fromkeys(agents)) |
| 365 | if len(unique_agents) > 1: |
| 366 | conflicts.append(_ConflictPrediction( |
| 367 | conflict_type="address_overlap", |
| 368 | addresses=[addr], |
| 369 | agents=unique_agents, |
| 370 | confidence=1.0, |
| 371 | description=( |
| 372 | f"{addr} reserved by {len(unique_agents)} agents simultaneously" |
| 373 | ), |
| 374 | )) |
| 375 | |
| 376 | # ββ Pass 2: Blast-radius overlap ββββββββββββββββββββββββββββββββββββββββββ |
| 377 | # Use the reverse call graph to detect indirect dependencies between |
| 378 | # reserved addresses. Skip silently (but warn) when the index is |
| 379 | # unavailable β do NOT catch all exceptions, as unexpected errors |
| 380 | # indicate real problems that agents must see. |
| 381 | commit = resolve_commit_ref(root, branch, None) |
| 382 | if commit is not None: |
| 383 | manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {} |
| 384 | try: |
| 385 | reverse = build_reverse_graph(root, manifest) |
| 386 | call_graph_available = True |
| 387 | all_addresses = list(addr_agents.keys()) |
| 388 | for i, addr_a in enumerate(all_addresses): |
| 389 | callers_a = transitive_callers(addr_a, reverse, max_depth=5) |
| 390 | callers_set: set[str] = {c for lvl in callers_a.values() for c in lvl} |
| 391 | for addr_b in all_addresses[i + 1:]: |
| 392 | if addr_b in callers_set: |
| 393 | agents_a = addr_agents.get(addr_a, []) |
| 394 | agents_b = addr_agents.get(addr_b, []) |
| 395 | if set(agents_a) != set(agents_b): |
| 396 | conflicts.append(_ConflictPrediction( |
| 397 | conflict_type="blast_radius_overlap", |
| 398 | addresses=[addr_a, addr_b], |
| 399 | agents=list(set(agents_a) | set(agents_b)), |
| 400 | confidence=0.75, |
| 401 | description=( |
| 402 | f"{addr_b} is in the transitive call chain of {addr_a}" |
| 403 | ), |
| 404 | )) |
| 405 | except (OSError, KeyError, ValueError, AttributeError) as exc: |
| 406 | # Expected: index not built, object not found, or malformed data. |
| 407 | # Record as a warning β the caller must know blast-radius analysis |
| 408 | # was skipped rather than receiving a false-clean forecast. |
| 409 | warn = f"blast_radius_overlap skipped β call graph unavailable: {exc}" |
| 410 | warnings.append(warn) |
| 411 | logger.debug("Call graph unavailable for forecast: %s", exc) |
| 412 | else: |
| 413 | warnings.append( |
| 414 | "blast_radius_overlap skipped β no commits on this branch yet" |
| 415 | ) |
| 416 | |
| 417 | # ββ Pass 3: Operation conflicts βββββββββββββββββββββββββββββββββββββββββββ |
| 418 | # Detect intents where one agent intends delete and another modify/rename. |
| 419 | intent_ops: _AgentListMap = {} |
| 420 | intent_agents: _AgentListMap = {} |
| 421 | for it in intents: |
| 422 | for addr in it.addresses: |
| 423 | intent_ops.setdefault(addr, []).append(it.operation) |
| 424 | intent_agents.setdefault(addr, []).append(f"{it.run_id}@{it.branch}") |
| 425 | |
| 426 | for addr, ops in sorted(intent_ops.items()): |
| 427 | if len(set(ops)) <= 1 and len(set(intent_agents.get(addr, []))) <= 1: |
| 428 | continue # Same op by same agent β not a conflict. |
| 429 | has_delete = "delete" in ops |
| 430 | has_modify = any(op in ("modify", "rename", "extract") for op in ops) |
| 431 | if has_delete and has_modify: |
| 432 | agents = list(dict.fromkeys(intent_agents.get(addr, []))) |
| 433 | conflicts.append(_ConflictPrediction( |
| 434 | conflict_type="operation_conflict", |
| 435 | addresses=[addr], |
| 436 | agents=agents, |
| 437 | confidence=0.9, |
| 438 | description=f"delete vs modify conflict on {addr}", |
| 439 | )) |
| 440 | |
| 441 | # ββ Post-pass filters βββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 442 | |
| 443 | # --run-id: keep only conflicts that mention this agent. |
| 444 | if run_id_filter is not None: |
| 445 | tag = run_id_filter # agents are stored as "run_id@branch" |
| 446 | conflicts = [ |
| 447 | c for c in conflicts |
| 448 | if any(a.split("@")[0] == tag for a in c.agents) |
| 449 | ] |
| 450 | |
| 451 | # --min-confidence: suppress low-signal predictions. |
| 452 | if min_confidence > 0.0: |
| 453 | conflicts = [c for c in conflicts if c.confidence >= min_confidence] |
| 454 | |
| 455 | partial_forecast = bool(warnings) |
| 456 | |
| 457 | # ββ JSON output βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 458 | if json_out: |
| 459 | print(json.dumps(_ForecastJson( |
| 460 | **make_envelope(elapsed, warnings=warnings), |
| 461 | current_branch=branch, |
| 462 | branch_filter=branch_filter, |
| 463 | run_id_filter=run_id_filter, |
| 464 | min_confidence=min_confidence, |
| 465 | active_reservations=len(reservations), |
| 466 | intents_count=len(intents), |
| 467 | call_graph_available=call_graph_available, |
| 468 | partial_forecast=partial_forecast, |
| 469 | conflicts=[c.to_dict() for c in conflicts], |
| 470 | high_risk=sum(1 for c in conflicts if c.confidence >= 0.9), |
| 471 | medium_risk=sum(1 for c in conflicts if 0.5 <= c.confidence < 0.9), |
| 472 | low_risk=sum(1 for c in conflicts if c.confidence < 0.5), |
| 473 | ))) |
| 474 | return |
| 475 | |
| 476 | # ββ Text output βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 477 | print( |
| 478 | f"\nConflict forecast β " |
| 479 | f"{len(reservations)} active reservation(s), {len(intents)} intent(s)" |
| 480 | ) |
| 481 | print("β" * 62) |
| 482 | |
| 483 | if warnings: |
| 484 | for w in warnings: |
| 485 | print(f"\n β Note: {w}") |
| 486 | |
| 487 | if not conflicts: |
| 488 | print("\n β No conflicts predicted.") |
| 489 | if not reservations: |
| 490 | print(" (no active reservations β run 'muse coord reserve' first)") |
| 491 | else: |
| 492 | for c in conflicts: |
| 493 | icon = "π΄" if c.confidence >= 0.9 else "β οΈ " |
| 494 | print(f"\n{icon} {c.conflict_type} (confidence {c.confidence:.2f})") |
| 495 | for addr in c.addresses: |
| 496 | print(f" {sanitize_display(addr)}") |
| 497 | for agent in c.agents: |
| 498 | print(f" agent: {sanitize_display(agent)}") |
| 499 | print(f" β {sanitize_display(c.description)}") |
| 500 | |
| 501 | high = sum(1 for c in conflicts if c.confidence >= 0.9) |
| 502 | med = sum(1 for c in conflicts if 0.5 <= c.confidence < 0.9) |
| 503 | print(f"\n {high} high-risk, {med} medium-risk conflict(s) predicted") |
| 504 | print(" Run 'muse coord plan-merge' for a detailed merge strategy.") |
| 505 | |
| 506 | print(f"\n ({elapsed():.3f}s)") |