reserve.py
python
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
13 days ago
| 1 | """``muse coord reserve`` — advisory symbol reservation for parallel agents. |
| 2 | |
| 3 | Places an advisory lock on one or more symbol addresses. This does NOT block |
| 4 | other agents from editing those symbols — it is a coordination signal, not an |
| 5 | enforcement mechanism. Other agents can check existing reservations via |
| 6 | ``muse forecast`` or ``muse reconcile`` before starting work. |
| 7 | |
| 8 | Why reservations? |
| 9 | ----------------- |
| 10 | When millions of agents operate on a codebase simultaneously, merge conflicts |
| 11 | are inevitable *if* agents don't communicate intent. Reservations give agents |
| 12 | a low-cost way to say "I'm about to touch this function" before they do it, |
| 13 | so that: |
| 14 | |
| 15 | 1. Other agents can check with ``muse forecast`` and re-route if needed. |
| 16 | 2. ``muse plan-merge`` can predict conflicts with higher accuracy. |
| 17 | 3. ``muse reconcile`` can recommend merge ordering. |
| 18 | |
| 19 | A reservation expires after ``--ttl`` seconds (default: 1 hour) and is never |
| 20 | enforced — the VCS engine ignores them for correctness. They are purely |
| 21 | advisory. |
| 22 | |
| 23 | Dependency ordering |
| 24 | ------------------- |
| 25 | Pass ``--depends-on RESID`` (repeatable) to declare that this reservation must |
| 26 | wait for another reservation to complete (be released or expire) before it |
| 27 | starts work. The declared dependencies form a DAG that agents can inspect with |
| 28 | ``muse coord dag``. Examples:: |
| 29 | |
| 30 | # B must wait for A to finish |
| 31 | muse coord reserve "billing.py::process" --run-id agent-B \\ |
| 32 | --depends-on <A-reservation-id> |
| 33 | |
| 34 | # C must wait for both A and B |
| 35 | muse coord reserve "auth.py::validate" --run-id agent-C \\ |
| 36 | --depends-on <A-id> --depends-on <B-id> |
| 37 | |
| 38 | Usage:: |
| 39 | |
| 40 | muse reserve "src/billing.py::compute_total" --run-id agent-42 |
| 41 | muse reserve "src/auth.py::validate_token" "src/auth.py::refresh_token" \\ |
| 42 | --run-id pipeline-7 --ttl 7200 |
| 43 | muse reserve "src/core.py::hash_content" --op rename --run-id refactor-bot |
| 44 | |
| 45 | Output (text):: |
| 46 | |
| 47 | ✅ Reserved 1 address(es) for run-id 'agent-42' |
| 48 | Reservation ID: <uuid> |
| 49 | Expires: 2026-03-18T13:00:00 |
| 50 | |
| 51 | ⚠️ src/billing.py::compute_total |
| 52 | already reserved by run-id 'agent-41' (expires 2026-03-18T12:30:00) |
| 53 | |
| 54 | Output (``--json``):: |
| 55 | |
| 56 | { |
| 57 | "schema_version": "...", |
| 58 | "reservation_id": "<uuid>", |
| 59 | "run_id": "agent-42", |
| 60 | "branch": "main", |
| 61 | "addresses": ["src/billing.py::compute_total"], |
| 62 | "created_at": "2026-03-18T12:00:00+00:00", |
| 63 | "expires_at": "2026-03-18T13:00:00+00:00", |
| 64 | "operation": null, |
| 65 | "conflicts": [], |
| 66 | "depends_on": [], |
| 67 | "dependency_error": null |
| 68 | } |
| 69 | |
| 70 | Exit codes:: |
| 71 | |
| 72 | 0 — reservation created (conflicts are warnings, not errors) |
| 73 | 1 — validation error (bad --ttl, bad --op, bad --depends-on UUID, cycle) |
| 74 | 3 — internal error (repository not found) |
| 75 | |
| 76 | Flags: |
| 77 | |
| 78 | ``--run-id ID`` |
| 79 | Agent/pipeline identifier for conflict detection (default: ``"unknown"``). |
| 80 | Maximum length: 256 characters. |
| 81 | |
| 82 | ``--ttl N`` |
| 83 | Reservation duration in seconds (default: 3600; range: 1–31 536 000). |
| 84 | |
| 85 | ``--op OPERATION`` |
| 86 | Declared operation type. Must be one of: ``rename``, ``move``, |
| 87 | ``modify``, ``extract``, ``delete``. Omit to leave unspecified. |
| 88 | |
| 89 | ``--depends-on RESID`` |
| 90 | UUID of a reservation that must complete before this one starts. |
| 91 | May be repeated to declare multiple dependencies. |
| 92 | Each ID is validated as a UUID before any file I/O. |
| 93 | |
| 94 | ``--json`` / ``--format json`` |
| 95 | Emit reservation details as JSON on stdout. |
| 96 | """ |
| 97 | |
| 98 | from __future__ import annotations |
| 99 | |
| 100 | import argparse |
| 101 | import json |
| 102 | import logging |
| 103 | import sys |
| 104 | |
| 105 | from muse.core.coordination import active_reservations, create_reservation |
| 106 | from muse.core.dag import add_dependencies |
| 107 | from muse.core.errors import ExitCode |
| 108 | from muse.core.repo import require_repo |
| 109 | from muse.core.store import read_current_branch |
| 110 | from muse.core.validation import clamp_int |
| 111 | |
| 112 | |
| 113 | |
| 114 | type _ReserveOut = dict[str, str | int | list[str] | None] |
| 115 | type _ConflictMap = dict[str, list[str]] |
| 116 | logger = logging.getLogger(__name__) |
| 117 | |
| 118 | # ── Input constraints ───────────────────────────────────────────────────────── |
| 119 | # These limits prevent unbounded file growth and keep reservation records |
| 120 | # human-readable. They are generous enough for any real agent workflow. |
| 121 | |
| 122 | #: Maximum number of symbol addresses allowed in a single reservation call. |
| 123 | _MAX_ADDRESSES: int = 1000 |
| 124 | |
| 125 | #: Maximum byte-length of the ``--run-id`` value. |
| 126 | _MAX_RUN_ID_LEN: int = 256 |
| 127 | |
| 128 | #: Allowed values for ``--op``. Stored verbatim; validated here so the |
| 129 | #: coordination layer never receives an arbitrary string from the CLI. |
| 130 | _VALID_OPS: frozenset[str] = frozenset( |
| 131 | {"rename", "move", "modify", "extract", "delete"} |
| 132 | ) |
| 133 | |
| 134 | |
| 135 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 136 | """Register the ``muse coord reserve`` subcommand and its flags. |
| 137 | |
| 138 | Adds the ``reserve`` parser to *subparsers* (the ``coord`` subgroup) and |
| 139 | wires its ``func`` default to :func:`run`. All flag defaults and choices |
| 140 | are defined here so ``--help`` output is accurate and complete. |
| 141 | """ |
| 142 | parser = subparsers.add_parser( |
| 143 | "reserve", |
| 144 | help="Place advisory reservations on symbol addresses.", |
| 145 | description=__doc__, |
| 146 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 147 | ) |
| 148 | parser.add_argument( |
| 149 | "addresses", |
| 150 | nargs="+", |
| 151 | metavar="ADDRESS", |
| 152 | help=( |
| 153 | "Symbol address(es) to reserve, e.g. " |
| 154 | '"src/billing.py::compute_total". ' |
| 155 | f"Maximum {_MAX_ADDRESSES} per call." |
| 156 | ), |
| 157 | ) |
| 158 | parser.add_argument( |
| 159 | "--run-id", |
| 160 | dest="run_id", |
| 161 | default="unknown", |
| 162 | metavar="ID", |
| 163 | help=( |
| 164 | "Agent/pipeline identifier used for conflict detection " |
| 165 | f"(default: 'unknown'; max {_MAX_RUN_ID_LEN} chars)." |
| 166 | ), |
| 167 | ) |
| 168 | parser.add_argument( |
| 169 | "--ttl", |
| 170 | type=int, |
| 171 | default=3600, |
| 172 | metavar="SECONDS", |
| 173 | help="Reservation duration in seconds (default: 3600; range: 1–31 536 000).", |
| 174 | ) |
| 175 | parser.add_argument( |
| 176 | "--op", |
| 177 | dest="operation", |
| 178 | default=None, |
| 179 | choices=sorted(_VALID_OPS), |
| 180 | metavar="OPERATION", |
| 181 | help=( |
| 182 | "Declared operation type — one of: " |
| 183 | + ", ".join(sorted(_VALID_OPS)) |
| 184 | + ". Omit to leave unspecified." |
| 185 | ), |
| 186 | ) |
| 187 | parser.add_argument( |
| 188 | "--depends-on", |
| 189 | dest="depends_on", |
| 190 | action="append", |
| 191 | default=[], |
| 192 | metavar="RESID", |
| 193 | help=( |
| 194 | "UUID of a reservation that must complete before this one starts. " |
| 195 | "May be repeated: --depends-on A --depends-on B. " |
| 196 | "Each value is validated as a UUID before any file I/O." |
| 197 | ), |
| 198 | ) |
| 199 | parser.add_argument( |
| 200 | "--format", "-f", |
| 201 | default="text", |
| 202 | dest="fmt", |
| 203 | choices=("text", "json"), |
| 204 | help="Output format: text (default) or json.", |
| 205 | ) |
| 206 | parser.add_argument( |
| 207 | "--json", |
| 208 | action="store_const", |
| 209 | const="json", |
| 210 | dest="fmt", |
| 211 | help="Shorthand for --format json.", |
| 212 | ) |
| 213 | parser.set_defaults(func=run) |
| 214 | |
| 215 | |
| 216 | def run(args: argparse.Namespace) -> None: |
| 217 | """Place advisory reservations on one or more symbol addresses. |
| 218 | |
| 219 | Reservations are write-once, expiry-based advisory signals. They do not |
| 220 | block other agents or affect VCS correctness — they enable conflict |
| 221 | *prediction* via ``muse forecast`` and ``muse reconcile``. |
| 222 | |
| 223 | Execution order |
| 224 | --------------- |
| 225 | 1. **Validate inputs** — ``--ttl`` range, ``--run-id`` length, address |
| 226 | count, ``--op`` membership (enforced by argparse ``choices``), and |
| 227 | ``--depends-on`` UUID syntax. Any validation failure exits 1 with a |
| 228 | message to *stderr* before any file I/O. |
| 229 | 2. **Conflict check** — calls :func:`~muse.core.coordination.active_reservations` |
| 230 | to identify existing reservations by other agents on the same addresses. |
| 231 | Conflicts are *warnings*, not errors — the reservation is always created. |
| 232 | 3. **Create reservation** — writes |
| 233 | ``.muse/coordination/reservations/<uuid>.json`` atomically via |
| 234 | :func:`~muse.core.store.write_text_atomic`. |
| 235 | 4. **Record dependency edges** (optional) — if ``--depends-on`` IDs are |
| 236 | supplied, calls :func:`~muse.core.dag.add_dependencies` to write the |
| 237 | DAG record. If the new edges would create a cycle the command exits 1, |
| 238 | but **the reservation itself is not rolled back** (reservations are |
| 239 | advisory and immutable; the DAG is best-effort). |
| 240 | 5. **Emit output** — text to *stdout* (or JSON when ``--format json``). |
| 241 | |
| 242 | Security |
| 243 | -------- |
| 244 | * ``--ttl`` is clamped to ``[1, 31_536_000]`` by :func:`~muse.core.validation.clamp_int`. |
| 245 | * ``--run-id`` is truncated to :data:`_MAX_RUN_ID_LEN` bytes to prevent |
| 246 | arbitrarily large reservation files. |
| 247 | * ``addresses`` count is capped at :data:`_MAX_ADDRESSES`. |
| 248 | * ``--op`` is restricted to :data:`_VALID_OPS` by argparse ``choices``. |
| 249 | * Every ``--depends-on`` UUID is validated by |
| 250 | :func:`~muse.core.dag.add_dependencies` before any path is constructed, |
| 251 | preventing directory traversal. |
| 252 | |
| 253 | Args: |
| 254 | args: Parsed ``argparse.Namespace`` with attributes ``addresses``, |
| 255 | ``run_id``, ``ttl``, ``operation``, ``depends_on``, and ``fmt``. |
| 256 | |
| 257 | Exit codes: |
| 258 | 0 — reservation created successfully (conflicts are warnings). |
| 259 | 1 — validation error or dependency cycle detected. |
| 260 | 3 — repository not found (``require_repo`` raises ``SystemExit``). |
| 261 | """ |
| 262 | addresses: list[str] = args.addresses |
| 263 | run_id: str = args.run_id |
| 264 | operation: str | None = args.operation |
| 265 | as_json: bool = args.fmt == "json" |
| 266 | depends_on: list[str] = args.depends_on or [] |
| 267 | |
| 268 | # ── Input validation (before any file I/O) ──────────────────────────────── |
| 269 | |
| 270 | if len(run_id) > _MAX_RUN_ID_LEN: |
| 271 | print( |
| 272 | f"❌ --run-id is too long ({len(run_id)} chars; max {_MAX_RUN_ID_LEN}).", |
| 273 | file=sys.stderr, |
| 274 | ) |
| 275 | raise SystemExit(ExitCode.USER_ERROR) |
| 276 | |
| 277 | if len(addresses) > _MAX_ADDRESSES: |
| 278 | print( |
| 279 | f"❌ Too many addresses: {len(addresses)} (max {_MAX_ADDRESSES}).", |
| 280 | file=sys.stderr, |
| 281 | ) |
| 282 | raise SystemExit(ExitCode.USER_ERROR) |
| 283 | |
| 284 | try: |
| 285 | ttl: int = clamp_int(args.ttl, 1, 31_536_000, "ttl") |
| 286 | except ValueError as exc: |
| 287 | print(f"❌ Invalid --ttl: {exc}", file=sys.stderr) |
| 288 | raise SystemExit(ExitCode.USER_ERROR) from exc |
| 289 | |
| 290 | root = require_repo() |
| 291 | branch = read_current_branch(root) |
| 292 | |
| 293 | # ── Conflict check ──────────────────────────────────────────────────────── |
| 294 | # Build an address → conflicting-reservation map in a single pass over the |
| 295 | # active reservations list, instead of an O(addresses × reservations) nested |
| 296 | # loop. This keeps conflict detection O(active_reservations + addresses) |
| 297 | # even when both are large. |
| 298 | existing = active_reservations(root) |
| 299 | addr_to_conflicts: _ConflictMap = {} |
| 300 | for res in existing: |
| 301 | if res.run_id == run_id: |
| 302 | continue |
| 303 | for addr in res.addresses: |
| 304 | if addr in set(addresses): |
| 305 | addr_to_conflicts.setdefault(addr, []).append( |
| 306 | f" ⚠️ {addr}\n" |
| 307 | f" already reserved by run-id {res.run_id!r}" |
| 308 | f" (expires {res.expires_at.isoformat()[:19]})" |
| 309 | ) |
| 310 | |
| 311 | conflicts: list[str] = [msg for msgs in addr_to_conflicts.values() for msg in msgs] |
| 312 | |
| 313 | # ── Create reservation ──────────────────────────────────────────────────── |
| 314 | res = create_reservation(root, run_id, branch, addresses, ttl, operation) |
| 315 | |
| 316 | # ── Record dependency edges ─────────────────────────────────────────────── |
| 317 | dep_record = None |
| 318 | dep_error: str | None = None |
| 319 | if depends_on: |
| 320 | try: |
| 321 | dep_record = add_dependencies(root, res.reservation_id, depends_on) |
| 322 | except (ValueError, FileExistsError) as exc: |
| 323 | dep_error = str(exc) |
| 324 | logger.warning( |
| 325 | "Failed to record dependencies for %s: %s", |
| 326 | res.reservation_id[:8], |
| 327 | exc, |
| 328 | ) |
| 329 | |
| 330 | # ── Output ──────────────────────────────────────────────────────────────── |
| 331 | if as_json: |
| 332 | out: _ReserveOut = { |
| 333 | **res.to_dict(), |
| 334 | "conflicts": conflicts, |
| 335 | "depends_on": dep_record.depends_on if dep_record else [], |
| 336 | "dependency_error": dep_error, |
| 337 | } |
| 338 | print(json.dumps(out)) |
| 339 | if dep_error: |
| 340 | raise SystemExit(ExitCode.USER_ERROR) |
| 341 | return |
| 342 | |
| 343 | if conflicts: |
| 344 | for c in conflicts: |
| 345 | print(c) |
| 346 | |
| 347 | print( |
| 348 | f"\n✅ Reserved {len(addresses)} address(es) for run-id {run_id!r}\n" |
| 349 | f" Reservation ID: {res.reservation_id}\n" |
| 350 | f" Expires: {res.expires_at.isoformat()[:19]}" |
| 351 | ) |
| 352 | if operation: |
| 353 | print(f" Operation: {operation}") |
| 354 | if dep_record and dep_record.depends_on: |
| 355 | print(f" Depends on ({len(dep_record.depends_on)}):") |
| 356 | for dep_id in dep_record.depends_on: |
| 357 | print(f" {dep_id[:8]}…") |
| 358 | if conflicts: |
| 359 | print( |
| 360 | f"\n ⚠️ {len(conflicts)} conflict(s) detected. " |
| 361 | "Run 'muse forecast' for details." |
| 362 | ) |
| 363 | if dep_error: |
| 364 | print(f"\n ⚠️ Dependency error: {dep_error}", file=sys.stderr) |
| 365 | raise SystemExit(ExitCode.USER_ERROR) |
File History
1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
13 days ago