forecast.py python
531 lines 21.4 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 33 days ago
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 "elapsed_seconds": 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 from __future__ import annotations
107
108 import argparse
109 import json
110 import logging
111 import sys
112 import time
113
114 from muse._version import __version__
115 from muse.core.coordination import active_reservations, load_all_intents
116 from muse.core.errors import ExitCode
117 from muse.core.repo import read_repo_id, require_repo
118 from muse.core.store import (
119 get_commit_snapshot_manifest,
120 read_current_branch,
121 resolve_commit_ref,
122 )
123 from muse.core.validation import sanitize_display
124 from muse.plugins.code._callgraph import build_reverse_graph, transitive_callers
125
126 type _ConflictDict = dict[str, str | float | list[str]]
127 type _AgentListMap = dict[str, list[str]]
128
129 logger = logging.getLogger(__name__)
130
131 # ── Input constraints ─────────────────────────────────────────────────────────
132
133 #: Maximum byte-length of the ``--run-id`` filter value. Matches the cap
134 #: applied to run-id in all other coordination commands.
135 _MAX_RUN_ID_LEN: int = 256
136
137
138 # ── Data model ────────────────────────────────────────────────────────────────
139
140
141 class _ConflictPrediction:
142 """A single predicted merge conflict between two or more agents.
143
144 Attributes
145 ----------
146 conflict_type:
147 One of ``"address_overlap"``, ``"blast_radius_overlap"``, or
148 ``"operation_conflict"``.
149 addresses:
150 The symbol address(es) at the centre of the conflict.
151 agents:
152 List of ``"run_id@branch"`` strings identifying the conflicting agents.
153 confidence:
154 Float in ``[0, 1]``. 1.0 = certain conflict; < 0.5 = speculative.
155 description:
156 Human-readable explanation of why this is a conflict.
157 """
158
159 def __init__(
160 self,
161 conflict_type: str,
162 addresses: list[str],
163 agents: list[str],
164 confidence: float,
165 description: str,
166 ) -> None:
167 self.conflict_type = conflict_type
168 self.addresses = addresses
169 self.agents = agents
170 self.confidence = confidence
171 self.description = description
172
173 def to_dict(self) -> _ConflictDict:
174 """Serialise to a JSON-safe dict.
175
176 Returns
177 -------
178 dict
179 Keys: ``conflict_type``, ``addresses``, ``agents``,
180 ``confidence`` (rounded to 3 d.p.), ``description``.
181 """
182 return {
183 "conflict_type": self.conflict_type,
184 "addresses": self.addresses,
185 "agents": self.agents,
186 "confidence": round(self.confidence, 3),
187 "description": self.description,
188 }
189
190
191 # ── CLI registration ──────────────────────────────────────────────────────────
192
193
194 def register(
195 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
196 ) -> None:
197 """Register the ``forecast`` subcommand on *subparsers* (under ``muse coord``).
198
199 Wires all flags with their defaults, choices, and help text so that
200 ``--help`` output is accurate. Sets ``func`` to :func:`run`.
201 """
202 parser = subparsers.add_parser(
203 "forecast",
204 help="Predict merge conflicts from active reservations and intents.",
205 description=__doc__,
206 formatter_class=argparse.RawDescriptionHelpFormatter,
207 )
208 parser.add_argument(
209 "--branch", "-b",
210 dest="branch_filter",
211 default=None,
212 metavar="BRANCH",
213 help="Restrict to reservations/intents on this branch.",
214 )
215 parser.add_argument(
216 "--run-id",
217 dest="run_id_filter",
218 default=None,
219 metavar="ID",
220 help=(
221 "Show only conflicts involving this agent run-id. "
222 f"Maximum {_MAX_RUN_ID_LEN} characters."
223 ),
224 )
225 parser.add_argument(
226 "--min-confidence",
227 dest="min_confidence",
228 type=float,
229 default=0.0,
230 metavar="FLOAT",
231 help=(
232 "Hide conflicts below this confidence threshold "
233 "(default: 0.0 — show all). Range: [0.0, 1.0]."
234 ),
235 )
236 parser.add_argument(
237 "--format", "-f",
238 default="text",
239 dest="fmt",
240 choices=("text", "json"),
241 help="Output format: text (default) or json.",
242 )
243 parser.add_argument(
244 "--json",
245 action="store_const",
246 const="json",
247 dest="fmt",
248 help="Shorthand for --format json.",
249 )
250 parser.set_defaults(func=run)
251
252
253 # ── Command implementation ────────────────────────────────────────────────────
254
255
256 def run(args: argparse.Namespace) -> None:
257 """Predict merge conflicts from active reservations and intents.
258
259 Execution order
260 ---------------
261 1. **Validate inputs** — ``--run-id`` length and ``--min-confidence``
262 range. Fires before any file I/O; any failure exits
263 :attr:`~muse.core.errors.ExitCode.USER_ERROR` (1).
264 2. **Load state** — calls :func:`~muse.core.coordination.active_reservations`
265 and :func:`~muse.core.coordination.load_all_intents`, then applies
266 ``--branch`` and ``--run-id`` filters in memory.
267 3. **Run three analysis passes** (see below).
268 4. **Apply ``--min-confidence`` filter** to the conflict list.
269 5. **Emit output** — compact JSON to *stdout* when ``--format json``,
270 or human-readable text otherwise.
271
272 Analysis passes
273 ---------------
274 **Pass 1 — Direct address overlap** (always runs)
275 Scans all active reservations and finds symbol addresses that more
276 than one agent has reserved simultaneously. Confidence 1.0.
277
278 **Pass 2 — Blast-radius overlap** (runs when code index is available)
279 Builds the reverse call graph for the current commit and checks
280 whether any two reserved addresses lie in each other's transitive
281 call chains. Confidence 0.75.
282
283 When the code index is unavailable (no commits yet, or ``muse code
284 index`` has not been run), this pass is skipped. The reason is
285 recorded in ``warnings`` (JSON) or printed as a notice (text).
286 ``partial_forecast`` is ``true`` whenever any pass was skipped.
287 The forecast is **never silently presented as complete** — agents
288 should check ``partial_forecast`` before treating an empty
289 ``conflicts`` list as authoritative.
290
291 **Pass 3 — Operation conflicts** (always runs)
292 Scans intents and finds addresses where one agent intends to
293 ``delete`` and another intends to ``modify``, ``rename``, or
294 ``extract``. Confidence 0.9.
295
296 Filtering
297 ---------
298 * ``--branch`` filters reservations and intents to a single branch before
299 any analysis pass runs, reducing the O(n²) cost of Pass 2.
300 * ``--run-id`` keeps only conflicts where the named agent appears in the
301 ``agents`` list. Applied after all passes so the agent sees its full
302 blast radius.
303 * ``--min-confidence`` hides low-signal conflicts. Applied last so
304 ``high_risk`` / ``medium_risk`` / ``low_risk`` counts reflect the
305 *filtered* list.
306
307 Error handling
308 --------------
309 * ``(OSError, KeyError, ValueError, AttributeError)`` from the call
310 graph build are treated as "index not available" — a warning is
311 emitted and the blast-radius pass is skipped.
312 * Any other exception propagates so that the caller sees a real error
313 rather than a false-clean forecast.
314
315 Security
316 --------
317 All agent-supplied strings (``run_id``, ``branch``, ``addresses``) are
318 passed through :func:`~muse.core.validation.sanitize_display` before
319 printing to strip ANSI escape sequences and control characters.
320 ``--run-id`` is capped at :data:`_MAX_RUN_ID_LEN` to bound memory use.
321 No user-supplied string is ever used to construct a file path.
322
323 Performance
324 -----------
325 Pass 1 and Pass 3 are O(n × m) where n = reservations and m = intents.
326 Pass 2 is O(n² × D) where D is the BFS depth limit (default 5) — on
327 large swarms with many reservations, use ``--branch`` to reduce n.
328
329 Args:
330 args: Parsed ``argparse.Namespace`` with attributes ``branch_filter``,
331 ``run_id_filter``, ``min_confidence``, and ``fmt``.
332
333 Exit codes:
334 0 — success (zero conflicts is still success).
335 1 — bad arguments.
336 """
337 t0 = time.monotonic()
338
339 branch_filter: str | None = args.branch_filter
340 run_id_filter: str | None = args.run_id_filter
341 min_confidence: float = args.min_confidence
342 fmt: str = args.fmt
343 as_json = fmt == "json"
344
345 # ── Input validation (before any file I/O) ────────────────────────────────
346
347 if run_id_filter is not None and len(run_id_filter) > _MAX_RUN_ID_LEN:
348 msg = f"--run-id is too long ({len(run_id_filter)} chars; max {_MAX_RUN_ID_LEN})"
349 if as_json:
350 print(json.dumps({"error": msg, "status": "bad_args"}))
351 else:
352 print(f"❌ {msg}", file=sys.stderr)
353 raise SystemExit(ExitCode.USER_ERROR)
354
355 if not (0.0 <= min_confidence <= 1.0):
356 msg = f"--min-confidence must be in [0.0, 1.0], got {min_confidence}"
357 if as_json:
358 print(json.dumps({"error": msg, "status": "bad_args"}))
359 else:
360 print(f"❌ {msg}", file=sys.stderr)
361 raise SystemExit(ExitCode.USER_ERROR)
362
363 root = require_repo()
364 branch = read_current_branch(root)
365
366 reservations = active_reservations(root)
367 intents = load_all_intents(root)
368
369 if branch_filter:
370 reservations = [r for r in reservations if r.branch == branch_filter]
371 intents = [i for i in intents if i.branch == branch_filter]
372
373 conflicts: list[_ConflictPrediction] = []
374 warnings: list[str] = []
375 call_graph_available = False
376
377 # ── Pass 1: Direct address overlap ────────────────────────────────────────
378 # Build: address → list of "run_id@branch" labels.
379 addr_agents: _AgentListMap = {}
380 for res in reservations:
381 for addr in res.addresses:
382 addr_agents.setdefault(addr, []).append(f"{res.run_id}@{res.branch}")
383
384 for addr, agents in sorted(addr_agents.items()):
385 unique_agents = list(dict.fromkeys(agents))
386 if len(unique_agents) > 1:
387 conflicts.append(_ConflictPrediction(
388 conflict_type="address_overlap",
389 addresses=[addr],
390 agents=unique_agents,
391 confidence=1.0,
392 description=(
393 f"{addr} reserved by {len(unique_agents)} agents simultaneously"
394 ),
395 ))
396
397 # ── Pass 2: Blast-radius overlap ──────────────────────────────────────────
398 # Use the reverse call graph to detect indirect dependencies between
399 # reserved addresses. Skip silently (but warn) when the index is
400 # unavailable — do NOT catch all exceptions, as unexpected errors
401 # indicate real problems that agents must see.
402 repo_id = read_repo_id(root)
403 commit = resolve_commit_ref(root, repo_id, branch, None)
404 if commit is not None:
405 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
406 try:
407 reverse = build_reverse_graph(root, manifest)
408 call_graph_available = True
409 all_addresses = list(addr_agents.keys())
410 for i, addr_a in enumerate(all_addresses):
411 callers_a = transitive_callers(addr_a, reverse, max_depth=5)
412 callers_set: set[str] = {c for lvl in callers_a.values() for c in lvl}
413 for addr_b in all_addresses[i + 1:]:
414 if addr_b in callers_set:
415 agents_a = addr_agents.get(addr_a, [])
416 agents_b = addr_agents.get(addr_b, [])
417 if set(agents_a) != set(agents_b):
418 conflicts.append(_ConflictPrediction(
419 conflict_type="blast_radius_overlap",
420 addresses=[addr_a, addr_b],
421 agents=list(set(agents_a) | set(agents_b)),
422 confidence=0.75,
423 description=(
424 f"{addr_b} is in the transitive call chain of {addr_a}"
425 ),
426 ))
427 except (OSError, KeyError, ValueError, AttributeError) as exc:
428 # Expected: index not built, object not found, or malformed data.
429 # Record as a warning — the caller must know blast-radius analysis
430 # was skipped rather than receiving a false-clean forecast.
431 warn = f"blast_radius_overlap skipped — call graph unavailable: {exc}"
432 warnings.append(warn)
433 logger.debug("Call graph unavailable for forecast: %s", exc)
434 else:
435 warnings.append(
436 "blast_radius_overlap skipped — no commits on this branch yet"
437 )
438
439 # ── Pass 3: Operation conflicts ───────────────────────────────────────────
440 # Detect intents where one agent intends delete and another modify/rename.
441 intent_ops: _AgentListMap = {}
442 intent_agents: _AgentListMap = {}
443 for it in intents:
444 for addr in it.addresses:
445 intent_ops.setdefault(addr, []).append(it.operation)
446 intent_agents.setdefault(addr, []).append(f"{it.run_id}@{it.branch}")
447
448 for addr, ops in sorted(intent_ops.items()):
449 if len(set(ops)) <= 1 and len(set(intent_agents.get(addr, []))) <= 1:
450 continue # Same op by same agent — not a conflict.
451 has_delete = "delete" in ops
452 has_modify = any(op in ("modify", "rename", "extract") for op in ops)
453 if has_delete and has_modify:
454 agents = list(dict.fromkeys(intent_agents.get(addr, [])))
455 conflicts.append(_ConflictPrediction(
456 conflict_type="operation_conflict",
457 addresses=[addr],
458 agents=agents,
459 confidence=0.9,
460 description=f"delete vs modify conflict on {addr}",
461 ))
462
463 # ── Post-pass filters ─────────────────────────────────────────────────────
464
465 # --run-id: keep only conflicts that mention this agent.
466 if run_id_filter is not None:
467 tag = run_id_filter # agents are stored as "run_id@branch"
468 conflicts = [
469 c for c in conflicts
470 if any(a.split("@")[0] == tag for a in c.agents)
471 ]
472
473 # --min-confidence: suppress low-signal predictions.
474 if min_confidence > 0.0:
475 conflicts = [c for c in conflicts if c.confidence >= min_confidence]
476
477 partial_forecast = bool(warnings)
478 elapsed = round(time.monotonic() - t0, 4)
479
480 # ── JSON output ───────────────────────────────────────────────────────────
481 if as_json:
482 print(json.dumps({
483 "schema_version": __version__,
484 "current_branch": branch,
485 "branch_filter": branch_filter,
486 "run_id_filter": run_id_filter,
487 "min_confidence": min_confidence,
488 "active_reservations": len(reservations),
489 "intents_count": len(intents),
490 "call_graph_available": call_graph_available,
491 "partial_forecast": partial_forecast,
492 "warnings": warnings,
493 "conflicts": [c.to_dict() for c in conflicts],
494 "high_risk": sum(1 for c in conflicts if c.confidence >= 0.9),
495 "medium_risk": sum(1 for c in conflicts if 0.5 <= c.confidence < 0.9),
496 "low_risk": sum(1 for c in conflicts if c.confidence < 0.5),
497 "elapsed_seconds": elapsed,
498 }))
499 return
500
501 # ── Text output ───────────────────────────────────────────────────────────
502 print(
503 f"\nConflict forecast — "
504 f"{len(reservations)} active reservation(s), {len(intents)} intent(s)"
505 )
506 print("─" * 62)
507
508 if warnings:
509 for w in warnings:
510 print(f"\n ⚠ Note: {w}")
511
512 if not conflicts:
513 print("\n ✅ No conflicts predicted.")
514 if not reservations:
515 print(" (no active reservations — run 'muse coord reserve' first)")
516 else:
517 for c in conflicts:
518 icon = "🔴" if c.confidence >= 0.9 else "⚠️ "
519 print(f"\n{icon} {c.conflict_type} (confidence {c.confidence:.2f})")
520 for addr in c.addresses:
521 print(f" {sanitize_display(addr)}")
522 for agent in c.agents:
523 print(f" agent: {sanitize_display(agent)}")
524 print(f" → {sanitize_display(c.description)}")
525
526 high = sum(1 for c in conflicts if c.confidence >= 0.9)
527 med = sum(1 for c in conflicts if 0.5 <= c.confidence < 0.9)
528 print(f"\n {high} high-risk, {med} medium-risk conflict(s) predicted")
529 print(" Run 'muse coord plan-merge' for a detailed merge strategy.")
530
531 print(f"\n ({elapsed:.3f}s)")
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 33 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 33 days ago