reserve.py
python
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f
fix: show full cryptographic IDs in all human-readable CLI output
Sonnet 4.6
patch
9 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: <sha256:...> |
| 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": "<sha256:...>", |
| 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 ID, 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 | ID 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 sha256: content ID before any file I/O. |
| 93 | |
| 94 | ``--json`` / ``--format json`` |
| 95 | Emit reservation details as JSON on stdout. |
| 96 | """ |
| 97 | |
| 98 | import argparse |
| 99 | import json |
| 100 | import logging |
| 101 | import sys |
| 102 | from typing import TypedDict |
| 103 | |
| 104 | from muse.core.coordination import active_reservations, create_reservation |
| 105 | from muse.core.dag import add_dependencies |
| 106 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 107 | from muse.core.errors import ExitCode |
| 108 | from muse.core.repo import require_repo |
| 109 | from muse.core.refs import read_current_branch |
| 110 | from muse.core.timing import start_timer |
| 111 | from muse.core.validation import clamp_int |
| 112 | |
| 113 | type _ConflictMap = dict[str, list[str]] |
| 114 | |
| 115 | class _ReserveJson(EnvelopeJson, total=False): |
| 116 | """JSON output for a successful reservation.""" |
| 117 | |
| 118 | reservation_id: str |
| 119 | run_id: str |
| 120 | branch: str |
| 121 | addresses: list[str] |
| 122 | created_at: str |
| 123 | expires_at: str |
| 124 | operation: str | None |
| 125 | conflicts: list[str] |
| 126 | depends_on: list[str] |
| 127 | dependency_error: str | None |
| 128 | logger = logging.getLogger(__name__) |
| 129 | |
| 130 | # ── Input constraints ───────────────────────────────────────────────────────── |
| 131 | # These limits prevent unbounded file growth and keep reservation records |
| 132 | # human-readable. They are generous enough for any real agent workflow. |
| 133 | |
| 134 | #: Maximum number of symbol addresses allowed in a single reservation call. |
| 135 | _MAX_ADDRESSES: int = 1000 |
| 136 | |
| 137 | #: Maximum byte-length of the ``--run-id`` value. |
| 138 | _MAX_RUN_ID_LEN: int = 256 |
| 139 | |
| 140 | #: Allowed values for ``--op``. Stored verbatim; validated here so the |
| 141 | #: coordination layer never receives an arbitrary string from the CLI. |
| 142 | _VALID_OPS: frozenset[str] = frozenset( |
| 143 | {"rename", "move", "modify", "extract", "delete"} |
| 144 | ) |
| 145 | |
| 146 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 147 | """Register the ``muse coord reserve`` subcommand and its flags. |
| 148 | |
| 149 | Adds the ``reserve`` parser to *subparsers* (the ``coord`` subgroup) and |
| 150 | wires its ``func`` default to :func:`run`. All flag defaults and choices |
| 151 | are defined here so ``--help`` output is accurate and complete. |
| 152 | """ |
| 153 | parser = subparsers.add_parser( |
| 154 | "reserve", |
| 155 | help="Place advisory reservations on symbol addresses.", |
| 156 | description=__doc__, |
| 157 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 158 | ) |
| 159 | parser.add_argument( |
| 160 | "addresses", |
| 161 | nargs="+", |
| 162 | metavar="ADDRESS", |
| 163 | help=( |
| 164 | "Symbol address(es) to reserve, e.g. " |
| 165 | '"src/billing.py::compute_total". ' |
| 166 | f"Maximum {_MAX_ADDRESSES} per call." |
| 167 | ), |
| 168 | ) |
| 169 | parser.add_argument( |
| 170 | "--run-id", |
| 171 | dest="run_id", |
| 172 | default="unknown", |
| 173 | metavar="ID", |
| 174 | help=( |
| 175 | "Agent/pipeline identifier used for conflict detection " |
| 176 | f"(default: 'unknown'; max {_MAX_RUN_ID_LEN} chars)." |
| 177 | ), |
| 178 | ) |
| 179 | parser.add_argument( |
| 180 | "--ttl", |
| 181 | type=int, |
| 182 | default=3600, |
| 183 | metavar="SECONDS", |
| 184 | help="Reservation duration in seconds (default: 3600; range: 1–31 536 000).", |
| 185 | ) |
| 186 | parser.add_argument( |
| 187 | "--op", |
| 188 | dest="operation", |
| 189 | default=None, |
| 190 | choices=sorted(_VALID_OPS), |
| 191 | metavar="OPERATION", |
| 192 | help=( |
| 193 | f"Declared operation type — one of: {', '.join(sorted(_VALID_OPS))}. Omit to leave unspecified." |
| 194 | ), |
| 195 | ) |
| 196 | parser.add_argument( |
| 197 | "--depends-on", |
| 198 | dest="depends_on", |
| 199 | action="append", |
| 200 | default=[], |
| 201 | metavar="RESID", |
| 202 | help=( |
| 203 | "ID of a reservation that must complete before this one starts. " |
| 204 | "May be repeated: --depends-on A --depends-on B. " |
| 205 | "Each value is validated as a sha256: content ID before any file I/O." |
| 206 | ), |
| 207 | ) |
| 208 | parser.add_argument( |
| 209 | "--json", "-j", |
| 210 | action="store_true", |
| 211 | dest="json_out", |
| 212 | help="Emit machine-readable JSON.", |
| 213 | ) |
| 214 | parser.set_defaults(func=run) |
| 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`` ID 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/<id>.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`` ID is validated by |
| 250 | :func:`~muse.core.dag.add_dependencies` before any path is constructed, |
| 251 | preventing directory traversal. |
| 252 | |
| 253 | Agent quickstart:: |
| 254 | |
| 255 | muse coord reserve "billing.py::compute_total" --run-id agent-42 --json |
| 256 | muse coord reserve "auth.py::validate" --run-id agent-42 --op modify --json |
| 257 | muse coord reserve "billing.py::process" --run-id agent-B --depends-on <sha256:...> --json |
| 258 | muse coord reserve "a.py::Fn" "b.py::Fn" --run-id pipeline-7 --ttl 7200 --json |
| 259 | |
| 260 | JSON fields:: |
| 261 | |
| 262 | reservation_id str content-id of the newly created reservation |
| 263 | run_id str agent identifier supplied via --run-id |
| 264 | branch str branch the reservation was made on |
| 265 | addresses list[str] symbol addresses reserved |
| 266 | created_at str ISO 8601 creation timestamp |
| 267 | expires_at str ISO 8601 expiry timestamp |
| 268 | operation str|null operation type or null if unspecified |
| 269 | conflicts list[str] human-readable messages for conflicting reservations |
| 270 | depends_on list[str] IDs of prerequisite reservations |
| 271 | dependency_error str|null error message if dependency recording failed |
| 272 | |
| 273 | Exit codes:: |
| 274 | |
| 275 | 0 Reservation created (conflicts are warnings, not errors). |
| 276 | 1 Validation error or dependency cycle detected. |
| 277 | 3 Repository not found. |
| 278 | """ |
| 279 | elapsed = start_timer() |
| 280 | addresses: list[str] = args.addresses |
| 281 | run_id: str = args.run_id |
| 282 | operation: str | None = args.operation |
| 283 | as_json: bool = args.json_out |
| 284 | depends_on: list[str] = args.depends_on or [] |
| 285 | |
| 286 | # ── Input validation (before any file I/O) ──────────────────────────────── |
| 287 | |
| 288 | if len(run_id) > _MAX_RUN_ID_LEN: |
| 289 | print( |
| 290 | f"❌ --run-id is too long ({len(run_id)} chars; max {_MAX_RUN_ID_LEN}).", |
| 291 | file=sys.stderr, |
| 292 | ) |
| 293 | raise SystemExit(ExitCode.USER_ERROR) |
| 294 | |
| 295 | if len(addresses) > _MAX_ADDRESSES: |
| 296 | print( |
| 297 | f"❌ Too many addresses: {len(addresses)} (max {_MAX_ADDRESSES}).", |
| 298 | file=sys.stderr, |
| 299 | ) |
| 300 | raise SystemExit(ExitCode.USER_ERROR) |
| 301 | |
| 302 | try: |
| 303 | ttl: int = clamp_int(args.ttl, 1, 31_536_000, "ttl") |
| 304 | except ValueError as exc: |
| 305 | print(f"❌ Invalid --ttl: {exc}", file=sys.stderr) |
| 306 | raise SystemExit(ExitCode.USER_ERROR) from exc |
| 307 | |
| 308 | root = require_repo() |
| 309 | branch = read_current_branch(root) |
| 310 | |
| 311 | # ── Conflict check ──────────────────────────────────────────────────────── |
| 312 | # Build an address → conflicting-reservation map in a single pass over the |
| 313 | # active reservations list, instead of an O(addresses × reservations) nested |
| 314 | # loop. This keeps conflict detection O(active_reservations + addresses) |
| 315 | # even when both are large. |
| 316 | existing = active_reservations(root) |
| 317 | addr_to_conflicts: _ConflictMap = {} |
| 318 | for res in existing: |
| 319 | if res.run_id == run_id: |
| 320 | continue |
| 321 | for addr in res.addresses: |
| 322 | if addr in set(addresses): |
| 323 | addr_to_conflicts.setdefault(addr, []).append( |
| 324 | f" ⚠️ {addr}\n" |
| 325 | f" already reserved by run-id {res.run_id!r}" |
| 326 | f" (expires {res.expires_at.isoformat()[:19]})" |
| 327 | ) |
| 328 | |
| 329 | conflicts: list[str] = [msg for msgs in addr_to_conflicts.values() for msg in msgs] |
| 330 | |
| 331 | # ── Create reservation ──────────────────────────────────────────────────── |
| 332 | res = create_reservation(root, run_id, branch, addresses, ttl, operation) |
| 333 | |
| 334 | # ── Record dependency edges ─────────────────────────────────────────────── |
| 335 | dep_record = None |
| 336 | dep_error: str | None = None |
| 337 | if depends_on: |
| 338 | try: |
| 339 | dep_record = add_dependencies(root, res.reservation_id, depends_on) |
| 340 | except (ValueError, FileExistsError) as exc: |
| 341 | dep_error = str(exc) |
| 342 | logger.warning( |
| 343 | "Failed to record dependencies for %s: %s", |
| 344 | res.reservation_id[:8], |
| 345 | exc, |
| 346 | ) |
| 347 | |
| 348 | # ── Output ──────────────────────────────────────────────────────────────── |
| 349 | if as_json: |
| 350 | exit_code_val = int(ExitCode.USER_ERROR) if dep_error else 0 |
| 351 | out = _ReserveJson( |
| 352 | **make_envelope(elapsed, exit_code=exit_code_val), |
| 353 | **res.to_dict(), |
| 354 | conflicts=conflicts, |
| 355 | depends_on=dep_record.depends_on if dep_record else [], |
| 356 | dependency_error=dep_error, |
| 357 | ) |
| 358 | print(json.dumps(out)) |
| 359 | if dep_error: |
| 360 | raise SystemExit(ExitCode.USER_ERROR) |
| 361 | return |
| 362 | |
| 363 | if conflicts: |
| 364 | for c in conflicts: |
| 365 | print(c) |
| 366 | |
| 367 | print( |
| 368 | f"\n✅ Reserved {len(addresses)} address(es) for run-id {run_id!r}\n" |
| 369 | f" Reservation ID: {res.reservation_id}\n" |
| 370 | f" Expires: {res.expires_at.isoformat()[:19]}" |
| 371 | ) |
| 372 | if operation: |
| 373 | print(f" Operation: {operation}") |
| 374 | if dep_record and dep_record.depends_on: |
| 375 | print(f" Depends on ({len(dep_record.depends_on)}):") |
| 376 | for dep_id in dep_record.depends_on: |
| 377 | print(f" {dep_id[:8]}…") |
| 378 | if conflicts: |
| 379 | print( |
| 380 | f"\n ⚠️ {len(conflicts)} conflict(s) detected. " |
| 381 | "Run 'muse forecast' for details." |
| 382 | ) |
| 383 | if dep_error: |
| 384 | print(f"\n ⚠️ Dependency error: {dep_error}", file=sys.stderr) |
| 385 | raise SystemExit(ExitCode.USER_ERROR) |
File History
1 commit
sha256:1ddad36d76d3a8d323f9b3664169cb184b7a38b39208214a2ae504154260826f
fix: show full cryptographic IDs in all human-readable CLI output
Sonnet 4.6
patch
9 days ago