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.
- {meshcode-2.11.143 → meshcode-2.11.145}/PKG-INFO +1 -1
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/__init__.py +1 -1
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/hostd.py +78 -6
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/run_agent.py +10 -1
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode.egg-info/PKG-INFO +1 -1
- {meshcode-2.11.143 → meshcode-2.11.145}/pyproject.toml +1 -1
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_hostd_zombie_sessions.py +283 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/README.md +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/__main__.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/_session_handoff_template.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/_stop_hook_template.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/ascii_art.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/atomic_push.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/claude_update.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/cli.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/comms_v4.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/compat.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/daemon.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/date_parse.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/doctor.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/error_hints.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/exceptions.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/helper_visuals.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/hooks/__init__.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/hooks/repo_path_lock.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/invites.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/launcher.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/launcher_install.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/__init__.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/__main__.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/backend.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/realtime.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/server.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/sleep_signals.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/swarm.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/test_backend.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/test_boot_timing.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/test_install_guard.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/test_prefs_claude_version.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/test_realtime.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/test_server_wrapper.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/meshcode_mcp/test_swarm.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/preferences.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/protocol_handler.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/protocol_v2.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/quickstart.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/rpc_allowlist.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/scripts/check_secrets.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/scripts/race_rate_harness.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/secrets.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/self_update.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/setup_clients.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/supervisor.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/up.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode/upload.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode.egg-info/SOURCES.txt +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode.egg-info/dependency_links.txt +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode.egg-info/entry_points.txt +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode.egg-info/requires.txt +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/meshcode.egg-info/top_level.txt +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/setup.cfg +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_auto_update_hardening.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_autonomous_closegap_1.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_autonomous_closegap_2.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_autonomous_closegap_3.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_autonomous_prompt_inject.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_boot_bug_regression.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_color_truecolor.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_core.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_cross_agent_messaging.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_date_parse.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_doctor.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_epistemic_v1_python_sdk.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_epistemic_v1_stop_conditions.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_esc_deaf_state.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_exceptions.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_file_upload.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_helper_visuals.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_init_device_code.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_install_guard.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_lease_sigterm_release.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_live_mesh_guard.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_mark_read_batch.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_marketplace_ratings.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_migration_integrity.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_pretrust_claude.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_realtime_event_freshness.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_rls_cross_tenant.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_rpc_grants.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_rpc_migrations.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_run_agent_dry_run.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_run_agent_no_server_import.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_security_regressions.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_self_update_user_site.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_sentinel.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_session_replay_gate.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_setup_path.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_sleep_signals.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_status_enum_coverage.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_stay_on_loop_hook.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_stop_ghost_terminal.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_swarm_events.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_task_progress.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_terminal_lifecycle.py +0 -0
- {meshcode-2.11.143 → meshcode-2.11.145}/tests/test_wait_open_tasks_contradiction.py +0 -0
|
@@ -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
|
|
152
|
-
#
|
|
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"
|
|
157
|
-
|
|
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,
|
|
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,
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|