meshcode 2.11.143__tar.gz → 2.11.145__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. {meshcode-2.11.143 → meshcode-2.11.145}/PKG-INFO +1 -1
  2. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/__init__.py +1 -1
  3. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/hostd.py +78 -6
  4. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/run_agent.py +10 -1
  5. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode.egg-info/PKG-INFO +1 -1
  6. {meshcode-2.11.143 → meshcode-2.11.145}/pyproject.toml +1 -1
  7. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_hostd_zombie_sessions.py +283 -0
  8. {meshcode-2.11.143 → meshcode-2.11.145}/README.md +0 -0
  9. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/__main__.py +0 -0
  10. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/_session_handoff_template.py +0 -0
  11. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/_stop_hook_template.py +0 -0
  12. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/ascii_art.py +0 -0
  13. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/atomic_push.py +0 -0
  14. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/claude_update.py +0 -0
  15. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/cli.py +0 -0
  16. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/comms_v4.py +0 -0
  17. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/compat.py +0 -0
  18. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/daemon.py +0 -0
  19. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/date_parse.py +0 -0
  20. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/doctor.py +0 -0
  21. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/error_hints.py +0 -0
  22. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/exceptions.py +0 -0
  23. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/helper_visuals.py +0 -0
  24. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/hooks/__init__.py +0 -0
  25. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/hooks/repo_path_lock.py +0 -0
  26. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/invites.py +0 -0
  27. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/launcher.py +0 -0
  28. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/launcher_install.py +0 -0
  29. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/__init__.py +0 -0
  30. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/__main__.py +0 -0
  31. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/backend.py +0 -0
  32. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/realtime.py +0 -0
  33. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/server.py +0 -0
  34. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/sleep_signals.py +0 -0
  35. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/swarm.py +0 -0
  36. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/test_backend.py +0 -0
  37. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/test_boot_timing.py +0 -0
  38. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/test_install_guard.py +0 -0
  39. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/test_prefs_claude_version.py +0 -0
  40. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/test_realtime.py +0 -0
  41. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
  42. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/test_swarm.py +0 -0
  43. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/preferences.py +0 -0
  44. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/protocol_handler.py +0 -0
  45. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/protocol_v2.py +0 -0
  46. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/quickstart.py +0 -0
  47. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/rpc_allowlist.py +0 -0
  48. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/scripts/check_secrets.py +0 -0
  49. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/scripts/race_rate_harness.py +0 -0
  50. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/secrets.py +0 -0
  51. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/self_update.py +0 -0
  52. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/setup_clients.py +0 -0
  53. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/supervisor.py +0 -0
  54. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/up.py +0 -0
  55. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/upload.py +0 -0
  56. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode.egg-info/SOURCES.txt +0 -0
  57. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode.egg-info/dependency_links.txt +0 -0
  58. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode.egg-info/entry_points.txt +0 -0
  59. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode.egg-info/requires.txt +0 -0
  60. {meshcode-2.11.143 → meshcode-2.11.145}/meshcode.egg-info/top_level.txt +0 -0
  61. {meshcode-2.11.143 → meshcode-2.11.145}/setup.cfg +0 -0
  62. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_auto_update_hardening.py +0 -0
  63. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_autonomous_closegap_1.py +0 -0
  64. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_autonomous_closegap_2.py +0 -0
  65. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_autonomous_closegap_3.py +0 -0
  66. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_autonomous_prompt_inject.py +0 -0
  67. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_boot_bug_regression.py +0 -0
  68. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_color_truecolor.py +0 -0
  69. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_core.py +0 -0
  70. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_cross_agent_messaging.py +0 -0
  71. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_date_parse.py +0 -0
  72. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_doctor.py +0 -0
  73. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_epistemic_v1_python_sdk.py +0 -0
  74. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_epistemic_v1_stop_conditions.py +0 -0
  75. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_esc_deaf_state.py +0 -0
  76. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_exceptions.py +0 -0
  77. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_file_upload.py +0 -0
  78. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_helper_visuals.py +0 -0
  79. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_init_device_code.py +0 -0
  80. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_install_guard.py +0 -0
  81. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_lease_sigterm_release.py +0 -0
  82. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_live_mesh_guard.py +0 -0
  83. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_mark_read_batch.py +0 -0
  84. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_marketplace_ratings.py +0 -0
  85. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_migration_integrity.py +0 -0
  86. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_pretrust_claude.py +0 -0
  87. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_realtime_event_freshness.py +0 -0
  88. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_rls_cross_tenant.py +0 -0
  89. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_rpc_grants.py +0 -0
  90. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_rpc_migrations.py +0 -0
  91. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_run_agent_dry_run.py +0 -0
  92. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_run_agent_no_server_import.py +0 -0
  93. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_security_regressions.py +0 -0
  94. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_self_update_user_site.py +0 -0
  95. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_sentinel.py +0 -0
  96. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_session_replay_gate.py +0 -0
  97. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_setup_path.py +0 -0
  98. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_sleep_signals.py +0 -0
  99. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_status_enum_coverage.py +0 -0
  100. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_stay_on_loop_hook.py +0 -0
  101. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_stop_ghost_terminal.py +0 -0
  102. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_swarm_events.py +0 -0
  103. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_task_progress.py +0 -0
  104. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_terminal_lifecycle.py +0 -0
  105. {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_wait_open_tasks_contradiction.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 2.11.143
3
+ Version: 2.11.145
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -1,5 +1,5 @@
1
1
  """MeshCode — Real-time communication between AI agents."""
2
- __version__ = "2.11.143"
2
+ __version__ = "2.11.145"
3
3
 
4
4
  # Exception hierarchy — eagerly imported (lightweight, no deps)
5
5
  from meshcode.exceptions import ( # noqa: F401
@@ -148,13 +148,31 @@ def _maybe_self_restart_on_version_drift() -> None:
148
148
  if _has_supervisor():
149
149
  _log("supervisor present -> clean exit; launchd/systemd/schtasks relaunches on new code")
150
150
  os._exit(0)
151
- # No supervisor (e.g. dev foreground run): execv is the only in-place restart. If it's blocked,
152
- # do NOT self-destruct (nothing would relaunch us) stay on old code, retry after the guard window.
151
+ # No supervisor (e.g. dev foreground run / sandboxed Mac terminal):
152
+ # try execv first (fastest, in-place); if blocked (macOS sandbox), spawn a
153
+ # DETACHED hostd on the new wheel and exit. The new process inherits nothing
154
+ # from us — clean slate on the on-disk version.
153
155
  try:
154
156
  os.execv(sys.executable, [sys.executable, "-m", "meshcode"] + sys.argv[1:])
155
157
  except Exception as e:
156
- _log(f"WARN: no supervisor + execv failed ({e}); staying on {_RUNNING_VERSION}, retry after guard window")
157
- return
158
+ _log(f"execv blocked ({e}); attempting detached self-relaunch via subprocess")
159
+ try:
160
+ argv = _hostd_run_argv()
161
+ if sys.platform == "win32":
162
+ # DETACHED_PROCESS: no console inheritance, survives our exit.
163
+ subprocess.Popen(argv, creationflags=subprocess.DETACHED_PROCESS,
164
+ close_fds=True, stdin=subprocess.DEVNULL,
165
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
166
+ else:
167
+ subprocess.Popen(argv, start_new_session=True,
168
+ close_fds=True, stdin=subprocess.DEVNULL,
169
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
170
+ _log(f"detached hostd spawned ({' '.join(argv)}); exiting old ({_RUNNING_VERSION})")
171
+ os._exit(0)
172
+ except Exception as e2:
173
+ _log(f"WARN: no supervisor + execv failed + detached spawn failed ({e2}); "
174
+ f"staying on {_RUNNING_VERSION}, retry after guard window")
175
+ return
158
176
 
159
177
  STATE_DIR = Path.home() / ".meshcode"
160
178
  HOST_ID_PATH = STATE_DIR / "host_id"
@@ -2243,8 +2261,6 @@ def _do_reap(api_key: str, host_id: str) -> int:
2243
2261
  if not cfg or not cfg.get("ok"):
2244
2262
  return 0
2245
2263
  agents = cfg.get("agents") or []
2246
- if not agents:
2247
- return 0
2248
2264
 
2249
2265
  def _alive(p) -> bool:
2250
2266
  if not p:
@@ -2332,6 +2348,62 @@ def _do_reap(api_key: str, host_id: str) -> int:
2332
2348
  elif now - float(first) >= _REAP_ORPHAN_GRACE_SEC:
2333
2349
  n += _reap(target, pid, f"deleted-agent orphan; alive {int(now-float(first))}s after roster removal")
2334
2350
  seen.pop(target, None)
2351
+ # (D) STALE-ENV SERVE ORPHAN (version-split fix): an MCP serve process whose interpreter
2352
+ # lives in ~/.meshcode/envs/<old_version>/ while the active installed version is different.
2353
+ # These are leftovers from a version split: the agent relaunched on the new env but the old
2354
+ # serve (child of the old claude session) keeps running, potentially with stale bindings.
2355
+ # Reap them. DRY-RUN gated like everything else.
2356
+ # Cross-platform: posix = .../envs/<ver>/bin/python3, win = ...\envs\<ver>\Scripts\python.exe.
2357
+ # Use os.path.normcase for case-insensitive matching on Windows.
2358
+ try:
2359
+ import importlib.metadata as _ilmd
2360
+ _active_ver = _ilmd.version("meshcode")
2361
+ except Exception:
2362
+ _active_ver = None
2363
+ if _active_ver:
2364
+ _envs_prefix = os.path.normcase(str(STATE_DIR / "envs"))
2365
+ ps = _psutil()
2366
+ if ps:
2367
+ try:
2368
+ for p in ps.process_iter():
2369
+ try:
2370
+ cl = " ".join(p.cmdline() or [])
2371
+ if "meshcode" not in cl or "serve" not in cl or "meshcode_mcp" not in cl:
2372
+ continue
2373
+ if p.pid == _own_pid():
2374
+ continue
2375
+ # Check if the interpreter (argv[0]) is from an old env
2376
+ _argv = p.cmdline() or []
2377
+ if not _argv:
2378
+ continue
2379
+ _interp = os.path.normcase(_argv[0])
2380
+ if _envs_prefix not in _interp:
2381
+ continue # not running from a versioned env — skip (e.g. anaconda)
2382
+ # Extract version from path (separator-agnostic):
2383
+ # posix: .../envs/2.11.140/bin/python3
2384
+ # win: ...\envs\2.11.140\Scripts\python.exe
2385
+ _env_ver = None
2386
+ try:
2387
+ _rest = _interp[len(_envs_prefix):]
2388
+ # Strip leading separators (/ or \)
2389
+ _rest = _rest.lstrip("/").lstrip("\\")
2390
+ # Split on either separator to get the version component
2391
+ import re as _re
2392
+ _parts = _re.split(r"[/\\]", _rest)
2393
+ _env_ver = _parts[0] if _parts and _parts[0] else None
2394
+ except Exception:
2395
+ pass
2396
+ if _env_ver and _env_ver != _active_ver:
2397
+ _env_info = p.environ() or {}
2398
+ _s_agent = _env_info.get("MESHCODE_AGENT", "?")
2399
+ _s_proj = _env_info.get("MESHCODE_PROJECT", "?")
2400
+ _s_target = f"{_s_proj}/{_s_agent}"
2401
+ n += _reap(_s_target, p.pid,
2402
+ f"stale-env serve: interp={_env_ver}, active={_active_ver}")
2403
+ except Exception:
2404
+ continue
2405
+ except Exception:
2406
+ pass
2335
2407
  # prune grace clocks only for targets that are BOTH gone from the roster AND have no alive recorded PID
2336
2408
  for t in list(seen.keys()):
2337
2409
  if t not in live_targets and not _alive(pids.get(t)):
@@ -1257,8 +1257,17 @@ def run(agent: str, project: Optional[str] = None, editor_override: Optional[str
1257
1257
  'chief', 'captain', 'boss', 'head agent')
1258
1258
  is_cmd = any(k in _leader_haystack for k in _LEADER_KW)
1259
1259
  agent_stats = _fetch_agent_stats(agent, resolved_project)
1260
+ # Banner must show the version the agent will ACTUALLY run — the
1261
+ # boot-env target resolved by ensure_boot_env() above (always-latest),
1262
+ # NOT the launcher's own installed __version__. The launcher's global
1263
+ # install can lag (its self-update is non-blocking → one-launch
1264
+ # behind), which made the banner read e.g. v2.11.140 while the agent
1265
+ # actually booted on .143 (Samuel 2026-06-17). _boot_env_ver is None on
1266
+ # offline / dry-run / editable installs → fall back to the launcher
1267
+ # version so the banner never goes blank.
1268
+ _banner_version = _boot_env_ver or cli_version
1260
1269
  print(render_welcome(
1261
- agent, resolved_project, ascii_art, cli_version,
1270
+ agent, resolved_project, ascii_art, _banner_version,
1262
1271
  is_commander=is_cmd, role=agent_role, stats=agent_stats,
1263
1272
  profile_color=profile_color,
1264
1273
  mascot_config=mascot_cfg,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshcode
3
- Version: 2.11.143
3
+ Version: 2.11.145
4
4
  Summary: Real-time communication between AI agents — Supabase-backed CLI
5
5
  Author-email: MeshCode <hello@meshcode.io>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "meshcode"
7
- version = "2.11.143"
7
+ version = "2.11.145"
8
8
  description = "Real-time communication between AI agents — Supabase-backed CLI"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -20,6 +20,7 @@ Field data behind the fix: 14 live sessions for 5 agents (up to 3 per agent),
20
20
  each splitting the inbox -> 'non-responsive with a fresh heartbeat'.
21
21
  """
22
22
  import os
23
+ import subprocess
23
24
  import unittest
24
25
  from unittest import mock
25
26
 
@@ -768,5 +769,287 @@ class RpcErrorBodyTests(unittest.TestCase):
768
769
  self.assertIn("host_id", joined)
769
770
 
770
771
 
772
+ class SelfRelaunchDetachedTests(unittest.TestCase):
773
+ """Fix #1: when no supervisor and execv is blocked, hostd spawns a detached
774
+ process on the new wheel and exits, instead of staying on old code forever."""
775
+
776
+ def setUp(self):
777
+ self.logs = []
778
+ self._orig_version = hostd._RUNNING_VERSION
779
+ self._orig_guard_logged = hostd._REEXEC_GUARD_LOGGED
780
+
781
+ def tearDown(self):
782
+ hostd._RUNNING_VERSION = self._orig_version
783
+ hostd._REEXEC_GUARD_LOGGED = self._orig_guard_logged
784
+
785
+ def test_detached_spawn_on_execv_blocked(self):
786
+ """execv raises -> subprocess.Popen detached -> os._exit(0)."""
787
+ hostd._RUNNING_VERSION = "2.11.140"
788
+ popen_calls = []
789
+
790
+ def fake_popen(argv, **kw):
791
+ popen_calls.append((argv, kw))
792
+ return mock.Mock()
793
+
794
+ with mock.patch.object(hostd, "_log", self.logs.append), \
795
+ mock.patch.object(hostd, "_load_state", lambda: {}), \
796
+ mock.patch.object(hostd, "_save_state", lambda st: None), \
797
+ mock.patch.object(hostd, "_has_supervisor", lambda: False), \
798
+ mock.patch("importlib.metadata.version", return_value="2.11.144"), \
799
+ mock.patch("os.execv", side_effect=OSError("blocked")), \
800
+ mock.patch("subprocess.Popen", fake_popen), \
801
+ self.assertRaises(SystemExit):
802
+ # os._exit raises SystemExit in test (mock it to raise)
803
+ with mock.patch("os._exit", side_effect=SystemExit(0)):
804
+ hostd._maybe_self_restart_on_version_drift()
805
+
806
+ self.assertEqual(len(popen_calls), 1, "should have spawned exactly one detached process")
807
+ argv = popen_calls[0][0]
808
+ self.assertIn("hostd", " ".join(argv), "detached process must run hostd")
809
+ kw = popen_calls[0][1]
810
+ self.assertTrue(kw.get("start_new_session") or kw.get("creationflags"),
811
+ "must be detached (start_new_session or DETACHED_PROCESS)")
812
+ joined = "\n".join(self.logs)
813
+ self.assertIn("detached hostd spawned", joined)
814
+
815
+ def test_stays_on_old_if_both_fail(self):
816
+ """execv fails + Popen fails -> stay on old code, no exit."""
817
+ hostd._RUNNING_VERSION = "2.11.140"
818
+
819
+ with mock.patch.object(hostd, "_log", self.logs.append), \
820
+ mock.patch.object(hostd, "_load_state", lambda: {}), \
821
+ mock.patch.object(hostd, "_save_state", lambda st: None), \
822
+ mock.patch.object(hostd, "_has_supervisor", lambda: False), \
823
+ mock.patch("importlib.metadata.version", return_value="2.11.144"), \
824
+ mock.patch("os.execv", side_effect=OSError("blocked")), \
825
+ mock.patch("subprocess.Popen", side_effect=OSError("popen failed")):
826
+ hostd._maybe_self_restart_on_version_drift() # should return, NOT exit
827
+
828
+ joined = "\n".join(self.logs)
829
+ self.assertIn("detached spawn failed", joined)
830
+ self.assertIn("staying on 2.11.140", joined)
831
+
832
+ def test_detached_spawn_win32(self):
833
+ """On Windows, uses DETACHED_PROCESS creationflags."""
834
+ hostd._RUNNING_VERSION = "2.11.140"
835
+ popen_calls = []
836
+ _DETACHED = 0x00000008 # subprocess.DETACHED_PROCESS value on Windows
837
+
838
+ def fake_popen(argv, **kw):
839
+ popen_calls.append((argv, kw))
840
+ return mock.Mock()
841
+
842
+ with mock.patch.object(hostd, "_log", self.logs.append), \
843
+ mock.patch.object(hostd, "_load_state", lambda: {}), \
844
+ mock.patch.object(hostd, "_save_state", lambda st: None), \
845
+ mock.patch.object(hostd, "_has_supervisor", lambda: False), \
846
+ mock.patch("importlib.metadata.version", return_value="2.11.144"), \
847
+ mock.patch("os.execv", side_effect=OSError("blocked")), \
848
+ mock.patch("subprocess.Popen", fake_popen), \
849
+ mock.patch.object(hostd.sys, "platform", "win32"), \
850
+ mock.patch.object(hostd.subprocess, "DETACHED_PROCESS", _DETACHED, create=True), \
851
+ mock.patch("os._exit", side_effect=SystemExit(0)), \
852
+ self.assertRaises(SystemExit):
853
+ hostd._maybe_self_restart_on_version_drift()
854
+
855
+ self.assertEqual(len(popen_calls), 1)
856
+ kw = popen_calls[0][1]
857
+ self.assertIn("creationflags", kw, "Windows must use creationflags")
858
+ self.assertEqual(kw["creationflags"], _DETACHED)
859
+
860
+ def test_supervisor_path_unchanged(self):
861
+ """With a supervisor present, behavior is unchanged: os._exit(0)."""
862
+ hostd._RUNNING_VERSION = "2.11.140"
863
+
864
+ with mock.patch.object(hostd, "_log", self.logs.append), \
865
+ mock.patch.object(hostd, "_load_state", lambda: {}), \
866
+ mock.patch.object(hostd, "_save_state", lambda st: None), \
867
+ mock.patch.object(hostd, "_has_supervisor", lambda: True), \
868
+ mock.patch("importlib.metadata.version", return_value="2.11.144"), \
869
+ mock.patch("os._exit", side_effect=SystemExit(0)), \
870
+ self.assertRaises(SystemExit):
871
+ hostd._maybe_self_restart_on_version_drift()
872
+
873
+ joined = "\n".join(self.logs)
874
+ self.assertIn("supervisor present", joined)
875
+
876
+
877
+ class StaleEnvServeReaperTests(ZombieFixBase):
878
+ """Fix #2: _do_reap case (D) kills MCP serve processes running from an old
879
+ env (e.g. ~/.meshcode/envs/2.11.140/) when the active version is newer."""
880
+
881
+ def setUp(self):
882
+ super().setUp()
883
+ # Clear the per-sweep TTL cache so each test gets a fresh RPC call
884
+ hostd._HOST_CFG_CACHE.update({"key": None, "at": 0.0, "cfg": None})
885
+
886
+ def _add_serve_proc(self, pid, version, agent="backend", project="self-improve"):
887
+ """Add a fake MCP serve process with interpreter from envs/<version>/."""
888
+ interp = f"/Users/test/.meshcode/envs/{version}/bin/python3"
889
+ self.ps.add(pid, 1,
890
+ f"{interp} -m meshcode.meshcode_mcp serve",
891
+ name="python3",
892
+ env={"MESHCODE_PROJECT": project, "MESHCODE_AGENT": agent})
893
+
894
+ def test_reaps_old_env_serve(self):
895
+ """A serve running from envs/2.11.140 is reaped when active is 2.11.144."""
896
+ self.add_hostd()
897
+ self._add_serve_proc(200, "2.11.140")
898
+
899
+ self.rpc.script("mc_host_config_get", {"ok": True, "agents": []})
900
+
901
+ import pathlib
902
+ fake_state_dir = pathlib.PurePosixPath("/Users/test/.meshcode")
903
+
904
+ with mock.patch("importlib.metadata.version", return_value="2.11.144"), \
905
+ mock.patch.object(hostd, "STATE_DIR", fake_state_dir):
906
+ hostd._do_reap("key", "host1")
907
+
908
+ # _REAP_DRYRUN is True, so it should LOG but not kill
909
+ joined = "\n".join(self.logs)
910
+ self.assertIn("stale-env serve", joined)
911
+ self.assertIn("2.11.140", joined)
912
+ self.assertIn("2.11.144", joined)
913
+
914
+ def test_does_not_reap_active_env_serve(self):
915
+ """A serve running from the ACTIVE env version is NOT reaped."""
916
+ self.add_hostd()
917
+ self._add_serve_proc(200, "2.11.144")
918
+
919
+ self.rpc.script("mc_host_config_get", {"ok": True, "agents": []})
920
+
921
+ import pathlib
922
+ fake_state_dir = pathlib.PurePosixPath("/Users/test/.meshcode")
923
+
924
+ with mock.patch("importlib.metadata.version", return_value="2.11.144"), \
925
+ mock.patch.object(hostd, "STATE_DIR", fake_state_dir):
926
+ hostd._do_reap("key", "host1")
927
+
928
+ joined = "\n".join(self.logs)
929
+ self.assertNotIn("stale-env serve", joined,
930
+ "active-version serve must NOT be flagged for reap")
931
+
932
+ def test_does_not_reap_non_env_serve(self):
933
+ """A serve running from anaconda (not envs/) is NOT reaped."""
934
+ self.add_hostd()
935
+ self.ps.add(200, 1,
936
+ "/opt/anaconda3/bin/python3 -m meshcode.meshcode_mcp serve",
937
+ name="python3",
938
+ env={"MESHCODE_PROJECT": "self-improve", "MESHCODE_AGENT": "backend"})
939
+
940
+ self.rpc.script("mc_host_config_get", {"ok": True, "agents": []})
941
+
942
+ import pathlib
943
+ fake_state_dir = pathlib.PurePosixPath("/Users/test/.meshcode")
944
+
945
+ with mock.patch("importlib.metadata.version", return_value="2.11.144"), \
946
+ mock.patch.object(hostd, "STATE_DIR", fake_state_dir):
947
+ hostd._do_reap("key", "host1")
948
+
949
+ joined = "\n".join(self.logs)
950
+ self.assertNotIn("stale-env serve", joined,
951
+ "non-env interpreter must NOT be flagged")
952
+
953
+ def test_reaps_old_env_serve_windows_path(self):
954
+ """Windows-style path: envs\\2.11.140\\Scripts\\python.exe is reaped."""
955
+ self.add_hostd()
956
+ win_interp = "C:\\Users\\Usuario\\.meshcode\\envs\\2.11.140\\Scripts\\python.exe"
957
+ self.ps.add(200, 1,
958
+ f"{win_interp} -m meshcode.meshcode_mcp serve",
959
+ name="python.exe",
960
+ env={"MESHCODE_PROJECT": "mesh-core", "MESHCODE_AGENT": "qa"})
961
+
962
+ self.rpc.script("mc_host_config_get", {"ok": True, "agents": []})
963
+
964
+ import pathlib
965
+ fake_state_dir = pathlib.PureWindowsPath("C:\\Users\\Usuario\\.meshcode")
966
+
967
+ with mock.patch("importlib.metadata.version", return_value="2.11.144"), \
968
+ mock.patch.object(hostd, "STATE_DIR", fake_state_dir), \
969
+ mock.patch("os.path.normcase", side_effect=lambda p: p.lower().replace("/", "\\")):
970
+ hostd._do_reap("key", "host1")
971
+
972
+ joined = "\n".join(self.logs)
973
+ self.assertIn("stale-env serve", joined)
974
+ self.assertIn("2.11.140", joined)
975
+
976
+ def test_reaps_old_env_serve_windows_case_mismatch(self):
977
+ """Windows case mismatch: interp uses USUARIO while STATE_DIR has Usuario.
978
+ os.path.normcase normalizes both to lowercase so the match succeeds."""
979
+ self.add_hostd()
980
+ # Interp has UPPERCASE path component — psutil cmdline can return this
981
+ win_interp = "C:\\Users\\USUARIO\\.meshcode\\envs\\2.11.140\\Scripts\\python.exe"
982
+ self.ps.add(200, 1,
983
+ f"{win_interp} -m meshcode.meshcode_mcp serve",
984
+ name="python.exe",
985
+ env={"MESHCODE_PROJECT": "mesh-core", "MESHCODE_AGENT": "qa"})
986
+
987
+ self.rpc.script("mc_host_config_get", {"ok": True, "agents": []})
988
+
989
+ import pathlib
990
+ # STATE_DIR has different casing (Title case from Path.home())
991
+ fake_state_dir = pathlib.PureWindowsPath("C:\\Users\\Usuario\\.meshcode")
992
+
993
+ with mock.patch("importlib.metadata.version", return_value="2.11.144"), \
994
+ mock.patch.object(hostd, "STATE_DIR", fake_state_dir), \
995
+ mock.patch("os.path.normcase", side_effect=lambda p: p.lower().replace("/", "\\")):
996
+ hostd._do_reap("key", "host1")
997
+
998
+ joined = "\n".join(self.logs)
999
+ self.assertIn("stale-env serve", joined,
1000
+ "case mismatch must NOT prevent the match on Windows")
1001
+ self.assertIn("2.11.140", joined)
1002
+
1003
+ def test_does_not_reap_active_env_windows(self):
1004
+ """Windows: serve from ACTIVE version is NOT reaped."""
1005
+ self.add_hostd()
1006
+ win_interp = "C:\\Users\\Usuario\\.meshcode\\envs\\2.11.144\\Scripts\\python.exe"
1007
+ self.ps.add(200, 1,
1008
+ f"{win_interp} -m meshcode.meshcode_mcp serve",
1009
+ name="python.exe",
1010
+ env={"MESHCODE_PROJECT": "mesh-core", "MESHCODE_AGENT": "qa"})
1011
+
1012
+ self.rpc.script("mc_host_config_get", {"ok": True, "agents": []})
1013
+
1014
+ import pathlib
1015
+ fake_state_dir = pathlib.PureWindowsPath("C:\\Users\\Usuario\\.meshcode")
1016
+
1017
+ with mock.patch("importlib.metadata.version", return_value="2.11.144"), \
1018
+ mock.patch.object(hostd, "STATE_DIR", fake_state_dir), \
1019
+ mock.patch("os.path.normcase", side_effect=lambda p: p.lower().replace("/", "\\")):
1020
+ hostd._do_reap("key", "host1")
1021
+
1022
+ joined = "\n".join(self.logs)
1023
+ self.assertNotIn("stale-env serve", joined)
1024
+
1025
+
1026
+ class MeshBindingTests(ZombieFixBase):
1027
+ """Fix #3: verify that MCP serve processes bind to the correct meshwork
1028
+ via MESHCODE_PROJECT/MESHCODE_AGENT env vars, and that stale serves
1029
+ from old envs (which may have different bindings) are caught by the reaper."""
1030
+
1031
+ def test_serve_env_binding_correct(self):
1032
+ """A serve process with matching MESHCODE_PROJECT+AGENT is discoverable
1033
+ by _discover_serve_pids_ex with the correct target."""
1034
+ self.add_hostd()
1035
+ self.add_session(100, "self-improve/backend")
1036
+
1037
+ # The serve (pid 103) should be discoverable for self-improve/backend
1038
+ result = hostd._discover_serve_pids_ex("self-improve/backend")
1039
+ self.assertFalse(result.get("errored"))
1040
+ self.assertIn(103, result["pids"])
1041
+
1042
+ def test_serve_env_binding_no_cross_mesh(self):
1043
+ """A serve process bound to mesh-A must NOT be discovered when
1044
+ querying for mesh-B — env-var attribution prevents cross-mesh."""
1045
+ self.add_hostd()
1046
+ self.add_session(100, "self-improve/backend")
1047
+
1048
+ result = hostd._discover_serve_pids_ex("other-mesh/backend")
1049
+ self.assertFalse(result.get("errored"))
1050
+ self.assertNotIn(103, result["pids"],
1051
+ "serve bound to self-improve must not match other-mesh")
1052
+
1053
+
771
1054
  if __name__ == "__main__":
772
1055
  unittest.main()
File without changes
File without changes
File without changes
File without changes