qbraid-cli 0.16.9__tar.gz → 0.16.10__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.
- {qbraid_cli-0.16.9/qbraid_cli.egg-info → qbraid_cli-0.16.10}/PKG-INFO +4 -4
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/pyproject.toml +4 -4
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/agents/app.py +486 -94
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10/qbraid_cli.egg-info}/PKG-INFO +4 -4
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli.egg-info/requires.txt +3 -3
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/tests/test_cli_e2e.py +14 -4
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/LICENSE +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/README.md +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/__init__.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/_build_info.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/_version.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/account/__init__.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/account/app.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/admin/__init__.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/admin/app.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/admin/headers.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/admin/validation.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/agents/__init__.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/agents/permission_response.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/ai/__init__.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/ai/app.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/ai/token.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/comms/__init__.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/comms/app.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/compute/__init__.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/compute/app.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/compute/instances.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/compute/profiles.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/compute/server.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/compute/ssh.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/configure/__init__.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/configure/actions.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/configure/app.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/devices/__init__.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/devices/app.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/devices/validation.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/envs/__init__.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/envs/activate.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/envs/app.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/envs/create.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/envs/data_handling.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/envs/groups.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/envs/publish.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/envs/registry.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/envs/share.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/exceptions.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/handlers.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/jobs/__init__.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/jobs/app.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/jobs/groups.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/kernels/__init__.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/kernels/app.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/lab/__init__.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/lab/app.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/main.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/pip/__init__.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/pip/app.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/pip/hooks.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/projects/__init__.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/projects/app.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/py.typed +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/skills/__init__.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/skills/app.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/troubleshoot/__init__.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli/troubleshoot/app.py +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli.egg-info/SOURCES.txt +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli.egg-info/dependency_links.txt +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli.egg-info/entry_points.txt +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/qbraid_cli.egg-info/top_level.txt +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/setup.cfg +0 -0
- {qbraid_cli-0.16.9 → qbraid_cli-0.16.10}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qbraid-cli
|
|
3
|
-
Version: 0.16.
|
|
3
|
+
Version: 0.16.10
|
|
4
4
|
Summary: Command Line Interface for interacting with all parts of the qBraid platform.
|
|
5
5
|
Author-email: qBraid Development Team <contact@qbraid.com>
|
|
6
6
|
License-Expression: LicenseRef-Proprietary
|
|
@@ -30,11 +30,11 @@ Requires-Dist: typer>=0.12.1
|
|
|
30
30
|
Requires-Dist: rich>=10.11.0
|
|
31
31
|
Requires-Dist: click<=8.5.0
|
|
32
32
|
Requires-Dist: questionary>=2.0.0
|
|
33
|
-
Requires-Dist: qbraid-core>=0.6.
|
|
33
|
+
Requires-Dist: qbraid-core>=0.6.27
|
|
34
34
|
Provides-Extra: compute
|
|
35
|
-
Requires-Dist: qbraid-core[compute]>=0.6.
|
|
35
|
+
Requires-Dist: qbraid-core[compute]>=0.6.27; extra == "compute"
|
|
36
36
|
Provides-Extra: envs
|
|
37
|
-
Requires-Dist: qbraid-core[environments]>=0.6.
|
|
37
|
+
Requires-Dist: qbraid-core[environments]>=0.6.27; extra == "envs"
|
|
38
38
|
Provides-Extra: dev
|
|
39
39
|
Requires-Dist: isort; extra == "dev"
|
|
40
40
|
Requires-Dist: black; extra == "dev"
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "qbraid-cli"
|
|
7
|
-
version = "0.16.
|
|
7
|
+
version = "0.16.10"
|
|
8
8
|
description = "Command Line Interface for interacting with all parts of the qBraid platform."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "qBraid Development Team", email = "contact@qbraid.com" }]
|
|
@@ -32,7 +32,7 @@ dependencies = [
|
|
|
32
32
|
"rich>=10.11.0",
|
|
33
33
|
"click<=8.5.0",
|
|
34
34
|
"questionary>=2.0.0",
|
|
35
|
-
"qbraid-core>=0.6.
|
|
35
|
+
"qbraid-core>=0.6.27",
|
|
36
36
|
]
|
|
37
37
|
requires-python = ">= 3.10"
|
|
38
38
|
|
|
@@ -43,8 +43,8 @@ Documentation = "https://docs.qbraid.com/v2/cli/api-reference/qbraid"
|
|
|
43
43
|
Discord = "https://discord.gg/KugF6Cnncm"
|
|
44
44
|
|
|
45
45
|
[project.optional-dependencies]
|
|
46
|
-
compute = ["qbraid-core[compute]>=0.6.
|
|
47
|
-
envs = ["qbraid-core[environments]>=0.6.
|
|
46
|
+
compute = ["qbraid-core[compute]>=0.6.27"]
|
|
47
|
+
envs = ["qbraid-core[environments]>=0.6.27"]
|
|
48
48
|
dev = ["isort", "black", "pytest", "pytest-cov", "pytest-asyncio"]
|
|
49
49
|
|
|
50
50
|
[project.scripts]
|
|
@@ -134,6 +134,7 @@ def _status_style(status_value: str) -> str:
|
|
|
134
134
|
"idle": "dim",
|
|
135
135
|
"error": "red",
|
|
136
136
|
"stopped": "red",
|
|
137
|
+
"interrupted": "yellow",
|
|
137
138
|
}
|
|
138
139
|
return styles.get(status_value, "dim")
|
|
139
140
|
|
|
@@ -259,6 +260,38 @@ def _is_registry_error(err: Exception) -> bool:
|
|
|
259
260
|
return isinstance(err, AgentRegistryError)
|
|
260
261
|
|
|
261
262
|
|
|
263
|
+
def _lifecycle_view(registry, session):
|
|
264
|
+
"""The record as Core lists it (qbraid-core #419's read-time overlay).
|
|
265
|
+
|
|
266
|
+
Another Lab's live-or-unknown native row is stored as it was and listed
|
|
267
|
+
as interrupted (``status_overlay``). Displays and decisions that must
|
|
268
|
+
agree with the list classify through ``session_lifecycle_view``; an older
|
|
269
|
+
Core, or a view that fails on this row, gives the stored record.
|
|
270
|
+
"""
|
|
271
|
+
view = _core_method(registry, "session_lifecycle_view")
|
|
272
|
+
if view is None:
|
|
273
|
+
return session
|
|
274
|
+
try:
|
|
275
|
+
return view(session)
|
|
276
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
277
|
+
return session
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _interrupted(session) -> bool:
|
|
281
|
+
"""Stopped because its runtime vanished (or shown so), with nothing after."""
|
|
282
|
+
status = getattr(getattr(session, "status", None), "value", getattr(session, "status", None))
|
|
283
|
+
at = (session.metadata or {}).get("interrupted_at")
|
|
284
|
+
if status != "stopped" or isinstance(at, bool) or not isinstance(at, (int, float)):
|
|
285
|
+
return False
|
|
286
|
+
return (session.last_activity or at) <= at
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _status_label(session) -> str:
|
|
290
|
+
if _interrupted(session):
|
|
291
|
+
return "interrupted"
|
|
292
|
+
return session.status.value if session.status else "idle"
|
|
293
|
+
|
|
294
|
+
|
|
262
295
|
def _session_status(registry, session_id: str) -> Optional[str]:
|
|
263
296
|
"""The registry status of a session ("stopped", "working", ...), or None
|
|
264
297
|
when it is not registered or the registry cannot be read."""
|
|
@@ -340,6 +373,174 @@ def agents_register():
|
|
|
340
373
|
raise typer.Exit(code=0)
|
|
341
374
|
|
|
342
375
|
|
|
376
|
+
def _own_pid_namespace():
|
|
377
|
+
"""This process's PID namespace (``pid:[<inode>]``), or None without /proc."""
|
|
378
|
+
try:
|
|
379
|
+
return os.readlink("/proc/self/ns/pid")
|
|
380
|
+
except (OSError, AttributeError):
|
|
381
|
+
return None
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _foreign_pid_reason(session):
|
|
385
|
+
"""Why this session's recorded PID must not be signalled from here, or None.
|
|
386
|
+
|
|
387
|
+
A PID number names a process only in the namespace that issued it: a
|
|
388
|
+
session recorded in another PID namespace (another Lab, container or
|
|
389
|
+
machine) could name an unrelated process of ours.
|
|
390
|
+
"""
|
|
391
|
+
if (
|
|
392
|
+
getattr(session, "compute_id", None)
|
|
393
|
+
or getattr(session, "machine_id", None)
|
|
394
|
+
or getattr(session, "cluster_id", None)
|
|
395
|
+
):
|
|
396
|
+
return "it belongs to another machine."
|
|
397
|
+
metadata = getattr(session, "metadata", None) or {}
|
|
398
|
+
namespace = metadata.get("jupyter_process_pid_ns")
|
|
399
|
+
# Missing, null and malformed are one case: the namespace is unknown.
|
|
400
|
+
known = isinstance(namespace, str) and _PID_NAMESPACE.fullmatch(namespace) is not None
|
|
401
|
+
if not known and (metadata.get("jupyter_server_id") or namespace is not None):
|
|
402
|
+
return "it was recorded by a Lab server whose PID namespace is unknown."
|
|
403
|
+
if known:
|
|
404
|
+
own = _own_pid_namespace()
|
|
405
|
+
if own is None or namespace != own:
|
|
406
|
+
return "it was recorded in another PID namespace, where that number may be a different process."
|
|
407
|
+
# The agent's own scope, recorded on tmux and hook rows (qbraid-core #419).
|
|
408
|
+
if "agent_pid_ns" in metadata:
|
|
409
|
+
agent_ns = metadata.get("agent_pid_ns")
|
|
410
|
+
own = _own_pid_namespace()
|
|
411
|
+
if (
|
|
412
|
+
not (isinstance(agent_ns, str) and _PID_NAMESPACE.fullmatch(agent_ns))
|
|
413
|
+
or own is None
|
|
414
|
+
or agent_ns != own
|
|
415
|
+
):
|
|
416
|
+
return (
|
|
417
|
+
"its agent was recorded in another PID namespace, "
|
|
418
|
+
"where that number may be a different process."
|
|
419
|
+
)
|
|
420
|
+
if "agent_boot_id" in metadata:
|
|
421
|
+
boot = metadata.get("agent_boot_id")
|
|
422
|
+
own_boot = _own_boot_id()
|
|
423
|
+
if not isinstance(boot, str) or not boot or own_boot is None or boot != own_boot:
|
|
424
|
+
return "its agent was recorded under another boot, where that number may be a different process."
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _own_boot_id():
|
|
429
|
+
"""This kernel's boot id, or None without /proc."""
|
|
430
|
+
try:
|
|
431
|
+
with open("/proc/sys/kernel/random/boot_id", encoding="ascii") as handle:
|
|
432
|
+
return handle.read().strip() or None
|
|
433
|
+
except OSError:
|
|
434
|
+
return None
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
_PID_NAMESPACE = re.compile(r"pid:\[\d+\]")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _not_running_reason(session):
|
|
441
|
+
"""Why a row no longer names a running agent, or None."""
|
|
442
|
+
status = getattr(getattr(session, "status", None), "value", getattr(session, "status", None))
|
|
443
|
+
if status in ("stopped", "error"):
|
|
444
|
+
return "the session is not running, so its PID and window name may now be someone else's."
|
|
445
|
+
return None
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _signal_agent(pid, force):
|
|
449
|
+
sig = signal.SIGKILL if force else signal.SIGTERM
|
|
450
|
+
sig_name = "SIGKILL" if force else "SIGTERM"
|
|
451
|
+
try:
|
|
452
|
+
os.kill(pid, sig)
|
|
453
|
+
console.print(f"[green]Sent {sig_name} to process {pid}.[/green]")
|
|
454
|
+
except (OSError, ProcessLookupError):
|
|
455
|
+
console.print(f"[dim]Process {pid} already stopped.[/dim]")
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _kill_tmux_session(session):
|
|
459
|
+
"""Stop a tmux-hosted agent only through a verified target.
|
|
460
|
+
|
|
461
|
+
A saved window name is mutable: another session may hold it now. Core's
|
|
462
|
+
kill_bound closes the window by the runtime's immutable window id and
|
|
463
|
+
records the stop only once the agent exited. Anything unverified exits
|
|
464
|
+
non-zero and leaves the registry alone.
|
|
465
|
+
"""
|
|
466
|
+
from qbraid_core.services.agents import AgentLauncher, AgentLauncherError
|
|
467
|
+
|
|
468
|
+
launcher = AgentLauncher()
|
|
469
|
+
if not callable(getattr(launcher, "kill_bound", None)):
|
|
470
|
+
# Core has had kill_bound since 0.6.5; a saved window name alone is
|
|
471
|
+
# never trusted, so without it nothing is closed.
|
|
472
|
+
console.print(
|
|
473
|
+
"[red]Not stopped:[/red] this qbraid-core cannot verify a tmux window; update qbraid-core.",
|
|
474
|
+
highlight=False,
|
|
475
|
+
)
|
|
476
|
+
raise typer.Exit(code=1)
|
|
477
|
+
try:
|
|
478
|
+
launcher.kill_bound(session.session_id)
|
|
479
|
+
except AgentLauncherError as error:
|
|
480
|
+
console.print(
|
|
481
|
+
"[red]Not stopped: Core could not verify this session's tmux window.[/red] "
|
|
482
|
+
f"{escape(str(error))}",
|
|
483
|
+
highlight=False,
|
|
484
|
+
)
|
|
485
|
+
raise typer.Exit(code=1) from error
|
|
486
|
+
console.print(f"[green]Stopped the agent in tmux window '{escape(session.tmux_window)}'.[/green]")
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
_GONE = "gone"
|
|
490
|
+
# create_time() and started_at come from different clocks reads; allow this much.
|
|
491
|
+
_START_SLACK_S = 5.0
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _agent_identity_refusal(session):
|
|
495
|
+
"""Why the recorded PID must not be signalled as this session's agent, or None.
|
|
496
|
+
|
|
497
|
+
A registry outlives its processes (it is in $HOME across pod restarts),
|
|
498
|
+
so a PID number alone may now name an unrelated process. It is signalled
|
|
499
|
+
only when psutil shows this tool's agent, started no later than the
|
|
500
|
+
session. Anything that cannot be checked is refused. Returns ``_GONE``
|
|
501
|
+
when no process has that PID.
|
|
502
|
+
"""
|
|
503
|
+
status = getattr(getattr(session, "status", None), "value", getattr(session, "status", None))
|
|
504
|
+
if status in ("stopped", "error"):
|
|
505
|
+
return "the session is not running, so that number may now be another process."
|
|
506
|
+
try:
|
|
507
|
+
import psutil # pylint: disable=import-outside-toplevel
|
|
508
|
+
from qbraid_core.services.agents.registry import ( # pylint: disable=import-outside-toplevel
|
|
509
|
+
process_runs_agent,
|
|
510
|
+
)
|
|
511
|
+
except ImportError:
|
|
512
|
+
return "this qbraid-core cannot check the process's identity; update it."
|
|
513
|
+
started = getattr(session, "started_at", None)
|
|
514
|
+
tool = getattr(getattr(session, "tool", None), "value", None)
|
|
515
|
+
try:
|
|
516
|
+
process = psutil.Process(session.pid)
|
|
517
|
+
# This tool's agent, and already running when the session started.
|
|
518
|
+
if (
|
|
519
|
+
not tool
|
|
520
|
+
or not isinstance(started, (int, float))
|
|
521
|
+
or not process_runs_agent(process, tool)
|
|
522
|
+
or process.create_time() > started + _START_SLACK_S
|
|
523
|
+
):
|
|
524
|
+
return "it is not this session's agent."
|
|
525
|
+
except psutil.NoSuchProcess:
|
|
526
|
+
return _GONE
|
|
527
|
+
except (psutil.AccessDenied, psutil.Error):
|
|
528
|
+
return "its identity could not be checked (access denied)."
|
|
529
|
+
return None
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _end_reported_kwargs(method) -> dict:
|
|
533
|
+
"""`agent_end_reported=True` when this Core's deregister takes it."""
|
|
534
|
+
try:
|
|
535
|
+
return (
|
|
536
|
+
{"agent_end_reported": True}
|
|
537
|
+
if "agent_end_reported" in inspect.signature(method).parameters
|
|
538
|
+
else {}
|
|
539
|
+
)
|
|
540
|
+
except (TypeError, ValueError):
|
|
541
|
+
return {}
|
|
542
|
+
|
|
543
|
+
|
|
343
544
|
@agents_app.command("deregister", hidden=True)
|
|
344
545
|
def agents_deregister():
|
|
345
546
|
"""Deregister an agent session.
|
|
@@ -358,6 +559,9 @@ def agents_deregister():
|
|
|
358
559
|
from qbraid_core.services.agents import AgentSessionRegistryManager
|
|
359
560
|
|
|
360
561
|
registry = AgentSessionRegistryManager()
|
|
562
|
+
# This is the agent's own end hook: say so, where Core can record it
|
|
563
|
+
# (it lets a cleared PID read as exited). Nothing else may claim it.
|
|
564
|
+
ended = _end_reported_kwargs(registry.deregister_session)
|
|
361
565
|
|
|
362
566
|
session_id = data.get("session_id", data.get("id"))
|
|
363
567
|
pid = data.get("pid")
|
|
@@ -365,15 +569,19 @@ def agents_deregister():
|
|
|
365
569
|
# Try to find and deregister by session_id, then by pid
|
|
366
570
|
if session_id:
|
|
367
571
|
try:
|
|
368
|
-
registry.deregister_session(session_id=session_id)
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
572
|
+
ended_id = registry.deregister_session(session_id=session_id, **ended)
|
|
573
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
574
|
+
ended_id = None # unreadable by id: try the PID below
|
|
575
|
+
if ended_id is not None:
|
|
576
|
+
return # ended by id; the PID path is only for an unknown id
|
|
372
577
|
|
|
373
578
|
if pid:
|
|
579
|
+
# The hook's PID is in this process's scope: a row recorded in
|
|
580
|
+
# another namespace, boot or machine that holds the same number
|
|
581
|
+
# is someone else's agent, never this one's end.
|
|
374
582
|
session = registry.find_by_pid(int(pid))
|
|
375
|
-
if session:
|
|
376
|
-
registry.deregister_session(session_id=session.session_id)
|
|
583
|
+
if session and not _foreign_pid_reason(session):
|
|
584
|
+
registry.deregister_session(session_id=session.session_id, **ended)
|
|
377
585
|
|
|
378
586
|
except (typer.Exit, SystemExit):
|
|
379
587
|
raise
|
|
@@ -578,7 +786,7 @@ def agents_list(
|
|
|
578
786
|
if not name_val and s.cwd:
|
|
579
787
|
name_val = Path(s.cwd).name
|
|
580
788
|
tool_val = s.tool.value if s.tool else "-"
|
|
581
|
-
status_val = s
|
|
789
|
+
status_val = _status_label(s)
|
|
582
790
|
style = _status_style(status_val)
|
|
583
791
|
|
|
584
792
|
# Add time-ago to status
|
|
@@ -772,9 +980,9 @@ def agents_status(
|
|
|
772
980
|
console.print("[dim]Use 'qbraid agents list' to see active sessions.[/dim]")
|
|
773
981
|
raise typer.Exit(code=1)
|
|
774
982
|
|
|
775
|
-
# Build detail lines
|
|
776
|
-
s = match
|
|
777
|
-
status_val = s
|
|
983
|
+
# Build detail lines, as the list shows the record
|
|
984
|
+
s = _lifecycle_view(registry, match)
|
|
985
|
+
status_val = _status_label(s)
|
|
778
986
|
style = _status_style(status_val)
|
|
779
987
|
|
|
780
988
|
lines = []
|
|
@@ -1121,39 +1329,75 @@ def agents_kill(
|
|
|
1121
1329
|
session = matches[0]
|
|
1122
1330
|
pid = session.pid
|
|
1123
1331
|
|
|
1332
|
+
# Shown as interrupted (another Lab's row this Lab cannot verify):
|
|
1333
|
+
# refused before anything, Core included, so nothing is written for
|
|
1334
|
+
# it. Other native rows go to Core, which asks their owning server.
|
|
1335
|
+
if (_lifecycle_view(registry, session).metadata or {}).get("status_overlay"):
|
|
1336
|
+
console.print(
|
|
1337
|
+
"[red]Not stopped:[/red] it belongs to another Lab that this Lab cannot verify, so it is "
|
|
1338
|
+
"listed as interrupted. Resume it with --confirm-owner-gone, or stop it from its own Lab. "
|
|
1339
|
+
"Nothing was signalled or closed.",
|
|
1340
|
+
highlight=False,
|
|
1341
|
+
)
|
|
1342
|
+
raise typer.Exit(code=1)
|
|
1343
|
+
|
|
1124
1344
|
if session.metadata.get("terminal_backend") == "jupyter":
|
|
1125
|
-
|
|
1345
|
+
# A native agent belongs to its Lab server, which Core reaches and
|
|
1346
|
+
# verifies; its PID may be another namespace's. Never signalled here.
|
|
1347
|
+
from qbraid_core.services.agents import AgentLauncher, AgentLauncherError
|
|
1348
|
+
from qbraid_core.services.agents.exceptions import AgentRuntimeUnavailableError
|
|
1126
1349
|
|
|
1127
|
-
|
|
1350
|
+
try:
|
|
1351
|
+
AgentLauncher().kill_bound(session.session_id)
|
|
1352
|
+
except AgentRuntimeUnavailableError as error:
|
|
1353
|
+
if _not_running_reason(session):
|
|
1354
|
+
# Nothing to verify: Core says why (e.g. no longer active).
|
|
1355
|
+
console.print(f"[red]Not stopped:[/red] {escape(str(error))}", highlight=False)
|
|
1356
|
+
else:
|
|
1357
|
+
console.print(
|
|
1358
|
+
"[red]Could not verify the Lab server that owns this agent; "
|
|
1359
|
+
"nothing was stopped.[/red] "
|
|
1360
|
+
f"{escape(str(error))}",
|
|
1361
|
+
highlight=False,
|
|
1362
|
+
)
|
|
1363
|
+
raise typer.Exit(code=1)
|
|
1364
|
+
except AgentLauncherError as error:
|
|
1365
|
+
console.print(f"[red]Not stopped:[/red] {escape(str(error))}", highlight=False)
|
|
1366
|
+
raise typer.Exit(code=1)
|
|
1128
1367
|
console.print("[green]Stopped native Lab agent.[/green]")
|
|
1129
1368
|
return
|
|
1130
1369
|
|
|
1131
|
-
#
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1370
|
+
# Refused before anything is touched: a row of another PID namespace or
|
|
1371
|
+
# machine, or one no longer running, names a PID and a tmux window that
|
|
1372
|
+
# may now belong to someone else.
|
|
1373
|
+
refusal = _foreign_pid_reason(session) or _not_running_reason(session)
|
|
1374
|
+
if refusal:
|
|
1375
|
+
console.print(
|
|
1376
|
+
f"[red]Not stopped:[/red] {escape(refusal)} Nothing was signalled or closed.", highlight=False
|
|
1377
|
+
)
|
|
1378
|
+
raise typer.Exit(code=1)
|
|
1135
1379
|
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
check=False,
|
|
1140
|
-
)
|
|
1141
|
-
console.print(f"[green]Killed tmux window '{session.tmux_window}'.[/green]")
|
|
1142
|
-
except Exception:
|
|
1143
|
-
pass
|
|
1380
|
+
if session.tmux_session and session.tmux_window:
|
|
1381
|
+
_kill_tmux_session(session)
|
|
1382
|
+
return
|
|
1144
1383
|
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1384
|
+
if not (pid and pid > 0):
|
|
1385
|
+
console.print(
|
|
1386
|
+
"[red]Not stopped:[/red] no agent process or terminal is recorded for this session."
|
|
1387
|
+
)
|
|
1388
|
+
raise typer.Exit(code=1)
|
|
1389
|
+
identity = _agent_identity_refusal(session)
|
|
1390
|
+
if identity == _GONE:
|
|
1391
|
+
console.print(f"[dim]Process {pid} already stopped.[/dim]")
|
|
1392
|
+
elif identity:
|
|
1393
|
+
console.print(
|
|
1394
|
+
f"[red]Not stopped. Did not signal PID {pid}:[/red] {escape(identity)}", highlight=False
|
|
1395
|
+
)
|
|
1396
|
+
raise typer.Exit(code=1)
|
|
1397
|
+
else:
|
|
1398
|
+
_signal_agent(pid, force)
|
|
1155
1399
|
|
|
1156
|
-
#
|
|
1400
|
+
# Verified gone or signalled: mark stopped in registry
|
|
1157
1401
|
registry.deregister_session(session_id=session.session_id)
|
|
1158
1402
|
console.print(
|
|
1159
1403
|
f"[dim]Session {session.session_id[:8]} "
|
|
@@ -1623,6 +1867,139 @@ def _hook_trust_kwargs(method, value):
|
|
|
1623
1867
|
return {"trust_hooks": value}
|
|
1624
1868
|
|
|
1625
1869
|
|
|
1870
|
+
OWNER_GONE_PAUSED = "The old Lab may be paused; only confirm if you closed or restarted it."
|
|
1871
|
+
OWNER_GONE_WARNING = (
|
|
1872
|
+
"The old Lab may be paused; only confirm if you closed or restarted it. "
|
|
1873
|
+
"Confirming while it still runs starts a second copy of the agent."
|
|
1874
|
+
)
|
|
1875
|
+
|
|
1876
|
+
|
|
1877
|
+
def _owner_confirmation_supported(launcher) -> bool:
|
|
1878
|
+
"""Whether this Core can take a confirmation that a session's old Lab is gone."""
|
|
1879
|
+
try:
|
|
1880
|
+
return all(
|
|
1881
|
+
"confirm_owner_gone" in inspect.signature(method).parameters
|
|
1882
|
+
for method in (launcher.resume, launcher.resume_preflight)
|
|
1883
|
+
)
|
|
1884
|
+
except (AttributeError, TypeError, ValueError):
|
|
1885
|
+
return False
|
|
1886
|
+
|
|
1887
|
+
|
|
1888
|
+
def _exact_session_id(target: str) -> str:
|
|
1889
|
+
"""One session's full id for a full id, unique prefix or name."""
|
|
1890
|
+
from qbraid_core.services.agents import AgentLauncherError
|
|
1891
|
+
|
|
1892
|
+
sessions = _get_registry().search_sessions(include_stopped=True)
|
|
1893
|
+
exact = [s for s in sessions if s.session_id == target]
|
|
1894
|
+
if exact:
|
|
1895
|
+
return exact[0].session_id
|
|
1896
|
+
matches = [
|
|
1897
|
+
s
|
|
1898
|
+
for s in sessions
|
|
1899
|
+
if s.session_id.startswith(target)
|
|
1900
|
+
or (s.tmux_window and s.tmux_window == target)
|
|
1901
|
+
or (s.terminal_name and s.terminal_name == target)
|
|
1902
|
+
]
|
|
1903
|
+
if len(matches) != 1:
|
|
1904
|
+
raise AgentLauncherError(
|
|
1905
|
+
f"No session matches '{target}'."
|
|
1906
|
+
if not matches
|
|
1907
|
+
else f"Multiple sessions match '{target}'; give the full session ID."
|
|
1908
|
+
)
|
|
1909
|
+
return matches[0].session_id
|
|
1910
|
+
|
|
1911
|
+
|
|
1912
|
+
def _can_ask() -> bool:
|
|
1913
|
+
"""Whether a person is at a terminal to answer a prompt."""
|
|
1914
|
+
import sys
|
|
1915
|
+
|
|
1916
|
+
return sys.stdin.isatty()
|
|
1917
|
+
|
|
1918
|
+
|
|
1919
|
+
def _confirm_owner_gone(launcher, session_id: str, yes: bool):
|
|
1920
|
+
"""Gate `resume --confirm-owner-gone`: Core's preflight first, then the person.
|
|
1921
|
+
|
|
1922
|
+
Returns what the resume should carry as ``confirm_owner_gone``: exactly
|
|
1923
|
+
the Labs the preflight disclosed (``confirmServers``), or True from a Core
|
|
1924
|
+
that lists none (it covers the source's own Lab). Returns None when Core
|
|
1925
|
+
says no confirmation is needed. Exits otherwise.
|
|
1926
|
+
"""
|
|
1927
|
+
from qbraid_core.services.agents import AgentLauncherError
|
|
1928
|
+
|
|
1929
|
+
if not _owner_confirmation_supported(launcher):
|
|
1930
|
+
raise AgentLauncherError(
|
|
1931
|
+
"--confirm-owner-gone requires a newer qbraid-core (confirm_owner_gone) on this machine."
|
|
1932
|
+
)
|
|
1933
|
+
preflight = launcher.resume_preflight(session_id)
|
|
1934
|
+
if preflight.get("canResume"):
|
|
1935
|
+
console.print(
|
|
1936
|
+
"[dim]This session can be resumed without confirming anything; resuming normally.[/dim]"
|
|
1937
|
+
)
|
|
1938
|
+
return None
|
|
1939
|
+
reason = preflight.get("reason") or "This session cannot be resumed."
|
|
1940
|
+
if preflight.get("confirmable") is not True:
|
|
1941
|
+
console.print(f"[red]{escape(reason)}[/red]", highlight=False)
|
|
1942
|
+
wait = preflight.get("confirmableInS")
|
|
1943
|
+
if preflight.get("confirmable") is False and isinstance(wait, (int, float)) and wait > 0:
|
|
1944
|
+
minutes = max(1, -(-int(wait) // 60))
|
|
1945
|
+
console.print(
|
|
1946
|
+
f"You can confirm the old Lab is gone in about {minutes} min ({int(wait)} s). "
|
|
1947
|
+
"Run this again then."
|
|
1948
|
+
)
|
|
1949
|
+
elif "confirmable" not in preflight:
|
|
1950
|
+
console.print("Confirming the old Lab gone does not apply to this session.")
|
|
1951
|
+
raise typer.Exit(code=1)
|
|
1952
|
+
console.print(f"[yellow]{escape(reason)}[/yellow]", highlight=False)
|
|
1953
|
+
if preflight.get("confirmReason"):
|
|
1954
|
+
console.print(preflight["confirmReason"], markup=False, highlight=False)
|
|
1955
|
+
# Core's reason already says what confirming risks; only the rest is added.
|
|
1956
|
+
servers = preflight.get("confirmServers")
|
|
1957
|
+
servers = list(servers) if isinstance(servers, list) and servers else None
|
|
1958
|
+
if servers and len(servers) > 1:
|
|
1959
|
+
console.print(f"This confirms {len(servers)} Lab servers gone: {', '.join(servers)}", markup=False)
|
|
1960
|
+
console.print(
|
|
1961
|
+
f"[bold]{OWNER_GONE_PAUSED if preflight.get('confirmReason') else OWNER_GONE_WARNING}[/bold]",
|
|
1962
|
+
highlight=False,
|
|
1963
|
+
)
|
|
1964
|
+
confirmation = servers or True
|
|
1965
|
+
if yes:
|
|
1966
|
+
return confirmation
|
|
1967
|
+
if not _can_ask():
|
|
1968
|
+
console.print(
|
|
1969
|
+
"[red]No terminal to ask on.[/red] Pass --yes only after the person has "
|
|
1970
|
+
"confirmed that the old Lab is closed or restarted."
|
|
1971
|
+
)
|
|
1972
|
+
raise typer.Exit(code=2)
|
|
1973
|
+
if not typer.confirm("Resume this session anyway?", default=False):
|
|
1974
|
+
console.print("Not resumed.")
|
|
1975
|
+
raise typer.Exit(code=1)
|
|
1976
|
+
return confirmation
|
|
1977
|
+
|
|
1978
|
+
|
|
1979
|
+
#: A new runtime registers at once; one that started this long before the
|
|
1980
|
+
#: resume call was already running.
|
|
1981
|
+
_ALREADY_RUNNING_SLACK_S = 2.0
|
|
1982
|
+
|
|
1983
|
+
|
|
1984
|
+
def _already_running(session, resume_started: float) -> bool:
|
|
1985
|
+
"""Whether Core returned a runtime that was running before this resume.
|
|
1986
|
+
|
|
1987
|
+
Core #419 says so itself (`AgentSession.resumed_existing`: True only when
|
|
1988
|
+
resume returned the running resumption). Anything else (an older Core
|
|
1989
|
+
without the property) falls back to the start time: a runtime that
|
|
1990
|
+
started before this call was already running.
|
|
1991
|
+
"""
|
|
1992
|
+
said = getattr(session, "resumed_existing", None)
|
|
1993
|
+
if said is True or said is False:
|
|
1994
|
+
return said
|
|
1995
|
+
started = getattr(session, "started_at", None)
|
|
1996
|
+
return (
|
|
1997
|
+
isinstance(started, (int, float))
|
|
1998
|
+
and not isinstance(started, bool)
|
|
1999
|
+
and started < resume_started - _ALREADY_RUNNING_SLACK_S
|
|
2000
|
+
)
|
|
2001
|
+
|
|
2002
|
+
|
|
1626
2003
|
def _startup_pending(session):
|
|
1627
2004
|
return session.tool.value in ("codex", "claude") and not getattr(session, "startup_confirmed_at", None)
|
|
1628
2005
|
|
|
@@ -1967,6 +2344,24 @@ def agents_resume(
|
|
|
1967
2344
|
"Default: keep the answer given at launch."
|
|
1968
2345
|
),
|
|
1969
2346
|
),
|
|
2347
|
+
confirm_owner_gone: bool = typer.Option(
|
|
2348
|
+
False,
|
|
2349
|
+
"--confirm-owner-gone",
|
|
2350
|
+
help=(
|
|
2351
|
+
"For a session from a restarted Lab that cannot be verified: confirm its old Lab is "
|
|
2352
|
+
"closed or restarted, and resume anyway. Asks first; confirming while that Lab still "
|
|
2353
|
+
"runs starts a second copy of the agent."
|
|
2354
|
+
),
|
|
2355
|
+
),
|
|
2356
|
+
yes: bool = typer.Option(
|
|
2357
|
+
False,
|
|
2358
|
+
"--yes",
|
|
2359
|
+
"-y",
|
|
2360
|
+
help=(
|
|
2361
|
+
"With --confirm-owner-gone: do not ask. Agents must not pass this unless the person "
|
|
2362
|
+
"has confirmed the old Lab is gone."
|
|
2363
|
+
),
|
|
2364
|
+
),
|
|
1970
2365
|
compute: Optional[str] = typer.Option(None, "--compute", "-c", help="SSH alias for remote compute"),
|
|
1971
2366
|
):
|
|
1972
2367
|
"""Resume a previous agent session.
|
|
@@ -1974,10 +2369,24 @@ def agents_resume(
|
|
|
1974
2369
|
Resumes with the selected terminal backend. With ``--compute`` the session is resumed
|
|
1975
2370
|
on that machine, and a session id is required -- the interactive picker
|
|
1976
2371
|
reads the local registry and cannot see another host's sessions.
|
|
2372
|
+
|
|
2373
|
+
``--confirm-owner-gone`` is for a session whose old Lab (in another PID
|
|
2374
|
+
namespace, after a restart) cannot be verified gone. Core's preflight runs
|
|
2375
|
+
first; when it allows a confirmation, its reason and the risk are shown and
|
|
2376
|
+
the person is asked (default No). Without a terminal it refuses unless
|
|
2377
|
+
``--yes`` is given. An agent must never pass ``--yes`` on its own judgement:
|
|
2378
|
+
only after the person has confirmed that the old Lab is closed or restarted.
|
|
1977
2379
|
"""
|
|
1978
2380
|
try:
|
|
1979
2381
|
from qbraid_core.services.agents import AgentLauncherError
|
|
1980
2382
|
|
|
2383
|
+
if yes and not confirm_owner_gone:
|
|
2384
|
+
raise AgentLauncherError("--yes applies only to --confirm-owner-gone.")
|
|
2385
|
+
if confirm_owner_gone and compute:
|
|
2386
|
+
raise AgentLauncherError(
|
|
2387
|
+
"--confirm-owner-gone is not available with --compute; run it on that machine, "
|
|
2388
|
+
"where the person can be asked."
|
|
2389
|
+
)
|
|
1981
2390
|
if compute and backend != TerminalBackend.AUTO:
|
|
1982
2391
|
raise AgentLauncherError("Remote resumes select the backend on the destination; omit --backend.")
|
|
1983
2392
|
if compute:
|
|
@@ -2014,11 +2423,24 @@ def agents_resume(
|
|
|
2014
2423
|
|
|
2015
2424
|
launcher = _backend_launcher(backend)
|
|
2016
2425
|
|
|
2426
|
+
confirmation = {}
|
|
2427
|
+
if confirm_owner_gone:
|
|
2428
|
+
session_id = _exact_session_id(session_id)
|
|
2429
|
+
confirmed = _confirm_owner_gone(launcher, session_id, yes)
|
|
2430
|
+
if confirmed is not None:
|
|
2431
|
+
# Only an explicit yes (or --yes) reaches Core: the Labs it was shown.
|
|
2432
|
+
confirmation = {"confirm_owner_gone": confirmed}
|
|
2433
|
+
|
|
2434
|
+
# A runtime Core returns that started before this call is one already
|
|
2435
|
+
# running (Core hands back a live resumption instead of a second copy).
|
|
2436
|
+
resume_started = time.time()
|
|
2437
|
+
|
|
2017
2438
|
# Try resume — handle active session and ambiguous matches
|
|
2018
2439
|
try:
|
|
2019
2440
|
session = launcher.resume(
|
|
2020
2441
|
session_id=session_id,
|
|
2021
2442
|
name=name,
|
|
2443
|
+
**confirmation,
|
|
2022
2444
|
**(_approval_kwargs(launcher.resume, approval.value) if approval else {}),
|
|
2023
2445
|
**_hook_trust_kwargs(launcher.resume, True if trust_hooks else None),
|
|
2024
2446
|
**_prompt_answer_kwargs(launcher.resume, trust_folder),
|
|
@@ -2045,6 +2467,18 @@ def agents_resume(
|
|
|
2045
2467
|
)
|
|
2046
2468
|
|
|
2047
2469
|
if choice in ("a", "attach"):
|
|
2470
|
+
active = next(
|
|
2471
|
+
(s for s in _get_registry().load_registry().sessions if s.session_id == active_sid),
|
|
2472
|
+
None,
|
|
2473
|
+
)
|
|
2474
|
+
foreign = (
|
|
2475
|
+
_foreign_pid_reason(active)
|
|
2476
|
+
if active is not None
|
|
2477
|
+
else "the active session is not recorded."
|
|
2478
|
+
)
|
|
2479
|
+
if foreign:
|
|
2480
|
+
console.print(f"[red]Not attached:[/red] {escape(foreign)}", highlight=False)
|
|
2481
|
+
raise typer.Exit(code=1)
|
|
2048
2482
|
target = f"{tmux_sess}:{tmux_win}"
|
|
2049
2483
|
console.print(f"[dim]Attaching to {target}... (Ctrl+b d to detach)[/dim]")
|
|
2050
2484
|
os.execvp("tmux", ["tmux", "attach", "-t", target])
|
|
@@ -2154,6 +2588,16 @@ def agents_resume(
|
|
|
2154
2588
|
**_execution_kwargs(launcher.resume, execution_mode.value if execution_mode else None),
|
|
2155
2589
|
)
|
|
2156
2590
|
|
|
2591
|
+
if _already_running(session, resume_started):
|
|
2592
|
+
name_val = session.terminal_name or session.tmux_window
|
|
2593
|
+
label = f"{session.session_id[:8]}" + (f" ({escape(name_val)})" if name_val else "")
|
|
2594
|
+
console.print(
|
|
2595
|
+
f"This conversation is already running in {label}: attach with "
|
|
2596
|
+
f"[bold]qbraid agents attach {session.session_id}[/bold]",
|
|
2597
|
+
highlight=False,
|
|
2598
|
+
)
|
|
2599
|
+
return
|
|
2600
|
+
|
|
2157
2601
|
status_val = (
|
|
2158
2602
|
"starting / check terminal"
|
|
2159
2603
|
if _startup_pending(session)
|
|
@@ -2372,65 +2816,6 @@ def _entry_style(entry_type: str) -> str:
|
|
|
2372
2816
|
return styles.get(entry_type, "")
|
|
2373
2817
|
|
|
2374
2818
|
|
|
2375
|
-
def _check_permission_prompt(tmux_session: str, tmux_window: str) -> Optional[list[str]]:
|
|
2376
|
-
"""Read the tmux pane and check for a permission prompt.
|
|
2377
|
-
|
|
2378
|
-
Returns a list of option strings if a prompt is detected, None otherwise.
|
|
2379
|
-
"""
|
|
2380
|
-
import subprocess
|
|
2381
|
-
|
|
2382
|
-
result = subprocess.run(
|
|
2383
|
-
["tmux", "capture-pane", "-t", f"{tmux_session}:{tmux_window}", "-p", "-S", "-60"],
|
|
2384
|
-
capture_output=True,
|
|
2385
|
-
text=True,
|
|
2386
|
-
check=False,
|
|
2387
|
-
)
|
|
2388
|
-
if result.returncode != 0:
|
|
2389
|
-
return None
|
|
2390
|
-
|
|
2391
|
-
pane_text = result.stdout
|
|
2392
|
-
|
|
2393
|
-
# Look for Claude's permission prompt markers
|
|
2394
|
-
if "Do you want to" not in pane_text and "Allow" not in pane_text:
|
|
2395
|
-
return None
|
|
2396
|
-
|
|
2397
|
-
# Extract numbered options using regex — handles ANSI/unicode prefixes
|
|
2398
|
-
# Claude's format: "❯ 1. Yes" / " 2. Yes, allow..." / " 3. No"
|
|
2399
|
-
# Options may wrap across multiple lines in the terminal.
|
|
2400
|
-
options: list[str] = []
|
|
2401
|
-
pane_lines = pane_text.splitlines()
|
|
2402
|
-
for i, line in enumerate(pane_lines):
|
|
2403
|
-
match = re.search(r"(\d+)\.\s+(.+)", line)
|
|
2404
|
-
if match:
|
|
2405
|
-
num = int(match.group(1))
|
|
2406
|
-
text = match.group(2).strip()
|
|
2407
|
-
if num == len(options) + 1 and text:
|
|
2408
|
-
# Collect continuation lines (lines until next numbered option,
|
|
2409
|
-
# blank line, or "Esc to cancel" marker)
|
|
2410
|
-
for j in range(i + 1, len(pane_lines)):
|
|
2411
|
-
cont = pane_lines[j].strip()
|
|
2412
|
-
if not cont or re.search(r"^\d+\.", cont) or "Esc to cancel" in cont:
|
|
2413
|
-
break
|
|
2414
|
-
text += " " + cont
|
|
2415
|
-
options.append(f"{num}. {text}")
|
|
2416
|
-
|
|
2417
|
-
return options if options else None
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
def _send_permission_choice(tmux_session: str, tmux_window: str, choice_idx: int) -> None:
|
|
2421
|
-
"""Send arrow key presses and Enter to select a permission option."""
|
|
2422
|
-
import subprocess
|
|
2423
|
-
|
|
2424
|
-
target = f"{tmux_session}:{tmux_window}"
|
|
2425
|
-
|
|
2426
|
-
# Arrow down from position 1 (default) to the chosen option
|
|
2427
|
-
for _ in range(choice_idx):
|
|
2428
|
-
subprocess.run(["tmux", "send-keys", "-t", target, "Down"], check=False)
|
|
2429
|
-
time.sleep(0.05)
|
|
2430
|
-
|
|
2431
|
-
subprocess.run(["tmux", "send-keys", "-t", target, "Enter"], check=False)
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
2819
|
@agents_app.command("watch")
|
|
2435
2820
|
def agents_watch(
|
|
2436
2821
|
session_id: str = typer.Argument(..., help="Session ID (prefix match)"),
|
|
@@ -2899,6 +3284,13 @@ def agents_attach(
|
|
|
2899
3284
|
JupyterRuntimeClient(session.metadata["jupyter_server_id"]).attach(session)
|
|
2900
3285
|
return
|
|
2901
3286
|
|
|
3287
|
+
# A row recorded in another PID namespace, boot or Lab names a window
|
|
3288
|
+
# that may now be someone else's: never attached to by name.
|
|
3289
|
+
foreign = _foreign_pid_reason(session)
|
|
3290
|
+
if foreign:
|
|
3291
|
+
console.print(f"[red]Not attached:[/red] {escape(foreign)}", highlight=False)
|
|
3292
|
+
raise typer.Exit(code=1)
|
|
3293
|
+
|
|
2902
3294
|
tmux_session = session.tmux_session
|
|
2903
3295
|
tmux_window = session.tmux_window
|
|
2904
3296
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qbraid-cli
|
|
3
|
-
Version: 0.16.
|
|
3
|
+
Version: 0.16.10
|
|
4
4
|
Summary: Command Line Interface for interacting with all parts of the qBraid platform.
|
|
5
5
|
Author-email: qBraid Development Team <contact@qbraid.com>
|
|
6
6
|
License-Expression: LicenseRef-Proprietary
|
|
@@ -30,11 +30,11 @@ Requires-Dist: typer>=0.12.1
|
|
|
30
30
|
Requires-Dist: rich>=10.11.0
|
|
31
31
|
Requires-Dist: click<=8.5.0
|
|
32
32
|
Requires-Dist: questionary>=2.0.0
|
|
33
|
-
Requires-Dist: qbraid-core>=0.6.
|
|
33
|
+
Requires-Dist: qbraid-core>=0.6.27
|
|
34
34
|
Provides-Extra: compute
|
|
35
|
-
Requires-Dist: qbraid-core[compute]>=0.6.
|
|
35
|
+
Requires-Dist: qbraid-core[compute]>=0.6.27; extra == "compute"
|
|
36
36
|
Provides-Extra: envs
|
|
37
|
-
Requires-Dist: qbraid-core[environments]>=0.6.
|
|
37
|
+
Requires-Dist: qbraid-core[environments]>=0.6.27; extra == "envs"
|
|
38
38
|
Provides-Extra: dev
|
|
39
39
|
Requires-Dist: isort; extra == "dev"
|
|
40
40
|
Requires-Dist: black; extra == "dev"
|
|
@@ -2,10 +2,10 @@ typer>=0.12.1
|
|
|
2
2
|
rich>=10.11.0
|
|
3
3
|
click<=8.5.0
|
|
4
4
|
questionary>=2.0.0
|
|
5
|
-
qbraid-core>=0.6.
|
|
5
|
+
qbraid-core>=0.6.27
|
|
6
6
|
|
|
7
7
|
[compute]
|
|
8
|
-
qbraid-core[compute]>=0.6.
|
|
8
|
+
qbraid-core[compute]>=0.6.27
|
|
9
9
|
|
|
10
10
|
[dev]
|
|
11
11
|
isort
|
|
@@ -15,4 +15,4 @@ pytest-cov
|
|
|
15
15
|
pytest-asyncio
|
|
16
16
|
|
|
17
17
|
[envs]
|
|
18
|
-
qbraid-core[environments]>=0.6.
|
|
18
|
+
qbraid-core[environments]>=0.6.27
|
|
@@ -935,15 +935,20 @@ class TestAgentsE2E:
|
|
|
935
935
|
|
|
936
936
|
assert result.exit_code == 1
|
|
937
937
|
|
|
938
|
+
@patch("qbraid_core.services.agents.AgentLauncher")
|
|
938
939
|
@patch("qbraid_core.services.agents.AgentSessionRegistryManager")
|
|
939
|
-
def test_kill_session(self, mock_registry_cls):
|
|
940
|
-
"""Kill a session by ID."""
|
|
940
|
+
def test_kill_session(self, mock_registry_cls, mock_launcher_cls):
|
|
941
|
+
"""Kill a session by ID: a tmux row goes through Core's verified kill."""
|
|
941
942
|
mock_session = MagicMock()
|
|
942
943
|
mock_session.session_id = "abc12345-6789"
|
|
943
944
|
mock_session.tmux_window = "worker-0"
|
|
944
945
|
mock_session.terminal_name = None
|
|
945
946
|
mock_session.pid = 12345
|
|
946
947
|
mock_session.compute_id = None
|
|
948
|
+
mock_session.machine_id = None
|
|
949
|
+
mock_session.cluster_id = None
|
|
950
|
+
mock_session.metadata = {}
|
|
951
|
+
mock_session.status.value = "idle"
|
|
947
952
|
mock_session.tmux_session = "qbraid-agents"
|
|
948
953
|
|
|
949
954
|
mock_registry = MagicMock()
|
|
@@ -954,9 +959,14 @@ class TestAgentsE2E:
|
|
|
954
959
|
|
|
955
960
|
from qbraid_cli.agents.app import agents_app
|
|
956
961
|
|
|
957
|
-
|
|
962
|
+
with patch("subprocess.run") as tmux, patch("os.kill") as kill:
|
|
963
|
+
result = runner.invoke(agents_app, ["kill", "abc12345"])
|
|
958
964
|
|
|
959
|
-
assert result.exit_code == 0
|
|
965
|
+
assert result.exit_code == 0, result.output
|
|
966
|
+
mock_launcher_cls.return_value.kill_bound.assert_called_once_with("abc12345-6789")
|
|
967
|
+
# Nothing touches the window or the PID directly: Core verified the target.
|
|
968
|
+
tmux.assert_not_called()
|
|
969
|
+
kill.assert_not_called()
|
|
960
970
|
|
|
961
971
|
@patch("qbraid_core.services.agents.AgentSessionRegistryManager")
|
|
962
972
|
def test_discover_empty(self, mock_registry_cls):
|
|
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
|