qbraid-cli 0.16.13__tar.gz → 0.16.15__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.15}/PKG-INFO +4 -4
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/pyproject.toml +4 -4
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/agents/app.py +230 -76
- qbraid_cli-0.16.15/qbraid_cli/agents/keep_alive.py +357 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15/qbraid_cli.egg-info}/PKG-INFO +4 -4
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli.egg-info/SOURCES.txt +1 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli.egg-info/requires.txt +3 -3
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/tests/test_cli_e2e.py +30 -1
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/LICENSE +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/README.md +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/_build_info.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/_version.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/account/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/account/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/admin/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/admin/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/admin/headers.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/admin/validation.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/agents/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/agents/permission_response.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/agents/provider_children.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/agents/queue.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/agents/work.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/ai/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/ai/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/ai/token.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/comms/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/comms/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/compute/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/compute/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/compute/instances.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/compute/profiles.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/compute/server.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/compute/ssh.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/configure/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/configure/actions.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/configure/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/devices/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/devices/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/devices/validation.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/envs/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/envs/activate.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/envs/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/envs/create.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/envs/data_handling.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/envs/groups.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/envs/publish.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/envs/registry.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/envs/share.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/exceptions.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/handlers.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/jobs/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/jobs/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/jobs/groups.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/kernels/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/kernels/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/lab/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/lab/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/main.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/pip/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/pip/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/pip/hooks.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/projects/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/projects/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/py.typed +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/skills/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/skills/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/troubleshoot/__init__.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli/troubleshoot/app.py +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli.egg-info/dependency_links.txt +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli.egg-info/entry_points.txt +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/qbraid_cli.egg-info/top_level.txt +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/setup.cfg +0 -0
- {qbraid_cli-0.16.13 → qbraid_cli-0.16.15}/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.15
|
|
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.34
|
|
34
34
|
Provides-Extra: compute
|
|
35
|
-
Requires-Dist: qbraid-core[compute]>=0.6.
|
|
35
|
+
Requires-Dist: qbraid-core[compute]>=0.6.34; extra == "compute"
|
|
36
36
|
Provides-Extra: envs
|
|
37
|
-
Requires-Dist: qbraid-core[environments]>=0.6.
|
|
37
|
+
Requires-Dist: qbraid-core[environments]>=0.6.34; 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.15"
|
|
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.34",
|
|
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.34"]
|
|
47
|
+
envs = ["qbraid-core[environments]>=0.6.34"]
|
|
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},
|
|
557
|
+
)
|
|
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")
|
|
491
613
|
)
|
|
492
|
-
|
|
493
|
-
|
|
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"
|
|
@@ -896,12 +1031,19 @@ def agents_discover(
|
|
|
896
1031
|
include_error_traceback=False,
|
|
897
1032
|
)
|
|
898
1033
|
|
|
1034
|
+
registered_count = registry.merge_discovered_sessions(discovered) if discovered else 0
|
|
1035
|
+
# Refresh native provider children even when discovery found no new
|
|
1036
|
+
# process sessions. Their lifecycle is owned by the provider transcript
|
|
1037
|
+
# observer, not by process discovery; without this refresh a stopped
|
|
1038
|
+
# Claude child can remain "working" in the registry indefinitely.
|
|
1039
|
+
refresh = getattr(registry, "refresh_discovered_sessions", None)
|
|
1040
|
+
if callable(refresh):
|
|
1041
|
+
refresh()
|
|
1042
|
+
|
|
899
1043
|
if not discovered:
|
|
900
1044
|
console.print("[dim]No agent sessions found.[/dim]")
|
|
901
1045
|
return
|
|
902
1046
|
|
|
903
|
-
registered_count = registry.merge_discovered_sessions(discovered)
|
|
904
|
-
|
|
905
1047
|
table = Table(title=f"Discovered Sessions ({len(discovered)})")
|
|
906
1048
|
table.add_column("Session ID", style="cyan", no_wrap=True)
|
|
907
1049
|
table.add_column("Tool")
|
|
@@ -1258,6 +1400,8 @@ def agents_kill(
|
|
|
1258
1400
|
target: str = typer.Argument(..., help="Agent name, ID prefix, or PID"),
|
|
1259
1401
|
force: bool = typer.Option(False, "--force", "-f", help="Use SIGKILL instead of SIGTERM"),
|
|
1260
1402
|
compute: Optional[str] = typer.Option(None, "--compute", "-c", help="SSH alias for remote compute"),
|
|
1403
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Confirm disabling keep-alive before Stop."),
|
|
1404
|
+
as_json: bool = typer.Option(False, "--json", help="Print a JSON receipt; enrolled Stop requires --yes."),
|
|
1261
1405
|
):
|
|
1262
1406
|
"""Stop an agent by name, session ID, or PID.
|
|
1263
1407
|
|
|
@@ -1269,11 +1413,7 @@ def agents_kill(
|
|
|
1269
1413
|
from qbraid_core.services.agents import AgentSessionRegistryManager
|
|
1270
1414
|
|
|
1271
1415
|
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]")
|
|
1416
|
+
_remote_stop(compute, target, force, yes, as_json)
|
|
1277
1417
|
return
|
|
1278
1418
|
|
|
1279
1419
|
registry = AgentSessionRegistryManager()
|
|
@@ -1303,11 +1443,14 @@ def agents_kill(
|
|
|
1303
1443
|
]
|
|
1304
1444
|
|
|
1305
1445
|
if not matches:
|
|
1306
|
-
|
|
1307
|
-
raise typer.Exit(code=1)
|
|
1446
|
+
_stop_refused(f"No session found matching '{target}'.", as_json, "SESSION_NOT_FOUND")
|
|
1308
1447
|
|
|
1309
1448
|
# If multiple matches, show filtered picker
|
|
1310
1449
|
if len(matches) > 1:
|
|
1450
|
+
if as_json or not _can_ask():
|
|
1451
|
+
_stop_refused(
|
|
1452
|
+
"Multiple sessions match; use a complete, exact session ID.", as_json, "AMBIGUOUS_SESSION"
|
|
1453
|
+
)
|
|
1311
1454
|
import questionary
|
|
1312
1455
|
|
|
1313
1456
|
console.print(f"[yellow]Multiple sessions match '{target}':[/yellow]")
|
|
@@ -1338,92 +1481,103 @@ def agents_kill(
|
|
|
1338
1481
|
|
|
1339
1482
|
session = matches[0]
|
|
1340
1483
|
if getattr(session, "runtime_kind", None) == "provider_child":
|
|
1341
|
-
|
|
1342
|
-
"
|
|
1343
|
-
|
|
1484
|
+
_stop_refused(
|
|
1485
|
+
"Native provider children are read-only. Manage this child from its parent agent.",
|
|
1486
|
+
as_json,
|
|
1487
|
+
"READ_ONLY_SESSION",
|
|
1344
1488
|
)
|
|
1345
|
-
raise typer.Exit(code=1)
|
|
1346
1489
|
pid = session.pid
|
|
1347
1490
|
|
|
1348
1491
|
# Shown as interrupted (another Lab's row this Lab cannot verify):
|
|
1349
1492
|
# refused before anything, Core included, so nothing is written for
|
|
1350
1493
|
# it. Other native rows go to Core, which asks their owning server.
|
|
1351
1494
|
if (_lifecycle_view(registry, session).metadata or {}).get("status_overlay"):
|
|
1352
|
-
|
|
1353
|
-
"
|
|
1495
|
+
_stop_refused(
|
|
1496
|
+
"It belongs to another Lab that this Lab cannot verify, so it is "
|
|
1354
1497
|
"listed as interrupted. Resume it with --confirm-owner-gone, or stop it from its own Lab. "
|
|
1355
1498
|
"Nothing was signalled or closed.",
|
|
1356
|
-
|
|
1499
|
+
as_json,
|
|
1357
1500
|
)
|
|
1358
|
-
raise typer.Exit(code=1)
|
|
1359
1501
|
|
|
1360
|
-
|
|
1502
|
+
native = session.metadata.get("terminal_backend") == "jupyter"
|
|
1503
|
+
if not native:
|
|
1504
|
+
refusal = _foreign_pid_reason(session) or _not_running_reason(session)
|
|
1505
|
+
if refusal:
|
|
1506
|
+
_stop_refused(f"{refusal} Nothing was signalled or closed.", as_json)
|
|
1507
|
+
|
|
1508
|
+
from qbraid_core.services.agents import AgentLauncher
|
|
1509
|
+
|
|
1510
|
+
launcher = AgentLauncher(registry=registry)
|
|
1511
|
+
recovery, confirmation = stop_plan(launcher, session, yes=yes, as_json=as_json, can_ask=_can_ask)
|
|
1512
|
+
|
|
1513
|
+
if native:
|
|
1361
1514
|
# A native agent belongs to its Lab server, which Core reaches and
|
|
1362
1515
|
# verifies; its PID may be another namespace's. Never signalled here.
|
|
1363
1516
|
from qbraid_core.services.agents import AgentLauncher, AgentLauncherError
|
|
1364
1517
|
from qbraid_core.services.agents.exceptions import AgentRuntimeUnavailableError
|
|
1365
1518
|
|
|
1366
1519
|
try:
|
|
1367
|
-
|
|
1520
|
+
launcher.kill_bound(session.session_id, **_stop_options(confirmation))
|
|
1368
1521
|
except AgentRuntimeUnavailableError as error:
|
|
1369
1522
|
if _not_running_reason(session):
|
|
1370
1523
|
# Nothing to verify: Core says why (e.g. no longer active).
|
|
1371
|
-
|
|
1524
|
+
_stop_refused(str(error), as_json)
|
|
1372
1525
|
else:
|
|
1373
|
-
|
|
1374
|
-
"
|
|
1375
|
-
|
|
1376
|
-
f"{escape(str(error))}",
|
|
1377
|
-
highlight=False,
|
|
1526
|
+
_stop_refused(
|
|
1527
|
+
f"The owning Lab did not confirm Stop. Inspect its current status. {error}",
|
|
1528
|
+
as_json,
|
|
1378
1529
|
)
|
|
1379
|
-
raise typer.Exit(code=1)
|
|
1380
1530
|
except AgentLauncherError as error:
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1531
|
+
if as_json:
|
|
1532
|
+
from .keep_alive import _error
|
|
1533
|
+
|
|
1534
|
+
_error(error, True)
|
|
1535
|
+
_stop_refused(str(error), False)
|
|
1536
|
+
_stop_success(session, as_json, "Stopped native Lab agent.")
|
|
1384
1537
|
return
|
|
1385
1538
|
|
|
1386
1539
|
# Refused before anything is touched: a row of another PID namespace or
|
|
1387
1540
|
# machine, or one no longer running, names a PID and a tmux window that
|
|
1388
1541
|
# 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
1542
|
if session.tmux_session and session.tmux_window:
|
|
1397
|
-
_kill_tmux_session(session)
|
|
1543
|
+
_kill_tmux_session(session, launcher=launcher, confirmation=confirmation, as_json=as_json)
|
|
1398
1544
|
return
|
|
1399
1545
|
|
|
1400
1546
|
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
|
-
|
|
1547
|
+
_stop_refused("No agent process or terminal is recorded for this session.", as_json)
|
|
1548
|
+
guard = recovery.keep_alive_stop_guard(launcher, None, suppress=False) if recovery else nullcontext()
|
|
1549
|
+
with guard:
|
|
1550
|
+
if recovery and recovery.recovery_bound_pids(registry, {pid}):
|
|
1551
|
+
_stop_refused(
|
|
1552
|
+
"This PID belongs to a recovery-bound runtime. Use its exact managed session Stop.",
|
|
1553
|
+
as_json,
|
|
1554
|
+
)
|
|
1555
|
+
identity = _agent_identity_refusal(session)
|
|
1556
|
+
if identity == _GONE:
|
|
1557
|
+
if not as_json:
|
|
1558
|
+
console.print(f"[dim]Process {pid} already stopped.[/dim]")
|
|
1559
|
+
elif identity:
|
|
1560
|
+
_stop_refused(f"Did not signal PID {pid}: {identity}", as_json)
|
|
1561
|
+
else:
|
|
1562
|
+
_signal_agent(pid, force, quiet=as_json)
|
|
1563
|
+
registry.deregister_session(session_id=session.session_id)
|
|
1564
|
+
_stop_success(
|
|
1565
|
+
session,
|
|
1566
|
+
as_json,
|
|
1567
|
+
f"Session {session.session_id[:8]} "
|
|
1568
|
+
f"({session.terminal_name or session.tmux_window or '-'}) stopped.",
|
|
1421
1569
|
)
|
|
1422
1570
|
|
|
1423
1571
|
except (typer.Exit, SystemExit):
|
|
1424
1572
|
raise
|
|
1425
1573
|
except Exception as e:
|
|
1426
|
-
|
|
1574
|
+
from .keep_alive import _plain as safe_text
|
|
1575
|
+
|
|
1576
|
+
if as_json:
|
|
1577
|
+
from .keep_alive import _error
|
|
1578
|
+
|
|
1579
|
+
_error(e, True)
|
|
1580
|
+
console.print(f"[red]Stop was not confirmed:[/red] {escape(safe_text(e))}", highlight=False)
|
|
1427
1581
|
raise typer.Exit(code=1)
|
|
1428
1582
|
|
|
1429
1583
|
|
|
@@ -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.15
|
|
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.34
|
|
34
34
|
Provides-Extra: compute
|
|
35
|
-
Requires-Dist: qbraid-core[compute]>=0.6.
|
|
35
|
+
Requires-Dist: qbraid-core[compute]>=0.6.34; extra == "compute"
|
|
36
36
|
Provides-Extra: envs
|
|
37
|
-
Requires-Dist: qbraid-core[environments]>=0.6.
|
|
37
|
+
Requires-Dist: qbraid-core[environments]>=0.6.34; 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.34
|
|
6
6
|
|
|
7
7
|
[compute]
|
|
8
|
-
qbraid-core[compute]>=0.6.
|
|
8
|
+
qbraid-core[compute]>=0.6.34
|
|
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.34
|
|
@@ -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()
|
|
@@ -972,6 +976,7 @@ class TestAgentsE2E:
|
|
|
972
976
|
def test_discover_empty(self, mock_registry_cls):
|
|
973
977
|
"""Discover with no new sessions found."""
|
|
974
978
|
mock_registry = MagicMock()
|
|
979
|
+
mock_registry.refresh_discovered_sessions = MagicMock(return_value={"pruned": [], "reactivated": []})
|
|
975
980
|
mock_registry_cls.return_value = mock_registry
|
|
976
981
|
|
|
977
982
|
from qbraid_cli.agents.app import agents_app
|
|
@@ -980,6 +985,30 @@ class TestAgentsE2E:
|
|
|
980
985
|
result = runner.invoke(agents_app, ["discover"])
|
|
981
986
|
|
|
982
987
|
assert result.exit_code == 0
|
|
988
|
+
mock_registry.refresh_discovered_sessions.assert_called_once_with()
|
|
989
|
+
|
|
990
|
+
@patch("qbraid_core.services.agents.AgentSessionRegistryManager")
|
|
991
|
+
def test_discover_refreshes_children_after_merging(self, mock_registry_cls):
|
|
992
|
+
"""Provider child reconciliation follows process discovery and merge."""
|
|
993
|
+
events = []
|
|
994
|
+
mock_session = MagicMock()
|
|
995
|
+
mock_session.session_id = "abc12345-6789"
|
|
996
|
+
mock_session.tool.value = "codex"
|
|
997
|
+
mock_session.status.value = "idle"
|
|
998
|
+
mock_session.model_name = "test-model"
|
|
999
|
+
mock_session.cwd = "/tmp/project"
|
|
1000
|
+
mock_registry = MagicMock()
|
|
1001
|
+
mock_registry.merge_discovered_sessions.side_effect = lambda rows: events.append("merge") or 1
|
|
1002
|
+
mock_registry.refresh_discovered_sessions.side_effect = lambda: events.append("refresh")
|
|
1003
|
+
mock_registry_cls.return_value = mock_registry
|
|
1004
|
+
|
|
1005
|
+
from qbraid_cli.agents.app import agents_app
|
|
1006
|
+
|
|
1007
|
+
with patch("qbraid_core.services.agents.detect_sessions", return_value=[mock_session]):
|
|
1008
|
+
result = runner.invoke(agents_app, ["discover"])
|
|
1009
|
+
|
|
1010
|
+
assert result.exit_code == 0, result.output
|
|
1011
|
+
assert events == ["merge", "refresh"]
|
|
983
1012
|
|
|
984
1013
|
@patch("qbraid_core.services.agents.AgentSessionRegistryManager")
|
|
985
1014
|
def test_update_session(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
|
|
File without changes
|
|
File without changes
|