diff --git a/agent_sudo/doctor.py b/agent_sudo/doctor.py index afdcedc..d837d8f 100644 --- a/agent_sudo/doctor.py +++ b/agent_sudo/doctor.py @@ -69,9 +69,34 @@ def _install_health_checks( return [ _staleness_check(identity, inventory_report), _runtime_source_check(identity), + _duplicate_installs_check(inventory_report), ] +def _duplicate_installs_check(report: InventoryReport) -> DoctorCheck: + name = "single active install" + # Reuse inventory's classification (#101): an install is ACTIVE when a client + # config references it or it resolves on PATH. A pyenv shim is ACTIVE too but + # only *resolves to* a version install — counting both would double-count one + # install, so PYENV-SHIM records are excluded. Distinct roots means a shim and + # its target collapse to one, while two real installs (even same-version + # editables at different source roots) stay distinct. + roots = { + install.root + for install in report.installs + if "ACTIVE" in install.statuses and "PYENV-SHIM" not in install.statuses + } + if len(roots) <= 1: + detail = "one active install" if roots else "no active install detected" + return DoctorCheck(name, True, detail) + return DoctorCheck( + name, + False, + "Multiple active Agent_Sudo installs detected. Run `agent-sudo inventory` " + "to inspect and choose one canonical install.", + ) + + def _staleness_check(identity: SelfIdentity, report: InventoryReport) -> DoctorCheck: name = "install up to date" running = identity.version diff --git a/docs/command_reference.md b/docs/command_reference.md index be62c7c..c90382c 100644 --- a/docs/command_reference.md +++ b/docs/command_reference.md @@ -74,6 +74,9 @@ and troubleshooting); they are listed under their primary use. stale** (an older copy is resolving ahead of a newer one on the machine) or when an **editable install has drifted** from its registered source — so a shell silently running an out-of-date copy is caught here, not in production. + It also WARNs when **multiple active installs** are detected (more than one + Agent_Sudo resolving on PATH or referenced by client configs), pointing you + to `agent-sudo inventory` to pick one canonical install. - **Example:** `agent-sudo doctor` - **When to use:** right after install, or when something isn't working. - **Common mistakes:** expecting it to validate your MCP client config — it checks the diff --git a/tests/test_doctor.py b/tests/test_doctor.py index be12139..027e23d 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -292,5 +292,81 @@ def test_ok_when_no_broad_delegation(self) -> None: self.assertTrue(scope.ok) +class DuplicateInstallCheckTests(unittest.TestCase): + """doctor surfaces multiple active installs (issue #111), WARN-only.""" + + def _identity(self) -> SelfIdentity: + return SelfIdentity( + version="0.5.6", + install_type="editable", + source_path="/repo/Agent_Sudo", + package_path="/repo/Agent_Sudo/agent_sudo", + python_executable="/py/bin/python", + python_prefix="/py", + python_version="3.11.14", + origin="console-script", + ) + + def _report(self, installs) -> InventoryReport: + records = [ + InstallRecord( + root=root, executable="", version=version, statuses=list(statuses) + ) + for root, version, statuses in installs + ] + newest = max((r.version for r in records), default="") + return InventoryReport( + installs=records, configs=[], warnings=[], newest_version=newest + ) + + def _check(self, installs) -> DoctorCheck: + checks = run_doctor( + identity=self._identity(), inventory_report=self._report(installs) + ) + return next(c for c in checks if c.name == "single active install") + + def test_no_duplicates_is_ok(self) -> None: + check = self._check([("/repo/Agent_Sudo", "0.5.6", ["ACTIVE", "EDITABLE"])]) + self.assertTrue(check.ok) + + def test_duplicate_active_installs_warn(self) -> None: + installs = [ + ("/venv/a", "0.5.6", ["ACTIVE", "DUPLICATE INSTALL"]), + ("/venv/b", "0.5.6", ["ACTIVE", "DUPLICATE INSTALL"]), + ] + check = self._check(installs) + self.assertFalse(check.ok) + self.assertEqual( + check.detail, + "Multiple active Agent_Sudo installs detected. Run `agent-sudo inventory` " + "to inspect and choose one canonical install.", + ) + # WARN-only: must not fail the exit code. + checks = run_doctor( + identity=self._identity(), inventory_report=self._report(installs) + ) + self.assertEqual(doctor_exit_code(checks), 0) + + def test_pyenv_shim_plus_resolved_install_not_double_counted(self) -> None: + # A shim resolves to its version install; counting both would be a false + # duplicate. The PYENV-SHIM record is excluded, leaving one real install. + installs = [ + ("/home/.pyenv/shims", "", ["ACTIVE", "PYENV-SHIM"]), + ("/home/.pyenv/versions/3.11.14", "0.5.6", ["ACTIVE", "EDITABLE"]), + ] + check = self._check(installs) + self.assertTrue(check.ok) + + def test_same_version_editables_at_different_roots_warn(self) -> None: + # Two editable installs, same version, different source roots — still a + # duplicate because the roots differ. + installs = [ + ("/repo/A", "0.5.6", ["ACTIVE", "EDITABLE", "DUPLICATE INSTALL"]), + ("/repo/B", "0.5.6", ["ACTIVE", "EDITABLE", "DUPLICATE INSTALL"]), + ] + check = self._check(installs) + self.assertFalse(check.ok) + + if __name__ == "__main__": unittest.main()