|
11 | 11 | import asyncio |
12 | 12 | import json |
13 | 13 | import os |
14 | | -import re |
15 | 14 | import time |
16 | 15 | import uuid |
17 | 16 | from pathlib import Path |
18 | 17 | from typing import NamedTuple |
19 | 18 |
|
20 | | -from .. import idempotency as idem |
21 | 19 | from .. import run_status as rs |
22 | 20 | from ..config import Settings, TierNotConfiguredError |
23 | 21 | from ..engine.executor import Executor |
|
35 | 33 | from ..task_spec import TaskSpec |
36 | 34 | from ..ui import style |
37 | 35 | from ..metrics import format_metrics_line |
| 36 | +from .reservation import RunReservation |
38 | 37 |
|
39 | 38 |
|
40 | 39 | class GateOutcome(NamedTuple): |
@@ -70,6 +69,7 @@ def __init__(self, settings: Settings | None = None): |
70 | 69 | configured dir, and the planner over both. `last_run_dir` starts unset |
71 | 70 | and is filled by the first execution.""" |
72 | 71 | self.settings = settings or Settings() |
| 72 | + self.reservations = RunReservation(self.settings) |
73 | 73 | # provider-registry seam: a real llm.LLM under backend "api" |
74 | 74 | # (byte-identical), a HarnessLLM adapter under a harness backend |
75 | 75 | from ..providers import llm_for |
@@ -433,121 +433,23 @@ def resume_last(self) -> int: |
433 | 433 | print("no resumable run found") |
434 | 434 | return 1 |
435 | 435 |
|
436 | | - # -- MCP surface (reserve + child execute) -------------------------------- |
437 | | - # These support the start/poll MCP server (mcp_server.py). The CLI path |
438 | | - # (run_task/run_playbook) is deliberately untouched: it still gates BEFORE |
439 | | - # creating a run dir, so an aborted plan leaves no directory. Reservation |
440 | | - # (dir before plan) is an MCP-only shape whose blocked/failed outcome is a |
441 | | - # terminal poll record, not litter. |
442 | | - _RUN_ID_RE = re.compile(r"^run-\d{8}-\d{6}-[0-9a-f]{6}$") |
443 | | - |
| 436 | + # -- MCP child execution and compatibility reservation facade ----------- |
444 | 437 | @staticmethod |
445 | 438 | def _new_run_id() -> str: |
446 | | - """A fresh unique run id (`run-<ts>-<uuid6>`; same format the CLI uses).""" |
447 | | - return f"run-{time.strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}" |
| 439 | + return RunReservation.new_run_id() |
448 | 440 |
|
449 | 441 | def _contained_run_dir(self, run_id: str, *, must_exist: bool = True) -> Path: |
450 | | - """Validate `run_id` and resolve it to a directory strictly contained |
451 | | - under `run_root` — rejecting path traversal from an untrusted MCP arg. |
452 | | - Raises ValueError on a bad pattern, an escape, or (unless |
453 | | - `must_exist=False`) a missing run. |
454 | | - |
455 | | - `must_exist=False` is for the poll path, which must distinguish a |
456 | | - well-formed id whose run this server has never heard of — answered as |
457 | | - `state: unknown` so a client can tell "lost" from "still running" — from |
458 | | - a malformed or escaping id, which stays an error.""" |
459 | | - if not self._RUN_ID_RE.match(run_id or ""): |
460 | | - raise ValueError(f"invalid run_id: {run_id!r}") |
461 | | - root = self.settings.run_root.resolve() |
462 | | - run_dir = (self.settings.run_root / run_id).resolve() |
463 | | - if run_dir.parent != root: |
464 | | - raise ValueError(f"run_id escapes run_root: {run_id!r}") |
465 | | - if must_exist and not run_dir.exists(): |
466 | | - raise ValueError(f"no such run: {run_id!r}") |
467 | | - return run_dir |
| 442 | + return self.reservations.contained_run_dir(run_id, must_exist=must_exist) |
468 | 443 |
|
469 | 444 | def reserve_run(self, spec: TaskSpec, *, owner_server_id: str, |
470 | 445 | owner_server_pid: int, |
471 | 446 | idempotency_key: str = "") -> tuple[str, bool]: |
472 | | - """MCP-only: reserve a run and return `(run_id, created)` **without** |
473 | | - planning or executing (no LLM), so the tool call returns in ms and the |
474 | | - caller polls. Persists `request.json` (0600) + an initial `queued` |
475 | | - `run_status.json` stamped with the owning server; planning happens later |
476 | | - in the child. |
477 | | - |
478 | | - `created` is what the caller enqueues on. Deduplicating the id alone |
479 | | - would not deduplicate execution — the start path would hand back an |
480 | | - existing id and still launch a second child for it. |
481 | | - |
482 | | - With an `idempotency_key`, a retry that lost the original response gets |
483 | | - the same run — including a finished one, whose result it can then read |
484 | | - instead of re-reviewing the same commit. See `idempotency.py` for why a |
485 | | - spec hash alone cannot do this and why the scope is Strict-only. |
486 | | - |
487 | | - A `repo_path` on the spec is authorized and canonicalized HERE, so the |
488 | | - persisted request names one immutable checkout for the life of the run. |
489 | | - The child re-authorizes it anyway — `request.json` is untrusted — but |
490 | | - freezing it at reservation is what stops two concurrent per-call repos |
491 | | - from racing through shared settings, which is how the deleted |
492 | | - `configure_strict_repo` worked.""" |
493 | | - from .request_policy import authorize_repo_path |
494 | | - |
495 | | - if spec.repo_path: |
496 | | - spec = spec.model_copy(update={"repo_path": authorize_repo_path( |
497 | | - spec.repo, spec.repo_path, self.settings)}) |
498 | | - key = idem.validate_key(idempotency_key) |
499 | | - if not key: |
500 | | - return self._reserve_new(spec, owner_server_id, owner_server_pid), True |
501 | | - with idem.key_lock(self.settings.run_root, key): |
502 | | - return self._reserve_keyed(spec, key, owner_server_id, |
503 | | - owner_server_pid) |
504 | | - |
505 | | - def _reserve_keyed(self, spec: TaskSpec, key: str, owner_server_id: str, |
506 | | - owner_server_pid: int) -> tuple[str, bool]: |
507 | | - """Resolve or create the run for `key`. Caller holds the key lock.""" |
508 | | - fingerprint = idem.spec_fingerprint(spec.model_dump()) |
509 | | - entry = idem.read_entry(self.settings.run_root, key) |
510 | | - if entry: |
511 | | - run_dir = self.settings.run_root / str(entry.get("run_id") or "") |
512 | | - # The index is a cache; the persisted request is the authority. A |
513 | | - # key presented with a DIFFERENT spec is a caller error — serving |
514 | | - # the old run would quietly review the wrong thing under a name the |
515 | | - # caller believes means something else. |
516 | | - recorded = str(entry.get("spec_fingerprint") or "") |
517 | | - if run_dir.exists() and recorded and recorded != fingerprint: |
518 | | - raise idem.IdempotencyError( |
519 | | - f"idempotency_key {key!r} was already used for a different " |
520 | | - "request; use a new key for a new attempt") |
521 | | - if run_dir.exists() and idem.resolvable(run_dir): |
522 | | - return run_dir.name, False |
523 | | - if run_dir.exists() and idem.relaunchable(run_dir): |
524 | | - # Reserved but never launched — the owner died between |
525 | | - # publishing this entry and starting a child. Re-arm and report |
526 | | - # it as created so the caller enqueues it; the re-stamp is what |
527 | | - # stops a SECOND retry from enqueueing it again. |
528 | | - if rs.reclaim_queued(run_dir, owner_server_id=owner_server_id, |
529 | | - owner_server_pid=owner_server_pid): |
530 | | - return run_dir.name, True |
531 | | - return run_dir.name, False |
532 | | - run_id = self._reserve_new(spec, owner_server_id, owner_server_pid) |
533 | | - idem.write_entry(self.settings.run_root, key, run_id, fingerprint) |
534 | | - return run_id, True |
535 | | - |
536 | | - def _reserve_new(self, spec: TaskSpec, owner_server_id: str, |
537 | | - owner_server_pid: int) -> str: |
538 | | - """Create the run directory, request and initial status.""" |
539 | | - run_id = self._new_run_id() |
540 | | - run_dir = self.settings.run_root / run_id |
541 | | - run_dir.mkdir(parents=True, exist_ok=True) |
542 | | - req = run_dir / "request.json" |
543 | | - req.write_text(json.dumps(spec.model_dump(), indent=2), encoding="utf-8") |
544 | | - try: # least-privilege perms; advisory only against same-user tampering |
545 | | - os.chmod(req, 0o600) |
546 | | - except OSError: |
547 | | - pass |
548 | | - rs.init_queued(run_dir, run_id=run_id, owner_server_id=owner_server_id, |
549 | | - owner_server_pid=owner_server_pid) |
550 | | - return run_id |
| 447 | + """Compatibility entry for callers that still hold a ``Copilot``.""" |
| 448 | + return self.reservations.reserve( |
| 449 | + spec, owner_server_id=owner_server_id, |
| 450 | + owner_server_pid=owner_server_pid, |
| 451 | + idempotency_key=idempotency_key, |
| 452 | + ) |
551 | 453 |
|
552 | 454 | def execute_reserved(self, run_id: str) -> int: |
553 | 455 | """MCP-only child entry (subprocess, stdout -> console.log). Writes its |
|
0 commit comments