reserve.py python
365 lines 13.7 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 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 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 32 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 32 days ago