virtuai-cli 0.9.0__tar.gz → 1.0.0__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.
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/PKG-INFO +1 -1
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/pyproject.toml +1 -1
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/__init__.py +1 -1
- virtuai_cli-1.0.0/src/virtuai_cli/pull.py +335 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/runner.py +59 -36
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli.egg-info/SOURCES.txt +1 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/tests/test_browser.py +3 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/README.md +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/setup.cfg +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/browser.py +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/chat/__init__.py +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/chat/agents.py +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/chat/ask.py +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/chat/command.py +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/chat/history.py +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/chat/sse.py +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/chat/tui.py +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/chat/widgets.py +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/config.py +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/executor.py +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/main.py +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/opencode.py +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/security.py +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli.egg-info/requires.txt +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli.egg-info/top_level.txt +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/tests/test_frame_concurrency.py +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/tests/test_process_group_kill.py +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/tests/test_run_single_instance.py +0 -0
- {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/tests/test_session_isolation.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""VirtuAI local CLI."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""The runner pulls its work over HTTPS instead of holding a WebSocket.
|
|
2
|
+
|
|
3
|
+
CLI 1.0, phase 3 of docs/workitems/cli-http-transport.md, after the worker in
|
|
4
|
+
Claude Managed Agents' self-hosted sandboxes: the server never calls this
|
|
5
|
+
machine. The runner says hello, keeps one request open asking for work,
|
|
6
|
+
acknowledges each job as it starts it, heartbeats while it runs — carrying
|
|
7
|
+
the output so far and learning whether the caller gave up — and posts the
|
|
8
|
+
result.
|
|
9
|
+
|
|
10
|
+
A job is the same frame the socket used to carry, so it runs through the same
|
|
11
|
+
handler (runner._handle_frame / runner._spawn) with the same session order,
|
|
12
|
+
concurrency limits and path jails. Only where its replies go changes: a
|
|
13
|
+
_JobSocket collects what the handler would have sent down the WebSocket.
|
|
14
|
+
|
|
15
|
+
A server that does not offer this answers the hello with 404; run_pull then
|
|
16
|
+
raises PullUnavailable and the caller uses the WebSocket, so a 1.0 runner
|
|
17
|
+
works against any server.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import time
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Callable, Optional
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
from virtuai_cli import runner
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
_HEARTBEAT_SECONDS = 5.0
|
|
36
|
+
#: Output is sent this often while there is some, so a stream reads as one.
|
|
37
|
+
_FLUSH_SECONDS = 0.25
|
|
38
|
+
_RETRY_DELAYS = (1, 2, 5, 10, 20, 30)
|
|
39
|
+
#: How long a finished job keeps trying to hand in its result.
|
|
40
|
+
_RESULT_RETRY_SECONDS = 120.0
|
|
41
|
+
#: Past this many jobs in hand, wait for some to finish before asking for more.
|
|
42
|
+
_MAX_IN_HAND = 16
|
|
43
|
+
|
|
44
|
+
#: Output that goes back while a job runs. Only model tokens: the server
|
|
45
|
+
#: reads a model's stream as it grows, but nothing reads execute_output (the
|
|
46
|
+
#: socket router drops it too), and forwarding it put every command's output
|
|
47
|
+
#: through Redis twice — 1 MB commands filled dev's Redis in the benchmark.
|
|
48
|
+
_STREAM_TYPES = {"model_token"}
|
|
49
|
+
#: Output a handler sends that is dropped rather than forwarded.
|
|
50
|
+
_LOCAL_ONLY_TYPES = {"execute_output"}
|
|
51
|
+
#: This many 404s in a row on the work endpoint means the server turned pull
|
|
52
|
+
#: off (VIRTUAI_CLI_PULL unset); fall back to the socket rather than retry
|
|
53
|
+
#: forever. One or two can be a rolling deploy reaching an older pod.
|
|
54
|
+
_GONE_AFTER_404S = 5
|
|
55
|
+
#: Job ids remembered, so a job delivered twice is only taken once.
|
|
56
|
+
_SEEN_MAX = 2_000
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PullUnavailable(Exception):
|
|
60
|
+
"""The server does not offer pull; use the WebSocket."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TokenRejected(Exception):
|
|
64
|
+
"""The server rejected this runner's token.
|
|
65
|
+
|
|
66
|
+
Not PermissionError: that is an OSError, which the network retries catch —
|
|
67
|
+
a revoked token was retried forever.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _JobSocket:
|
|
72
|
+
"""Stands in for the WebSocket: output is kept for the heartbeat, the reply ends the job."""
|
|
73
|
+
|
|
74
|
+
def __init__(self):
|
|
75
|
+
self.events: list[dict] = []
|
|
76
|
+
self.final: Optional[dict] = None
|
|
77
|
+
self.done = asyncio.Event()
|
|
78
|
+
self.flush = asyncio.Event()
|
|
79
|
+
|
|
80
|
+
async def send(self, raw: str) -> None:
|
|
81
|
+
msg = json.loads(raw)
|
|
82
|
+
if msg.get("type") in _LOCAL_ONLY_TYPES:
|
|
83
|
+
return
|
|
84
|
+
if msg.get("type") in _STREAM_TYPES:
|
|
85
|
+
self.events.append(msg)
|
|
86
|
+
self.flush.set()
|
|
87
|
+
elif self.final is None:
|
|
88
|
+
self.final = msg
|
|
89
|
+
self.done.set()
|
|
90
|
+
|
|
91
|
+
def take_events(self) -> list[dict]:
|
|
92
|
+
events, self.events = self.events, []
|
|
93
|
+
self.flush.clear()
|
|
94
|
+
return events
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _wait(event: asyncio.Event, timeout: float) -> None:
|
|
98
|
+
try:
|
|
99
|
+
await asyncio.wait_for(event.wait(), timeout=timeout)
|
|
100
|
+
except asyncio.TimeoutError:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def _wait_any(events, timeout: float) -> None:
|
|
105
|
+
waiters = [asyncio.ensure_future(e.wait()) for e in events]
|
|
106
|
+
try:
|
|
107
|
+
await asyncio.wait(waiters, timeout=timeout, return_when=asyncio.FIRST_COMPLETED)
|
|
108
|
+
finally:
|
|
109
|
+
for w in waiters:
|
|
110
|
+
w.cancel()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _Session:
|
|
114
|
+
def __init__(self, server_url: str, token: str, workdir: Path,
|
|
115
|
+
on_status: Callable, on_output: Optional[Callable], kind: str):
|
|
116
|
+
import certifi
|
|
117
|
+
|
|
118
|
+
self.workdir = workdir
|
|
119
|
+
self.on_status = on_status
|
|
120
|
+
self.on_output = on_output
|
|
121
|
+
self.kind = kind
|
|
122
|
+
self.runner_id: Optional[str] = None
|
|
123
|
+
self.wait = 25
|
|
124
|
+
self.heartbeat = _HEARTBEAT_SECONDS
|
|
125
|
+
self.jobs: set = set()
|
|
126
|
+
self.seen: dict = {} # job id -> time seen; insertion-ordered, trimmed
|
|
127
|
+
self.stopping: Optional[BaseException] = None
|
|
128
|
+
self.stop = asyncio.Event()
|
|
129
|
+
self.http = httpx.AsyncClient(
|
|
130
|
+
base_url=server_url.rstrip("/"),
|
|
131
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
132
|
+
timeout=httpx.Timeout(10.0, read=40.0),
|
|
133
|
+
verify=certifi.where(),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# -- registration ------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
async def hello(self) -> None:
|
|
139
|
+
body = {
|
|
140
|
+
"kind": self.kind,
|
|
141
|
+
"previous_runner_id": self.runner_id,
|
|
142
|
+
"ready": await runner._ready_payload(self.workdir),
|
|
143
|
+
}
|
|
144
|
+
resp = await self.http.post("/api/cli/v2/hello", json=body)
|
|
145
|
+
if resp.status_code == 404:
|
|
146
|
+
raise PullUnavailable()
|
|
147
|
+
if resp.status_code == 401:
|
|
148
|
+
raise TokenRejected("the server rejected this runner's token; pair it again")
|
|
149
|
+
resp.raise_for_status()
|
|
150
|
+
data = resp.json()
|
|
151
|
+
self.runner_id = data["runner_id"]
|
|
152
|
+
self.wait = int(data.get("wait_seconds", self.wait))
|
|
153
|
+
self.heartbeat = float(data.get("heartbeat_seconds", self.heartbeat))
|
|
154
|
+
self.on_status("info", f"Connected (pull). Workdir: {self.workdir}")
|
|
155
|
+
|
|
156
|
+
async def bye(self) -> None:
|
|
157
|
+
if not self.runner_id:
|
|
158
|
+
return
|
|
159
|
+
try:
|
|
160
|
+
await self.http.post(f"/api/cli/v2/runners/{self.runner_id}/bye", timeout=5.0)
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
# -- the loop ----------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
async def serve(self) -> None:
|
|
167
|
+
attempt = 0
|
|
168
|
+
not_found = 0
|
|
169
|
+
while True:
|
|
170
|
+
if self.stopping is not None:
|
|
171
|
+
raise self.stopping
|
|
172
|
+
if len(self.jobs) >= _MAX_IN_HAND:
|
|
173
|
+
await asyncio.wait(self.jobs, return_when=asyncio.FIRST_COMPLETED)
|
|
174
|
+
continue
|
|
175
|
+
try:
|
|
176
|
+
# The poll can wait 25 s; a shutdown must not wait with it.
|
|
177
|
+
poll = asyncio.ensure_future(self.http.get(
|
|
178
|
+
f"/api/cli/v2/runners/{self.runner_id}/work",
|
|
179
|
+
params={"max": max(1, _MAX_IN_HAND - len(self.jobs)), "wait": self.wait},
|
|
180
|
+
))
|
|
181
|
+
stopper = asyncio.ensure_future(self.stop.wait())
|
|
182
|
+
await asyncio.wait({poll, stopper}, return_when=asyncio.FIRST_COMPLETED)
|
|
183
|
+
stopper.cancel()
|
|
184
|
+
if self.stopping is not None:
|
|
185
|
+
poll.cancel()
|
|
186
|
+
raise self.stopping
|
|
187
|
+
resp = poll.result()
|
|
188
|
+
if resp.status_code == 410:
|
|
189
|
+
# The server no longer knows this runner (it expired, or
|
|
190
|
+
# Redis lost it): register again and carry on.
|
|
191
|
+
await self.hello()
|
|
192
|
+
continue
|
|
193
|
+
if resp.status_code == 401:
|
|
194
|
+
raise TokenRejected("the server rejected this runner's token; pair it again")
|
|
195
|
+
if resp.status_code == 404:
|
|
196
|
+
not_found += 1
|
|
197
|
+
if not_found >= _GONE_AFTER_404S:
|
|
198
|
+
raise PullUnavailable()
|
|
199
|
+
await asyncio.sleep(1)
|
|
200
|
+
continue
|
|
201
|
+
resp.raise_for_status()
|
|
202
|
+
attempt = 0
|
|
203
|
+
not_found = 0
|
|
204
|
+
except (httpx.HTTPError, OSError) as exc:
|
|
205
|
+
delay = _RETRY_DELAYS[min(attempt, len(_RETRY_DELAYS) - 1)]
|
|
206
|
+
self.on_status("error", f"Could not reach the server: {exc}. Retrying in {delay}s ...")
|
|
207
|
+
await asyncio.sleep(delay)
|
|
208
|
+
attempt += 1
|
|
209
|
+
continue
|
|
210
|
+
for job in resp.json().get("jobs", []):
|
|
211
|
+
key = job.get("job_id") or job.get("id")
|
|
212
|
+
if key in self.seen:
|
|
213
|
+
# Delivered again while its first copy is still ours: the
|
|
214
|
+
# server's acknowledgement decides, and ours already went.
|
|
215
|
+
continue
|
|
216
|
+
self._remember(key)
|
|
217
|
+
task = asyncio.ensure_future(self._run(job))
|
|
218
|
+
self.jobs.add(task)
|
|
219
|
+
task.add_done_callback(self.jobs.discard)
|
|
220
|
+
|
|
221
|
+
def _remember(self, job_id) -> None:
|
|
222
|
+
self.seen[job_id] = time.monotonic()
|
|
223
|
+
while len(self.seen) > _SEEN_MAX:
|
|
224
|
+
self.seen.pop(next(iter(self.seen)))
|
|
225
|
+
|
|
226
|
+
async def _run(self, job: dict) -> None:
|
|
227
|
+
# The job's id addresses the queue; the frame's id is what the
|
|
228
|
+
# handler answers with, as it would over a socket.
|
|
229
|
+
job_id, entry = job.pop("job_id", None) or job.get("id"), job.pop("_entry", None)
|
|
230
|
+
base = f"/api/cli/v2/runners/{self.runner_id}/jobs/{job_id}"
|
|
231
|
+
start = None
|
|
232
|
+
for attempt in range(3):
|
|
233
|
+
try:
|
|
234
|
+
resp = await self.http.post(f"{base}/ack", json={"entry": entry})
|
|
235
|
+
if resp.status_code == 200:
|
|
236
|
+
start = bool(resp.json().get("start"))
|
|
237
|
+
break
|
|
238
|
+
except Exception as exc:
|
|
239
|
+
logger.warning("Could not acknowledge job %s: %s", job_id, exc)
|
|
240
|
+
await asyncio.sleep(0.5 * (attempt + 1))
|
|
241
|
+
if not start:
|
|
242
|
+
# Not acknowledged: the server delivers it again. Acknowledged by
|
|
243
|
+
# another delivery: that one runs it. Either way, not this one.
|
|
244
|
+
if start is None:
|
|
245
|
+
self.seen.pop(job_id, None) # let its redelivery through
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
sock = _JobSocket()
|
|
249
|
+
task: Optional[asyncio.Task] = None
|
|
250
|
+
if job.get("type") in runner._INLINE_TYPES:
|
|
251
|
+
# browser_action starts its own task. shutdown raises SystemExit,
|
|
252
|
+
# which inside this task would end nothing (a task keeps its
|
|
253
|
+
# exception): hand it to the loop in serve() instead.
|
|
254
|
+
try:
|
|
255
|
+
await runner._handle_frame(job, self.workdir, sock, self.on_status, self.on_output)
|
|
256
|
+
except SystemExit as exc:
|
|
257
|
+
sock.final = {"v": 1, "id": job.get("id"), "type": "error", "error": "the runner is shutting down"}
|
|
258
|
+
sock.done.set()
|
|
259
|
+
self.stopping = exc
|
|
260
|
+
self.stop.set()
|
|
261
|
+
else:
|
|
262
|
+
task = runner._spawn(job, self.workdir, sock, self.on_status, self.on_output)
|
|
263
|
+
|
|
264
|
+
last_beat = time.monotonic()
|
|
265
|
+
while not sock.done.is_set():
|
|
266
|
+
if task is not None and task.done():
|
|
267
|
+
break # the handler ended without a reply; answer for it below
|
|
268
|
+
if sock.events:
|
|
269
|
+
# Let a little more output gather — but the moment the job
|
|
270
|
+
# ends, its output goes with the result instead.
|
|
271
|
+
await _wait(sock.done, _FLUSH_SECONDS)
|
|
272
|
+
else:
|
|
273
|
+
await _wait_any((sock.done, sock.flush),
|
|
274
|
+
max(0.05, self.heartbeat - (time.monotonic() - last_beat)))
|
|
275
|
+
if not sock.events and time.monotonic() - last_beat < self.heartbeat:
|
|
276
|
+
continue
|
|
277
|
+
if sock.done.is_set():
|
|
278
|
+
break
|
|
279
|
+
try:
|
|
280
|
+
reply = await self.http.post(f"{base}/heartbeat", json={"events": sock.take_events()})
|
|
281
|
+
last_beat = time.monotonic()
|
|
282
|
+
if reply.status_code == 200 and reply.json().get("cancel"):
|
|
283
|
+
self.on_status("warn", f"Job {job_id} cancelled by the server")
|
|
284
|
+
if task is not None:
|
|
285
|
+
task.cancel()
|
|
286
|
+
sock.final = sock.final or runner._busy_reply(job, "cancelled by the server") or {
|
|
287
|
+
"v": 1, "id": job.get("id"), "type": "error", "error": "cancelled by the server"}
|
|
288
|
+
sock.done.set()
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
logger.debug("Heartbeat for %s failed: %s", job_id, exc)
|
|
291
|
+
|
|
292
|
+
final = sock.final or runner._busy_reply(job, "the runner finished this request without a reply") or {
|
|
293
|
+
"v": 1, "id": job.get("id"), "type": "error", "error": "no reply"}
|
|
294
|
+
# The work is done; losing its result now would waste it. Keep trying
|
|
295
|
+
# through a deploy or a network blip — every replica can take it —
|
|
296
|
+
# for as long as the caller could still be waiting.
|
|
297
|
+
deadline = time.monotonic() + _RESULT_RETRY_SECONDS
|
|
298
|
+
events = sock.take_events()
|
|
299
|
+
attempt = 0
|
|
300
|
+
while True:
|
|
301
|
+
try:
|
|
302
|
+
resp = await self.http.post(f"{base}/result", json={"result": final, "events": events})
|
|
303
|
+
if resp.status_code < 500:
|
|
304
|
+
return
|
|
305
|
+
except Exception as exc:
|
|
306
|
+
logger.warning("Could not post the result of %s: %s", job_id, exc)
|
|
307
|
+
if time.monotonic() >= deadline:
|
|
308
|
+
logger.error("Gave up posting the result of %s after %.0f s", job_id, _RESULT_RETRY_SECONDS)
|
|
309
|
+
return
|
|
310
|
+
await asyncio.sleep(min(5.0, 0.5 * 2 ** attempt))
|
|
311
|
+
attempt += 1
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
async def run_pull(server_url: str, token: str, workdir: Path,
|
|
315
|
+
on_status: Callable, on_output: Optional[Callable] = None,
|
|
316
|
+
*, kind: str = "host") -> None:
|
|
317
|
+
"""Serve work until the process ends. Raises PullUnavailable on an old server."""
|
|
318
|
+
session = _Session(server_url, token, workdir, on_status, on_output, kind)
|
|
319
|
+
try:
|
|
320
|
+
attempt = 0
|
|
321
|
+
while True:
|
|
322
|
+
try:
|
|
323
|
+
await session.hello()
|
|
324
|
+
break
|
|
325
|
+
except (PullUnavailable, TokenRejected):
|
|
326
|
+
raise
|
|
327
|
+
except (httpx.HTTPError, OSError) as exc:
|
|
328
|
+
delay = _RETRY_DELAYS[min(attempt, len(_RETRY_DELAYS) - 1)]
|
|
329
|
+
on_status("error", f"Could not reach the server: {exc}. Retrying in {delay}s ...")
|
|
330
|
+
await asyncio.sleep(delay)
|
|
331
|
+
attempt += 1
|
|
332
|
+
await session.serve()
|
|
333
|
+
finally:
|
|
334
|
+
await session.bye()
|
|
335
|
+
await session.http.aclose()
|
|
@@ -470,6 +470,40 @@ async def _reap(proc) -> None:
|
|
|
470
470
|
exec_.untrack_group(proc.pid)
|
|
471
471
|
|
|
472
472
|
|
|
473
|
+
async def _ready_payload(workdir: Path) -> dict:
|
|
474
|
+
"""What this runner reports about itself: in its ready frame, or its hello."""
|
|
475
|
+
return {
|
|
476
|
+
"cli_version": __version__,
|
|
477
|
+
"os": platform.system(),
|
|
478
|
+
"arch": platform.machine(),
|
|
479
|
+
# The server only ever recorded a fingerprint at pairing time, so a
|
|
480
|
+
# container reusing a stored token reported the machine that paired
|
|
481
|
+
# months ago. Reporting it on every ready frame keeps the row honest.
|
|
482
|
+
"host": socket.gethostname(),
|
|
483
|
+
"python": f"{sys.version_info.major}.{sys.version_info.minor}",
|
|
484
|
+
"workdir": str(workdir),
|
|
485
|
+
# Lets the server and the portal tell a shared runner apart from a
|
|
486
|
+
# personal one without guessing from the hostname.
|
|
487
|
+
"isolate_sessions": _isolation_enabled(),
|
|
488
|
+
# Which person this runner serves, when it serves exactly one.
|
|
489
|
+
#
|
|
490
|
+
# A browser session is one person's — their Chrome, their profile,
|
|
491
|
+
# their signed-in accounts. The server used to pick the newest
|
|
492
|
+
# connection for a workspace, which is right while there is one
|
|
493
|
+
# runner and wrong the moment a workspace has a sandbox per person:
|
|
494
|
+
# the action lands in whoever connected last. Reporting this lets
|
|
495
|
+
# the server route by owner instead of by recency.
|
|
496
|
+
#
|
|
497
|
+
# Empty for a laptop or a shared container, which serve anyone —
|
|
498
|
+
# that is the pre-per-person deployment and stays supported.
|
|
499
|
+
"sandbox_owner": _sandbox_owner(),
|
|
500
|
+
"local_skills": _read_local_skills(workdir),
|
|
501
|
+
"local_memory": _read_local_memory(workdir),
|
|
502
|
+
"ollama": await _probe_ollama(),
|
|
503
|
+
"browser": _probe_browser(),
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
|
|
473
507
|
async def _handle_frame(
|
|
474
508
|
frame: dict,
|
|
475
509
|
workdir: Path,
|
|
@@ -482,39 +516,7 @@ async def _handle_frame(
|
|
|
482
516
|
fid = frame.get("id")
|
|
483
517
|
|
|
484
518
|
if ftype == "hello":
|
|
485
|
-
reply = {
|
|
486
|
-
"v": 1,
|
|
487
|
-
"id": fid,
|
|
488
|
-
"type": "ready",
|
|
489
|
-
"cli_version": __version__,
|
|
490
|
-
"os": platform.system(),
|
|
491
|
-
"arch": platform.machine(),
|
|
492
|
-
# The server only ever recorded a fingerprint at pairing time, so a
|
|
493
|
-
# container reusing a stored token reported the machine that paired
|
|
494
|
-
# months ago. Reporting it on every ready frame keeps the row honest.
|
|
495
|
-
"host": socket.gethostname(),
|
|
496
|
-
"python": f"{sys.version_info.major}.{sys.version_info.minor}",
|
|
497
|
-
"workdir": str(workdir),
|
|
498
|
-
# Lets the server and the portal tell a shared runner apart from a
|
|
499
|
-
# personal one without guessing from the hostname.
|
|
500
|
-
"isolate_sessions": _isolation_enabled(),
|
|
501
|
-
# Which person this runner serves, when it serves exactly one.
|
|
502
|
-
#
|
|
503
|
-
# A browser session is one person's — their Chrome, their profile,
|
|
504
|
-
# their signed-in accounts. The server used to pick the newest
|
|
505
|
-
# connection for a workspace, which is right while there is one
|
|
506
|
-
# runner and wrong the moment a workspace has a sandbox per person:
|
|
507
|
-
# the action lands in whoever connected last. Reporting this lets
|
|
508
|
-
# the server route by owner instead of by recency.
|
|
509
|
-
#
|
|
510
|
-
# Empty for a laptop or a shared container, which serve anyone —
|
|
511
|
-
# that is the pre-per-person deployment and stays supported.
|
|
512
|
-
"sandbox_owner": _sandbox_owner(),
|
|
513
|
-
"local_skills": _read_local_skills(workdir),
|
|
514
|
-
"local_memory": _read_local_memory(workdir),
|
|
515
|
-
"ollama": await _probe_ollama(),
|
|
516
|
-
"browser": _probe_browser(),
|
|
517
|
-
}
|
|
519
|
+
reply = {"v": 1, "id": fid, "type": "ready", **(await _ready_payload(workdir))}
|
|
518
520
|
await ws.send(json.dumps(reply))
|
|
519
521
|
on_status("info", f"Connected. Workdir: {workdir}")
|
|
520
522
|
return
|
|
@@ -895,19 +897,24 @@ def _work_task_done(task: asyncio.Task) -> None:
|
|
|
895
897
|
logger.warning("Frame handling error: %s", exc)
|
|
896
898
|
|
|
897
899
|
|
|
898
|
-
def _spawn(frame: dict, workdir: Path, ws, on_status, on_output) ->
|
|
899
|
-
"""Hand a frame to a task, or refuse it while we still can answer.
|
|
900
|
+
def _spawn(frame: dict, workdir: Path, ws, on_status, on_output) -> Optional[asyncio.Task]:
|
|
901
|
+
"""Hand a frame to a task, or refuse it while we still can answer.
|
|
902
|
+
|
|
903
|
+
Returns the task, or None when refused: the pull transport cancels it
|
|
904
|
+
when the server says the caller gave up (virtuai_cli/pull.py).
|
|
905
|
+
"""
|
|
900
906
|
if len(_WORK_TASKS) >= _int_env("VIRTUAI_MAX_QUEUED_FRAMES", _MAX_QUEUED_FRAMES):
|
|
901
907
|
reply = _busy_reply(frame, "the runner has too many requests queued")
|
|
902
908
|
if reply is not None:
|
|
903
909
|
asyncio.ensure_future(_safe_send(ws, reply))
|
|
904
910
|
logger.warning("Refused a %r frame: %d already queued",
|
|
905
911
|
frame.get("type"), len(_WORK_TASKS))
|
|
906
|
-
return
|
|
912
|
+
return None
|
|
907
913
|
task = asyncio.ensure_future(_dispatch(frame, workdir, ws, on_status, on_output))
|
|
908
914
|
_WORK_TASKS.add(task)
|
|
909
915
|
_TASK_PHASE[task] = "queued"
|
|
910
916
|
task.add_done_callback(_work_task_done)
|
|
917
|
+
return task
|
|
911
918
|
|
|
912
919
|
|
|
913
920
|
async def _run_browser_action(frame: dict, ws, on_status: StatusCallback) -> None:
|
|
@@ -1079,6 +1086,22 @@ async def run_forever(
|
|
|
1079
1086
|
|
|
1080
1087
|
attempt = 0
|
|
1081
1088
|
try:
|
|
1089
|
+
# Pull first (virtuai_cli/pull.py): the server never has to reach
|
|
1090
|
+
# this machine. A server that does not offer it answers 404, and the
|
|
1091
|
+
# socket below is the fallback. VIRTUAI_TRANSPORT=socket skips pull.
|
|
1092
|
+
if os.environ.get("VIRTUAI_TRANSPORT", "auto").strip().lower() != "socket":
|
|
1093
|
+
from virtuai_cli import pull
|
|
1094
|
+
|
|
1095
|
+
try:
|
|
1096
|
+
await pull.run_pull(server_url, token, workdir, on_status, on_output)
|
|
1097
|
+
return
|
|
1098
|
+
except pull.PullUnavailable:
|
|
1099
|
+
on_status("info", "The server does not offer pull; using the WebSocket.")
|
|
1100
|
+
except pull.TokenRejected as exc:
|
|
1101
|
+
# The socket below retries with backoff, as it always has.
|
|
1102
|
+
on_status("error", str(exc))
|
|
1103
|
+
except SystemExit:
|
|
1104
|
+
return
|
|
1082
1105
|
while True:
|
|
1083
1106
|
try:
|
|
1084
1107
|
await run_session(ws_url, token, workdir, on_status, on_output)
|
|
@@ -1215,6 +1215,9 @@ def test_cli_shutdown_closes_the_browser(monkeypatch):
|
|
|
1215
1215
|
closed.append(True)
|
|
1216
1216
|
|
|
1217
1217
|
monkeypatch.setattr(B, "close_all_sessions", track)
|
|
1218
|
+
# The socket path: CLI 1.0 otherwise tries pull first, against a server
|
|
1219
|
+
# that does not exist, and keeps retrying.
|
|
1220
|
+
monkeypatch.setenv("VIRTUAI_TRANSPORT", "socket")
|
|
1218
1221
|
|
|
1219
1222
|
async def go():
|
|
1220
1223
|
async def bye(*a, **kw):
|
|
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
|