qbraid-cli 0.16.13__tar.gz → 0.16.14__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.13/qbraid_cli.egg-info → qbraid_cli-0.16.14}/PKG-INFO +4 -4
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/pyproject.toml +4 -4
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/agents/app.py +221 -74
- qbraid_cli-0.16.14/qbraid_cli/agents/keep_alive.py +357 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14/qbraid_cli.egg-info}/PKG-INFO +4 -4
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli.egg-info/SOURCES.txt +1 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli.egg-info/requires.txt +3 -3
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/tests/test_cli_e2e.py +5 -1
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/LICENSE +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/README.md +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/_build_info.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/_version.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/account/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/account/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/admin/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/admin/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/admin/headers.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/admin/validation.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/agents/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/agents/permission_response.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/agents/provider_children.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/agents/queue.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/agents/work.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/ai/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/ai/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/ai/token.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/comms/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/comms/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/compute/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/compute/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/compute/instances.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/compute/profiles.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/compute/server.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/compute/ssh.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/configure/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/configure/actions.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/configure/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/devices/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/devices/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/devices/validation.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/envs/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/envs/activate.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/envs/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/envs/create.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/envs/data_handling.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/envs/groups.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/envs/publish.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/envs/registry.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/envs/share.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/exceptions.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/handlers.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/jobs/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/jobs/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/jobs/groups.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/kernels/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/kernels/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/lab/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/lab/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/main.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/pip/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/pip/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/pip/hooks.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/projects/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/projects/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/py.typed +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/skills/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/skills/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/troubleshoot/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/troubleshoot/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli.egg-info/dependency_links.txt +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli.egg-info/entry_points.txt +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli.egg-info/top_level.txt +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/setup.cfg +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/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.14
|
|
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.33
|
|
34
34
|
Provides-Extra: compute
|
|
35
|
-
Requires-Dist: qbraid-core[compute]>=0.6.
|
|
35
|
+
Requires-Dist: qbraid-core[compute]>=0.6.33; extra == "compute"
|
|
36
36
|
Provides-Extra: envs
|
|
37
|
-
Requires-Dist: qbraid-core[environments]>=0.6.
|
|
37
|
+
Requires-Dist: qbraid-core[environments]>=0.6.33; 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.14"
|
|
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.33",
|
|
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.33"]
|
|
47
|
+
envs = ["qbraid-core[environments]>=0.6.33"]
|
|
48
48
|
dev = ["isort", "black", "pytest", "pytest-cov", "pytest-asyncio"]
|
|
49
49
|
|
|
50
50
|
[project.scripts]
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
|
|
4
4
|
"""Agents CLI - Monitor and manage AI coding agent sessions."""
|
|
5
5
|
|
|
6
|
+
import errno
|
|
6
7
|
import inspect
|
|
7
8
|
import json
|
|
8
9
|
import os
|
|
9
10
|
import re
|
|
10
11
|
import signal
|
|
11
12
|
import time
|
|
13
|
+
from contextlib import nullcontext
|
|
12
14
|
from datetime import datetime
|
|
13
15
|
from enum import Enum
|
|
14
16
|
from pathlib import Path
|
|
@@ -23,6 +25,7 @@ from rich.table import Table
|
|
|
23
25
|
|
|
24
26
|
from qbraid_cli.handlers import handle_error, run_progress_task
|
|
25
27
|
|
|
28
|
+
from .keep_alive import KeepAliveCommandError, keep_alive_app, stop_plan
|
|
26
29
|
from .queue import interrupt, queue_app
|
|
27
30
|
from .work import register_work_commands
|
|
28
31
|
|
|
@@ -55,6 +58,7 @@ hooks_app = typer.Typer(
|
|
|
55
58
|
)
|
|
56
59
|
|
|
57
60
|
agents_app.add_typer(hooks_app, name="hooks")
|
|
61
|
+
agents_app.add_typer(keep_alive_app, name="keep-alive")
|
|
58
62
|
agents_app.add_typer(queue_app, name="queue")
|
|
59
63
|
agents_app.command("interrupt")(interrupt)
|
|
60
64
|
|
|
@@ -452,17 +456,23 @@ def _not_running_reason(session):
|
|
|
452
456
|
return None
|
|
453
457
|
|
|
454
458
|
|
|
455
|
-
def _signal_agent(pid, force):
|
|
459
|
+
def _signal_agent(pid, force, *, quiet=False):
|
|
456
460
|
sig = signal.SIGKILL if force else signal.SIGTERM
|
|
457
461
|
sig_name = "SIGKILL" if force else "SIGTERM"
|
|
458
462
|
try:
|
|
459
463
|
os.kill(pid, sig)
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
464
|
+
if not quiet:
|
|
465
|
+
console.print(f"[green]Sent {sig_name} to process {pid}.[/green]")
|
|
466
|
+
except OSError as error:
|
|
467
|
+
if not isinstance(error, ProcessLookupError) and error.errno != errno.ESRCH:
|
|
468
|
+
raise KeepAliveCommandError(
|
|
469
|
+
"STOP_FAILED", f"Stop was not confirmed for PID {pid}: {error}. Inspect its current status."
|
|
470
|
+
) from error
|
|
471
|
+
if not quiet:
|
|
472
|
+
console.print(f"[dim]Process {pid} already stopped.[/dim]")
|
|
463
473
|
|
|
464
474
|
|
|
465
|
-
def _kill_tmux_session(session):
|
|
475
|
+
def _kill_tmux_session(session, *, launcher=None, confirmation=None, as_json=False):
|
|
466
476
|
"""Stop a tmux-hosted agent only through a verified target.
|
|
467
477
|
|
|
468
478
|
A saved window name is mutable: another session may hold it now. Core's
|
|
@@ -472,25 +482,150 @@ def _kill_tmux_session(session):
|
|
|
472
482
|
"""
|
|
473
483
|
from qbraid_core.services.agents import AgentLauncher, AgentLauncherError
|
|
474
484
|
|
|
475
|
-
launcher = AgentLauncher()
|
|
485
|
+
launcher = launcher or AgentLauncher()
|
|
476
486
|
if not callable(getattr(launcher, "kill_bound", None)):
|
|
477
487
|
# Core has had kill_bound since 0.6.5; a saved window name alone is
|
|
478
488
|
# never trusted, so without it nothing is closed.
|
|
479
|
-
|
|
480
|
-
"[red]Not stopped:[/red] this qbraid-core cannot verify a tmux window; update qbraid-core.",
|
|
481
|
-
highlight=False,
|
|
482
|
-
)
|
|
483
|
-
raise typer.Exit(code=1)
|
|
489
|
+
_stop_refused("This qbraid-core cannot verify a tmux window; update qbraid-core.", as_json)
|
|
484
490
|
try:
|
|
485
|
-
launcher.kill_bound(session.session_id)
|
|
491
|
+
launcher.kill_bound(session.session_id, **_stop_options(confirmation))
|
|
486
492
|
except AgentLauncherError as error:
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
493
|
+
if as_json:
|
|
494
|
+
from .keep_alive import _error
|
|
495
|
+
|
|
496
|
+
_error(error, True)
|
|
497
|
+
_stop_refused(f"Core could not verify this session's tmux window. {error}", False)
|
|
498
|
+
_stop_success(session, as_json, f"Stopped the agent in tmux window '{session.tmux_window}'.")
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _stop_options(confirmation):
|
|
502
|
+
return {"keep_alive_confirmation": confirmation} if confirmation is not None else {}
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _stop_refused(message, as_json, code="STOP_REFUSED"):
|
|
506
|
+
from .keep_alive import _error
|
|
507
|
+
from .keep_alive import _plain as safe_text
|
|
508
|
+
|
|
509
|
+
if as_json:
|
|
510
|
+
_error(KeepAliveCommandError(code, message), True)
|
|
511
|
+
console.print(f"[red]Not stopped:[/red] {escape(safe_text(message))}", highlight=False)
|
|
512
|
+
raise typer.Exit(code=1)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _stop_success(session, as_json, message):
|
|
516
|
+
from .keep_alive import _plain as safe_text
|
|
517
|
+
|
|
518
|
+
if as_json:
|
|
519
|
+
typer.echo(json.dumps({"ok": True, "sessionId": session.session_id, "stopped": True}))
|
|
520
|
+
else:
|
|
521
|
+
console.print(f"[green]{escape(safe_text(message))}[/green]", highlight=False)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _require_remote_stop_cli(compute):
|
|
525
|
+
"""Inspect help before any Stop; a legacy CLI cannot provide consent receipts."""
|
|
526
|
+
import subprocess
|
|
527
|
+
|
|
528
|
+
try:
|
|
529
|
+
probe = subprocess.run(
|
|
530
|
+
[
|
|
531
|
+
"ssh",
|
|
532
|
+
"-T",
|
|
533
|
+
"-o",
|
|
534
|
+
"BatchMode=yes",
|
|
535
|
+
"-o",
|
|
536
|
+
"ConnectTimeout=10",
|
|
537
|
+
compute,
|
|
538
|
+
"qbraid agents kill --help",
|
|
539
|
+
],
|
|
540
|
+
capture_output=True,
|
|
541
|
+
text=True,
|
|
542
|
+
check=False,
|
|
543
|
+
timeout=30,
|
|
544
|
+
stdin=subprocess.DEVNULL,
|
|
545
|
+
)
|
|
546
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
547
|
+
raise KeepAliveCommandError(
|
|
548
|
+
"DESTINATION_UNAVAILABLE",
|
|
549
|
+
"Could not inspect destination Stop capabilities. No Stop was attempted.",
|
|
550
|
+
{"attempted": False},
|
|
551
|
+
) from error
|
|
552
|
+
if probe.returncode == 255:
|
|
553
|
+
raise KeepAliveCommandError(
|
|
554
|
+
"DESTINATION_UNAVAILABLE",
|
|
555
|
+
"Destination SSH connection failed during the capability check. No Stop was attempted.",
|
|
556
|
+
{"attempted": False},
|
|
491
557
|
)
|
|
492
|
-
|
|
493
|
-
|
|
558
|
+
help_text = re.sub(r"\x1b\[[0-9;]*m", "", probe.stdout)
|
|
559
|
+
if probe.returncode or not all(option in help_text for option in ("--json", "--yes")):
|
|
560
|
+
raise KeepAliveCommandError(
|
|
561
|
+
"UNSUPPORTED_OPERATION",
|
|
562
|
+
"Update the destination qbraid-cli for confirmed remote Stop. No Stop was attempted.",
|
|
563
|
+
{"attempted": False},
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _remote_stop(compute, target, force, yes, as_json):
|
|
568
|
+
"""New destination CLI owns consent; unknown authority cannot run legacy kill."""
|
|
569
|
+
import shlex
|
|
570
|
+
import subprocess
|
|
571
|
+
|
|
572
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.@:-]{0,254}", compute):
|
|
573
|
+
raise KeepAliveCommandError("INVALID_ARGUMENT", "Use a configured SSH alias for --compute.")
|
|
574
|
+
_require_remote_stop_cli(compute)
|
|
575
|
+
command = "qbraid agents kill --json"
|
|
576
|
+
command += " --force" if force else ""
|
|
577
|
+
command += " --yes" if yes else ""
|
|
578
|
+
command += f" -- {shlex.quote(target)}"
|
|
579
|
+
try:
|
|
580
|
+
response = subprocess.run(
|
|
581
|
+
["ssh", "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", compute, command],
|
|
582
|
+
capture_output=True,
|
|
583
|
+
text=True,
|
|
584
|
+
check=False,
|
|
585
|
+
timeout=30,
|
|
586
|
+
stdin=subprocess.DEVNULL,
|
|
587
|
+
)
|
|
588
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
589
|
+
raise KeepAliveCommandError(
|
|
590
|
+
"UNKNOWN_OUTCOME",
|
|
591
|
+
"Remote Stop was not confirmed. It was not retried; inspect the destination.",
|
|
592
|
+
) from error
|
|
593
|
+
try:
|
|
594
|
+
result = json.loads(response.stdout)
|
|
595
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
596
|
+
raise KeepAliveCommandError(
|
|
597
|
+
"UNKNOWN_OUTCOME",
|
|
598
|
+
"Destination Stop could not be verified. Update its CLI/Core for confirmation support. "
|
|
599
|
+
"No command was retried; inspect the destination before retrying.",
|
|
600
|
+
) from None
|
|
601
|
+
if not isinstance(result, dict) or not isinstance(result.get("ok"), bool):
|
|
602
|
+
raise KeepAliveCommandError(
|
|
603
|
+
"UNKNOWN_OUTCOME", "Destination Stop receipt was invalid; no command was retried."
|
|
604
|
+
)
|
|
605
|
+
if not result["ok"]:
|
|
606
|
+
failure = result.get("error")
|
|
607
|
+
if not isinstance(failure, dict) or not isinstance(failure.get("message"), str):
|
|
608
|
+
raise KeepAliveCommandError(
|
|
609
|
+
"UNKNOWN_OUTCOME", "Destination Stop error was invalid; no command was retried."
|
|
610
|
+
)
|
|
611
|
+
raise KeepAliveCommandError(
|
|
612
|
+
failure.get("code") or "STOP_REFUSED", failure["message"], failure.get("details")
|
|
613
|
+
)
|
|
614
|
+
if (
|
|
615
|
+
response.returncode
|
|
616
|
+
or result.get("stopped") is not True
|
|
617
|
+
or not isinstance(result.get("sessionId"), str)
|
|
618
|
+
or not result["sessionId"]
|
|
619
|
+
):
|
|
620
|
+
raise KeepAliveCommandError(
|
|
621
|
+
"UNKNOWN_OUTCOME", "Destination did not confirm an exact stopped session; no command was retried."
|
|
622
|
+
)
|
|
623
|
+
if as_json:
|
|
624
|
+
typer.echo(json.dumps(result, ensure_ascii=True))
|
|
625
|
+
else:
|
|
626
|
+
from .keep_alive import _plain as safe_text
|
|
627
|
+
|
|
628
|
+
console.print(f"[green]Stopped '{escape(safe_text(target))}' on {escape(compute)}.[/green]")
|
|
494
629
|
|
|
495
630
|
|
|
496
631
|
_GONE = "gone"
|
|
@@ -1258,6 +1393,8 @@ def agents_kill(
|
|
|
1258
1393
|
target: str = typer.Argument(..., help="Agent name, ID prefix, or PID"),
|
|
1259
1394
|
force: bool = typer.Option(False, "--force", "-f", help="Use SIGKILL instead of SIGTERM"),
|
|
1260
1395
|
compute: Optional[str] = typer.Option(None, "--compute", "-c", help="SSH alias for remote compute"),
|
|
1396
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Confirm disabling keep-alive before Stop."),
|
|
1397
|
+
as_json: bool = typer.Option(False, "--json", help="Print a JSON receipt; enrolled Stop requires --yes."),
|
|
1261
1398
|
):
|
|
1262
1399
|
"""Stop an agent by name, session ID, or PID.
|
|
1263
1400
|
|
|
@@ -1269,11 +1406,7 @@ def agents_kill(
|
|
|
1269
1406
|
from qbraid_core.services.agents import AgentSessionRegistryManager
|
|
1270
1407
|
|
|
1271
1408
|
if compute:
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
flags = " --force" if force else ""
|
|
1275
|
-
_ssh_cmd(compute, f"qbraid agents kill {shlex.quote(target)}{flags}")
|
|
1276
|
-
console.print(f"[green]Stopped '{target}' on {compute}.[/green]")
|
|
1409
|
+
_remote_stop(compute, target, force, yes, as_json)
|
|
1277
1410
|
return
|
|
1278
1411
|
|
|
1279
1412
|
registry = AgentSessionRegistryManager()
|
|
@@ -1303,11 +1436,14 @@ def agents_kill(
|
|
|
1303
1436
|
]
|
|
1304
1437
|
|
|
1305
1438
|
if not matches:
|
|
1306
|
-
|
|
1307
|
-
raise typer.Exit(code=1)
|
|
1439
|
+
_stop_refused(f"No session found matching '{target}'.", as_json, "SESSION_NOT_FOUND")
|
|
1308
1440
|
|
|
1309
1441
|
# If multiple matches, show filtered picker
|
|
1310
1442
|
if len(matches) > 1:
|
|
1443
|
+
if as_json or not _can_ask():
|
|
1444
|
+
_stop_refused(
|
|
1445
|
+
"Multiple sessions match; use a complete, exact session ID.", as_json, "AMBIGUOUS_SESSION"
|
|
1446
|
+
)
|
|
1311
1447
|
import questionary
|
|
1312
1448
|
|
|
1313
1449
|
console.print(f"[yellow]Multiple sessions match '{target}':[/yellow]")
|
|
@@ -1338,92 +1474,103 @@ def agents_kill(
|
|
|
1338
1474
|
|
|
1339
1475
|
session = matches[0]
|
|
1340
1476
|
if getattr(session, "runtime_kind", None) == "provider_child":
|
|
1341
|
-
|
|
1342
|
-
"
|
|
1343
|
-
|
|
1477
|
+
_stop_refused(
|
|
1478
|
+
"Native provider children are read-only. Manage this child from its parent agent.",
|
|
1479
|
+
as_json,
|
|
1480
|
+
"READ_ONLY_SESSION",
|
|
1344
1481
|
)
|
|
1345
|
-
raise typer.Exit(code=1)
|
|
1346
1482
|
pid = session.pid
|
|
1347
1483
|
|
|
1348
1484
|
# Shown as interrupted (another Lab's row this Lab cannot verify):
|
|
1349
1485
|
# refused before anything, Core included, so nothing is written for
|
|
1350
1486
|
# it. Other native rows go to Core, which asks their owning server.
|
|
1351
1487
|
if (_lifecycle_view(registry, session).metadata or {}).get("status_overlay"):
|
|
1352
|
-
|
|
1353
|
-
"
|
|
1488
|
+
_stop_refused(
|
|
1489
|
+
"It belongs to another Lab that this Lab cannot verify, so it is "
|
|
1354
1490
|
"listed as interrupted. Resume it with --confirm-owner-gone, or stop it from its own Lab. "
|
|
1355
1491
|
"Nothing was signalled or closed.",
|
|
1356
|
-
|
|
1492
|
+
as_json,
|
|
1357
1493
|
)
|
|
1358
|
-
raise typer.Exit(code=1)
|
|
1359
1494
|
|
|
1360
|
-
|
|
1495
|
+
native = session.metadata.get("terminal_backend") == "jupyter"
|
|
1496
|
+
if not native:
|
|
1497
|
+
refusal = _foreign_pid_reason(session) or _not_running_reason(session)
|
|
1498
|
+
if refusal:
|
|
1499
|
+
_stop_refused(f"{refusal} Nothing was signalled or closed.", as_json)
|
|
1500
|
+
|
|
1501
|
+
from qbraid_core.services.agents import AgentLauncher
|
|
1502
|
+
|
|
1503
|
+
launcher = AgentLauncher(registry=registry)
|
|
1504
|
+
recovery, confirmation = stop_plan(launcher, session, yes=yes, as_json=as_json, can_ask=_can_ask)
|
|
1505
|
+
|
|
1506
|
+
if native:
|
|
1361
1507
|
# A native agent belongs to its Lab server, which Core reaches and
|
|
1362
1508
|
# verifies; its PID may be another namespace's. Never signalled here.
|
|
1363
1509
|
from qbraid_core.services.agents import AgentLauncher, AgentLauncherError
|
|
1364
1510
|
from qbraid_core.services.agents.exceptions import AgentRuntimeUnavailableError
|
|
1365
1511
|
|
|
1366
1512
|
try:
|
|
1367
|
-
|
|
1513
|
+
launcher.kill_bound(session.session_id, **_stop_options(confirmation))
|
|
1368
1514
|
except AgentRuntimeUnavailableError as error:
|
|
1369
1515
|
if _not_running_reason(session):
|
|
1370
1516
|
# Nothing to verify: Core says why (e.g. no longer active).
|
|
1371
|
-
|
|
1517
|
+
_stop_refused(str(error), as_json)
|
|
1372
1518
|
else:
|
|
1373
|
-
|
|
1374
|
-
"
|
|
1375
|
-
|
|
1376
|
-
f"{escape(str(error))}",
|
|
1377
|
-
highlight=False,
|
|
1519
|
+
_stop_refused(
|
|
1520
|
+
f"The owning Lab did not confirm Stop. Inspect its current status. {error}",
|
|
1521
|
+
as_json,
|
|
1378
1522
|
)
|
|
1379
|
-
raise typer.Exit(code=1)
|
|
1380
1523
|
except AgentLauncherError as error:
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1524
|
+
if as_json:
|
|
1525
|
+
from .keep_alive import _error
|
|
1526
|
+
|
|
1527
|
+
_error(error, True)
|
|
1528
|
+
_stop_refused(str(error), False)
|
|
1529
|
+
_stop_success(session, as_json, "Stopped native Lab agent.")
|
|
1384
1530
|
return
|
|
1385
1531
|
|
|
1386
1532
|
# Refused before anything is touched: a row of another PID namespace or
|
|
1387
1533
|
# machine, or one no longer running, names a PID and a tmux window that
|
|
1388
1534
|
# may now belong to someone else.
|
|
1389
|
-
refusal = _foreign_pid_reason(session) or _not_running_reason(session)
|
|
1390
|
-
if refusal:
|
|
1391
|
-
console.print(
|
|
1392
|
-
f"[red]Not stopped:[/red] {escape(refusal)} Nothing was signalled or closed.", highlight=False
|
|
1393
|
-
)
|
|
1394
|
-
raise typer.Exit(code=1)
|
|
1395
|
-
|
|
1396
1535
|
if session.tmux_session and session.tmux_window:
|
|
1397
|
-
_kill_tmux_session(session)
|
|
1536
|
+
_kill_tmux_session(session, launcher=launcher, confirmation=confirmation, as_json=as_json)
|
|
1398
1537
|
return
|
|
1399
1538
|
|
|
1400
1539
|
if not (pid and pid > 0):
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1540
|
+
_stop_refused("No agent process or terminal is recorded for this session.", as_json)
|
|
1541
|
+
guard = recovery.keep_alive_stop_guard(launcher, None, suppress=False) if recovery else nullcontext()
|
|
1542
|
+
with guard:
|
|
1543
|
+
if recovery and recovery.recovery_bound_pids(registry, {pid}):
|
|
1544
|
+
_stop_refused(
|
|
1545
|
+
"This PID belongs to a recovery-bound runtime. Use its exact managed session Stop.",
|
|
1546
|
+
as_json,
|
|
1547
|
+
)
|
|
1548
|
+
identity = _agent_identity_refusal(session)
|
|
1549
|
+
if identity == _GONE:
|
|
1550
|
+
if not as_json:
|
|
1551
|
+
console.print(f"[dim]Process {pid} already stopped.[/dim]")
|
|
1552
|
+
elif identity:
|
|
1553
|
+
_stop_refused(f"Did not signal PID {pid}: {identity}", as_json)
|
|
1554
|
+
else:
|
|
1555
|
+
_signal_agent(pid, force, quiet=as_json)
|
|
1556
|
+
registry.deregister_session(session_id=session.session_id)
|
|
1557
|
+
_stop_success(
|
|
1558
|
+
session,
|
|
1559
|
+
as_json,
|
|
1560
|
+
f"Session {session.session_id[:8]} "
|
|
1561
|
+
f"({session.terminal_name or session.tmux_window or '-'}) stopped.",
|
|
1421
1562
|
)
|
|
1422
1563
|
|
|
1423
1564
|
except (typer.Exit, SystemExit):
|
|
1424
1565
|
raise
|
|
1425
1566
|
except Exception as e:
|
|
1426
|
-
|
|
1567
|
+
from .keep_alive import _plain as safe_text
|
|
1568
|
+
|
|
1569
|
+
if as_json:
|
|
1570
|
+
from .keep_alive import _error
|
|
1571
|
+
|
|
1572
|
+
_error(e, True)
|
|
1573
|
+
console.print(f"[red]Stop was not confirmed:[/red] {escape(safe_text(e))}", highlight=False)
|
|
1427
1574
|
raise typer.Exit(code=1)
|
|
1428
1575
|
|
|
1429
1576
|
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
# Copyright (c) 2026, qBraid Development Team
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
"""Explicit local keep-alive controls and an optional foreground host."""
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import time
|
|
10
|
+
import unicodedata
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
keep_alive_app = typer.Typer(
|
|
19
|
+
help=(
|
|
20
|
+
"Opt-in local managed agent recovery.\n\n"
|
|
21
|
+
"Restore enrolled local conversations after an instance restart. "
|
|
22
|
+
"A process exit in the same instance pauses recovery for attention. "
|
|
23
|
+
"Off by default. Explicit Stop disables recovery. A compatible running host is required; "
|
|
24
|
+
"these commands do not install a daemon or support remote compute."
|
|
25
|
+
),
|
|
26
|
+
no_args_is_help=True,
|
|
27
|
+
)
|
|
28
|
+
console = Console()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class KeepAliveCommandError(Exception):
|
|
32
|
+
"""An actionable failure that also has a stable JSON representation."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, code, message, details=None):
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.code = code
|
|
37
|
+
self.details = details
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load():
|
|
41
|
+
"""Keep ordinary CLI imports usable with an older installed Core."""
|
|
42
|
+
try:
|
|
43
|
+
module = importlib.import_module("qbraid_core.services.agents.keep_alive")
|
|
44
|
+
manager = module.KeepAliveManager
|
|
45
|
+
supervisor = module.RecoverySupervisor
|
|
46
|
+
if not all(
|
|
47
|
+
callable(getattr(manager, method, None)) for method in ("enable", "list", "disable", "forget")
|
|
48
|
+
):
|
|
49
|
+
raise AttributeError("Incomplete keep-alive controls")
|
|
50
|
+
if not callable(getattr(supervisor, "tick", None)):
|
|
51
|
+
raise AttributeError("Missing keep-alive host")
|
|
52
|
+
return manager, supervisor
|
|
53
|
+
except (ImportError, AttributeError) as error:
|
|
54
|
+
raise KeepAliveCommandError(
|
|
55
|
+
"UNSUPPORTED_OPERATION",
|
|
56
|
+
"Keep-alive requires a compatible qbraid-core with KeepAliveManager and RecoverySupervisor. "
|
|
57
|
+
"Upgrade Core before using these commands. No policy was changed.",
|
|
58
|
+
) from error
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _require_recovery_protocol(*, host=False):
|
|
62
|
+
"""Check installed behavior; the recovery release has no assigned version yet."""
|
|
63
|
+
try:
|
|
64
|
+
module = importlib.import_module("qbraid_core.services.agents.keep_alive")
|
|
65
|
+
end = importlib.import_module("qbraid_core.services.agents.session_end_hook")
|
|
66
|
+
if not _protocol_is(module, "KEEP_ALIVE_STOP_PROTOCOL", 1) or not _protocol_is(
|
|
67
|
+
end, "BOUND_END_HOOK_PROTOCOL", 2
|
|
68
|
+
):
|
|
69
|
+
raise AttributeError("Missing reviewed recovery protocols")
|
|
70
|
+
if host and "supported_backends" not in inspect.signature(module.RecoverySupervisor).parameters:
|
|
71
|
+
raise AttributeError("Missing recovery-host backend selection")
|
|
72
|
+
except (ImportError, AttributeError, TypeError, ValueError) as error:
|
|
73
|
+
raise KeepAliveCommandError(
|
|
74
|
+
"UNSUPPORTED_OPERATION",
|
|
75
|
+
"Upgrade Core to the reviewed keep-alive and bound-end adapters before enabling or hosting "
|
|
76
|
+
"recovery. No recovery release version has been assigned. No policy was changed.",
|
|
77
|
+
) from error
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _protocol_is(module, name, expected):
|
|
81
|
+
value = getattr(module, name, None)
|
|
82
|
+
return isinstance(value, int) and not isinstance(value, bool) and value == expected
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def stop_plan(launcher, session, *, yes, as_json, can_ask):
|
|
86
|
+
"""Read consent without mutation; Core rechecks the token while stopping."""
|
|
87
|
+
name = "qbraid_core.services.agents.keep_alive"
|
|
88
|
+
try:
|
|
89
|
+
module = importlib.import_module(name)
|
|
90
|
+
except ModuleNotFoundError as error:
|
|
91
|
+
if error.name == name:
|
|
92
|
+
registry_path = getattr(getattr(launcher, "_registry", None), "registry_path", None)
|
|
93
|
+
from pathlib import Path
|
|
94
|
+
|
|
95
|
+
if session.metadata.get("keep_alive_provider") is not None or (
|
|
96
|
+
isinstance(registry_path, (str, Path))
|
|
97
|
+
and any((Path(registry_path).parent / "keep-alive").glob("*.json"))
|
|
98
|
+
):
|
|
99
|
+
raise KeepAliveCommandError(
|
|
100
|
+
"UNSUPPORTED_OPERATION",
|
|
101
|
+
"Update Core to inspect saved recovery policies before stopping this agent. "
|
|
102
|
+
"Nothing was stopped or disabled.",
|
|
103
|
+
) from error
|
|
104
|
+
return None, None # Released Core predating enrollment keeps its Stop behavior.
|
|
105
|
+
raise
|
|
106
|
+
try:
|
|
107
|
+
controls = importlib.import_module("qbraid_core.services.agents.runtime_controls")
|
|
108
|
+
guard = getattr(module, "keep_alive_stop_guard", None)
|
|
109
|
+
supported = (
|
|
110
|
+
_protocol_is(module, "KEEP_ALIVE_STOP_PROTOCOL", 1)
|
|
111
|
+
and callable(getattr(controls, "stop_preflight", None))
|
|
112
|
+
and callable(getattr(module, "recovery_bound_pids", None))
|
|
113
|
+
and callable(guard)
|
|
114
|
+
and "keep_alive_confirmation" in inspect.signature(guard).parameters
|
|
115
|
+
)
|
|
116
|
+
except (ImportError, TypeError, ValueError):
|
|
117
|
+
supported = False
|
|
118
|
+
if not supported:
|
|
119
|
+
raise KeepAliveCommandError(
|
|
120
|
+
"UNSUPPORTED_OPERATION",
|
|
121
|
+
"Update Core for atomic keep-alive Stop confirmation. Nothing was stopped or disabled.",
|
|
122
|
+
)
|
|
123
|
+
preflight = controls.stop_preflight(launcher, session.session_id)
|
|
124
|
+
if not isinstance(preflight, dict) or not isinstance(preflight.get("requiresConfirmation"), bool):
|
|
125
|
+
raise KeepAliveCommandError("INVALID_RESPONSE", "Core returned an invalid Stop preflight.")
|
|
126
|
+
confirmation = preflight.get("keepAliveConfirmation")
|
|
127
|
+
if preflight["requiresConfirmation"]:
|
|
128
|
+
if not isinstance(confirmation, dict):
|
|
129
|
+
raise KeepAliveCommandError("INVALID_RESPONSE", "Core did not bind its Stop confirmation.")
|
|
130
|
+
if not yes:
|
|
131
|
+
if as_json or not can_ask():
|
|
132
|
+
raise KeepAliveCommandError(
|
|
133
|
+
"KEEP_ALIVE_CONFIRMATION_REQUIRED",
|
|
134
|
+
"Keep-alive is enabled. Pass --yes to turn it off and stop the agent. "
|
|
135
|
+
"Nothing was stopped or disabled.",
|
|
136
|
+
preflight,
|
|
137
|
+
)
|
|
138
|
+
try:
|
|
139
|
+
confirmed = typer.confirm(
|
|
140
|
+
"Keep-alive is enabled. Turn it off and stop the agent?", default=False
|
|
141
|
+
)
|
|
142
|
+
except (typer.Abort, EOFError, KeyboardInterrupt):
|
|
143
|
+
confirmed = False
|
|
144
|
+
if not confirmed:
|
|
145
|
+
typer.echo("Not stopped. No policy was changed.")
|
|
146
|
+
raise typer.Exit(code=1)
|
|
147
|
+
elif confirmation is not None:
|
|
148
|
+
raise KeepAliveCommandError("INVALID_RESPONSE", "Core returned an unexpected Stop confirmation.")
|
|
149
|
+
return module, confirmation
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _manager(compute):
|
|
153
|
+
if compute is not None:
|
|
154
|
+
raise ValueError("Keep-alive is local only. Run this command on the session's original instance.")
|
|
155
|
+
manager, supervisor = _load()
|
|
156
|
+
from qbraid_core.services.agents import AgentLauncher
|
|
157
|
+
|
|
158
|
+
return manager(AgentLauncher()), supervisor
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _plain(value):
|
|
162
|
+
"""Keep terminal controls and invisible direction changes out of human output."""
|
|
163
|
+
return "".join(
|
|
164
|
+
f"\\u{ord(char):04x}" if unicodedata.category(char) in {"Cc", "Cf", "Cs", "Zl", "Zp"} else char
|
|
165
|
+
for char in str(value)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _error(error, as_json=False):
|
|
170
|
+
if as_json:
|
|
171
|
+
failure = {"code": getattr(error, "code", None) or "KEEP_ALIVE_ERROR", "message": str(error)}
|
|
172
|
+
details = getattr(error, "details", None)
|
|
173
|
+
if isinstance(details, dict):
|
|
174
|
+
failure["details"] = details
|
|
175
|
+
typer.echo(json.dumps({"ok": False, "error": failure}, ensure_ascii=True))
|
|
176
|
+
else:
|
|
177
|
+
typer.echo(f"Keep-alive: {_plain(error)}", err=True)
|
|
178
|
+
raise typer.Exit(code=1) from error
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _policy(manager, policy_id):
|
|
182
|
+
matches = [policy for policy in manager.list() if policy["policy_id"] == policy_id]
|
|
183
|
+
if len(matches) != 1:
|
|
184
|
+
raise ValueError("No policy has that exact ID. Use 'qbraid agents keep-alive list'.")
|
|
185
|
+
return matches[0]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _show(policies, as_json):
|
|
189
|
+
if as_json:
|
|
190
|
+
typer.echo(json.dumps(policies))
|
|
191
|
+
return
|
|
192
|
+
if not policies:
|
|
193
|
+
typer.echo("No keep-alive policies. Recovery is off for unenrolled sessions.")
|
|
194
|
+
return
|
|
195
|
+
table = Table("Policy ID", "Session ID", "State", "Reason")
|
|
196
|
+
for policy in policies:
|
|
197
|
+
table.add_row(
|
|
198
|
+
*(
|
|
199
|
+
Text(_plain(policy.get(key) or ""))
|
|
200
|
+
for key in (
|
|
201
|
+
"policy_id",
|
|
202
|
+
"active_session_id",
|
|
203
|
+
"state",
|
|
204
|
+
"reason",
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
console.print(table)
|
|
209
|
+
typer.echo("Enabled policies require a compatible host to be running.")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@keep_alive_app.command("list")
|
|
213
|
+
def list_policies(
|
|
214
|
+
as_json: bool = typer.Option(False, "--json", help="Print policy data as JSON."),
|
|
215
|
+
compute: Optional[str] = typer.Option(None, "--compute", hidden=True),
|
|
216
|
+
):
|
|
217
|
+
"""List local recovery policies.
|
|
218
|
+
|
|
219
|
+
Does not enroll or launch anything.
|
|
220
|
+
"""
|
|
221
|
+
try:
|
|
222
|
+
manager, _ = _manager(compute)
|
|
223
|
+
_show(manager.list(), as_json)
|
|
224
|
+
except Exception as error:
|
|
225
|
+
_error(error, as_json)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@keep_alive_app.command("enable")
|
|
229
|
+
def enable_policy(
|
|
230
|
+
session_id: str = typer.Argument(
|
|
231
|
+
..., help="Complete local managed session ID; names/prefixes are refused."
|
|
232
|
+
),
|
|
233
|
+
as_json: bool = typer.Option(False, "--json", help="Print the resulting policy as JSON."),
|
|
234
|
+
compute: Optional[str] = typer.Option(None, "--compute", hidden=True),
|
|
235
|
+
):
|
|
236
|
+
"""Enable recovery for one exact session.
|
|
237
|
+
|
|
238
|
+
Preserves the live conversation's recorded permissions.
|
|
239
|
+
"""
|
|
240
|
+
try:
|
|
241
|
+
manager, _ = _manager(compute)
|
|
242
|
+
_require_recovery_protocol()
|
|
243
|
+
session = manager.registry.get_session(session_id)
|
|
244
|
+
if session.session_id != session_id:
|
|
245
|
+
raise ValueError("Enable requires the complete, exact session ID.")
|
|
246
|
+
expected = {
|
|
247
|
+
"pid": session.pid,
|
|
248
|
+
"startedAt": session.started_at,
|
|
249
|
+
"tool": session.tool.value,
|
|
250
|
+
"terminalId": session.metadata.get("jupyter_terminal_id"),
|
|
251
|
+
"serverId": session.metadata.get("jupyter_server_id"),
|
|
252
|
+
"transcriptPath": session.transcript_path,
|
|
253
|
+
}
|
|
254
|
+
policy = manager.enable(session_id, expected)
|
|
255
|
+
if as_json:
|
|
256
|
+
typer.echo(json.dumps(policy))
|
|
257
|
+
else:
|
|
258
|
+
_show([policy], False)
|
|
259
|
+
except Exception as error:
|
|
260
|
+
_error(error, as_json)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@keep_alive_app.command("disable")
|
|
264
|
+
def disable_policy(
|
|
265
|
+
policy_id: str = typer.Argument(..., help="Complete policy ID from keep-alive list."),
|
|
266
|
+
as_json: bool = typer.Option(False, "--json", help="Print the resulting policy as JSON."),
|
|
267
|
+
compute: Optional[str] = typer.Option(None, "--compute", hidden=True),
|
|
268
|
+
):
|
|
269
|
+
"""Disable recovery; leave the agent running."""
|
|
270
|
+
try:
|
|
271
|
+
manager, _ = _manager(compute)
|
|
272
|
+
selected = _policy(manager, policy_id)
|
|
273
|
+
policy = manager.disable(policy_id, selected["revision"])
|
|
274
|
+
if as_json:
|
|
275
|
+
typer.echo(json.dumps(policy))
|
|
276
|
+
else:
|
|
277
|
+
typer.echo(f"Disabled keep-alive policy {_plain(policy_id)}. The running agent was not stopped.")
|
|
278
|
+
except Exception as error:
|
|
279
|
+
_error(error, as_json)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@keep_alive_app.command("forget")
|
|
283
|
+
def forget_policy(
|
|
284
|
+
policy_id: str = typer.Argument(..., help="Complete, already disabled policy ID."),
|
|
285
|
+
as_json: bool = typer.Option(False, "--json", help="Print a JSON acknowledgement."),
|
|
286
|
+
compute: Optional[str] = typer.Option(None, "--compute", hidden=True),
|
|
287
|
+
):
|
|
288
|
+
"""Remove an already disabled policy.
|
|
289
|
+
|
|
290
|
+
Does not change any agent runtime.
|
|
291
|
+
"""
|
|
292
|
+
try:
|
|
293
|
+
manager, _ = _manager(compute)
|
|
294
|
+
selected = _policy(manager, policy_id)
|
|
295
|
+
if selected["enabled"]:
|
|
296
|
+
raise ValueError("Disable this policy before removing it.")
|
|
297
|
+
manager.forget(policy_id, selected["revision"])
|
|
298
|
+
if as_json:
|
|
299
|
+
typer.echo(json.dumps({"policy_id": policy_id, "removed": True}))
|
|
300
|
+
else:
|
|
301
|
+
typer.echo(f"Removed disabled policy {_plain(policy_id)}.")
|
|
302
|
+
except Exception as error:
|
|
303
|
+
_error(error, as_json)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@keep_alive_app.command("run")
|
|
307
|
+
def run_host(
|
|
308
|
+
once: bool = typer.Option(False, "--once", help="Reconcile once and exit; installs no background host."),
|
|
309
|
+
interval: float = typer.Option(5.0, "--interval", min=1.0, max=300.0, help="Seconds between ticks."),
|
|
310
|
+
as_json: bool = typer.Option(
|
|
311
|
+
False, "--json", help="Print each tick's policies as newline-delimited JSON."
|
|
312
|
+
),
|
|
313
|
+
compute: Optional[str] = typer.Option(None, "--compute", hidden=True),
|
|
314
|
+
):
|
|
315
|
+
"""Run the local recovery host.
|
|
316
|
+
|
|
317
|
+
Ctrl-C exits without disabling policies.
|
|
318
|
+
Exiting leaves policies armed, but recovery requires another compatible host
|
|
319
|
+
to be running. Native policies pause here and require the owning Lab host.
|
|
320
|
+
This command never enrolls a session or installs a daemon.
|
|
321
|
+
"""
|
|
322
|
+
try:
|
|
323
|
+
if not math.isfinite(interval):
|
|
324
|
+
raise ValueError("The polling interval must be a finite number of seconds.")
|
|
325
|
+
manager, supervisor = _manager(compute)
|
|
326
|
+
_require_recovery_protocol(host=True)
|
|
327
|
+
host = supervisor(manager, supported_backends={"tmux"})
|
|
328
|
+
if not once:
|
|
329
|
+
typer.echo(
|
|
330
|
+
"Foreground keep-alive host running. No sessions are enrolled by this command.", err=True
|
|
331
|
+
)
|
|
332
|
+
try:
|
|
333
|
+
while True:
|
|
334
|
+
try:
|
|
335
|
+
policies = host.tick()
|
|
336
|
+
except Exception as error:
|
|
337
|
+
raise KeepAliveCommandError(
|
|
338
|
+
"HOST_FAILED",
|
|
339
|
+
f"Recovery host failed and stopped: {error}. This tick was not retried. "
|
|
340
|
+
"Inspect policy status before starting another host.",
|
|
341
|
+
) from error
|
|
342
|
+
if once or as_json:
|
|
343
|
+
# An overlapping host's nonblocking tick can return no
|
|
344
|
+
# results; it does not mean there are no saved policies.
|
|
345
|
+
_show(policies or manager.list(), as_json)
|
|
346
|
+
if once:
|
|
347
|
+
return
|
|
348
|
+
time.sleep(interval)
|
|
349
|
+
except KeyboardInterrupt:
|
|
350
|
+
return
|
|
351
|
+
finally:
|
|
352
|
+
typer.echo(
|
|
353
|
+
"Host stopped. Policies stay armed, but recovery requires a compatible host to be running.",
|
|
354
|
+
err=True,
|
|
355
|
+
)
|
|
356
|
+
except Exception as error:
|
|
357
|
+
_error(error, as_json)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qbraid-cli
|
|
3
|
-
Version: 0.16.
|
|
3
|
+
Version: 0.16.14
|
|
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.33
|
|
34
34
|
Provides-Extra: compute
|
|
35
|
-
Requires-Dist: qbraid-core[compute]>=0.6.
|
|
35
|
+
Requires-Dist: qbraid-core[compute]>=0.6.33; extra == "compute"
|
|
36
36
|
Provides-Extra: envs
|
|
37
|
-
Requires-Dist: qbraid-core[environments]>=0.6.
|
|
37
|
+
Requires-Dist: qbraid-core[environments]>=0.6.33; extra == "envs"
|
|
38
38
|
Provides-Extra: dev
|
|
39
39
|
Requires-Dist: isort; extra == "dev"
|
|
40
40
|
Requires-Dist: black; extra == "dev"
|
|
@@ -22,6 +22,7 @@ qbraid_cli/admin/headers.py
|
|
|
22
22
|
qbraid_cli/admin/validation.py
|
|
23
23
|
qbraid_cli/agents/__init__.py
|
|
24
24
|
qbraid_cli/agents/app.py
|
|
25
|
+
qbraid_cli/agents/keep_alive.py
|
|
25
26
|
qbraid_cli/agents/permission_response.py
|
|
26
27
|
qbraid_cli/agents/provider_children.py
|
|
27
28
|
qbraid_cli/agents/queue.py
|
|
@@ -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.33
|
|
6
6
|
|
|
7
7
|
[compute]
|
|
8
|
-
qbraid-core[compute]>=0.6.
|
|
8
|
+
qbraid-core[compute]>=0.6.33
|
|
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.33
|
|
@@ -937,7 +937,7 @@ class TestAgentsE2E:
|
|
|
937
937
|
|
|
938
938
|
@patch("qbraid_core.services.agents.AgentLauncher")
|
|
939
939
|
@patch("qbraid_core.services.agents.AgentSessionRegistryManager")
|
|
940
|
-
def test_kill_session(self, mock_registry_cls, mock_launcher_cls):
|
|
940
|
+
def test_kill_session(self, mock_registry_cls, mock_launcher_cls, tmp_path):
|
|
941
941
|
"""Kill a session by ID: a tmux row goes through Core's verified kill."""
|
|
942
942
|
mock_session = MagicMock()
|
|
943
943
|
mock_session.session_id = "abc12345-6789"
|
|
@@ -952,10 +952,12 @@ class TestAgentsE2E:
|
|
|
952
952
|
mock_session.tmux_session = "qbraid-agents"
|
|
953
953
|
|
|
954
954
|
mock_registry = MagicMock()
|
|
955
|
+
mock_registry.registry_path = tmp_path / "sessions.json"
|
|
955
956
|
all_sessions = MagicMock()
|
|
956
957
|
all_sessions.sessions = [mock_session]
|
|
957
958
|
mock_registry.load_registry.return_value = all_sessions
|
|
958
959
|
mock_registry_cls.return_value = mock_registry
|
|
960
|
+
mock_launcher_cls.return_value._registry = mock_registry
|
|
959
961
|
|
|
960
962
|
from qbraid_cli.agents.app import agents_app
|
|
961
963
|
|
|
@@ -964,6 +966,8 @@ class TestAgentsE2E:
|
|
|
964
966
|
|
|
965
967
|
assert result.exit_code == 0, result.output
|
|
966
968
|
mock_launcher_cls.return_value.kill_bound.assert_called_once_with("abc12345-6789")
|
|
969
|
+
assert not (tmp_path / "keep-alive").exists()
|
|
970
|
+
mock_registry.deregister_session.assert_not_called()
|
|
967
971
|
# Nothing touches the window or the PID directly: Core verified the target.
|
|
968
972
|
tmux.assert_not_called()
|
|
969
973
|
kill.assert_not_called()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|