qbraid-cli 0.16.8__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.8/qbraid_cli.egg-info → qbraid_cli-0.16.10}/PKG-INFO +4 -4
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/pyproject.toml +4 -4
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/agents/app.py +547 -94
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10/qbraid_cli.egg-info}/PKG-INFO +4 -4
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli.egg-info/requires.txt +3 -3
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/tests/test_cli_e2e.py +14 -4
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/LICENSE +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/README.md +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/__init__.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/_build_info.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/_version.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/account/__init__.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/account/app.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/admin/__init__.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/admin/app.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/admin/headers.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/admin/validation.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/agents/__init__.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/agents/permission_response.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/ai/__init__.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/ai/app.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/ai/token.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/comms/__init__.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/comms/app.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/compute/__init__.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/compute/app.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/compute/instances.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/compute/profiles.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/compute/server.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/compute/ssh.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/configure/__init__.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/configure/actions.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/configure/app.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/devices/__init__.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/devices/app.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/devices/validation.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/envs/__init__.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/envs/activate.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/envs/app.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/envs/create.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/envs/data_handling.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/envs/groups.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/envs/publish.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/envs/registry.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/envs/share.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/exceptions.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/handlers.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/jobs/__init__.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/jobs/app.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/jobs/groups.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/kernels/__init__.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/kernels/app.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/lab/__init__.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/lab/app.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/main.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/pip/__init__.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/pip/app.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/pip/hooks.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/projects/__init__.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/projects/app.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/py.typed +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/skills/__init__.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/skills/app.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/troubleshoot/__init__.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli/troubleshoot/app.py +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli.egg-info/SOURCES.txt +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli.egg-info/dependency_links.txt +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli.egg-info/entry_points.txt +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/qbraid_cli.egg-info/top_level.txt +0 -0
- {qbraid_cli-0.16.8 → qbraid_cli-0.16.10}/setup.cfg +0 -0
- {qbraid_cli-0.16.8 → 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]} "
|
|
@@ -1582,6 +1826,32 @@ def agents_settings(
|
|
|
1582
1826
|
handle_error(error_type=type(error).__name__, message=str(error), include_traceback=False)
|
|
1583
1827
|
|
|
1584
1828
|
|
|
1829
|
+
def _prompt_answer_kwargs(method, trust_folder=None, accept_bypass_warning=False):
|
|
1830
|
+
"""Answers to the agent's own startup prompts, given on the command line.
|
|
1831
|
+
|
|
1832
|
+
``trust_folder`` answers "trust this folder?" (Claude, Codex); either
|
|
1833
|
+
explicit answer needs a Core that takes it, because an older Core trusts
|
|
1834
|
+
every Claude launch folder by itself, so a silently dropped "no" would be
|
|
1835
|
+
a yes. ``accept_bypass_warning`` skips Claude's bypass-permissions
|
|
1836
|
+
confirmation for this run.
|
|
1837
|
+
"""
|
|
1838
|
+
from qbraid_core.services.agents import AgentLauncherError
|
|
1839
|
+
|
|
1840
|
+
params = inspect.signature(method).parameters
|
|
1841
|
+
open_kwargs = any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
|
|
1842
|
+
kwargs = {}
|
|
1843
|
+
for key, value, flag in (
|
|
1844
|
+
("trust_folder", trust_folder, "--trust-folder/--no-trust-folder"),
|
|
1845
|
+
("accept_bypass_warning", accept_bypass_warning or None, "--skip-bypass-confirmation"),
|
|
1846
|
+
):
|
|
1847
|
+
if value is None:
|
|
1848
|
+
continue
|
|
1849
|
+
if key not in params and not open_kwargs:
|
|
1850
|
+
raise AgentLauncherError(f"{flag} requires a newer qbraid-core on this machine.")
|
|
1851
|
+
kwargs[key] = value
|
|
1852
|
+
return kwargs
|
|
1853
|
+
|
|
1854
|
+
|
|
1585
1855
|
def _hook_trust_kwargs(method, value):
|
|
1586
1856
|
"""Fail before launch when an explicit choice cannot be honored."""
|
|
1587
1857
|
if value is None:
|
|
@@ -1597,6 +1867,139 @@ def _hook_trust_kwargs(method, value):
|
|
|
1597
1867
|
return {"trust_hooks": value}
|
|
1598
1868
|
|
|
1599
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
|
+
|
|
1600
2003
|
def _startup_pending(session):
|
|
1601
2004
|
return session.tool.value in ("codex", "claude") and not getattr(session, "startup_confirmed_at", None)
|
|
1602
2005
|
|
|
@@ -1673,6 +2076,21 @@ def agents_launch(
|
|
|
1673
2076
|
"resume. Refused with --repo. Independent of --approval and --yolo."
|
|
1674
2077
|
),
|
|
1675
2078
|
),
|
|
2079
|
+
trust_folder: Optional[bool] = typer.Option(
|
|
2080
|
+
None,
|
|
2081
|
+
"--trust-folder/--no-trust-folder",
|
|
2082
|
+
help=(
|
|
2083
|
+
"Answer the agent's 'trust this folder?' prompt now (Claude, Codex), so a detached "
|
|
2084
|
+
"agent does not stop at it. The answer is remembered for later launches and "
|
|
2085
|
+
"resumes. Trusting a folder lets its own settings, hooks and MCP servers run. "
|
|
2086
|
+
"Default: Claude trusts the folder, except a fresh --repo clone; Codex asks."
|
|
2087
|
+
),
|
|
2088
|
+
),
|
|
2089
|
+
skip_bypass_confirmation: bool = typer.Option(
|
|
2090
|
+
False,
|
|
2091
|
+
"--skip-bypass-confirmation",
|
|
2092
|
+
help="Claude with --yolo: skip its bypass-permissions confirmation, for this launch only.",
|
|
2093
|
+
),
|
|
1676
2094
|
yolo: bool = typer.Option(
|
|
1677
2095
|
False, "--yolo", help="Claude: skip permissions. Codex: bypass approvals and its sandbox."
|
|
1678
2096
|
),
|
|
@@ -1729,6 +2147,12 @@ def agents_launch(
|
|
|
1729
2147
|
if not compute or str(uuid.UUID(request_id)) != request_id:
|
|
1730
2148
|
raise AgentLauncherError("--request-id requires --compute and a canonical UUID.")
|
|
1731
2149
|
|
|
2150
|
+
if compute and (trust_folder is not None or skip_bypass_confirmation):
|
|
2151
|
+
raise AgentLauncherError(
|
|
2152
|
+
"--trust-folder and --skip-bypass-confirmation are not yet supported for remote "
|
|
2153
|
+
"launches; answer the prompts in the agent's terminal."
|
|
2154
|
+
)
|
|
2155
|
+
|
|
1732
2156
|
if compute and backend != TerminalBackend.AUTO:
|
|
1733
2157
|
raise AgentLauncherError("Remote launches select the backend on the destination; omit --backend.")
|
|
1734
2158
|
launcher = _backend_launcher(backend)
|
|
@@ -1766,6 +2190,7 @@ def agents_launch(
|
|
|
1766
2190
|
tags=tag,
|
|
1767
2191
|
**_approval_kwargs(launcher.launch, approval.value if approval else None),
|
|
1768
2192
|
**_hook_trust_kwargs(launcher.launch, True if trust_hooks else None),
|
|
2193
|
+
**_prompt_answer_kwargs(launcher.launch, trust_folder, skip_bypass_confirmation),
|
|
1769
2194
|
**_execution_kwargs(launcher.launch, execution_mode.value if execution_mode else None),
|
|
1770
2195
|
progress_callback=_clone_progress if repo else None,
|
|
1771
2196
|
)
|
|
@@ -1911,6 +2336,32 @@ def agents_resume(
|
|
|
1911
2336
|
"for this resume only. Hooks are reviewed again on every run that omits it."
|
|
1912
2337
|
),
|
|
1913
2338
|
),
|
|
2339
|
+
trust_folder: Optional[bool] = typer.Option(
|
|
2340
|
+
None,
|
|
2341
|
+
"--trust-folder/--no-trust-folder",
|
|
2342
|
+
help=(
|
|
2343
|
+
"Answer 'trust this folder?' for the folder it resumes in (remembered). "
|
|
2344
|
+
"Default: keep the answer given at launch."
|
|
2345
|
+
),
|
|
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
|
+
),
|
|
1914
2365
|
compute: Optional[str] = typer.Option(None, "--compute", "-c", help="SSH alias for remote compute"),
|
|
1915
2366
|
):
|
|
1916
2367
|
"""Resume a previous agent session.
|
|
@@ -1918,10 +2369,24 @@ def agents_resume(
|
|
|
1918
2369
|
Resumes with the selected terminal backend. With ``--compute`` the session is resumed
|
|
1919
2370
|
on that machine, and a session id is required -- the interactive picker
|
|
1920
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.
|
|
1921
2379
|
"""
|
|
1922
2380
|
try:
|
|
1923
2381
|
from qbraid_core.services.agents import AgentLauncherError
|
|
1924
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
|
+
)
|
|
1925
2390
|
if compute and backend != TerminalBackend.AUTO:
|
|
1926
2391
|
raise AgentLauncherError("Remote resumes select the backend on the destination; omit --backend.")
|
|
1927
2392
|
if compute:
|
|
@@ -1943,6 +2408,8 @@ def agents_resume(
|
|
|
1943
2408
|
flags += f" --approval {shlex.quote(approval.value)}"
|
|
1944
2409
|
if trust_hooks:
|
|
1945
2410
|
flags += " --trust-hooks"
|
|
2411
|
+
if trust_folder is not None:
|
|
2412
|
+
flags += " --trust-folder" if trust_folder else " --no-trust-folder"
|
|
1946
2413
|
# markup=False -- see agents_status.
|
|
1947
2414
|
console.print(
|
|
1948
2415
|
_ssh_cmd(compute, f"qbraid agents resume {shlex.quote(session_id)}{flags}"),
|
|
@@ -1956,13 +2423,27 @@ def agents_resume(
|
|
|
1956
2423
|
|
|
1957
2424
|
launcher = _backend_launcher(backend)
|
|
1958
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
|
+
|
|
1959
2438
|
# Try resume — handle active session and ambiguous matches
|
|
1960
2439
|
try:
|
|
1961
2440
|
session = launcher.resume(
|
|
1962
2441
|
session_id=session_id,
|
|
1963
2442
|
name=name,
|
|
2443
|
+
**confirmation,
|
|
1964
2444
|
**(_approval_kwargs(launcher.resume, approval.value) if approval else {}),
|
|
1965
2445
|
**_hook_trust_kwargs(launcher.resume, True if trust_hooks else None),
|
|
2446
|
+
**_prompt_answer_kwargs(launcher.resume, trust_folder),
|
|
1966
2447
|
**_execution_kwargs(launcher.resume, execution_mode.value if execution_mode else None),
|
|
1967
2448
|
)
|
|
1968
2449
|
except AgentLauncherError as e:
|
|
@@ -1986,6 +2467,18 @@ def agents_resume(
|
|
|
1986
2467
|
)
|
|
1987
2468
|
|
|
1988
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)
|
|
1989
2482
|
target = f"{tmux_sess}:{tmux_win}"
|
|
1990
2483
|
console.print(f"[dim]Attaching to {target}... (Ctrl+b d to detach)[/dim]")
|
|
1991
2484
|
os.execvp("tmux", ["tmux", "attach", "-t", target])
|
|
@@ -2013,6 +2506,7 @@ def agents_resume(
|
|
|
2013
2506
|
# --trust-hooks on this command bypasses hook review.
|
|
2014
2507
|
True if trust_hooks else None,
|
|
2015
2508
|
),
|
|
2509
|
+
**_prompt_answer_kwargs(launcher.launch, trust_folder),
|
|
2016
2510
|
# A fork of a detached agent must not quietly
|
|
2017
2511
|
# become one that waits for a human.
|
|
2018
2512
|
**_approval_kwargs(
|
|
@@ -2090,9 +2584,20 @@ def agents_resume(
|
|
|
2090
2584
|
name=name,
|
|
2091
2585
|
**(_approval_kwargs(launcher.resume, approval.value) if approval else {}),
|
|
2092
2586
|
**_hook_trust_kwargs(launcher.resume, True if trust_hooks else None),
|
|
2587
|
+
**_prompt_answer_kwargs(launcher.resume, trust_folder),
|
|
2093
2588
|
**_execution_kwargs(launcher.resume, execution_mode.value if execution_mode else None),
|
|
2094
2589
|
)
|
|
2095
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
|
+
|
|
2096
2601
|
status_val = (
|
|
2097
2602
|
"starting / check terminal"
|
|
2098
2603
|
if _startup_pending(session)
|
|
@@ -2311,65 +2816,6 @@ def _entry_style(entry_type: str) -> str:
|
|
|
2311
2816
|
return styles.get(entry_type, "")
|
|
2312
2817
|
|
|
2313
2818
|
|
|
2314
|
-
def _check_permission_prompt(tmux_session: str, tmux_window: str) -> Optional[list[str]]:
|
|
2315
|
-
"""Read the tmux pane and check for a permission prompt.
|
|
2316
|
-
|
|
2317
|
-
Returns a list of option strings if a prompt is detected, None otherwise.
|
|
2318
|
-
"""
|
|
2319
|
-
import subprocess
|
|
2320
|
-
|
|
2321
|
-
result = subprocess.run(
|
|
2322
|
-
["tmux", "capture-pane", "-t", f"{tmux_session}:{tmux_window}", "-p", "-S", "-60"],
|
|
2323
|
-
capture_output=True,
|
|
2324
|
-
text=True,
|
|
2325
|
-
check=False,
|
|
2326
|
-
)
|
|
2327
|
-
if result.returncode != 0:
|
|
2328
|
-
return None
|
|
2329
|
-
|
|
2330
|
-
pane_text = result.stdout
|
|
2331
|
-
|
|
2332
|
-
# Look for Claude's permission prompt markers
|
|
2333
|
-
if "Do you want to" not in pane_text and "Allow" not in pane_text:
|
|
2334
|
-
return None
|
|
2335
|
-
|
|
2336
|
-
# Extract numbered options using regex — handles ANSI/unicode prefixes
|
|
2337
|
-
# Claude's format: "❯ 1. Yes" / " 2. Yes, allow..." / " 3. No"
|
|
2338
|
-
# Options may wrap across multiple lines in the terminal.
|
|
2339
|
-
options: list[str] = []
|
|
2340
|
-
pane_lines = pane_text.splitlines()
|
|
2341
|
-
for i, line in enumerate(pane_lines):
|
|
2342
|
-
match = re.search(r"(\d+)\.\s+(.+)", line)
|
|
2343
|
-
if match:
|
|
2344
|
-
num = int(match.group(1))
|
|
2345
|
-
text = match.group(2).strip()
|
|
2346
|
-
if num == len(options) + 1 and text:
|
|
2347
|
-
# Collect continuation lines (lines until next numbered option,
|
|
2348
|
-
# blank line, or "Esc to cancel" marker)
|
|
2349
|
-
for j in range(i + 1, len(pane_lines)):
|
|
2350
|
-
cont = pane_lines[j].strip()
|
|
2351
|
-
if not cont or re.search(r"^\d+\.", cont) or "Esc to cancel" in cont:
|
|
2352
|
-
break
|
|
2353
|
-
text += " " + cont
|
|
2354
|
-
options.append(f"{num}. {text}")
|
|
2355
|
-
|
|
2356
|
-
return options if options else None
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
def _send_permission_choice(tmux_session: str, tmux_window: str, choice_idx: int) -> None:
|
|
2360
|
-
"""Send arrow key presses and Enter to select a permission option."""
|
|
2361
|
-
import subprocess
|
|
2362
|
-
|
|
2363
|
-
target = f"{tmux_session}:{tmux_window}"
|
|
2364
|
-
|
|
2365
|
-
# Arrow down from position 1 (default) to the chosen option
|
|
2366
|
-
for _ in range(choice_idx):
|
|
2367
|
-
subprocess.run(["tmux", "send-keys", "-t", target, "Down"], check=False)
|
|
2368
|
-
time.sleep(0.05)
|
|
2369
|
-
|
|
2370
|
-
subprocess.run(["tmux", "send-keys", "-t", target, "Enter"], check=False)
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
2819
|
@agents_app.command("watch")
|
|
2374
2820
|
def agents_watch(
|
|
2375
2821
|
session_id: str = typer.Argument(..., help="Session ID (prefix match)"),
|
|
@@ -2838,6 +3284,13 @@ def agents_attach(
|
|
|
2838
3284
|
JupyterRuntimeClient(session.metadata["jupyter_server_id"]).attach(session)
|
|
2839
3285
|
return
|
|
2840
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
|
+
|
|
2841
3294
|
tmux_session = session.tmux_session
|
|
2842
3295
|
tmux_window = session.tmux_window
|
|
2843
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
|