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.
Files changed (75) hide show
  1. {qbraid_cli-0.16.13/qbraid_cli.egg-info → qbraid_cli-0.16.14}/PKG-INFO +4 -4
  2. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/pyproject.toml +4 -4
  3. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/agents/app.py +221 -74
  4. qbraid_cli-0.16.14/qbraid_cli/agents/keep_alive.py +357 -0
  5. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14/qbraid_cli.egg-info}/PKG-INFO +4 -4
  6. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli.egg-info/SOURCES.txt +1 -0
  7. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli.egg-info/requires.txt +3 -3
  8. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/tests/test_cli_e2e.py +5 -1
  9. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/LICENSE +0 -0
  10. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/README.md +0 -0
  11. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/__init__.py +0 -0
  12. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/_build_info.py +0 -0
  13. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/_version.py +0 -0
  14. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/account/__init__.py +0 -0
  15. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/account/app.py +0 -0
  16. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/admin/__init__.py +0 -0
  17. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/admin/app.py +0 -0
  18. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/admin/headers.py +0 -0
  19. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/admin/validation.py +0 -0
  20. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/agents/__init__.py +0 -0
  21. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/agents/permission_response.py +0 -0
  22. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/agents/provider_children.py +0 -0
  23. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/agents/queue.py +0 -0
  24. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/agents/work.py +0 -0
  25. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/ai/__init__.py +0 -0
  26. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/ai/app.py +0 -0
  27. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/ai/token.py +0 -0
  28. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/comms/__init__.py +0 -0
  29. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/comms/app.py +0 -0
  30. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/compute/__init__.py +0 -0
  31. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/compute/app.py +0 -0
  32. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/compute/instances.py +0 -0
  33. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/compute/profiles.py +0 -0
  34. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/compute/server.py +0 -0
  35. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/compute/ssh.py +0 -0
  36. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/configure/__init__.py +0 -0
  37. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/configure/actions.py +0 -0
  38. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/configure/app.py +0 -0
  39. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/devices/__init__.py +0 -0
  40. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/devices/app.py +0 -0
  41. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/devices/validation.py +0 -0
  42. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/envs/__init__.py +0 -0
  43. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/envs/activate.py +0 -0
  44. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/envs/app.py +0 -0
  45. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/envs/create.py +0 -0
  46. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/envs/data_handling.py +0 -0
  47. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/envs/groups.py +0 -0
  48. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/envs/publish.py +0 -0
  49. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/envs/registry.py +0 -0
  50. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/envs/share.py +0 -0
  51. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/exceptions.py +0 -0
  52. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/handlers.py +0 -0
  53. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/jobs/__init__.py +0 -0
  54. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/jobs/app.py +0 -0
  55. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/jobs/groups.py +0 -0
  56. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/kernels/__init__.py +0 -0
  57. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/kernels/app.py +0 -0
  58. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/lab/__init__.py +0 -0
  59. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/lab/app.py +0 -0
  60. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/main.py +0 -0
  61. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/pip/__init__.py +0 -0
  62. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/pip/app.py +0 -0
  63. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/pip/hooks.py +0 -0
  64. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/projects/__init__.py +0 -0
  65. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/projects/app.py +0 -0
  66. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/py.typed +0 -0
  67. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/skills/__init__.py +0 -0
  68. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/skills/app.py +0 -0
  69. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/troubleshoot/__init__.py +0 -0
  70. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli/troubleshoot/app.py +0 -0
  71. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli.egg-info/dependency_links.txt +0 -0
  72. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli.egg-info/entry_points.txt +0 -0
  73. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/qbraid_cli.egg-info/top_level.txt +0 -0
  74. {qbraid_cli-0.16.13 → qbraid_cli-0.16.14}/setup.cfg +0 -0
  75. {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.13
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.32
33
+ Requires-Dist: qbraid-core>=0.6.33
34
34
  Provides-Extra: compute
35
- Requires-Dist: qbraid-core[compute]>=0.6.32; extra == "compute"
35
+ Requires-Dist: qbraid-core[compute]>=0.6.33; extra == "compute"
36
36
  Provides-Extra: envs
37
- Requires-Dist: qbraid-core[environments]>=0.6.32; extra == "envs"
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.13"
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.32",
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.32"]
47
- envs = ["qbraid-core[environments]>=0.6.32"]
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
- console.print(f"[green]Sent {sig_name} to process {pid}.[/green]")
461
- except (OSError, ProcessLookupError):
462
- console.print(f"[dim]Process {pid} already stopped.[/dim]")
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
- console.print(
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
- console.print(
488
- "[red]Not stopped: Core could not verify this session's tmux window.[/red] "
489
- f"{escape(str(error))}",
490
- highlight=False,
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
- raise typer.Exit(code=1) from error
493
- console.print(f"[green]Stopped the agent in tmux window '{escape(session.tmux_window)}'.[/green]")
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
- import shlex
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
- console.print(f"[red]No session found matching '{target}'.[/red]")
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
- console.print(
1342
- "[red]Not stopped:[/red] native provider children are read-only. "
1343
- "Manage this child from its parent agent."
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
- console.print(
1353
- "[red]Not stopped:[/red] it belongs to another Lab that this Lab cannot verify, so it is "
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
- highlight=False,
1492
+ as_json,
1357
1493
  )
1358
- raise typer.Exit(code=1)
1359
1494
 
1360
- if session.metadata.get("terminal_backend") == "jupyter":
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
- AgentLauncher().kill_bound(session.session_id)
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
- console.print(f"[red]Not stopped:[/red] {escape(str(error))}", highlight=False)
1517
+ _stop_refused(str(error), as_json)
1372
1518
  else:
1373
- console.print(
1374
- "[red]Could not verify the Lab server that owns this agent; "
1375
- "nothing was stopped.[/red] "
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
- console.print(f"[red]Not stopped:[/red] {escape(str(error))}", highlight=False)
1382
- raise typer.Exit(code=1)
1383
- console.print("[green]Stopped native Lab agent.[/green]")
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
- console.print(
1402
- "[red]Not stopped:[/red] no agent process or terminal is recorded for this session."
1403
- )
1404
- raise typer.Exit(code=1)
1405
- identity = _agent_identity_refusal(session)
1406
- if identity == _GONE:
1407
- console.print(f"[dim]Process {pid} already stopped.[/dim]")
1408
- elif identity:
1409
- console.print(
1410
- f"[red]Not stopped. Did not signal PID {pid}:[/red] {escape(identity)}", highlight=False
1411
- )
1412
- raise typer.Exit(code=1)
1413
- else:
1414
- _signal_agent(pid, force)
1415
-
1416
- # Verified gone or signalled: mark stopped in registry
1417
- registry.deregister_session(session_id=session.session_id)
1418
- console.print(
1419
- f"[dim]Session {session.session_id[:8]} "
1420
- f"({session.terminal_name or session.tmux_window or '-'}) stopped.[/dim]"
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
- handle_error(message=str(e))
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.13
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.32
33
+ Requires-Dist: qbraid-core>=0.6.33
34
34
  Provides-Extra: compute
35
- Requires-Dist: qbraid-core[compute]>=0.6.32; extra == "compute"
35
+ Requires-Dist: qbraid-core[compute]>=0.6.33; extra == "compute"
36
36
  Provides-Extra: envs
37
- Requires-Dist: qbraid-core[environments]>=0.6.32; extra == "envs"
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.32
5
+ qbraid-core>=0.6.33
6
6
 
7
7
  [compute]
8
- qbraid-core[compute]>=0.6.32
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.32
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