agentic-runner 3.28.1__tar.gz → 3.30.0__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 (81) hide show
  1. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/PKG-INFO +2 -2
  2. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/pyproject.toml +1 -1
  3. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/__init__.py +1 -1
  4. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/cli.py +57 -1
  5. agentic_runner-3.30.0/src/agentic_runner/organisation_name.py +30 -0
  6. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/service.py +38 -7
  7. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/workstation.py +74 -10
  8. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/.gitignore +0 -0
  9. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/LICENSE +0 -0
  10. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/README.md +0 -0
  11. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/activities.py +0 -0
  12. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/attempts.py +0 -0
  13. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/auth_mode.py +0 -0
  14. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/build.py +0 -0
  15. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/callback.py +0 -0
  16. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/child_watcher.py +0 -0
  17. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/cli_floor.py +0 -0
  18. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/config.py +0 -0
  19. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/credentials.py +0 -0
  20. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/egress.py +0 -0
  21. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/harness_self_test.py +0 -0
  22. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/heartbeat_link.py +0 -0
  23. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/hooks.py +0 -0
  24. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/host_store.py +0 -0
  25. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/integrations/__init__.py +0 -0
  26. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  27. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  28. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  29. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  30. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  31. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  32. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  33. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  34. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  35. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/integrations/oauth/__init__.py +0 -0
  36. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/integrations/oauth/fake_provider.py +0 -0
  37. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/launch.py +0 -0
  38. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/lifecycle.py +0 -0
  39. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/llm_contracts.py +0 -0
  40. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/llm_proxy.py +0 -0
  41. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/mcp.py +0 -0
  42. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/message_store.py +0 -0
  43. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/oauth_connectors.py +0 -0
  44. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/pool_key.py +0 -0
  45. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/private_state.py +0 -0
  46. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/py.typed +0 -0
  47. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  48. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/registration.py +0 -0
  49. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/runner_limit.py +0 -0
  50. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/runtime/__init__.py +0 -0
  51. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  52. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/sealed_box.py +0 -0
  53. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/sign_in.py +0 -0
  54. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/skill.py +0 -0
  55. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/skills/agentic-runner-setup/SKILL.md +0 -0
  56. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/testing/__init__.py +0 -0
  57. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/testing/__main__.py +0 -0
  58. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/testing/control_plane.py +0 -0
  59. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/testing/plugin.py +0 -0
  60. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/testing/test_conformance.py +0 -0
  61. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/testing/workflow.py +0 -0
  62. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/tiny_http.py +0 -0
  63. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/triage_activities.py +0 -0
  64. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/usage_windows.py +0 -0
  65. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/user_sources.py +0 -0
  66. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/workers/__init__.py +0 -0
  67. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
  68. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/workers/acp_runtime.py +0 -0
  69. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/workers/agent_runtime.py +0 -0
  70. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
  71. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/workers/claude_sign_in.py +0 -0
  72. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
  73. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/workers/command_policy.py +0 -0
  74. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
  75. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
  76. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/workers/harness_logins.py +0 -0
  77. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/workers/harness_outcome.py +0 -0
  78. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  79. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  80. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/workers/settings.py +0 -0
  81. {agentic_runner-3.28.1 → agentic_runner-3.30.0}/src/agentic_runner/workers/skills.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-runner
3
- Version: 3.28.1
3
+ Version: 3.30.0
4
4
  Summary: The Agentic OS Runner: an activity-only Temporal executor for workspaces, Agent Runtimes and verb seams
5
5
  License-Expression: AGPL-3.0-only
6
6
  License-File: LICENSE
7
7
  Requires-Python: >=3.12
8
- Requires-Dist: agentic-runner-contracts==3.28.1
8
+ Requires-Dist: agentic-runner-contracts==3.30.0
9
9
  Requires-Dist: cryptography<51,>=50
10
10
  Requires-Dist: httpx<1.0,>=0.27
11
11
  Requires-Dist: pydantic-settings<3.0,>=2.0
@@ -10,7 +10,7 @@ dependencies = [
10
10
  # Exactly this release's contracts: the two ship as one version, and an unpinned
11
11
  # requirement let `uv tool upgrade` keep an older contracts wheel the Runner cannot
12
12
  # import from (QTS-1473). `scripts/set-version.py` moves it with the version.
13
- "agentic-runner-contracts==3.28.1",
13
+ "agentic-runner-contracts==3.30.0",
14
14
  # Imported directly by `registration.py` (the Ed25519 identity that signs every
15
15
  # heartbeat); already present transitively through pyjwt[crypto], declared because
16
16
  # we import it.
@@ -7,6 +7,6 @@ stay on the platform; this package imports none of them, and a CI partition test
7
7
  (``tests/test_package_partition.py``) fails the build if that changes.
8
8
  """
9
9
 
10
- __version__ = "3.28.1"
10
+ __version__ = "3.30.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -11,7 +11,7 @@ namespace and queue this process was assigned. It is idempotent by state file --
11
11
  restart re-reads what is already there rather than registering a second Runner against
12
12
  the Organisation's cap.
13
13
 
14
- ``install | start | stop | status <name>``, ``credential set`` and ``list`` are the
14
+ ``install | start | stop | restart | status <name>``, ``credential set`` and ``list`` are the
15
15
  workstation Runner (PRD issue 47, ``workstation.py``): one login agent per Runner, managed
16
16
  by the user who hosts it, with no ``sudo`` anywhere. ``<name>`` is the Runner's, the one the
17
17
  console lists it under (QTS-1773); a machine with one Runner needs none (QTS-1774).
@@ -215,6 +215,17 @@ def main(argv: Sequence[str] | None = None) -> int:
215
215
  driven.add_argument("name", nargs="?", help=_INSTALLED_NAME_HELP)
216
216
  driven.add_argument("--root", default=None, type=Path)
217
217
 
218
+ restart = subcommands.add_parser(
219
+ "restart",
220
+ help="restart running workstation Runners on the agentic-runner installed now, as "
221
+ "after `uv tool upgrade`",
222
+ )
223
+ restart.add_argument("name", nargs="?", help=_INSTALLED_NAME_HELP)
224
+ which = restart.add_mutually_exclusive_group()
225
+ which.add_argument("--name", dest="name_flag", default=None, help=_INSTALLED_NAME_HELP)
226
+ which.add_argument("--all", action="store_true", help="every Runner running on this machine")
227
+ restart.add_argument("--root", default=None, type=Path)
228
+
218
229
  listed = subcommands.add_parser(
219
230
  "list", help="the workstation Runners installed on this machine, and which are running"
220
231
  )
@@ -368,6 +379,8 @@ def main(argv: Sequence[str] | None = None) -> int:
368
379
  return asyncio.run(service.run(attestation=_image_attestation(), install=_run_install()))
369
380
  if arguments.command in {"install", "start", "stop", "credential"}:
370
381
  return _workstation(arguments)
382
+ if arguments.command == "restart":
383
+ return _restart(arguments)
371
384
  if arguments.command == "list":
372
385
  for line in workstation.list_lines(arguments.root or workstation.default_root()):
373
386
  print(line)
@@ -462,6 +475,49 @@ def _workstation(arguments: argparse.Namespace) -> int:
462
475
  return 1
463
476
 
464
477
 
478
+ def _restart(arguments: argparse.Namespace) -> int:
479
+ """``restart <name> | --name <name> | --all``: exit 2 names the Runners installed here.
480
+
481
+ A stopped Runner is not started: it was stopped on purpose, and ``systemctl restart``
482
+ would start it where ``launchctl kickstart`` refuses to, so the two OSes would differ.
483
+ """
484
+
485
+ root = arguments.root or workstation.default_root()
486
+ name = arguments.name or arguments.name_flag
487
+ if arguments.all and name:
488
+ print("restart takes a name or --all, not both", file=sys.stderr)
489
+ return 2
490
+ try:
491
+ if arguments.all:
492
+ targets = [
493
+ workstation.RunnerPaths(root=root, name=installed)
494
+ for installed in workstation.installed_names(root)
495
+ ]
496
+ if not targets:
497
+ raise ValueError(f"no workstation Runner is installed under {root}")
498
+ elif name:
499
+ targets = [workstation.installed_runner(root, name)]
500
+ else:
501
+ targets = [workstation.only_runner(root, how="name one, or restart --all")]
502
+ except ValueError as error:
503
+ print(error, file=sys.stderr)
504
+ return 2
505
+ failed = False
506
+ for paths in targets:
507
+ if workstation.process_id(paths) is None:
508
+ print(f"{paths.name} is stopped; `agentic-runner start {paths.name}` starts it")
509
+ failed = failed or not arguments.all
510
+ continue
511
+ try:
512
+ pid, version = workstation.restart(paths)
513
+ except (FileNotFoundError, RuntimeError) as error:
514
+ print(f"{paths.name}: restart failed: {error}", file=sys.stderr)
515
+ failed = True
516
+ continue
517
+ print(f"restarted {paths.name}: pid {pid}, agentic-runner {version}")
518
+ return 1 if failed else 0
519
+
520
+
465
521
  def _image_attestation() -> HostAttestation:
466
522
  """What a container Runner says about itself, where issue 47 gave the workstation its
467
523
  own: the CLIs it found are what the registry serves and routing matches on."""
@@ -0,0 +1,30 @@
1
+ """The Organisation this Runner serves, by the name the last heartbeat ack gave (QTS-1909).
2
+
3
+ ``agentic-runner list`` is a separate process and reads it from this file, which the
4
+ running Runner rewrites whenever an ack changes it. Absent while the control plane names
5
+ no Organisation -- one below contracts 3.29 -- so ``list`` shows the id instead.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Final
12
+
13
+ from agentic_runner.private_state import private_read, private_write
14
+
15
+ __all__ = ["ORGANISATION_NAME_FILENAME", "load_organisation_name", "save_organisation_name"]
16
+
17
+ ORGANISATION_NAME_FILENAME: Final[str] = "organisation-name"
18
+
19
+
20
+ def save_organisation_name(state_dir: Path, name: str | None) -> None:
21
+ path = state_dir / ORGANISATION_NAME_FILENAME
22
+ if name is None:
23
+ path.unlink(missing_ok=True)
24
+ else:
25
+ private_write(path, name.encode("utf-8"))
26
+
27
+
28
+ def load_organisation_name(state_dir: Path) -> str | None:
29
+ raw = private_read(state_dir / ORGANISATION_NAME_FILENAME)
30
+ return None if raw is None else raw.decode("utf-8", errors="replace")
@@ -33,7 +33,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequenc
33
33
  from dataclasses import dataclass, field
34
34
  from datetime import UTC, datetime, timedelta
35
35
  from pathlib import Path
36
- from typing import Any, Final, Protocol
36
+ from typing import Any, Final, Literal, Protocol
37
37
  from uuid import UUID
38
38
 
39
39
  import httpx
@@ -85,6 +85,7 @@ from agentic_runner.llm_proxy import (
85
85
  from agentic_runner.mcp import ToolServerHealthLog
86
86
  from agentic_runner.message_store import MESSAGES_DIR, MessageStore, TemporalWorkflowSignaller
87
87
  from agentic_runner.oauth_connectors import OAuthConnectors
88
+ from agentic_runner.organisation_name import save_organisation_name
88
89
  from agentic_runner.pool_key import (
89
90
  POOL_KEY_ENV,
90
91
  POOL_KEY_FILENAME,
@@ -852,6 +853,9 @@ class ControlPlaneStream:
852
853
  # QTS-1773: the control plane's contracts version, from its ack; None until an ack from
853
854
  # one on 3.27 or later, the first to send it and to parse `RunnerInstall.name`.
854
855
  _control_plane_contracts: str | None = None
856
+ # QTS-1909: the name in the copy `list` reads, None while there is none. "unwritten"
857
+ # until this process first writes it, so the first ack replaces what a previous one left.
858
+ _organisation_name: str | None | Literal["unwritten"] = "unwritten"
855
859
  _last_seen: tuple[float, datetime] | None = None
856
860
 
857
861
  def _observe_sleep(self, now: datetime) -> None:
@@ -1081,6 +1085,7 @@ class ControlPlaneStream:
1081
1085
  self._take_pool(ack.pool)
1082
1086
  if ack.contracts_version is not None:
1083
1087
  self._control_plane_contracts = ack.contracts_version
1088
+ self._take_organisation_name(ack.organisation_name)
1084
1089
  await self._sync_llm_slots()
1085
1090
  if ack.runner_token is not None:
1086
1091
  self.runner_token = ack.runner_token
@@ -1139,6 +1144,15 @@ class ControlPlaneStream:
1139
1144
  if self.state_dir is not None:
1140
1145
  save_llm_contracts(self.state_dir, terms)
1141
1146
 
1147
+ def _take_organisation_name(self, name: str | None) -> None:
1148
+ """Keep the copy ``list`` reads when the ack's name changes. Unlike the Contract
1149
+ terms, an ack without one clears it: only the name, never the id, can go stale."""
1150
+
1151
+ if self.state_dir is None or name == self._organisation_name:
1152
+ return
1153
+ save_organisation_name(self.state_dir, name)
1154
+ self._organisation_name = name
1155
+
1142
1156
  def _take_pool(self, pool: PoolTerms | None) -> None:
1143
1157
  """Keep the ack's pool. An ack without one (a personal Runner, or a control plane
1144
1158
  below 3.26) leaves what is held."""
@@ -1287,14 +1301,17 @@ async def sign_in(which: str | None, *, store: KeyStore, method: str = OAUTH_TOK
1287
1301
  can = can_separate_uids()
1288
1302
  try:
1289
1303
  require_isolation_supported(mode, can_separate_uids=can)
1304
+ # `run` gives the settings this default; a sign-in must build the same ones, or a
1305
+ # workstation, whose environment sets only the control plane, is refused (QTS-2157).
1306
+ _default_callback_url(os.environ.get(CONTROL_PLANE_ENV, "").strip())
1290
1307
  settings = get_worker_settings()
1291
1308
  except RunnerRegistrationError as error:
1292
1309
  print(f"refusing to sign in ({error.reason}): {error}", file=sys.stderr)
1293
1310
  return 1
1294
- except ValidationError:
1311
+ except ValidationError as error:
1295
1312
  print(
1296
- "refusing to sign in: run this with the Runner's environment (`docker exec`, "
1297
- "`kubectl exec`, or on the workstation that runs it)",
1313
+ f"refusing to sign in: {_invalid_settings(error)}; run this with the Runner's "
1314
+ "environment (`docker exec`, `kubectl exec`, or on the workstation that runs it)",
1298
1315
  file=sys.stderr,
1299
1316
  )
1300
1317
  return 2
@@ -1322,6 +1339,22 @@ async def sign_in(which: str | None, *, store: KeyStore, method: str = OAUTH_TOK
1322
1339
  _SIGN_IN_CLIS: Final[Mapping[str, str]] = {"claude_code": "claude", "codex_cli": "codex"}
1323
1340
 
1324
1341
 
1342
+ def _default_callback_url(control_plane: str) -> None:
1343
+ # The control plane the activities call back is the one this process registered
1344
+ # with, unless the operator points them at an internal address explicitly.
1345
+ if control_plane:
1346
+ os.environ.setdefault("INTERNAL_FASTAPI_BASE_URL", control_plane)
1347
+
1348
+
1349
+ def _invalid_settings(error: ValidationError) -> str:
1350
+ """``INTERNAL_FASTAPI_BASE_URL (Field required)``, one per field, never the value."""
1351
+
1352
+ return ", ".join(
1353
+ f"{'.'.join(str(part) for part in detail['loc'])} ({detail['msg']})"
1354
+ for detail in error.errors(include_input=False, include_url=False)
1355
+ )
1356
+
1357
+
1325
1358
  async def run(
1326
1359
  *,
1327
1360
  http_client: httpx.AsyncClient | None = None,
@@ -1402,9 +1435,7 @@ async def _run(
1402
1435
  except (RunnerRegistrationError, UnsafeStateError) as error:
1403
1436
  print(f"refusing to start ({error.reason}): {error}", flush=True)
1404
1437
  return 1
1405
- # The control plane the activities call back is the one this process registered
1406
- # with, unless the operator points them at an internal address explicitly.
1407
- os.environ.setdefault("INTERNAL_FASTAPI_BASE_URL", control_plane)
1438
+ _default_callback_url(control_plane)
1408
1439
  settings = get_worker_settings()
1409
1440
  try:
1410
1441
  attestation = with_profile_acp_cli_kinds(with_acp_bridges(attestation, settings), settings)
@@ -1,7 +1,7 @@
1
1
  """The workstation Runner: one login agent per Runner (PRD issue 47, map ticket 23).
2
2
 
3
- ``agentic-runner install | start | stop | status <name>`` and ``list``. Everything a CI-runner
4
- precedent does on a developer machine and nothing a daemon would need:
3
+ ``agentic-runner install | start | stop | restart | status <name>`` and ``list``. Everything
4
+ a CI-runner precedent does on a developer machine and nothing a daemon would need:
5
5
 
6
6
  * **One process per Runner** (23 item 3), each with its own state directory, identity,
7
7
  namespace, Runner Token, hooks, credential store and Workspaces under ``<root>/<name>/``.
@@ -37,11 +37,12 @@ import shutil
37
37
  import subprocess
38
38
  import sys
39
39
  import tempfile
40
+ import time
40
41
  from collections.abc import Callable, Mapping, Sequence
41
42
  from dataclasses import dataclass
42
43
  from datetime import UTC, datetime
43
44
  from pathlib import Path
44
- from typing import Any, Final
45
+ from typing import Any, Final, Literal
45
46
  from xml.sax.saxutils import escape
46
47
 
47
48
  import httpx
@@ -55,6 +56,7 @@ from agentic_runner.config import CONFIG_FILE_ENV
55
56
  from agentic_runner.host_store import open_workstation_store
56
57
  from agentic_runner.lifecycle import LifecycleOutbox
57
58
  from agentic_runner.llm_contracts import load_llm_contracts
59
+ from agentic_runner.organisation_name import load_organisation_name
58
60
  from agentic_runner.pool_key import parse_pool_key, save_pool_key
59
61
  from agentic_runner.private_state import UnsafeStateError
60
62
  from agentic_runner.registration import load_state
@@ -64,6 +66,7 @@ from agentic_runner_contracts.public_metadata import NAMESPACE_PREFIX
64
66
  from agentic_runner_contracts.runner_registration import (
65
67
  RUNNER_NAME_PATTERN,
66
68
  CliVersion,
69
+ ContractLlmTerms,
67
70
  EgressPosture,
68
71
  HostAttestation,
69
72
  InstallChannel,
@@ -89,11 +92,13 @@ __all__ = [
89
92
  "install",
90
93
  "install_channel",
91
94
  "installed_names",
95
+ "installed_runner",
92
96
  "list_lines",
93
97
  "locate_clis",
94
98
  "only_runner",
95
99
  "parse_acp_cli_kinds",
96
100
  "require_any_cli",
101
+ "restart",
97
102
  "run_environment",
98
103
  "run_process",
99
104
  "service_definition",
@@ -156,6 +161,16 @@ def installed_names(root: Path) -> list[str]:
156
161
  )
157
162
 
158
163
 
164
+ def installed_runner(root: Path, name: str) -> RunnerPaths:
165
+ """``<name>``, refused with the names installed here when it is not one of them."""
166
+
167
+ names = installed_names(root)
168
+ if name in names:
169
+ return RunnerPaths(root=root, name=name)
170
+ installed = ", ".join(names) if names else "none"
171
+ raise ValueError(f"no Runner named {name!r} is installed under {root}; installed: {installed}")
172
+
173
+
159
174
  def only_runner(root: Path, *, how: str = "name one") -> RunnerPaths:
160
175
  """The one Runner installed here, so a command needs no name (QTS-1774)."""
161
176
 
@@ -442,6 +457,7 @@ class ServiceDefinition:
442
457
  install: list[list[str]]
443
458
  start: list[list[str]]
444
459
  stop: list[list[str]]
460
+ restart: list[list[str]]
445
461
 
446
462
 
447
463
  def runner_command(name: str, root: Path) -> list[str]:
@@ -502,6 +518,9 @@ def _launch_agent(
502
518
  install=[],
503
519
  start=[["launchctl", "bootstrap", domain, str(plist)]],
504
520
  stop=[["launchctl", "bootout", f"{domain}/{name}"]],
521
+ # One call, not bootout then bootstrap: launchd refuses a bootstrap of a label it
522
+ # still holds, and it holds it until the old process has drained.
523
+ restart=[["launchctl", "kickstart", "-k", f"{domain}/{name}"]],
505
524
  )
506
525
 
507
526
 
@@ -544,6 +563,7 @@ def _systemd_user_unit(
544
563
  ],
545
564
  start=[[*systemctl, "enable", "--now", unit_name]],
546
565
  stop=[[*systemctl, "disable", "--now", unit_name]],
566
+ restart=[[*systemctl, "restart", unit_name]],
547
567
  )
548
568
 
549
569
 
@@ -597,6 +617,10 @@ def _logon_task(
597
617
  ["schtasks", "/End", "/TN", task_name],
598
618
  ["schtasks", "/Change", "/TN", task_name, "/DISABLE"],
599
619
  ],
620
+ restart=[
621
+ ["schtasks", "/End", "/TN", task_name],
622
+ ["schtasks", "/Run", "/TN", task_name],
623
+ ],
600
624
  )
601
625
 
602
626
 
@@ -670,9 +694,9 @@ _LIST_HEADER: Final = (
670
694
  def list_lines(root: Path) -> list[str]:
671
695
  """``list``: one row per Runner installed here (QTS-1774).
672
696
 
673
- Ids, not names: the Runner is told neither the Organisation's nor the Contract's name.
674
- Eight characters, as the console shows a Runner's id beside its name (Spec: Runners
675
- rev 7).
697
+ The Organisation's and the Contract's names once an ack from a control plane on 3.29
698
+ named them (QTS-1909); before that, the first eight characters of their ids, as the
699
+ console shows a Runner's id beside its name (Spec: Runners rev 7).
676
700
  """
677
701
 
678
702
  names = installed_names(root)
@@ -705,17 +729,21 @@ def _list_row_or_reason(paths: RunnerPaths) -> tuple[str, str, str, str, str, st
705
729
  def _list_row(paths: RunnerPaths) -> tuple[str, str, str, str, str, str]:
706
730
  state = load_state(paths.state_dir)
707
731
  contracts = load_llm_contracts(paths.state_dir)
732
+ organisation_name = _printable(load_organisation_name(paths.state_dir))
708
733
  try:
709
734
  version = paths.started_version.read_text(encoding="utf-8").strip() or "-"
710
735
  except OSError:
711
736
  version = "-"
712
737
  return (
713
738
  paths.name,
714
- state.temporal_namespace.removeprefix(NAMESPACE_PREFIX)[:8] if state else "-",
739
+ (
740
+ organisation_name
741
+ or (state.temporal_namespace.removeprefix(NAMESPACE_PREFIX)[:8] if state else "-")
742
+ ),
715
743
  (
716
744
  "-"
717
745
  if not contracts
718
- else str(contracts[0].contract_id)[:8]
746
+ else _contract_label(contracts[0])
719
747
  if len(contracts) == 1
720
748
  else f"{len(contracts)} Contracts"
721
749
  ),
@@ -725,6 +753,15 @@ def _list_row(paths: RunnerPaths) -> tuple[str, str, str, str, str, str]:
725
753
  )
726
754
 
727
755
 
756
+ def _contract_label(terms: ContractLlmTerms) -> str:
757
+ return _printable(terms.contract_name) or str(terms.contract_id)[:8]
758
+
759
+
760
+ def _printable(name: str | None) -> str:
761
+ # A name someone typed in the console: an escape sequence in it would reach the terminal.
762
+ return "".join(character for character in name or "" if character.isprintable())
763
+
764
+
728
765
  def next_step_lines(host_party: str) -> list[str]:
729
766
  """Where to go once the Runner is live (QTS-1314): the dogfood install ended at
730
767
  ``started`` and left the person with no idea what the console wanted next.
@@ -822,9 +859,36 @@ def stop(paths: RunnerPaths, **driver: Any) -> None:
822
859
  _drive_service(paths, "stop", **driver)
823
860
 
824
861
 
862
+ def restart(
863
+ paths: RunnerPaths,
864
+ *,
865
+ timeout: float = 60.0,
866
+ sleep: Callable[[float], None] = time.sleep,
867
+ clock: Callable[[], float] = time.monotonic,
868
+ **driver: Any,
869
+ ) -> tuple[int, str]:
870
+ """Restart a running Runner and wait for its new process: ``(pid, version)``.
871
+
872
+ ``uv tool upgrade`` replaces the console script, not the process, so a login agent keeps
873
+ running the old version until this (QTS-2158). The process writes its version before
874
+ its pidfile, so a new pid means the version beside it is the new process's.
875
+ """
876
+
877
+ previous = process_id(paths)
878
+ _drive_service(paths, "restart", **driver)
879
+ deadline = clock() + timeout
880
+ while (pid := process_id(paths)) is None or pid == previous:
881
+ if clock() >= deadline:
882
+ raise RuntimeError(
883
+ f"no new process wrote {paths.pidfile} within {timeout:.0f}s; see {paths.log}"
884
+ )
885
+ sleep(0.5)
886
+ return pid, paths.started_version.read_text(encoding="utf-8").strip()
887
+
888
+
825
889
  def _drive_service(
826
890
  paths: RunnerPaths,
827
- verb: str,
891
+ verb: Literal["start", "stop", "restart"],
828
892
  *,
829
893
  platform: str = sys.platform,
830
894
  home: Path | None = None,
@@ -832,7 +896,7 @@ def _drive_service(
832
896
  ) -> None:
833
897
  settings = WorkstationSettings.load(paths)
834
898
  definition = service_definition(paths, settings, platform=platform, home=home)
835
- drive(definition.start if verb == "start" else definition.stop, run=run)
899
+ drive(getattr(definition, verb), run=run)
836
900
 
837
901
 
838
902
  def run_process(paths: RunnerPaths, *, login_agent: bool) -> int:
File without changes