gabriel / muse public
forecast.py python
506 lines 20.8 KB
Raw
sha256:c5131d76c6eada02939111fda4aa8e51b0c1456b9983727cfd6be101916de14e merge: pull local/dev — resolve trivial _EXT_MAP symbol con… Sonnet 4.6 patch 13 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 "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)")
File History 5 commits
sha256:c5131d76c6eada02939111fda4aa8e51b0c1456b9983727cfd6be101916de14e merge: pull local/dev — resolve trivial _EXT_MAP symbol con… Sonnet 4.6 patch 13 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 20 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 23 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 29 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago