oc_orchestrator
Loading...
Searching...
No Matches
worktree_env.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_orchestrator/worktree_env.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by test_241_worktree_env.py"
6"""
7Per-worktree uv environments: prepare and teardown.
8
9Purpose
10-------
11Plan 241 (ENV-01). The shared ``G:\\PythonApps\\option_c\\.venv`` carries
12thirteen editable installs that all point at the MAIN tree, so a worker's
13probe or suite silently imports the main tree unless ``PYTHONPATH`` lists
14the worktree first — the F17 shadow. This module ends that class: it builds
15a venv INSIDE the worktree whose editable installs point at that worktree,
16and it VERIFIES the result by importing every package in a clean
17interpreter with no ``PYTHONPATH`` before it reports success.
18
19Responsibilities
20----------------
21- :data:`EDITABLE_PACKAGES` — the one declared table of the monorepo's
22 editable packages (path, distribution name, import name, extras, and
23 the modules those extras must make importable).
24- :data:`SMOKE_CHECKS` — plan 243 P4: the two commands every worker runs
25 first, executed inside the prepared worktree on ``--smoke``.
26- :func:`ensure_branch_and_worktree` — the idempotent git anchors; the ONE
27 implementation, shared with :mod:`oc_orchestrator.dispatch`.
28- :func:`prepare_worktree` — branch, worktree, ``uv venv``, the editable
29 installs FROM THE WORKTREE, the ``sitecustomize.py`` copy, and the
30 verification. Idempotent: a second run reports "already prepared" and
31 still repairs a missing ``sitecustomize.py``.
32- :func:`teardown_worktree` — the symmetric removal, which names the known
33 Windows ``.git/worktrees/<name>`` permission failure rather than leaving
34 the orchestrator to recognise it.
35- :class:`WorktreeEnvError` — every refusal, named, raised BEFORE the step
36 it refuses so a rerun can always repair what a failure left behind.
37- :data:`OctHook` / :func:`use_oct_hook` — plan 270's seam: the optional
38 ``oct`` timing ledger, absent by default, registered by a consumer
39 that has it.
40
41Diagnostics
42-----------
43Domain: OC_CHAT.MAIN
44Levels:
45 L2 — lifecycle events (prepare/teardown attempted, refused, completed).
46 L3 — semantic details (discovery, install, verification).
47 L4 — deep tracing.
48
49Contracts
50---------
51- Verification IS the contract (plan 243 P3): a prepare that prints
52 "verified" means STEP-0 and both suites can run in that worktree, and a
53 prepare that cannot say so refuses naming EVERY module that failed —
54 never the first one only. No worker repairs a prepared venv by hand.
55- ``uv`` is an external tool the operator installs; this module CHECKS for
56 it and refuses by name when it is absent. It never installs it, and it
57 never silently falls back to the shared venv, which would re-open the
58 shadow this module exists to close.
59- The uv cache must live on the same drive as the venv it fills: across
60 drives uv cannot hardlink and silently copies. A cross-drive cache is
61 refused with both paths named.
62- Nothing outside ``worktree`` (and the git metadata of ``repo_root``) is
63 written. The shared main venv is never touched.
64- ``oct`` is optional and absent by default (plan 270 P1/P4). Every edge
65 to it goes through :data:`OctHook`, whose default refuses, so the
66 timing ledger degrades to a null context and nothing in this package's
67 import graph names the toolchain.
68- :data:`EDITABLE_PACKAGES` and :data:`SMOKE_CHECKS` are DATA describing
69 the Option C monorepo, not a contract of this package: a different
70 consumer supplies a different tree and would not use them. They are
71 the honest residue of the split, named here rather than hidden.
72
73Dependencies
74------------
75- oc_diagnostics (structured debug logging via _dbg; also the home of the venv repair file this module copies into a fresh venv)
76- uv (not imported; external tool run as a subprocess for the venv and its editable installs, checked for, never installed)
77"""
78
79from __future__ import annotations
80
81from dataclasses import dataclass, field
82import json
83import os
84from pathlib import Path
85import shutil
86import subprocess
87import sys
88import tempfile
89import time
90from typing import Any, Callable
91
92try: # pragma: no cover - 3.11+ everywhere this module runs
93 import tomllib
94except ModuleNotFoundError: # pragma: no cover - 3.10 has no tomllib
95 tomllib = None # type: ignore[assignment]
96
97import oc_diagnostics
98from oc_diagnostics import _dbg
99
100_dbg("OC_CHAT.MAIN", "module", "worktree_env loaded", 4)
101
102
103class WorktreeEnvError(RuntimeError):
104 """A named refusal at the worktree-environment boundary."""
105
106
107#: Resolves one member of the optional ``oct`` sibling by name.
108OctHook = Callable[[str], Any]
109
110#: The one target the hook still resolves. Plan 270 P1 retired the second
111#: (``"package"``): the venv repair file lives in ``oc_diagnostics`` now,
112#: so :func:`sitecustomize_source` reaches ``oct`` not at all.
113OCT_HOOK_TARGETS = ("timed_stage",)
114
115
116def _default_oct_hook(target: str) -> Any:
117 """Refuse every target: this package does not know ``oct`` exists.
118
119 Plan 257 P5 made ``oct`` reachable through exactly one seam so that a
120 packaging plan could make it a genuine optional extra by replacing
121 this default and changing nothing else. Plan 270 is that plan, and
122 this is that replacement: the default now raises ``ImportError`` for
123 a known target, which is precisely what :func:`_engine_timing` reads
124 as "oct is absent" and degrades to a null context for. A standalone
125 dispatch therefore records no timings and reaches no toolchain, and
126 ``oct`` appears nowhere in this package's import graph.
127
128 A consumer that HAS ``oct`` installs the live hook at its boundary
129 with :func:`use_oct_hook` — ``oc_chat`` does, in its
130 ``core/worktree_env.py`` shim — and the timing ledger fills exactly
131 as it did before the split.
132
133 An UNKNOWN target is still a :class:`WorktreeEnvError`, deliberately
134 not an ``ImportError`` subclass, so the timing edge's ``except
135 ImportError`` cannot swallow a programming mistake as "oct is absent".
136 """
137 func = "_default_oct_hook"
138 if target in OCT_HOOK_TARGETS:
139 _dbg("OC_CHAT.MAIN", func, f"no oct hook installed for {target!r}", 4)
140 raise ImportError(
141 f"no oct hook is installed, so {target!r} cannot be resolved; "
142 f"a consumer that has oct registers one with use_oct_hook()"
143 )
144 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: unknown target {target!r}", 3)
145 raise WorktreeEnvError(f"unknown oct hook target {target!r}")
146
147
148#: The module default; a caller passes ``oct_hook=`` to substitute one for
149#: a single call, or :func:`use_oct_hook` to substitute the default.
150_oct_hook: OctHook = _default_oct_hook
151
152
153def use_oct_hook(hook: OctHook | None) -> OctHook:
154 """Install *hook* as this module's default, returning the previous one.
155
156 The registration point for a consumer that ships ``oct`` (plan 270
157 P4). Passing ``None`` restores :func:`_default_oct_hook`, which is
158 what a test that borrowed the seam uses to give it back.
159 """
160 func = "use_oct_hook"
161 global _oct_hook
162 previous = _oct_hook
163 _oct_hook = hook if hook is not None else _default_oct_hook
164 _dbg("OC_CHAT.MAIN", func, f"oct hook is now {getattr(_oct_hook, '__name__', _oct_hook)!r}", 3)
165 return previous
166
167
168@dataclass(frozen=True)
170 """One editable install: where it lives, what it is called, what it needs."""
171
172 relative_path: str
173 dist_name: str
174 import_name: str
175 extras: tuple[str, ...] = ()
176 #: Plan 243 P1. Modules the declared ``extras`` must make importable,
177 #: beyond ``import_name`` itself: the imports the dev-engine tools and
178 #: the two suites actually make. These live in the venv's
179 #: ``site-packages``, not in the worktree's source tree, so they are
180 #: verified for importability and venv residence rather than for
181 #: resolving inside the checkout.
182 verify_imports: tuple[str, ...] = ()
183
184 def source_dir(self, root: Path) -> Path:
185 """This package's project directory beneath *root*."""
186 return root / Path(self.relative_path)
187
188 def install_spec(self, root: Path) -> str:
189 """The ``uv pip install -e`` argument for this package under *root*."""
190 target = str(self.source_dir(root))
191 if self.extras:
192 return f"{target}[{','.join(self.extras)}]"
193 return target
194
195 def declares_its_extras(self, root: Path) -> bool:
196 """Whether this package's own pyproject declares every named extra.
197
198 A stand-in package built by a fixture (and any checkout that has
199 renamed an extra) declares none of them: ``uv`` only warns for an
200 extra that does not exist, so nothing is installed and there is
201 nothing to verify. Saying so is honest; demanding the modules
202 anyway would refuse every fixture tree. The live checkout is
203 fenced separately by the plan-243 W5 witness, which asserts each
204 declared extra really exists.
205 """
206 if not self.extras:
207 return True
208 if tomllib is None: # pragma: no cover - Python 3.10 has no tomllib
209 return False
210 path = self.source_dir(root) / "pyproject.toml"
211 try:
212 with path.open("rb") as handle:
213 loaded = tomllib.load(handle)
214 except (OSError, ValueError):
215 return False
216 project = loaded.get("project")
217 declared = project.get("optional-dependencies") if isinstance(project, dict) else None
218 if not isinstance(declared, dict):
219 return False
220 return all(extra in declared for extra in self.extras)
221
222
223#: The monorepo's editable packages, in install order. Verified against the
224#: checkout by ``test_w1_declared_package_roots_all_exist_in_this_checkout``.
225#: Plan 243 P1 (ENV-02): the table declares what a DEVELOPMENT checkout
226#: needs, so ``oct`` installs its aggregate ``dev`` extra (test, docs, mcp,
227#: metrics, claude) and ``oc_chat`` its ``terminal`` and ``test`` extras.
228#: The ``verify_imports`` columns are the imports the dev-engine tools and
229#: the two suites actually make: ``yaml`` for the STEP-0 ``plan_status``
230#: tool, ``mcp.server.mcpserver`` for ``oct/tests/tests_mcp`` collection,
231#: ``jinja2`` and ``sphinx`` for ``tests_docs``, and
232#: ``setuptools``/``setuptools_scm`` for the two named oc_chat tests. Both
233#: worktrees prepared before this landed were short of exactly those, and
234#: both workers repaired them by hand.
235EDITABLE_PACKAGES: tuple[EditablePackage, ...] = (
236 EditablePackage("oc_diagnostics", "oc_diagnostics", "oc_diagnostics"),
238 "oct",
239 "oct",
240 "oct",
241 ("dev",),
242 (
243 "pytest",
244 "xdist",
245 "pytest_timeout",
246 # NOT bare ``mcp``: what oct/tests/tests_mcp needs is the module
247 # oct's own server imports, and mcp 2.x renamed it. A resolved
248 # mcp that cannot serve oct is a refusal here rather than a
249 # green prepare followed by an aborted oct collection.
250 "mcp.server.mcpserver", # plan 244: the v2 SDK module oct-mcp builds on
251 "jinja2",
252 "sphinx",
253 ),
254 ),
255 EditablePackage("oc_pricing", "oc-pricing", "oc_pricing"),
256 EditablePackage("oc_connect", "oc-connect", "oc_connect"),
257 # Plan 270 P2: the dispatch layer's own package. It MUST be installed
258 # before oc_chat can be, because oc_chat now declares it — uv resolves
259 # the whole `-e` set in one command, so the row is what makes the
260 # requirement resolvable at all inside a worktree.
261 EditablePackage("oc_orchestrator", "oc-orchestrator", "oc_orchestrator"),
263 "contracts/option_c_agent_contracts",
264 "option-c-agent-contracts",
265 "option_c_agent_contracts",
266 ),
268 "contracts/option_c_connector_contracts",
269 "option-c-connector-contracts",
270 "option_c_connector_contracts",
271 ),
273 "contracts/option_c_registry_contracts",
274 "option-c-registry-contracts",
275 "option_c_registry_contracts",
276 ),
278 "oc_chat/oc_ui", "oc_ui", "oc_ui", ("terminal",), ("rich", "prompt_toolkit")
279 ),
281 "oc_chat/oc_chat",
282 "oc_chat",
283 "oc_chat",
284 ("terminal", "test"),
285 ("yaml", "setuptools_scm", "setuptools", "rich", "prompt_toolkit"),
286 ),
288 "oc_chat/providers/deepseek", "providers-deepseek", "providers.deepseek"
289 ),
291 "oc_chat/providers/example", "providers-example", "providers.example"
292 ),
294 "oc_chat/providers/ollama", "providers-ollama", "providers.ollama"
295 ),
297 "oc_chat/providers/openai_compat",
298 "providers-openai-compat",
299 "providers.openai_compat",
300 ),
301)
302
303#: Written into the prepared venv so a second run can tell "already
304#: prepared" from "needs installing" without re-resolving thirteen builds.
305STAMP_FILENAME = ".oc_prepare.json"
306STAMP_VERSION = 1
307
308#: The workspace-root cache directory plan 241 P2 pins on the venvs' drive.
309UV_CACHE_DIRNAME = ".uv-cache"
310
311#: Plan 243 P4: the two things every worker actually does first, run inside
312#: the prepared worktree on ``--smoke``. Verification proves the modules
313#: import; these prove the tool and the suite prerequisites really run.
314#: Plan 270: this table, like :data:`EDITABLE_PACKAGES`, states what the
315#: Option C MONOREPO's workers do. The first entry names the module path
316#: the rendered brief tells the worker to run — ``oc_chat``'s shim, not
317#: this package's own module. Naming the package here instead would
318#: quietly stop proving that the brief's OWN command works, which is the
319#: whole point of the check: a brief handed out before its command is
320#: known to work is the hand-repair loop ENV-02 exists to end.
321SMOKE_CHECKS: tuple[tuple[str, tuple[str, ...]], ...] = (
322 (
323 "STEP-0 tool (plan_status --help)",
324 ("-m", "oc_chat.scripts.plan_status", "--help"),
325 ),
326 (
327 "dev-engine imports",
328 ("-c", "import yaml, mcp, jinja2, setuptools_scm, setuptools"),
329 ),
330)
331
332_UV_INSTALL_HINT = (
333 "install it (https://docs.astral.sh/uv/) and re-run, or dispatch with "
334 "--shared-venv to render the legacy shared-venv brief explicitly"
335)
336_HARDLINK_FALLBACK_MARKER = "failed to hardlink"
337
338
339# ── git plumbing (shared with oc_orchestrator.dispatch) ───────────────────────
340
341
343 repo_root: Path, *args: str, check: bool = True
344) -> subprocess.CompletedProcess[str]:
345 """Run one git command in *repo_root*, refusing by name when it fails."""
346 result = subprocess.run(
347 ["git", *args],
348 cwd=repo_root,
349 text=True,
350 capture_output=True,
351 check=False,
352 )
353 if check and result.returncode != 0:
354 detail = (result.stderr or result.stdout).strip()
355 raise WorktreeEnvError(f"git {' '.join(args)} failed: {detail}")
356 return result
357
358
359def _branch_exists(repo_root: Path, branch: str) -> bool:
360 probe = _run_git(
361 repo_root, "show-ref", "--verify", f"refs/heads/{branch}", check=False
362 )
363 return probe.returncode == 0
364
365
366def _same_path(left: Path, right: Path) -> bool:
367 return str(left.resolve()).casefold() == str(right.resolve()).casefold()
368
369
370def worktree_entries(repo_root: Path) -> list[tuple[Path, str | None]]:
371 """Return ``(path, branch)`` for every worktree registered to *repo_root*."""
372 result = _run_git(repo_root, "worktree", "list", "--porcelain")
373 entries: list[tuple[Path, str | None]] = []
374 path: Path | None = None
375 branch: str | None = None
376 for line in [*result.stdout.splitlines(), ""]:
377 if line.startswith("worktree "):
378 path = Path(line.removeprefix("worktree ")).resolve()
379 elif line.startswith("branch refs/heads/"):
380 branch = line.removeprefix("branch refs/heads/")
381 elif not line and path is not None:
382 entries.append((path, branch))
383 path = None
384 branch = None
385 return entries
386
387
389 repo_root: Path,
390 branch: str,
391 worktree: Path,
392 *,
393 base_sha: str | None = None,
394) -> None:
395 """Create the branch and worktree when absent; reuse them when present.
396
397 *base_sha* is the anchor the branch is created at (``master`` when it is
398 ``None``). When it is supplied AND the branch already exists at a
399 different commit the call refuses: an operator who names an anchor is
400 asserting where the branch starts, and silently accepting a branch that
401 starts somewhere else is the lie plan 223 P4 removed from BASE_SHA.
402 Dispatch passes ``None`` — it has already recorded the merge-base.
403 """
404 func = "ensure_branch_and_worktree"
405 entries = worktree_entries(repo_root)
406 for existing_path, existing_branch in entries:
407 if existing_branch == branch:
408 if _same_path(existing_path, worktree):
409 _dbg("OC_CHAT.MAIN", func, f"{branch} already at {worktree}", 3)
410 return
411 raise WorktreeEnvError(
412 f"branch {branch!r} is already checked out at {existing_path}"
413 )
414 if _same_path(existing_path, worktree):
415 raise WorktreeEnvError(
416 f"worktree path {worktree} is registered to branch {existing_branch!r}"
417 )
418 if worktree.exists():
419 raise WorktreeEnvError(
420 f"worktree path {worktree} exists but is not registered with git"
421 )
422 created_branch = not _branch_exists(repo_root, branch)
423 if created_branch:
424 _run_git(repo_root, "branch", branch, base_sha or "master")
425 elif base_sha is not None:
426 tip = _run_git(repo_root, "rev-parse", branch).stdout.strip()
427 anchor = _run_git(repo_root, "rev-parse", base_sha).stdout.strip()
428 if tip != anchor:
429 raise WorktreeEnvError(
430 f"branch {branch!r} exists at {tip} but the requested anchor is "
431 f"{anchor}; refusing to move or reuse a branch that starts elsewhere"
432 )
433 try:
434 worktree.parent.mkdir(parents=True, exist_ok=True)
435 _run_git(repo_root, "worktree", "add", str(worktree), branch)
436 except Exception:
437 if created_branch:
438 _run_git(repo_root, "branch", "-d", branch, check=False)
439 raise
440 _dbg("OC_CHAT.MAIN", func, f"{branch} checked out at {worktree}", 2)
441
442
443# ── venv layout helpers ────────────────────────────────────────────────────
444
445
446def venv_scripts_dir(venv: Path) -> Path:
447 """The venv's executables directory (``Scripts`` on Windows, ``bin`` else)."""
448 posix = venv / "bin"
449 if posix.is_dir() and not (venv / "Scripts").is_dir():
450 return posix
451 return venv / "Scripts"
452
453
454def venv_python(venv: Path) -> Path:
455 """The venv's interpreter path."""
456 scripts = venv_scripts_dir(venv)
457 return scripts / ("python.exe" if scripts.name == "Scripts" else "python")
458
459
460def venv_site_packages(venv: Path) -> Path:
461 """The venv's ``site-packages`` directory, refusing when it is absent."""
462 windows = venv / "Lib" / "site-packages"
463 if windows.is_dir():
464 return windows
465 for candidate in sorted((venv / "lib").glob("python*/site-packages")):
466 return candidate
467 raise WorktreeEnvError(f"no site-packages directory under {venv}")
468
469
470def _drive_of(path: Path) -> str:
471 """The drive (or UNC share) *path* lives on; patched by the W2 witness."""
472 return Path(path).drive.casefold()
473
474
475def discover_packages(root: Path) -> tuple[EditablePackage, ...]:
476 """Return the declared packages that actually exist beneath *root*."""
477 func = "discover_packages"
478 found = tuple(
479 package
480 for package in EDITABLE_PACKAGES
481 if (package.source_dir(root) / "pyproject.toml").is_file()
482 )
483 _dbg("OC_CHAT.MAIN", func, f"{len(found)} package(s) under {root}", 3)
484 return found
485
486
487def sitecustomize_source(worktree: Path) -> Path:
488 """Locate ``venv_sitecustomize.py``, preferring the worktree's own copy.
489
490 A fresh venv without this file resolves ``oc_diagnostics`` as a bare
491 namespace package (``__file__`` is ``None``) — the OCON incident.
492
493 Plan 270 P1 moved the file into ``oc_diagnostics``, the package it
494 repairs, so the order is: the worktree's own copy first (a checkout
495 ships its own), then the copy inside the ``oc_diagnostics`` importable
496 in THIS process, then — for one arc — the pre-270 location under the
497 worktree's ``oct/oct/tools/``, which is what a checkout cut before the
498 move and a fixture tree with no toolchain of its own still carry. With
499 the file re-homed, ``oct`` is no longer reached from this edge at all,
500 which is what takes ``oct`` out of the dispatcher package's graph.
501
502 Unlike the timing edge, this one is a HARD refusal, and it stays that
503 way — an unusable venv is worse than a loud failure. The refusal names
504 every location that was tried, never just the first.
505 """
506 func = "sitecustomize_source"
507 candidates = [
508 worktree / "oc_diagnostics" / "oc_diagnostics" / "venv_sitecustomize.py",
509 Path(oc_diagnostics.__file__).resolve().parent / "venv_sitecustomize.py"
510 if oc_diagnostics.__file__
511 else None,
512 worktree / "oct" / "oct" / "tools" / "venv_sitecustomize.py",
513 ]
514 for candidate in candidates:
515 if candidate is not None and candidate.is_file():
516 _dbg("OC_CHAT.MAIN", func, f"venv_sitecustomize.py at {candidate}", 3)
517 return candidate
518 tried = ", ".join(str(candidate) for candidate in candidates if candidate is not None)
519 raise WorktreeEnvError(f"cannot locate venv_sitecustomize.py: tried {tried}")
520
521
522# ── prepare ────────────────────────────────────────────────────────────────
523
524
525@dataclass(frozen=True)
527 """All caller-controlled inputs to one prepare attempt."""
528
529 repo_root: Path
530 branch: str
531 worktree: Path
532 base_sha: str | None = None
533 uv_executable: str = "uv"
534 uv_cache_dir: Path | None = None
535 interpreter: Path = field(default_factory=lambda: Path(sys.executable))
536 #: Plan 243 P4. Off for the bare CLI command so the 241 witnesses'
537 #: expectations of it stay byte-identical; ``dispatch`` passes it.
538 smoke: bool = False
539
540
541@dataclass(frozen=True)
543 """What one prepare attempt actually did."""
544
545 worktree: Path
546 branch: str
547 venv: Path | None
548 python: Path | None
549 installed: tuple[str, ...]
550 verified: tuple[tuple[str, str], ...]
551 uv_cache_dir: Path | None
552 link_mode: str
553 already_prepared: bool
554 repaired: tuple[str, ...]
555 skipped_reason: str | None
556 elapsed_seconds: float
557 #: Plan 243 P1: ``(module, __file__)`` for every ``verify_imports``
558 #: module the declared extras had to supply.
559 verified_extras: tuple[tuple[str, str], ...] = ()
560 #: Packages whose own pyproject declares none of the extras the table
561 #: names for them, so nothing was installed and nothing was verified.
562 extras_not_declared: tuple[str, ...] = ()
563 #: Plan 243 P4: the ``--smoke`` checks that ran and passed.
564 smoke_checks: tuple[str, ...] = ()
565 #: Plan 243 P3: what the stamp of an older prepare disagreed about.
566 stale_reason: str | None = None
567
568 def report_lines(self) -> list[str]:
569 """Operator-facing lines; surfaced verbatim by dispatch and the CLI."""
570 if self.skipped_reason is not None:
571 return [f"prepare skipped: {self.skipped_reason}"]
572 state = "already prepared" if self.already_prepared else "prepared"
573 lines = [
574 f"Worktree env {state}: {self.worktree}",
575 f" venv: {self.venv}",
576 f" uv cache: {self.uv_cache_dir} (link mode: {self.link_mode})",
577 f" installed: {len(self.installed)} editable package(s)",
578 f" verified: {len(self.verified)} import(s) inside the worktree",
579 f" extras: {self._extras_summary()}",
580 f" wall clock: {self.elapsed_seconds:.1f}s",
581 ]
582 if self.stale_reason is not None:
583 lines.append(f" stale: {self.stale_reason}")
584 if self.smoke_checks:
585 lines.append(f" smoke: {', '.join(self.smoke_checks)}")
586 for repair in self.repaired:
587 lines.append(f" repaired: {repair}")
588 return lines
589
590 def _extras_summary(self) -> str:
591 """One line naming what the declared extras were asked to supply."""
592 summary = f"{len(self.verified_extras)} declared import(s) verified"
593 if self.extras_not_declared:
594 summary += (
595 f" ({len(self.extras_not_declared)} package(s) declare no such "
596 f"extra: {', '.join(self.extras_not_declared)})"
597 )
598 return summary
599
600
601def _resolve_uv(executable: str) -> str:
602 """Return the resolved ``uv`` path, or refuse by name with the hint."""
603 resolved = shutil.which(executable)
604 if resolved is None:
605 raise WorktreeEnvError(
606 f"{executable!r} is not on PATH; per-worktree environments need it — "
607 f"{_UV_INSTALL_HINT}"
608 )
609 return resolved
610
611
612def _resolve_cache_dir(request: PrepareRequest, worktree: Path) -> Path:
613 """Pin the uv cache to the worktree's drive, or refuse naming both paths."""
614 func = "_resolve_cache_dir"
615 cache = (
616 Path(request.uv_cache_dir).resolve()
617 if request.uv_cache_dir is not None
618 else (request.repo_root.resolve() / UV_CACHE_DIRNAME)
619 )
620 if _drive_of(cache) != _drive_of(worktree):
621 raise WorktreeEnvError(
622 f"uv cache {cache} is on a different drive from the venv it fills "
623 f"({worktree}); across drives uv cannot hardlink and silently copies. "
624 f"Point --uv-cache-dir at a directory on the venv's drive."
625 )
626 cache.mkdir(parents=True, exist_ok=True)
627 _dbg("OC_CHAT.MAIN", func, f"uv cache at {cache}", 3)
628 return cache
629
630
631def _uv_env(cache: Path) -> dict[str, str]:
632 env = dict(os.environ)
633 env["UV_CACHE_DIR"] = str(cache)
634 return env
635
636
638 uv: str, args: list[str], cache: Path, *, context: str
639) -> subprocess.CompletedProcess[str]:
640 """Run one uv command, refusing with uv's own diagnostic when it fails."""
641 func = "_run_uv"
642 result = subprocess.run(
643 [uv, *args],
644 text=True,
645 capture_output=True,
646 check=False,
647 env=_uv_env(cache),
648 )
649 if result.returncode != 0:
650 detail = (result.stderr or result.stdout).strip()
651 raise WorktreeEnvError(f"{context} failed: {detail}")
652 _dbg("OC_CHAT.MAIN", func, f"{context} ok", 3)
653 return result
654
655
657 packages: tuple[EditablePackage, ...], worktree: Path, interpreter: Path
658) -> dict[str, object]:
659 return {
660 "version": STAMP_VERSION,
661 "worktree": str(worktree),
662 "interpreter": str(interpreter),
663 "packages": [package.install_spec(worktree) for package in packages],
664 }
665
666
667def _read_stamp(venv: Path) -> dict[str, object] | None:
668 path = venv / STAMP_FILENAME
669 if not path.is_file():
670 return None
671 try:
672 loaded = json.loads(path.read_text(encoding="utf-8"))
673 except (OSError, ValueError):
674 return None
675 return loaded if isinstance(loaded, dict) else None
676
677
678def _link_mode(install_output: str, *, installed: bool) -> str:
679 """Report the link strategy uv actually used, never a guess."""
680 if not installed:
681 return "n/a (nothing installed this run)"
682 if _HARDLINK_FALLBACK_MARKER in install_output.casefold():
683 return "copy (uv fell back from hardlink)"
684 configured = os.environ.get("UV_LINK_MODE")
685 return configured if configured else "uv default (hardlink on Windows NTFS)"
686
687
688@dataclass(frozen=True)
690 """What one verification run proved, split by what it proves about."""
691
692 packages: tuple[tuple[str, str], ...]
693 extras: tuple[tuple[str, str], ...]
694 not_declared: tuple[str, ...]
695
696
697def _clean_env() -> dict[str, str]:
698 """The probe environment: no ``PYTHONPATH``, no user site-packages."""
699 env = {key: value for key, value in os.environ.items() if key != "PYTHONPATH"}
700 env["PYTHONNOUSERSITE"] = "1"
701 return env
702
703
705 python: Path,
706 packages: tuple[EditablePackage, ...],
707 worktree: Path,
708 venv: Path,
709) -> _Verification:
710 """Import every package and every declared extra, refusing by name.
711
712 Runs with ``PYTHONPATH`` REMOVED and from a neutral temporary directory,
713 so a pass means the editable installs alone resolve inside the worktree —
714 which is the whole point of the exercise.
715
716 Plan 243 P3 adds the second class: the modules the declared ``extras``
717 exist to supply. Those live in the venv's ``site-packages`` rather than
718 in the checkout, so they are held to importability and venv residence.
719 EVERY failure across both classes is named in one refusal — a worker
720 who has to re-run prepare once per missing package learns nothing the
721 first refusal could not have told them.
722 """
723 func = "_verify_imports"
724 script = (
725 # OCT-LINT: disable=no_hardcoded_secrets reason="inline probe SOURCE passed to python -c; mixed case plus \\t/\\n escapes read as high entropy, no credential material"
726 "import importlib, sys\n"
727 "for name in sys.argv[1:]:\n"
728 " try:\n"
729 " module = importlib.import_module(name)\n"
730 " except Exception as exc:\n"
731 " print(name + '\\tERROR\\t' + type(exc).__name__ + ': ' + str(exc))\n"
732 " continue\n"
733 " print(name + '\\tOK\\t' + str(getattr(module, '__file__', None)))\n"
734 )
735 names = [package.import_name for package in packages]
736 owners = {package.import_name: package.dist_name for package in packages}
737 extra_names: list[str] = []
738 not_declared: list[str] = []
739 for package in packages:
740 if not package.verify_imports:
741 continue
742 if not package.declares_its_extras(worktree):
743 not_declared.append(package.install_spec(worktree))
744 continue
745 for module in package.verify_imports:
746 if module not in extra_names and module not in names:
747 extra_names.append(module)
748 owners[module] = f"{package.dist_name}[{','.join(package.extras)}]"
749 with tempfile.TemporaryDirectory(prefix="oc_verify_") as neutral:
750 result = subprocess.run(
751 [str(python), "-c", script, *names, *extra_names],
752 text=True,
753 capture_output=True,
754 check=False,
755 cwd=neutral,
756 env=_clean_env(),
757 )
758 if result.returncode != 0:
759 raise WorktreeEnvError(
760 f"verification interpreter {python} failed: "
761 f"{(result.stderr or result.stdout).strip()}"
762 )
763
764 resolved = worktree.resolve()
765 resolved_venv = venv.resolve()
766 verified: list[tuple[str, str]] = []
767 verified_extras: list[tuple[str, str]] = []
768 problems: list[str] = []
769 seen: set[str] = set()
770 for line in result.stdout.splitlines():
771 if line.count("\t") < 2:
772 continue
773 name, status, value = line.split("\t", 2)
774 seen.add(name)
775 owner = owners.get(name, "?")
776 is_extra = name in extra_names
777 if status != "OK":
778 problems.append(f"{name}: import failed ({value}) — declared by {owner}")
779 continue
780 if value in ("None", ""):
781 problems.append(
782 f"{name}: resolved as a namespace package (__file__ is None) — "
783 f"the sitecustomize.py copy is missing or ineffective"
784 )
785 continue
786 if is_extra:
787 if not Path(value).resolve().is_relative_to(resolved_venv):
788 problems.append(
789 f"{name}: resolves OUTSIDE the prepared venv at {value} — "
790 f"{owner} did not supply it, something else on the path did"
791 )
792 continue
793 verified_extras.append((name, value))
794 continue
795 if not Path(value).resolve().is_relative_to(resolved):
796 problems.append(f"{name}: resolves OUTSIDE the worktree at {value}")
797 continue
798 verified.append((name, value))
799 problems.extend(
800 f"{name}: not reported by the probe"
801 for name in [*names, *extra_names]
802 if name not in seen
803 )
804 if problems:
805 raise WorktreeEnvError(
806 f"prepared venv {python.parent.parent} failed verification:\n "
807 + "\n ".join(problems)
808 )
809 _dbg(
810 "OC_CHAT.MAIN",
811 func,
812 f"verified {len(verified)} package + {len(verified_extras)} extra import(s)",
813 2,
814 )
815 return _Verification(
816 packages=tuple(verified),
817 extras=tuple(verified_extras),
818 not_declared=tuple(not_declared),
819 )
820
821
822def _run_smoke(python: Path, worktree: Path) -> tuple[str, ...]:
823 """Run the worker's own first two commands, refusing by name.
824
825 Plan 243 P4. Verification proves the modules import in a neutral
826 directory; this proves the STEP-0 tool and the suite prerequisites run
827 from the worktree the worker will actually sit in. Every check runs so
828 one refusal names everything that is wrong.
829 """
830 func = "_run_smoke"
831 problems: list[str] = []
832 passed: list[str] = []
833 for label, argv in SMOKE_CHECKS:
834 result = subprocess.run(
835 [str(python), *argv],
836 text=True,
837 capture_output=True,
838 check=False,
839 cwd=str(worktree),
840 env=_clean_env(),
841 )
842 rendered = " ".join(argv)
843 if result.returncode == 0:
844 passed.append(label)
845 continue
846 detail = (result.stderr or result.stdout).strip().splitlines()
847 tail = detail[-1] if detail else "(no output)"
848 problems.append(
849 f"{label}: `python {rendered}` exited {result.returncode} — {tail}"
850 )
851 if problems:
852 raise WorktreeEnvError(
853 f"prepared venv {python.parent.parent} failed the --smoke checks "
854 f"in {worktree}:\n " + "\n ".join(problems)
855 )
856 _dbg("OC_CHAT.MAIN", func, f"{len(passed)} smoke check(s) passed", 2)
857 return tuple(passed)
858
859
860def _describe_stale(previous: dict[str, object], current: dict[str, object]) -> str:
861 """Say what an older prepare's stamp disagrees with the table about.
862
863 Plan 243 P3. A table change (an added extra, a new package) must make
864 an already-prepared worktree report itself stale rather than silently
865 reinstall, so an operator reading the summary learns WHY the run was
866 not the no-op they expected.
867 """
868 old = [str(spec) for spec in _stamp_specs(previous)]
869 new = [str(spec) for spec in _stamp_specs(current)]
870 parts: list[str] = []
871 added = [spec for spec in new if spec not in old]
872 removed = [spec for spec in old if spec not in new]
873 if added:
874 parts.append("now installs " + ", ".join(added))
875 if removed:
876 parts.append("no longer installs " + ", ".join(removed))
877 if previous.get("interpreter") != current.get("interpreter"):
878 parts.append(
879 f"interpreter changed from {previous.get('interpreter')} to "
880 f"{current.get('interpreter')}"
881 )
882 if not parts:
883 parts.append("the recorded stamp differs from the declared table")
884 return "declared spec list changed since this venv was prepared: " + "; ".join(parts)
885
886
887def _stamp_specs(stamp: dict[str, object]) -> list[object]:
888 packages = stamp.get("packages")
889 return list(packages) if isinstance(packages, list) else []
890
891
893 repo_root: Path, stage: str, plan: str, *, oct_hook: OctHook | None = None
894):
895 """Load optional timing support without making oct an engine dependency."""
896 from contextlib import nullcontext
897 import re
898 func = "_engine_timing"
899 try:
900 timed_stage = (oct_hook or _oct_hook)("timed_stage")
901 except ImportError:
902 _dbg("OC_CHAT.MAIN", func, "timing unavailable without oct", 3)
903 return nullcontext(None)
904 match = re.search(r"(?:^|[/_-])(\d+)(?:$|[/_-])", plan)
905 return timed_stage(repo_root, stage, scope=match.group(1) if match else None,
906 files=0 if stage == "engine.prepare" else None)
907
908
909def prepare_worktree(request: PrepareRequest) -> PrepareResult:
910 """Create the branch, worktree and its own uv venv, then verify it."""
911 func = "prepare_worktree"
912 with _engine_timing(request.repo_root, "engine.prepare", request.branch) as timing:
913 started = time.monotonic()
914 repo_root = Path(request.repo_root).resolve()
915 worktree = Path(request.worktree).resolve()
916 _dbg("OC_CHAT.MAIN", func, f"prepare {request.branch} at {worktree}", 2)
917
919 repo_root, request.branch, worktree, base_sha=request.base_sha
920 )
921
922 packages = discover_packages(worktree)
923 if not packages:
924 reason = (
925 f"no declared Option C package roots exist under {worktree}; "
926 f"there is nothing to install and no editable shadow to close"
927 )
928 _dbg("OC_CHAT.MAIN", func, reason, 2)
929 return PrepareResult(
930 worktree=worktree,
931 branch=request.branch,
932 venv=None,
933 python=None,
934 installed=(),
935 verified=(),
936 uv_cache_dir=None,
937 link_mode="n/a",
938 already_prepared=False,
939 repaired=(),
940 skipped_reason=reason,
941 elapsed_seconds=time.monotonic() - started,
942 )
943
944 uv = _resolve_uv(request.uv_executable)
945 cache = _resolve_cache_dir(request, worktree)
946 venv = worktree / ".venv"
947 python = venv_python(venv)
948
949 if not python.is_file():
950 _run_uv(
951 uv,
952 ["venv", "--python", str(request.interpreter), str(venv)],
953 cache,
954 context=f"uv venv at {venv}",
955 )
956 python = venv_python(venv)
957 if not python.is_file():
958 raise WorktreeEnvError(f"uv venv produced no interpreter at {python}")
959
960 stamp = _stamp_payload(packages, worktree, request.interpreter)
961 previous = _read_stamp(venv)
962 already = previous == stamp
963 stale_reason = None if already or previous is None else _describe_stale(previous, stamp)
964 if stale_reason is not None:
965 _dbg("OC_CHAT.MAIN", func, f"stale stamp: {stale_reason}", 2)
966 installed: tuple[str, ...] = ()
967 install_output = ""
968 if not already:
969 specs = [package.install_spec(worktree) for package in packages]
970 result = _run_uv(
971 uv,
972 ["pip", "install", "--python", str(python), *_editable(specs)],
973 cache,
974 context=f"uv pip install -e into {venv}",
975 )
976 install_output = f"{result.stdout}\n{result.stderr}"
977 installed = tuple(package.dist_name for package in packages)
978 if timing is not None:
979 timing.files = len(installed)
980 (venv / STAMP_FILENAME).write_text(
981 json.dumps(stamp, indent=2, sort_keys=True), encoding="utf-8"
982 )
983
984 repaired = _ensure_sitecustomize(venv, worktree)
985 try:
986 verification = _verify_imports(python, packages, worktree, venv)
987 smoke_checks = _run_smoke(python, worktree) if request.smoke else ()
988 except WorktreeEnvError:
989 # The stamp claims "prepared" and verification says otherwise — the
990 # state a changed DECLARATION leaves behind, since the stamp records
991 # the resolved spec list and an edited pyproject does not change it.
992 # Leaving it would make every re-run skip the install and fail
993 # identically, so the only way out would be deleting it by hand: the
994 # hand repair this module exists to end. Clearing it makes the next
995 # prepare reinstall and try again.
996 (venv / STAMP_FILENAME).unlink(missing_ok=True)
997 raise
998 elapsed = time.monotonic() - started
999 _dbg(
1000 "OC_CHAT.MAIN",
1001 func,
1002 f"prepared {worktree} in {elapsed:.1f}s "
1003 f"({len(verification.packages)} verified)",
1004 2,
1005 )
1006 return PrepareResult(
1007 worktree=worktree,
1008 branch=request.branch,
1009 venv=venv,
1010 python=python,
1011 installed=installed,
1012 verified=verification.packages,
1013 uv_cache_dir=cache,
1014 link_mode=_link_mode(install_output, installed=bool(installed)),
1015 already_prepared=already,
1016 repaired=repaired,
1017 skipped_reason=None,
1018 elapsed_seconds=elapsed,
1019 verified_extras=verification.extras,
1020 extras_not_declared=verification.not_declared,
1021 smoke_checks=smoke_checks,
1022 stale_reason=stale_reason,
1023 )
1024
1025
1026def _editable(specs: list[str]) -> list[str]:
1027 """Interleave ``-e`` before every install spec."""
1028 argv: list[str] = []
1029 for spec in specs:
1030 argv.extend(["-e", spec])
1031 return argv
1032
1033
1034def _ensure_sitecustomize(venv: Path, worktree: Path) -> tuple[str, ...]:
1035 """Copy ``venv_sitecustomize.py`` in, reporting a repair when it was gone."""
1036 func = "_ensure_sitecustomize"
1037 source = sitecustomize_source(worktree)
1038 target = venv_site_packages(venv) / "sitecustomize.py"
1039 existed = target.is_file()
1040 if existed and target.read_bytes() == source.read_bytes():
1041 return ()
1042 shutil.copyfile(source, target)
1043 verb = "refreshed" if existed else "installed"
1044 note = f"sitecustomize.py {verb} at {target}"
1045 _dbg("OC_CHAT.MAIN", func, note, 3)
1046 return (note,)
1047
1048
1049# ── teardown ───────────────────────────────────────────────────────────────
1050
1051
1052@dataclass(frozen=True)
1054 """What one teardown attempt actually did."""
1055
1056 worktree: Path
1057 removed: bool
1058 messages: tuple[str, ...]
1059
1060 def report_lines(self) -> list[str]:
1061 """Operator-facing lines; surfaced verbatim by the CLI."""
1062 head = (
1063 f"Removed worktree {self.worktree}"
1064 if self.removed
1065 else f"Worktree {self.worktree} was NOT removed"
1066 )
1067 return [head, *(f" {message}" for message in self.messages)]
1068
1069
1071 repo_root: Path, worktree: Path, *, branch: str | None = None
1072) -> TeardownResult:
1073 """Remove a prepared worktree and its venv, naming any residue by path."""
1074 func = "teardown_worktree"
1075 with _engine_timing(repo_root, "engine.teardown", branch or worktree.name) as timing:
1076 repo_root = Path(repo_root).resolve()
1077 worktree = Path(worktree).resolve()
1078 metadata = repo_root / ".git" / "worktrees" / worktree.name
1079 registered = any(
1080 _same_path(path, worktree) for path, _branch in worktree_entries(repo_root)
1081 )
1082 if not registered:
1083 if worktree.exists():
1084 raise WorktreeEnvError(
1085 f"{worktree} exists but is not a worktree of {repo_root}; "
1086 f"refusing to delete a directory git does not own"
1087 )
1088 _dbg("OC_CHAT.MAIN", func, f"{worktree} already gone", 2)
1089 return TeardownResult(
1090 worktree=worktree,
1091 removed=True,
1092 messages=("already removed (not registered, nothing on disk)",),
1093 )
1094
1095 removal = _run_git(
1096 repo_root, "worktree", "remove", "--force", str(worktree), check=False
1097 )
1098 if removal.returncode != 0:
1099 detail = (removal.stderr or removal.stdout).strip()
1100 _dbg("OC_CHAT.MAIN", func, f"remove refused: {detail}", 2)
1101 if timing is not None:
1102 timing.outcome = "error"
1103 return TeardownResult(
1104 worktree=worktree,
1105 removed=False,
1106 messages=(
1107 f"git worktree remove failed: {detail}",
1108 f"the known Windows failure leaves {metadata} behind; nothing "
1109 f"else was deleted. Remove that directory by hand, then run "
1110 f"`git worktree prune` in {repo_root}.",
1111 ),
1112 )
1113
1114 _run_git(repo_root, "worktree", "prune", check=False)
1115 if branch is not None and _branch_exists(repo_root, branch):
1116 _dbg("OC_CHAT.MAIN", func, f"branch {branch} left in place", 3)
1117 if worktree.exists():
1118 if timing is not None:
1119 timing.outcome = "error"
1120 return TeardownResult(
1121 worktree=worktree,
1122 removed=False,
1123 messages=(
1124 f"git reported success but {worktree} is still on disk; "
1125 f"a handle is open on it (an editor, a shell, or a running "
1126 f"interpreter from {worktree / '.venv'}).",
1127 ),
1128 )
1129 _dbg("OC_CHAT.MAIN", func, f"removed {worktree}", 2)
1130 return TeardownResult(
1131 worktree=worktree,
1132 removed=True,
1133 messages=(f"venv and checkout deleted; {metadata} pruned",),
1134 )
1135
1136
1137__all__ = [
1138 "EDITABLE_PACKAGES",
1139 "EditablePackage",
1140 "PrepareRequest",
1141 "PrepareResult",
1142 "SMOKE_CHECKS",
1143 "STAMP_FILENAME",
1144 "TeardownResult",
1145 "OCT_HOOK_TARGETS",
1146 "OctHook",
1147 "UV_CACHE_DIRNAME",
1148 "WorktreeEnvError",
1149 "discover_packages",
1150 "ensure_branch_and_worktree",
1151 "prepare_worktree",
1152 "sitecustomize_source",
1153 "teardown_worktree",
1154 "use_oct_hook",
1155 "venv_python",
1156 "venv_scripts_dir",
1157 "venv_site_packages",
1158 "worktree_entries",
1159]
bool _branch_exists(Path repo_root, str branch)
Path sitecustomize_source(Path worktree)
bool _same_path(Path left, Path right)
dict[str, str] _uv_env(Path cache)
_engine_timing(Path repo_root, str stage, str plan, *, OctHook|None oct_hook=None)
str _describe_stale(dict[str, object] previous, dict[str, object] current)
str _link_mode(str install_output, *, bool installed)
OctHook use_oct_hook(OctHook|None hook)
None ensure_branch_and_worktree(Path repo_root, str branch, Path worktree, *, str|None base_sha=None)
_Verification _verify_imports(Path python, tuple[EditablePackage,...] packages, Path worktree, Path venv)
tuple[EditablePackage,...] discover_packages(Path root)
Path _resolve_cache_dir(PrepareRequest request, Path worktree)
subprocess.CompletedProcess[str] _run_uv(str uv, list[str] args, Path cache, *, str context)
dict[str, object] _stamp_payload(tuple[EditablePackage,...] packages, Path worktree, Path interpreter)
list[object] _stamp_specs(dict[str, object] stamp)
tuple[str,...] _run_smoke(Path python, Path worktree)
str _resolve_uv(str executable)
list[str] _editable(list[str] specs)
dict[str, object]|None _read_stamp(Path venv)
Path venv_scripts_dir(Path venv)
Any _default_oct_hook(str target)
Path venv_site_packages(Path venv)
PrepareResult prepare_worktree(PrepareRequest request)
subprocess.CompletedProcess[str] _run_git(Path repo_root, *str args, bool check=True)
TeardownResult teardown_worktree(Path repo_root, Path worktree, *, str|None branch=None)
tuple[str,...] _ensure_sitecustomize(Path venv, Path worktree)
list[tuple[Path, str|None]] worktree_entries(Path repo_root)