Skip to content

Commit 9b1a1c0

Browse files
committed
fix(heal): target ghost repair directly to main environment
- now checks physical site-packages paths, bypassing bubble pollution. - now forces strategy during repair. - Fixes bug where healed packages were bubbled while broken metadata was restored to main.
1 parent 290da0e commit 9b1a1c0

1 file changed

Lines changed: 162 additions & 25 deletions

File tree

src/omnipkg/commands/run.py

Lines changed: 162 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -546,26 +546,48 @@ def analyze_runtime_failure_and_heal(
546546

547547
attempted_fixes.add(ghost_key)
548548

549-
# Force reinstall using stable-main (fix the environment)
549+
# Ghost repair: install DIRECTLY to main env.
550+
# We MUST NOT use stable-main here — that strategy stashes the old
551+
# version, lets uv install the new one, then immediately moves the
552+
# new install into a bubble and restores the stash. For a ghost
553+
# (files deleted, metadata still present) that would put the fresh
554+
# copy into a bubble while the broken ghost goes back to main —
555+
# exactly the failure mode that triggered this healer in the first
556+
# place. latest-active writes straight to site-packages and leaves
557+
# it there.
558+
#
559+
# Pin the version to whatever the CLI owner's resolved_deps snapshot
560+
# recorded. This avoids pulling a newer version that the rest of the
561+
# ecosystem wasn't resolved against.
562+
pinned_spec = _get_pinned_version_for_dep(
563+
omnipkg_instance, cli_owner_spec, pkg_name
564+
)
565+
safe_print(
566+
_(" - Installing '{}' directly to main env (no stash/bubble)...").format(pinned_spec)
567+
)
550568

551-
with temporary_install_strategy(omnipkg_instance, "stable-main"):
569+
with temporary_install_strategy(omnipkg_instance, "latest-active"):
552570

553-
ret = omnipkg_instance.smart_install([pkg_name], force_reinstall=True)
571+
ret = omnipkg_instance.smart_install([pinned_spec], force_reinstall=True)
554572

555573
if ret == 0:
556574

557575
safe_print("✅ Reinstall successful. Restarting script...")
558576

577+
# After fixing main env, if we have a CLI owner re-launch inside
578+
# a bubble so all the owner's deps are loaded cleanly at runtime.
579+
# Pass the pinned_spec as the override so heal_with_missing_package
580+
# does not re-resolve from scratch.
559581
return heal_with_missing_package(
560-
pkg_name,
582+
pinned_spec,
561583
Path(cmd_args[0]),
562584
cmd_args[1:],
563585
original_script_path_for_analysis,
564586
config_manager,
565587
is_context_aware_run,
566588
omnipkg_instance=omnipkg_instance,
567589
attempted_fixes=attempted_fixes,
568-
error_context=stderr, # <--- PASS STDERR HERE
590+
error_context=stderr,
569591
cli_owner_spec=cli_owner_spec,
570592
python_executable=python_executable,
571593
)
@@ -980,29 +1002,108 @@ def is_package_corrupted(pkg_name, missing_module_name):
9801002
"""
9811003
Checks if a package is 'Ghosted' (metadata exists so pip thinks it's installed,
9821004
but the actual module cannot be imported).
1005+
1006+
NOTE: We deliberately check the real site-packages filesystem instead of
1007+
importlib.util.find_spec(), because the healer process may have bubble paths
1008+
injected into sys.path. find_spec() would follow those and falsely report the
1009+
module as present even when the *main-env* copy is missing/broken.
9831010
"""
9841011
try:
985-
import importlib.metadata as importlib_metadata
1012+
import importlib.metadata
1013+
import sysconfig
9861014
except ImportError:
987-
import importlib_metadata
988-
import importlib.util
1015+
return False
9891016

990-
# 1. Is it installed according to metadata?
1017+
# 1. Is it installed according to metadata in the main env?
9911018
try:
9921019
importlib.metadata.distribution(pkg_name)
9931020
except importlib.metadata.PackageNotFoundError:
994-
return False # Not installed -> Not corrupted (just missing)
1021+
return False # Not installed at all -> not corrupted (just missing)
9951022

996-
# 2. Can we actually find the module spec?
1023+
# 2. Check the real main-env site-packages on disk (bypass any bubble sys.path pollution).
9971024
try:
998-
# If find_spec returns None, the python files are missing
999-
if importlib.util.find_spec(missing_module_name) is None:
1025+
sp = Path(sysconfig.get_path("purelib"))
1026+
has_package_dir = (sp / missing_module_name / "__init__.py").exists() or \
1027+
(sp / missing_module_name).is_dir()
1028+
has_module_file = (sp / f"{missing_module_name}.py").exists()
1029+
# Also check platlib (for compiled extensions)
1030+
platlib = Path(sysconfig.get_path("platlib"))
1031+
has_ext = any(platlib.glob(f"{missing_module_name}*.so")) or \
1032+
any(platlib.glob(f"{missing_module_name}*.pyd")) if platlib != sp else False
1033+
if not has_package_dir and not has_module_file and not has_ext:
1034+
return True # Metadata present but files gone → ghost
1035+
except Exception:
1036+
# Fallback to find_spec if filesystem check fails
1037+
import importlib.util
1038+
try:
1039+
if importlib.util.find_spec(missing_module_name) is None:
1040+
return True
1041+
except (ImportError, ValueError, AttributeError):
10001042
return True
1001-
except (ImportError, ValueError, AttributeError):
1002-
return True # Import system choked on it -> Corrupted
10031043

10041044
return False
10051045

1046+
def _get_resolved_deps_from_kb(omnipkg_instance, pkg_name, version=None):
1047+
"""
1048+
Reads the ``resolved_deps`` JSON field from the KB instance record for
1049+
*pkg_name*. Returns a {canonical_name: version_str} dict, or {} on any
1050+
failure.
1051+
1052+
Key reconstruction:
1053+
main_key = redis_key_prefix + pkg_name
1054+
inst_hash = main_key["active_version_instance_hash"]
1055+
inst_ver = version or main_key["active_version"]
1056+
inst_key = prefix.replace(':pkg:',':inst:') + pkg + ':' + ver + ':' + hash
1057+
"""
1058+
try:
1059+
if not omnipkg_instance or not getattr(omnipkg_instance, "cache_client", None):
1060+
return {}
1061+
cc = omnipkg_instance.cache_client
1062+
prefix = omnipkg_instance.redis_key_prefix # e.g. omnipkg:env_XXX:py3.11:pkg:
1063+
inst_prefix = prefix.replace(":pkg:", ":inst:")
1064+
from packaging.utils import canonicalize_name
1065+
canon = canonicalize_name(pkg_name)
1066+
main_key = f"{prefix}{canon}"
1067+
inst_ver = version or cc.hget(main_key, "active_version")
1068+
inst_hash = cc.hget(main_key, "active_version_instance_hash")
1069+
if not inst_ver or not inst_hash:
1070+
return {}
1071+
inst_key = f"{inst_prefix}{canon}:{inst_ver}:{inst_hash}"
1072+
raw = cc.hget(inst_key, "resolved_deps")
1073+
if raw:
1074+
import json as _json
1075+
return _json.loads(raw)
1076+
except Exception:
1077+
pass
1078+
return {}
1079+
1080+
1081+
def _get_pinned_version_for_dep(omnipkg_instance, cli_owner_spec, dep_pkg_name):
1082+
"""
1083+
Given a CLI owner spec like ``jupyterlab==4.5.7`` and a broken dep name
1084+
like ``attrs``, look up what version the owner's resolved_deps snapshot
1085+
requires. Returns e.g. ``"attrs==26.1.0"`` or bare ``dep_pkg_name`` if
1086+
not found.
1087+
"""
1088+
try:
1089+
if not cli_owner_spec:
1090+
return dep_pkg_name
1091+
parts = cli_owner_spec.split("==", 1)
1092+
owner_name = parts[0].strip()
1093+
owner_ver = parts[1].strip() if len(parts) > 1 else None
1094+
deps = _get_resolved_deps_from_kb(omnipkg_instance, owner_name, owner_ver)
1095+
if not deps:
1096+
return dep_pkg_name
1097+
from packaging.utils import canonicalize_name
1098+
canon_dep = canonicalize_name(dep_pkg_name)
1099+
pinned_ver = deps.get(canon_dep)
1100+
if pinned_ver:
1101+
return f"{dep_pkg_name}=={pinned_ver}"
1102+
except Exception:
1103+
pass
1104+
return dep_pkg_name
1105+
1106+
10061107
_CF_MAPPING_CACHE = {}
10071108
_CF_CACHE_LOADED = False
10081109

@@ -1602,9 +1703,21 @@ def heal_with_missing_package(
16021703
if not omnipkg_instance:
16031704
omnipkg_instance = OmnipkgCore(config_manager)
16041705

1605-
# Use stable-main strategy to protect the environment during this repair
1606-
# We pass force_reinstall=force to the core installer
1607-
with temporary_install_strategy(omnipkg_instance, "stable-main"):
1706+
# If we know the CLI owner, pin the dep version from the owner's resolved_deps
1707+
# snapshot rather than always fetching latest. Also install via latest-active
1708+
# so the package lands directly in main env — not bubbled — which is the only
1709+
# place the CLI subprocess will find it without an explicit loader.
1710+
# (stable-main's stash mechanism would move the fresh install into a bubble and
1711+
# restore the ghost to main, repeating the exact failure we are trying to fix.)
1712+
if cli_owner_spec and "==" not in pkg_name:
1713+
pkg_name = _get_pinned_version_for_dep(omnipkg_instance, cli_owner_spec, pkg_name)
1714+
1715+
if cli_owner_spec:
1716+
install_strategy = "latest-active"
1717+
else:
1718+
install_strategy = "stable-main"
1719+
1720+
with temporary_install_strategy(omnipkg_instance, install_strategy):
16081721
return_code = omnipkg_instance.smart_install([pkg_name], force_reinstall=force)
16091722

16101723
if return_code != 0:
@@ -1615,10 +1728,37 @@ def heal_with_missing_package(
16151728
safe_print(_("🚀 Re-running..."))
16161729

16171730
if cli_owner_spec:
1618-
# Re-run as CLI inside a bubble, preserving the original python interpreter.
1731+
# Re-run as CLI inside a bubble loader that covers the FULL owner dep tree.
1732+
# Pull all resolved_deps from the owner's KB record so every dep is shimmed,
1733+
# not just the one we just fixed. This prevents the same symptom re-appearing
1734+
# for a sibling dep that is also broken in main env.
16191735
command = temp_script_path.name if isinstance(temp_script_path, Path) else str(temp_script_path)
1736+
1737+
owner_dep_specs = [cli_owner_spec]
1738+
try:
1739+
parts = cli_owner_spec.split("==", 1)
1740+
owner_deps = _get_resolved_deps_from_kb(
1741+
omnipkg_instance, parts[0].strip(),
1742+
parts[1].strip() if len(parts) > 1 else None
1743+
)
1744+
if owner_deps:
1745+
# Build pinned specs for every dep; put the owner first so the primary
1746+
# bubble is registered, then add deps as additional specs.
1747+
dep_specs = [
1748+
f"{dep_name}=={dep_ver}"
1749+
for dep_name, dep_ver in owner_deps.items()
1750+
]
1751+
# De-duplicate: owner spec is already first
1752+
seen = {cli_owner_spec}
1753+
for s in dep_specs:
1754+
if s not in seen:
1755+
owner_dep_specs.append(s)
1756+
seen.add(s)
1757+
except Exception:
1758+
pass # fall back to just cli_owner_spec
1759+
16201760
exit_code, heal_stats, wrapper_output = run_cli_with_healing_wrapper(
1621-
[cli_owner_spec],
1761+
owner_dep_specs,
16221762
command,
16231763
list(temp_script_args),
16241764
config_manager,
@@ -2855,7 +2995,7 @@ def execute_run_command(
28552995
is_stdin_mode = True
28562996
script_args = cmd_args[2:] # Everything after '-'
28572997
# Check if only 'python' with no other args and stdin has data
2858-
elif len(cmd_args) == 1 and not sys.stdin.isatty():
2998+
elif len(cmd_args) == 1 and not is_interactive_session():
28592999
is_stdin_mode = True
28603000
script_args = []
28613001

@@ -3243,10 +3383,7 @@ def _run_script_logic(
32433383

32443384
# 1. Run interactively attached to terminal
32453385
# In non-interactive mode, don't inherit stdin — use DEVNULL
3246-
_is_noninteractive = (
3247-
not is_interactive_session()
3248-
or os.environ.get("OMNIPKG_NONINTERACTIVE")
3249-
)
3386+
_is_noninteractive = not is_interactive_session()
32503387

32513388
try:
32523389
try:

0 commit comments

Comments
 (0)