gemini-computer-use 0.2.4 → 0.4.1
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.
- package/README.md +733 -16
- package/bin/cli.js +112 -17
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/agent.cpython-313.pyc +0 -0
- package/core/__pycache__/code_cache.cpython-313.pyc +0 -0
- package/core/__pycache__/mcp_tools.cpython-313.pyc +0 -0
- package/core/__pycache__/schemas.cpython-313.pyc +0 -0
- package/core/__pycache__/server.cpython-313.pyc +0 -0
- package/core/__pycache__/vitals.cpython-313.pyc +0 -0
- package/core/__pycache__/web_share.cpython-313.pyc +0 -0
- package/core/agent.py +503 -18
- package/core/agent_harness.py +9 -2
- package/core/code_cache.py +15 -3
- package/core/device.py +2400 -0
- package/core/domain.py +303 -0
- package/core/mcp_tools.py +1840 -112
- package/core/policies.py +35 -5
- package/core/server.py +78 -32
- package/core/termux.py +916 -0
- package/core/updater.py +1713 -0
- package/core/version.py +31 -0
- package/core/vitals.py +66 -2
- package/core/web_share.py +814 -0
- package/gateway.py +1469 -98
- package/install-gui.cmd +40 -0
- package/install-gui.ps1 +1882 -0
- package/install-gui.strings.json +211 -0
- package/install.ps1 +960 -92
- package/install.sh +1214 -81
- package/ops/doctor.ps1 +735 -0
- package/ops/mesh-domain.sh +86 -0
- package/ops/nginx/README.md +77 -0
- package/ops/nginx/antigravity-mesh-mcp.conf +172 -0
- package/ops/nginx/mesh-domain.vhost.template +58 -0
- package/ops/nginx/render-domain.sh +123 -0
- package/ops/update.ps1 +373 -0
- package/ops/windows/README.md +298 -0
- package/ops/windows/agent-watchdog.ps1 +575 -0
- package/ops/windows/run-hidden.vbs +64 -0
- package/package.json +6 -2
- package/skills/orchestrator.md +12 -0
package/core/agent.py
CHANGED
|
@@ -13,11 +13,17 @@ Configuration (read once at import/startup)::
|
|
|
13
13
|
MESH_CONFIG_FILE KEY=VALUE file used for any of the three above that is not
|
|
14
14
|
set in the environment (default:
|
|
15
15
|
~/.config/antigravity-mesh/agent.env). This keeps the token
|
|
16
|
-
out of launchers such as the Windows Startup script.
|
|
16
|
+
out of launchers such as the Windows Startup script. Any
|
|
17
|
+
other MESH_* key in that file is exported as well.
|
|
18
|
+
MESH_SHELL Windows command shell: "cmd" or "git-bash" (default:
|
|
19
|
+
PowerShell, falling back to cmd.exe when PowerShell is absent)
|
|
17
20
|
MESH_WORKSPACE base directory for relative paths (default: cwd)
|
|
18
21
|
MESH_READ_ONLY "1"/"true" -> mutating tools refuse to run
|
|
19
22
|
MESH_WRITE_ROOTS pathsep-separated list of roots allowed for writes
|
|
20
23
|
|
|
24
|
+
MESH_UPDATE_* self-update: see core/updater.py. A running node checks for a
|
|
25
|
+
newer GitHub release in the background and installs it
|
|
26
|
+
|
|
21
27
|
Resilience contract:
|
|
22
28
|
|
|
23
29
|
* every link failure (DNS, TLS, reset, half-open socket after sleep) is retried
|
|
@@ -31,16 +37,30 @@ Resilience contract:
|
|
|
31
37
|
"""
|
|
32
38
|
|
|
33
39
|
import asyncio
|
|
40
|
+
import atexit
|
|
41
|
+
import faulthandler
|
|
34
42
|
import json
|
|
35
43
|
import logging
|
|
36
44
|
import os
|
|
37
45
|
import random
|
|
46
|
+
import signal
|
|
47
|
+
import socket
|
|
48
|
+
import subprocess
|
|
38
49
|
import sys
|
|
50
|
+
import threading
|
|
39
51
|
import time
|
|
52
|
+
from typing import Optional
|
|
40
53
|
|
|
41
|
-
|
|
54
|
+
try:
|
|
55
|
+
import websockets
|
|
56
|
+
except ImportError as _websockets_error: # a launcher pinned a Python without the dep
|
|
57
|
+
websockets = None # type: ignore[assignment]
|
|
58
|
+
_WEBSOCKETS_IMPORT_ERROR = _websockets_error
|
|
59
|
+
else:
|
|
60
|
+
_WEBSOCKETS_IMPORT_ERROR = None
|
|
42
61
|
|
|
43
|
-
from core import mcp_tools
|
|
62
|
+
from core import domain, mcp_tools, updater
|
|
63
|
+
from core import version as version_module
|
|
44
64
|
|
|
45
65
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
|
46
66
|
logger = logging.getLogger("agy-agent")
|
|
@@ -48,6 +68,16 @@ logger = logging.getLogger("agy-agent")
|
|
|
48
68
|
CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "antigravity-mesh")
|
|
49
69
|
DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "agent.env")
|
|
50
70
|
|
|
71
|
+
#: Diagnostics that answer "why is this node offline?" without the tunnel. The
|
|
72
|
+
#: watchdog (ops/windows/agent-watchdog.ps1) and ops/doctor.ps1 read these files,
|
|
73
|
+
#: and the incident that motivated them - a launcher pinned to the Microsoft
|
|
74
|
+
#: Store Python alias that left a single torn line in agent.log and kept the node
|
|
75
|
+
#: offline for a day - is exactly what they prevent.
|
|
76
|
+
HEARTBEAT_FILE = os.path.join(CONFIG_DIR, "agent.heartbeat")
|
|
77
|
+
FAULT_FILE = os.path.join(CONFIG_DIR, "agent.fault.log")
|
|
78
|
+
STOP_MARKER_FILE = os.path.join(CONFIG_DIR, "agent.stopped")
|
|
79
|
+
HEARTBEAT_INTERVAL = 15.0
|
|
80
|
+
|
|
51
81
|
#: Backoff cap for ordinary link failures.
|
|
52
82
|
MAX_BACKOFF = 15.0
|
|
53
83
|
#: Wait after the gateway rejects the token: retrying fast cannot help, but the
|
|
@@ -56,19 +86,79 @@ UNAUTHORIZED_BACKOFF = 60.0
|
|
|
56
86
|
|
|
57
87
|
|
|
58
88
|
def read_env_file(path: str) -> dict:
|
|
59
|
-
"""Parse a KEY=VALUE file; tolerates a UTF-8 BOM (Windows PowerShell 5 writes one).
|
|
60
|
-
|
|
89
|
+
"""Parse a KEY=VALUE file; tolerates a UTF-8 BOM (Windows PowerShell 5 writes one).
|
|
90
|
+
|
|
91
|
+
The implementation lives in :mod:`core.domain`, which reads ``domain.env`` with
|
|
92
|
+
the same rules; it is re-exported here so existing importers of
|
|
93
|
+
``core.agent.read_env_file`` keep working and the project keeps exactly one
|
|
94
|
+
parser.
|
|
95
|
+
"""
|
|
96
|
+
return domain.read_env_file(path)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
#: Hostnames that identify no machine. Android answers "localhost" to
|
|
100
|
+
#: ``gethostname(2)`` for every device, and Termux has no hostname of its own, so
|
|
101
|
+
#: a node that trusted it would collide with every other phone: the gateway keeps
|
|
102
|
+
#: exactly one tunnel per name and the newer one evicts the older.
|
|
103
|
+
_USELESS_HOSTNAMES = frozenset({"", "localhost", "localhost.localdomain", "android"})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def android_device_name() -> str:
|
|
107
|
+
"""The phone's model as a node name (``Pixel 7 Pro`` -> ``pixel7pro``).
|
|
108
|
+
|
|
109
|
+
``getprop`` ships with Android itself, needs no root and is on PATH inside
|
|
110
|
+
Termux. A device that does not answer yields "" and the caller falls back to
|
|
111
|
+
the generic name - this helper never raises.
|
|
112
|
+
"""
|
|
113
|
+
for prop in ("ro.product.model", "ro.product.device"):
|
|
114
|
+
try:
|
|
115
|
+
completed = subprocess.run(["getprop", prop], capture_output=True, timeout=5)
|
|
116
|
+
except Exception:
|
|
117
|
+
continue
|
|
118
|
+
value = (completed.stdout or b"").decode("utf-8", "replace")
|
|
119
|
+
safe = "".join(c for c in value.strip().lower() if c.isalnum() or c in "-_")
|
|
120
|
+
if safe:
|
|
121
|
+
return safe
|
|
122
|
+
return ""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def default_node_name() -> str:
|
|
126
|
+
"""Node name used when ``MESH_USER`` is not configured: the machine's name.
|
|
127
|
+
|
|
128
|
+
This used to be a personal nickname, so every node that did not set
|
|
129
|
+
``MESH_USER`` claimed that *same* name on the shared gateway - and the gateway
|
|
130
|
+
keeps exactly one tunnel per name, evicting the previous one. Two unrelated
|
|
131
|
+
users would therefore knock each other offline in an endless loop, which is
|
|
132
|
+
precisely the flapping the instance lock exists to prevent (and that lock is
|
|
133
|
+
per machine, so it cannot help across machines). The installers already
|
|
134
|
+
register the sanitised hostname, so the agent now agrees with them.
|
|
135
|
+
|
|
136
|
+
On a phone the hostname is worse than useless - it is the same "localhost" for
|
|
137
|
+
every device - so Termux uses the device model instead, exactly as the
|
|
138
|
+
installer registers it.
|
|
139
|
+
"""
|
|
61
140
|
try:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
141
|
+
hostname = socket.gethostname()
|
|
142
|
+
except Exception:
|
|
143
|
+
hostname = ""
|
|
144
|
+
safe = "".join(c for c in hostname.strip().lower() if c.isalnum() or c in "-_")
|
|
145
|
+
if safe in _USELESS_HOSTNAMES and domain.is_termux():
|
|
146
|
+
safe = android_device_name()
|
|
147
|
+
return safe or "node"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def configuration_error() -> Optional[str]:
|
|
151
|
+
"""Human-readable reason this node cannot connect, or None when it is usable.
|
|
152
|
+
|
|
153
|
+
An empty token is not a transient link failure: the gateway rejects the node
|
|
154
|
+
on every attempt, so retrying forever only fills agent.log and leaves a
|
|
155
|
+
broken autostart behind. The caller reports this and stops.
|
|
156
|
+
"""
|
|
157
|
+
if not TOKEN:
|
|
158
|
+
return ("MESH_TOKEN is empty (environment and %s). The gateway rejects a node "
|
|
159
|
+
"without a token, so the tunnel cannot work - re-run the installer."
|
|
160
|
+
% (os.environ.get("MESH_CONFIG_FILE") or DEFAULT_CONFIG_FILE))
|
|
161
|
+
return None
|
|
72
162
|
|
|
73
163
|
|
|
74
164
|
def _load_settings() -> dict:
|
|
@@ -77,9 +167,22 @@ def _load_settings() -> dict:
|
|
|
77
167
|
def pick(key: str, default: str) -> str:
|
|
78
168
|
return os.environ.get(key) or file_values.get(key) or default
|
|
79
169
|
|
|
170
|
+
# Every other MESH_* key the operator put in the same file is exported too, so
|
|
171
|
+
# one agent.env (or one Windows Startup launcher) configures the whole node:
|
|
172
|
+
# MESH_SHELL for the Windows command shell, MESH_WORKSPACE, MESH_READ_ONLY,
|
|
173
|
+
# MESH_WRITE_ROOTS, MESH_JOBS_DIR, MESH_MAX_OUTPUT_CHARS. The real environment
|
|
174
|
+
# always wins over the file.
|
|
175
|
+
for key, value in file_values.items():
|
|
176
|
+
if key.startswith("MESH_") and not os.environ.get(key):
|
|
177
|
+
os.environ[key] = value
|
|
178
|
+
|
|
80
179
|
return {
|
|
81
|
-
|
|
82
|
-
|
|
180
|
+
# The host the node dials. core.domain owns the order: the configured
|
|
181
|
+
# public domain (MESH_PUBLIC_URL, or domain.env) wins, and the legacy
|
|
182
|
+
# MESH_GATEWAY - which the loop above has just exported from agent.env - is
|
|
183
|
+
# only consulted while nothing else names a domain.
|
|
184
|
+
"gateway": domain.gateway_host(),
|
|
185
|
+
"user": pick("MESH_USER", default_node_name()),
|
|
83
186
|
"token": pick("MESH_TOKEN", ""),
|
|
84
187
|
}
|
|
85
188
|
|
|
@@ -105,6 +208,26 @@ def configure_from_env() -> None:
|
|
|
105
208
|
write_roots=write_roots,
|
|
106
209
|
jobs_dir=jobs_dir,
|
|
107
210
|
max_output_chars=max_chars,
|
|
211
|
+
# Public shares: the node name in a share link must be the name this
|
|
212
|
+
# tunnel registers with the gateway (the gateway routes
|
|
213
|
+
# /<node>/<slug>/... back to that tunnel), so it is passed explicitly
|
|
214
|
+
# rather than left to web_share's MESH_USER fallback - which would say
|
|
215
|
+
# "anonymous" whenever the operator relied on the hostname default.
|
|
216
|
+
# ``public_url`` is not passed here: core/domain.py owns the domain.
|
|
217
|
+
web_dir=os.environ.get("MESH_WEB_DIR") or None,
|
|
218
|
+
mesh_user=USER,
|
|
219
|
+
max_share_bytes=os.environ.get("MESH_WEB_MAX_BYTES"),
|
|
220
|
+
max_shares=os.environ.get("MESH_WEB_MAX_SHARES"),
|
|
221
|
+
web_listing=os.environ.get("MESH_WEB_LISTING"),
|
|
222
|
+
# Device branch (core/device.py, core/termux.py). "auto" is the default and
|
|
223
|
+
# keeps the tools advertised on every platform - the phone-only ones then
|
|
224
|
+
# answer with a reason instead of vanishing, because the gateway's static
|
|
225
|
+
# tool surface must match this node's exactly.
|
|
226
|
+
device=os.environ.get("MESH_DEVICE") or "auto",
|
|
227
|
+
device_actions=os.environ.get("MESH_DEVICE_ACTIONS") or None,
|
|
228
|
+
capture_dir=os.environ.get("MESH_DEVICE_CAPTURE_DIR") or None,
|
|
229
|
+
device_pim=_env_flag("MESH_DEVICE_PIM"),
|
|
230
|
+
device_quick=_env_flag("MESH_DEVICE_QUICK"),
|
|
108
231
|
)
|
|
109
232
|
|
|
110
233
|
|
|
@@ -113,6 +236,302 @@ def configure_from_env() -> None:
|
|
|
113
236
|
configure_from_env()
|
|
114
237
|
|
|
115
238
|
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
# Runtime diagnostics: banner, fault log, heartbeat, clean-stop marker
|
|
241
|
+
#
|
|
242
|
+
# A node that never connects leaves the client with an opaque frontend error, so
|
|
243
|
+
# every start records what it is: which interpreter, which websockets, from which
|
|
244
|
+
# directory. The heartbeat makes "the agent runs but is not connected" visible to
|
|
245
|
+
# the watchdog, and the absence of a clean-stop marker tells it that the process
|
|
246
|
+
# was killed instead of shutting down.
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
_RUNTIME_FACTS = None
|
|
250
|
+
_FAULT_HANDLE = None
|
|
251
|
+
|
|
252
|
+
#: Live state reported through the heartbeat. Written by the event loop and read
|
|
253
|
+
#: by the heartbeat thread; plain dict operations are atomic enough under the GIL
|
|
254
|
+
#: and a lost update only costs one cycle of freshness.
|
|
255
|
+
_HEARTBEAT_STATE = {
|
|
256
|
+
"connected": False,
|
|
257
|
+
"connects": 0,
|
|
258
|
+
"last_tool": "",
|
|
259
|
+
"last_tool_at": 0.0,
|
|
260
|
+
"last_error": "",
|
|
261
|
+
"started_at": 0.0,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def runtime_facts() -> dict:
|
|
266
|
+
"""Interpreter, dependency and location facts of this agent process."""
|
|
267
|
+
global _RUNTIME_FACTS
|
|
268
|
+
if _RUNTIME_FACTS is None:
|
|
269
|
+
version = getattr(websockets, "__version__", "MISSING") if websockets else "MISSING"
|
|
270
|
+
_RUNTIME_FACTS = {
|
|
271
|
+
"python": "%d.%d.%d" % sys.version_info[:3],
|
|
272
|
+
"interpreter": sys.executable or "",
|
|
273
|
+
"websockets": version,
|
|
274
|
+
"node_version": version_module.__version__,
|
|
275
|
+
}
|
|
276
|
+
facts = dict(_RUNTIME_FACTS)
|
|
277
|
+
# Read fresh: tests (and a re-configured process) may change these.
|
|
278
|
+
facts["gateway"] = GATEWAY_HOST
|
|
279
|
+
facts["user"] = USER
|
|
280
|
+
return facts
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def log_runtime_banner() -> dict:
|
|
284
|
+
"""Log one line naming the interpreter a launcher actually started.
|
|
285
|
+
|
|
286
|
+
The Windows incident produced no such line: the launcher ran the Microsoft
|
|
287
|
+
Store alias, which printed ``Python `` and exited, so nothing in the log said
|
|
288
|
+
which interpreter was expected.
|
|
289
|
+
"""
|
|
290
|
+
facts = runtime_facts()
|
|
291
|
+
logger.info("Runtime: node=%s interpreter=%s python=%s websockets=%s cwd=%s pid=%s",
|
|
292
|
+
facts.get("node_version") or version_module.__version__,
|
|
293
|
+
facts["interpreter"] or "<unknown>", facts["python"],
|
|
294
|
+
facts["websockets"], os.getcwd(), os.getpid())
|
|
295
|
+
if _WEBSOCKETS_IMPORT_ERROR is not None:
|
|
296
|
+
logger.error("The 'websockets' package is not importable by this interpreter (%s): %s. "
|
|
297
|
+
"The tunnel cannot start; run: %s -m pip install websockets",
|
|
298
|
+
facts["interpreter"] or "<unknown>", _WEBSOCKETS_IMPORT_ERROR,
|
|
299
|
+
facts["interpreter"] or "python")
|
|
300
|
+
if "\\WindowsApps\\" in facts["interpreter"] or "/WindowsApps/" in facts["interpreter"]:
|
|
301
|
+
logger.error("This agent runs through the Microsoft Store 'python.exe' alias (%s). That alias "
|
|
302
|
+
"stops working after a Store repair or an update; the installer must pin a real "
|
|
303
|
+
"interpreter instead.", facts["interpreter"])
|
|
304
|
+
return facts
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def write_heartbeat(path: Optional[str] = None, **extra) -> dict:
|
|
308
|
+
"""Atomically refresh the heartbeat file read by the watchdog and the doctor."""
|
|
309
|
+
target = path or HEARTBEAT_FILE
|
|
310
|
+
payload = {"ts": time.time(), "pid": os.getpid()}
|
|
311
|
+
payload.update(runtime_facts())
|
|
312
|
+
payload.update(_HEARTBEAT_STATE)
|
|
313
|
+
payload.update(extra)
|
|
314
|
+
try:
|
|
315
|
+
directory = os.path.dirname(target)
|
|
316
|
+
if directory:
|
|
317
|
+
os.makedirs(directory, exist_ok=True)
|
|
318
|
+
temporary = target + ".tmp"
|
|
319
|
+
with open(temporary, "w", encoding="utf-8") as handle:
|
|
320
|
+
json.dump(payload, handle, ensure_ascii=False, sort_keys=True)
|
|
321
|
+
handle.write("\n")
|
|
322
|
+
os.replace(temporary, target)
|
|
323
|
+
except Exception as exc: # diagnostics never break the tunnel
|
|
324
|
+
logger.debug("Could not write the heartbeat %s: %s", target, exc)
|
|
325
|
+
return payload
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def read_heartbeat(path: Optional[str] = None) -> dict:
|
|
329
|
+
"""The last heartbeat, or an empty dict when there is none or it is unreadable."""
|
|
330
|
+
target = path or HEARTBEAT_FILE
|
|
331
|
+
try:
|
|
332
|
+
with open(target, "r", encoding="utf-8") as handle:
|
|
333
|
+
data = json.load(handle)
|
|
334
|
+
except Exception:
|
|
335
|
+
return {}
|
|
336
|
+
return data if isinstance(data, dict) else {}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def heartbeat_age(path: Optional[str] = None) -> float:
|
|
340
|
+
"""Seconds since the last heartbeat; ``inf`` when there is none."""
|
|
341
|
+
try:
|
|
342
|
+
return max(0.0, time.time() - float(read_heartbeat(path).get("ts")))
|
|
343
|
+
except Exception:
|
|
344
|
+
return float("inf")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def mark_connected(connected: bool, reason: str = "") -> None:
|
|
348
|
+
"""Record the tunnel state for the heartbeat."""
|
|
349
|
+
_HEARTBEAT_STATE["connected"] = bool(connected)
|
|
350
|
+
if connected:
|
|
351
|
+
_HEARTBEAT_STATE["connects"] = int(_HEARTBEAT_STATE.get("connects", 0)) + 1
|
|
352
|
+
_HEARTBEAT_STATE["last_error"] = ""
|
|
353
|
+
elif reason:
|
|
354
|
+
_HEARTBEAT_STATE["last_error"] = str(reason)[:300]
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def start_heartbeat(path: Optional[str] = None, interval: float = HEARTBEAT_INTERVAL):
|
|
358
|
+
"""Start the daemon heartbeat thread (it must never keep the process alive)."""
|
|
359
|
+
_HEARTBEAT_STATE["started_at"] = time.time()
|
|
360
|
+
write_heartbeat(path)
|
|
361
|
+
|
|
362
|
+
def _beat():
|
|
363
|
+
while True:
|
|
364
|
+
time.sleep(interval)
|
|
365
|
+
write_heartbeat(path)
|
|
366
|
+
|
|
367
|
+
thread = threading.Thread(target=_beat, name="mesh-heartbeat", daemon=True)
|
|
368
|
+
thread.start()
|
|
369
|
+
return thread
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def enable_fault_log(path: Optional[str] = None) -> Optional[str]:
|
|
373
|
+
"""Send fatal-error tracebacks (native crash, deadlock) to their own file.
|
|
374
|
+
|
|
375
|
+
Without this the only trace of an abnormal end is a torn line in agent.log.
|
|
376
|
+
"""
|
|
377
|
+
global _FAULT_HANDLE
|
|
378
|
+
target = path or FAULT_FILE
|
|
379
|
+
try:
|
|
380
|
+
directory = os.path.dirname(target)
|
|
381
|
+
if directory:
|
|
382
|
+
os.makedirs(directory, exist_ok=True)
|
|
383
|
+
_FAULT_HANDLE = open(target, "a", encoding="utf-8")
|
|
384
|
+
faulthandler.enable(file=_FAULT_HANDLE, all_threads=True)
|
|
385
|
+
except Exception as exc:
|
|
386
|
+
logger.debug("Could not enable the fault log %s: %s", target, exc)
|
|
387
|
+
return None
|
|
388
|
+
return target
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def write_stop_marker(reason: str = "stop") -> None:
|
|
392
|
+
"""Record a clean stop: a missing marker means the process was killed."""
|
|
393
|
+
try:
|
|
394
|
+
directory = os.path.dirname(STOP_MARKER_FILE)
|
|
395
|
+
if directory:
|
|
396
|
+
os.makedirs(directory, exist_ok=True)
|
|
397
|
+
with open(STOP_MARKER_FILE, "w", encoding="utf-8") as handle:
|
|
398
|
+
handle.write("%s pid=%s reason=%s\n"
|
|
399
|
+
% (time.strftime("%Y-%m-%dT%H:%M:%S"), os.getpid(), reason))
|
|
400
|
+
except Exception:
|
|
401
|
+
pass
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def install_exit_markers() -> None:
|
|
405
|
+
"""Write the clean-stop marker on a normal exit or on a received signal."""
|
|
406
|
+
atexit.register(write_stop_marker, "atexit")
|
|
407
|
+
|
|
408
|
+
def _on_signal(signum, _frame):
|
|
409
|
+
write_stop_marker("signal=%s" % signum)
|
|
410
|
+
raise SystemExit(0)
|
|
411
|
+
|
|
412
|
+
for name in ("SIGINT", "SIGTERM", "SIGBREAK"):
|
|
413
|
+
number = getattr(signal, name, None)
|
|
414
|
+
if number is None:
|
|
415
|
+
continue
|
|
416
|
+
try:
|
|
417
|
+
signal.signal(number, _on_signal)
|
|
418
|
+
except Exception:
|
|
419
|
+
continue
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# ---------------------------------------------------------------------------
|
|
423
|
+
# Self-update
|
|
424
|
+
#
|
|
425
|
+
# The updater itself lives in core/updater.py and is deliberately unaware of this
|
|
426
|
+
# module: it can check, download, verify and swap a payload with no agent around
|
|
427
|
+
# (ops/update.ps1 and the CLI drive it that way). What only the agent can do is
|
|
428
|
+
# stop cleanly so the swapped code is what runs next - that is the hook below.
|
|
429
|
+
# ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
#: The updater's last answer, carried in every heartbeat so ops/doctor.ps1 can
|
|
432
|
+
#: show "a newer release exists" (or "the last check failed") without a network.
|
|
433
|
+
_UPDATE_STATE: dict = {}
|
|
434
|
+
_RESTART_EVENT = threading.Event()
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def note_update_state(result: dict) -> None:
|
|
438
|
+
"""Record one updater answer in the heartbeat."""
|
|
439
|
+
if not isinstance(result, dict):
|
|
440
|
+
return
|
|
441
|
+
check = result.get("check") if isinstance(result.get("check"), dict) else result
|
|
442
|
+
payload = {
|
|
443
|
+
"current": check.get("current") or version_module.__version__,
|
|
444
|
+
"latest": check.get("latest") or "",
|
|
445
|
+
"available": bool(check.get("update_available")),
|
|
446
|
+
"checked_at": check.get("checked_at") or "",
|
|
447
|
+
"reason": str(check.get("reason") or "")[:200],
|
|
448
|
+
}
|
|
449
|
+
applied = result.get("apply")
|
|
450
|
+
if isinstance(applied, dict) and applied.get("applied"):
|
|
451
|
+
payload["applied"] = applied.get("to") or ""
|
|
452
|
+
_UPDATE_STATE.clear()
|
|
453
|
+
_UPDATE_STATE.update(payload)
|
|
454
|
+
_HEARTBEAT_STATE["update"] = dict(_UPDATE_STATE)
|
|
455
|
+
write_heartbeat()
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def request_restart(delay: float = 5.0) -> bool:
|
|
459
|
+
"""Stop this agent (once) so the restart helper can start the updated copy.
|
|
460
|
+
|
|
461
|
+
The delay is what lets an in-flight tool result reach the caller: a
|
|
462
|
+
``mesh_update`` call answers "applied and restarting" and only then does the
|
|
463
|
+
process leave, which is also what releases the tunnel and the instance lock
|
|
464
|
+
the new agent needs.
|
|
465
|
+
"""
|
|
466
|
+
if _RESTART_EVENT.is_set():
|
|
467
|
+
return False
|
|
468
|
+
_RESTART_EVENT.set()
|
|
469
|
+
name = "SIGBREAK" if os.name == "nt" else "SIGTERM"
|
|
470
|
+
number = getattr(signal, name, None)
|
|
471
|
+
if number is None:
|
|
472
|
+
number = getattr(signal, "SIGTERM", None)
|
|
473
|
+
|
|
474
|
+
def _fire():
|
|
475
|
+
time.sleep(max(0.0, delay))
|
|
476
|
+
logger.warning("Stopping this agent so the updated version can start (signal %s).", name)
|
|
477
|
+
try:
|
|
478
|
+
signal.raise_signal(number)
|
|
479
|
+
except Exception:
|
|
480
|
+
# A platform without raise_signal still has to end up updated; the
|
|
481
|
+
# restart helper is already waiting for this pid either way.
|
|
482
|
+
os._exit(0)
|
|
483
|
+
|
|
484
|
+
threading.Thread(target=_fire, name="mesh-restart", daemon=True).start()
|
|
485
|
+
return True
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def start_update_thread():
|
|
489
|
+
"""Check for a newer release in the background and install it.
|
|
490
|
+
|
|
491
|
+
The first check is delayed (``MESH_UPDATE_INITIAL_DELAY``, 90 s by default)
|
|
492
|
+
so a node that was just started - including one that was just updated - gets
|
|
493
|
+
its tunnel up and serves a few calls before anything restarts.
|
|
494
|
+
"""
|
|
495
|
+
config = updater.config_from_env()
|
|
496
|
+
if not config["enabled"]:
|
|
497
|
+
logger.info("Self-update is disabled (MESH_UPDATE_CHECK=0).")
|
|
498
|
+
return None
|
|
499
|
+
# Registered even when automatic updating is off: an operator (or the model
|
|
500
|
+
# through mesh_update) can still ask for an update, and the node then has to
|
|
501
|
+
# stop itself for the new version to run.
|
|
502
|
+
updater.set_restart_hook(request_restart)
|
|
503
|
+
try:
|
|
504
|
+
initial = float(os.environ.get("MESH_UPDATE_INITIAL_DELAY", "") or 90.0)
|
|
505
|
+
except ValueError:
|
|
506
|
+
initial = 90.0
|
|
507
|
+
|
|
508
|
+
def _loop():
|
|
509
|
+
time.sleep(max(0.0, initial))
|
|
510
|
+
while True:
|
|
511
|
+
try:
|
|
512
|
+
result = updater.background_tick(config=config)
|
|
513
|
+
note_update_state(result)
|
|
514
|
+
check = result.get("check") or {}
|
|
515
|
+
applied = result.get("apply") or {}
|
|
516
|
+
if applied.get("applied"):
|
|
517
|
+
logger.warning("Self-update: %s -> %s applied from %s; restarting.",
|
|
518
|
+
applied.get("from"), applied.get("to"), applied.get("asset"))
|
|
519
|
+
elif check.get("update_available"):
|
|
520
|
+
logger.info("Self-update: release %s is available (%s).",
|
|
521
|
+
check.get("latest"), check.get("reason"))
|
|
522
|
+
elif check.get("ok"):
|
|
523
|
+
logger.debug("Self-update: up to date (%s).", check.get("current"))
|
|
524
|
+
else:
|
|
525
|
+
logger.warning("Self-update check failed: %s", check.get("reason"))
|
|
526
|
+
except Exception as exc: # the updater must never take the node down
|
|
527
|
+
logger.warning("Self-update iteration failed: %s", exc)
|
|
528
|
+
time.sleep(max(60, int(config["interval"])))
|
|
529
|
+
|
|
530
|
+
thread = threading.Thread(target=_loop, name="mesh-updater", daemon=True)
|
|
531
|
+
thread.start()
|
|
532
|
+
return thread
|
|
533
|
+
|
|
534
|
+
|
|
116
535
|
# ---------------------------------------------------------------------------
|
|
117
536
|
# Single instance per node name
|
|
118
537
|
# ---------------------------------------------------------------------------
|
|
@@ -171,7 +590,14 @@ def wait_for_instance_lock(user: str) -> InstanceLock:
|
|
|
171
590
|
async def handle_tool_call(name: str, args: dict) -> dict:
|
|
172
591
|
"""Forward one tool call to the shared implementation in a worker thread."""
|
|
173
592
|
logger.info("Executing %s", name)
|
|
174
|
-
|
|
593
|
+
_HEARTBEAT_STATE["last_tool"] = name or ""
|
|
594
|
+
_HEARTBEAT_STATE["last_tool_at"] = time.time()
|
|
595
|
+
try:
|
|
596
|
+
return await asyncio.to_thread(mcp_tools.call_tool, name, args or {})
|
|
597
|
+
finally:
|
|
598
|
+
# A heartbeat right after every call makes "the agent is alive and served
|
|
599
|
+
# this tool" visible to the watchdog without reading the tunnel state.
|
|
600
|
+
write_heartbeat()
|
|
175
601
|
|
|
176
602
|
|
|
177
603
|
async def _serve_call(ws, req_id, params: dict) -> None:
|
|
@@ -182,6 +608,7 @@ async def _serve_call(ws, req_id, params: dict) -> None:
|
|
|
182
608
|
except Exception as tool_exc:
|
|
183
609
|
# A tool must never take the tunnel down with it.
|
|
184
610
|
logger.warning(f"Tool {tool_name} failed: {tool_exc}")
|
|
611
|
+
_HEARTBEAT_STATE["last_error"] = "%s: %s" % (type(tool_exc).__name__, tool_exc)
|
|
185
612
|
res = {"error": f"{type(tool_exc).__name__}: {tool_exc}"}
|
|
186
613
|
try:
|
|
187
614
|
await ws.send(json.dumps({"id": req_id, "result": res}, ensure_ascii=False))
|
|
@@ -189,6 +616,8 @@ async def _serve_call(ws, req_id, params: dict) -> None:
|
|
|
189
616
|
# The link dropped while the tool ran; the gateway already failed the
|
|
190
617
|
# call, and the reconnect loop restores the tunnel.
|
|
191
618
|
logger.warning(f"Could not deliver result of {tool_name}: {send_exc}")
|
|
619
|
+
_HEARTBEAT_STATE["last_error"] = "send failed: %s" % send_exc
|
|
620
|
+
write_heartbeat()
|
|
192
621
|
|
|
193
622
|
|
|
194
623
|
def _is_unauthorized(exc: BaseException) -> bool:
|
|
@@ -212,6 +641,25 @@ def _is_unauthorized(exc: BaseException) -> bool:
|
|
|
212
641
|
return status in (401, 403)
|
|
213
642
|
|
|
214
643
|
|
|
644
|
+
def _looks_like_dns_failure(exc: BaseException) -> bool:
|
|
645
|
+
"""True when the connection failed because the gateway name did not resolve.
|
|
646
|
+
|
|
647
|
+
A wrong or dead gateway domain produced an endless stream of generic
|
|
648
|
+
"Connection lost" lines, which reads like a network outage. Naming the cause
|
|
649
|
+
once is the difference between a five-minute fix and a long hunt.
|
|
650
|
+
"""
|
|
651
|
+
if isinstance(exc, socket.gaierror):
|
|
652
|
+
return True
|
|
653
|
+
text = str(exc).lower()
|
|
654
|
+
return any(marker in text for marker in (
|
|
655
|
+
"name or service not known", # Linux getaddrinfo
|
|
656
|
+
"nodename nor servname", # BSD / macOS
|
|
657
|
+
"getaddrinfo failed", # Windows
|
|
658
|
+
"temporary failure in name resolution",
|
|
659
|
+
"no address associated with hostname",
|
|
660
|
+
))
|
|
661
|
+
|
|
662
|
+
|
|
215
663
|
def tunnel_uri() -> str:
|
|
216
664
|
"""Canonical tunnel URL: one shared domain, node selected by ?user=."""
|
|
217
665
|
base = GATEWAY_HOST.rstrip("/")
|
|
@@ -242,6 +690,8 @@ async def run_agent():
|
|
|
242
690
|
max_size=None) as ws:
|
|
243
691
|
logger.info(f"Connected to Mesh Gateway as '{USER}'!")
|
|
244
692
|
backoff = 1.0 # healthy again
|
|
693
|
+
mark_connected(True)
|
|
694
|
+
write_heartbeat()
|
|
245
695
|
async for raw_msg in ws:
|
|
246
696
|
try:
|
|
247
697
|
data = json.loads(raw_msg)
|
|
@@ -259,10 +709,19 @@ async def run_agent():
|
|
|
259
709
|
except asyncio.CancelledError:
|
|
260
710
|
raise
|
|
261
711
|
except Exception as e:
|
|
712
|
+
# The heartbeat is how a watchdog - and an operator reading the files
|
|
713
|
+
# later - can tell "running but disconnected" from "not running".
|
|
714
|
+
mark_connected(False, "%s: %s" % (type(e).__name__, e))
|
|
715
|
+
write_heartbeat()
|
|
262
716
|
if _is_unauthorized(e):
|
|
263
717
|
delay = UNAUTHORIZED_BACKOFF
|
|
264
718
|
logger.error("Gateway rejected node '%s' (4001 Unauthorized): the token is wrong or the "
|
|
265
719
|
"node was removed. Re-run the installer. Retrying in %.0fs.", USER, delay)
|
|
720
|
+
elif _looks_like_dns_failure(e):
|
|
721
|
+
delay = min(backoff, MAX_BACKOFF) + random.uniform(0, 0.5)
|
|
722
|
+
logger.error("Gateway '%s' does not resolve (%s). Check the gateway name (MESH_GATEWAY) "
|
|
723
|
+
"and this machine's DNS. Retrying in %.0fs.", GATEWAY_HOST, e, delay)
|
|
724
|
+
backoff = min(backoff * 2, MAX_BACKOFF)
|
|
266
725
|
else:
|
|
267
726
|
delay = min(backoff, MAX_BACKOFF) + random.uniform(0, 0.5)
|
|
268
727
|
logger.warning(f"Connection lost: {e}. Reconnecting in {delay:.1f}s...")
|
|
@@ -271,11 +730,37 @@ async def run_agent():
|
|
|
271
730
|
|
|
272
731
|
|
|
273
732
|
def main() -> None:
|
|
733
|
+
# Fail fast on a configuration that can never work, instead of retrying the
|
|
734
|
+
# gateway every 60 seconds forever from a Windows Startup launcher.
|
|
735
|
+
problem = configuration_error()
|
|
736
|
+
if problem:
|
|
737
|
+
logger.error("%s Refusing to start.", problem)
|
|
738
|
+
sys.exit(2)
|
|
739
|
+
install_exit_markers()
|
|
740
|
+
enable_fault_log()
|
|
741
|
+
log_runtime_banner()
|
|
742
|
+
# An update that was interrupted mid-swap (a kill, a power cut) is settled
|
|
743
|
+
# here, before the tunnel starts: either finished or rolled back.
|
|
744
|
+
recovery = updater.recover_pending()
|
|
745
|
+
if recovery:
|
|
746
|
+
logger.warning("Self-update recovery: %s", recovery)
|
|
747
|
+
start_heartbeat()
|
|
748
|
+
if _WEBSOCKETS_IMPORT_ERROR is not None:
|
|
749
|
+
# The banner above already named the interpreter and the missing module.
|
|
750
|
+
logger.error("Refusing to start: this interpreter cannot import 'websockets'.")
|
|
751
|
+
sys.exit(3)
|
|
274
752
|
_lock = wait_for_instance_lock(USER) # held for the life of the process
|
|
753
|
+
write_heartbeat()
|
|
754
|
+
logger.info("Instance lock acquired: this is the only agent for node '%s'.", USER)
|
|
755
|
+
# Only the agent that owns the node updates it: a second instance waiting for
|
|
756
|
+
# the lock must not swap files under the process that is actually running.
|
|
757
|
+
start_update_thread()
|
|
275
758
|
try:
|
|
276
759
|
asyncio.run(run_agent())
|
|
277
760
|
except KeyboardInterrupt:
|
|
278
761
|
sys.exit(0)
|
|
762
|
+
finally:
|
|
763
|
+
write_heartbeat(stopping=True)
|
|
279
764
|
|
|
280
765
|
|
|
281
766
|
if __name__ == "__main__":
|
package/core/agent_harness.py
CHANGED
|
@@ -7,8 +7,15 @@ from core.triggers import create_mesh_watchdog_trigger, create_config_trigger
|
|
|
7
7
|
from core.policies import get_mesh_policies, ALLOWED_WORKSPACES
|
|
8
8
|
from core.subagents import get_mesh_subagents, get_root_capabilities_config
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
# Derived from the coordinator workspace so the harness works wherever that root
|
|
11
|
+
# is: the documented /root/agy-gdrive-runner on Linux, %USERPROFILE%\agy-gdrive-runner
|
|
12
|
+
# on Windows. A hardcoded POSIX literal was rejected there by the SDK
|
|
13
|
+
# ("app_data_dir must be an absolute path") because os.path.isabs('/root/...') is
|
|
14
|
+
# False on Windows. os.path.abspath keeps it absolute even if the operator pointed
|
|
15
|
+
# MESH_COORDINATOR_WORKSPACE at a relative path.
|
|
16
|
+
_COORDINATOR_ROOT = os.path.abspath(ALLOWED_WORKSPACES[0])
|
|
17
|
+
DEFAULT_SAVE_DIR = os.path.join(_COORDINATOR_ROOT, ".state", "sessions")
|
|
18
|
+
DEFAULT_APP_DATA_DIR = os.path.join(_COORDINATOR_ROOT, ".state", "artifacts")
|
|
12
19
|
|
|
13
20
|
ROOT_SYSTEM_INSTRUCTIONS = (
|
|
14
21
|
"You are the Root Orchestrator for the Antigravity Remote Execution Mesh. "
|