gabriel / muse public
reserve.py python
385 lines 14.9 KB
Raw
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