@@ -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