agentic-runner 3.30.4__tar.gz → 3.31.1__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 (82) hide show
  1. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/PKG-INFO +2 -2
  2. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/pyproject.toml +1 -1
  3. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/__init__.py +1 -1
  4. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/activities.py +65 -0
  5. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/cli.py +135 -0
  6. agentic_runner-3.31.1/src/agentic_runner/connections.py +315 -0
  7. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/registration.py +61 -2
  8. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/service.py +358 -54
  9. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/sign_in.py +55 -8
  10. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/testing/__main__.py +9 -2
  11. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/testing/control_plane.py +285 -18
  12. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/testing/plugin.py +6 -3
  13. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/testing/test_conformance.py +93 -3
  14. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/triage_activities.py +21 -1
  15. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workstation.py +95 -18
  16. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/.gitignore +0 -0
  17. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/LICENSE +0 -0
  18. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/README.md +0 -0
  19. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/attempts.py +0 -0
  20. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/auth_mode.py +0 -0
  21. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/build.py +0 -0
  22. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/callback.py +0 -0
  23. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/child_watcher.py +0 -0
  24. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/cli_floor.py +0 -0
  25. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/config.py +0 -0
  26. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/credentials.py +0 -0
  27. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/egress.py +0 -0
  28. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/harness_self_test.py +0 -0
  29. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/heartbeat_link.py +0 -0
  30. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/hooks.py +0 -0
  31. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/host_store.py +0 -0
  32. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/__init__.py +0 -0
  33. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/git/__init__.py +0 -0
  34. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/git/contracts.py +0 -0
  35. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/git/evidence.py +0 -0
  36. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  37. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/git/workspace.py +0 -0
  38. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/github/__init__.py +0 -0
  39. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/github/auth.py +0 -0
  40. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  41. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  42. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/oauth/__init__.py +0 -0
  43. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/oauth/fake_provider.py +0 -0
  44. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/launch.py +0 -0
  45. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/lifecycle.py +0 -0
  46. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/llm_contracts.py +0 -0
  47. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/llm_proxy.py +0 -0
  48. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/mcp.py +0 -0
  49. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/message_store.py +0 -0
  50. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/oauth_connectors.py +0 -0
  51. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/organisation_name.py +0 -0
  52. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/pool_key.py +0 -0
  53. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/private_state.py +0 -0
  54. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/py.typed +0 -0
  55. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/recipient_key_secret.py +0 -0
  56. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/runner_limit.py +0 -0
  57. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/runtime/__init__.py +0 -0
  58. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/runtime/verifier_command.py +0 -0
  59. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/sealed_box.py +0 -0
  60. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/skill.py +0 -0
  61. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/skills/agentic-runner-setup/SKILL.md +0 -0
  62. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/testing/__init__.py +0 -0
  63. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/testing/workflow.py +0 -0
  64. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/tiny_http.py +0 -0
  65. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/usage_windows.py +0 -0
  66. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/user_sources.py +0 -0
  67. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/__init__.py +0 -0
  68. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/_runtime_support.py +0 -0
  69. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/acp_runtime.py +0 -0
  70. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/agent_runtime.py +0 -0
  71. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/claude_runtime.py +0 -0
  72. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/claude_sign_in.py +0 -0
  73. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/codex_runtime.py +0 -0
  74. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/command_policy.py +0 -0
  75. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/contract_isolation.py +0 -0
  76. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/fastapi_client.py +0 -0
  77. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/harness_logins.py +0 -0
  78. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/harness_outcome.py +0 -0
  79. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/harness_usage.py +0 -0
  80. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/mcp_config.py +0 -0
  81. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/settings.py +0 -0
  82. {agentic_runner-3.30.4 → agentic_runner-3.31.1}/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.30.4
3
+ Version: 3.31.1
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.30.4
8
+ Requires-Dist: agentic-runner-contracts==3.31.1
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.30.4",
13
+ "agentic-runner-contracts==3.31.1",
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.30.4"
10
+ __version__ = "3.31.1"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -73,6 +73,7 @@ from agentic_runner.callback import (
73
73
  VerbRequest,
74
74
  VerbResponse,
75
75
  )
76
+ from agentic_runner.connections import contract_refusal
76
77
  from agentic_runner.credentials import (
77
78
  CREDENTIAL_RESOLVED_SOURCE,
78
79
  CREDENTIAL_UNRESOLVABLE_SOURCE,
@@ -1737,8 +1738,15 @@ class RunnerRalphActivities:
1737
1738
  runner_limit: AppliedRunnerLimit | None = None,
1738
1739
  llm_contracts: Callable[[], Sequence[ContractLlmTerms] | None] | None = None,
1739
1740
  llm_keys: LlmKeyOpener | None = None,
1741
+ connection_organisation: Callable[[], UUID | None] | None = None,
1740
1742
  ) -> None:
1741
1743
  self._fastapi_client = fastapi_client
1744
+ # The Organisation whose connection this Worker polls (Spec: Runners rev 14). One
1745
+ # process holds every connected Organisation's credentials, harness roots and
1746
+ # Workspaces, so a Directive is served only under a Contract of its own
1747
+ # connection's Organisation (`_assert_connection_serves`). None, or a callable
1748
+ # answering None, on a Runner polling only the namespace it registered in.
1749
+ self._connection_organisation = connection_organisation
1742
1750
  # Opens the pool's LLM Keys with the Pool Key (Spec: Runners rev 3). None, or one
1743
1751
  # holding no Pool Key, fails every `llm_key` Directive closed.
1744
1752
  self._llm_keys = llm_keys
@@ -1891,6 +1899,59 @@ class RunnerRalphActivities:
1891
1899
  return self._routing_identity
1892
1900
  return replace(self._routing_identity, tags=acked)
1893
1901
 
1902
+ async def _assert_connection_serves(
1903
+ self, work_record_id: str, contract_id: str | None, *, workspace_path: str = ""
1904
+ ) -> None:
1905
+ """Fail closed unless the Contract belongs to this Worker's connection's
1906
+ Organisation (Spec: Runners rev 14, QTS-2252).
1907
+
1908
+ Called once the Contract is known and before any Workspace, credential or
1909
+ harness root is touched: a Directive delivered in one Organisation's namespace
1910
+ that names another's Contract -- or another Contract's Workspace -- would
1911
+ otherwise run under credentials and a sign-in that Organisation never gave it.
1912
+ Not retryable: the same task on the same connection is refused again.
1913
+ """
1914
+
1915
+ organisation = self._connection_organisation() if self._connection_organisation else None
1916
+ if organisation is None:
1917
+ return
1918
+ terms = (self._llm_contracts() if self._llm_contracts is not None else None) or ()
1919
+ reason = contract_refusal(organisation, terms, _optional_uuid(contract_id))
1920
+ if (
1921
+ reason is None
1922
+ and workspace_path
1923
+ and not self._within_contract_workspaces(workspace_path, contract_id)
1924
+ ):
1925
+ reason = "workspace_of_another_contract"
1926
+ if reason is None:
1927
+ return
1928
+ refusal = ApplicationError(
1929
+ f"refused: {reason} on the connection to Organisation {organisation}",
1930
+ type=reason,
1931
+ non_retryable=True,
1932
+ )
1933
+ if not work_record_id:
1934
+ raise refusal
1935
+ await _raise_recorded(
1936
+ refusal,
1937
+ self._fastapi_client,
1938
+ work_record_id,
1939
+ source=_ROUTING_EVIDENCE_SOURCE,
1940
+ actor=self._actor(),
1941
+ payload={
1942
+ "event": "directive.routing_refused",
1943
+ "reason": reason,
1944
+ "organisation_id": str(organisation),
1945
+ "contract_id": contract_id,
1946
+ },
1947
+ )
1948
+
1949
+ def _within_contract_workspaces(self, workspace_path: str, contract_id: str | None) -> bool:
1950
+ if self._workspace_root is None:
1951
+ return False
1952
+ contract_root = self._workspace_root.resolve() / contract_path_segment(contract_id)
1953
+ return Path(workspace_path).resolve().is_relative_to(contract_root)
1954
+
1894
1955
  def _actor(self) -> str:
1895
1956
  return (
1896
1957
  f"runner:{self._routing_identity.runner_id}"
@@ -3835,6 +3896,9 @@ class RunnerRalphActivities:
3835
3896
  runtime_context = await self._runtime_context_resolver.resolve(
3836
3897
  work_record_id, agent_id=agent_id or None
3837
3898
  )
3899
+ await self._assert_connection_serves(
3900
+ work_record_id, runtime_context.contract_id, workspace_path=workspace_path
3901
+ )
3838
3902
  resolved_workspace = Path(workspace_path) if workspace_path else None
3839
3903
  if resolved_workspace is None and self._workspace_root is not None:
3840
3904
  resolved_workspace = _contract_workspace_path(
@@ -5990,6 +6054,7 @@ class RunnerRalphActivities:
5990
6054
  """
5991
6055
 
5992
6056
  await self._assert_routed(request)
6057
+ await self._assert_connection_serves("", request.contract_id or None)
5993
6058
  isolation = self._contract_isolation
5994
6059
  segment = contract_path_segment(request.contract_id or None)
5995
6060
  held = (
@@ -21,6 +21,11 @@ through the vendor's own sign-in in this terminal, or an API key stored in the R
21
21
  own store (``sign_in.py``, Spec: Runners rev 3). No ids, and no code or token passes
22
22
  through the control plane.
23
23
 
24
+ ``login [name]`` makes this Runner the person's with a device flow -- a code shown here,
25
+ confirmed in the browser, no token copied -- and ``connect [organisation]`` asks for a
26
+ connection to an Organisation they belong to (Spec: Runners rev 14). One Runner then takes
27
+ the work of every Organisation it is connected to, under one concurrency limit.
28
+
24
29
  ``skill install | print`` hands the setup skill to the person's own Claude Code or Codex
25
30
  (``skill.py``, console-v2 issue 21).
26
31
 
@@ -42,6 +47,7 @@ import os
42
47
  import re
43
48
  import socket
44
49
  import sys
50
+ import webbrowser
45
51
  from collections.abc import Sequence
46
52
  from pathlib import Path
47
53
 
@@ -108,6 +114,8 @@ _NAME_HELP = (
108
114
  "machine takes it"
109
115
  )
110
116
  _INSTALLED_NAME_HELP = "the Runner's name; omitted when this machine runs one Runner"
117
+ # `login` on a workstation with no name: the console then lists it under this one.
118
+ DEFAULT_LOGIN_NAME = "runner"
111
119
 
112
120
 
113
121
  def _old_org_flag(parser: argparse.ArgumentParser) -> None:
@@ -208,6 +216,34 @@ def main(argv: Sequence[str] | None = None) -> int:
208
216
  )
209
217
  install.add_argument("--no-start", action="store_true", help="write the agent, do not start")
210
218
  install.add_argument("--root", default=None, type=Path, help="state root (default per OS)")
219
+ login = subcommands.add_parser(
220
+ "login",
221
+ help="make this Runner yours: confirm a code in the browser, no token to copy",
222
+ )
223
+ login.add_argument(
224
+ "name",
225
+ nargs="?",
226
+ default=None,
227
+ help=f"{_NAME_HELP}; default `{DEFAULT_LOGIN_NAME}` on a workstation, `RUNNER_NAME` "
228
+ "in a container",
229
+ )
230
+ login.add_argument(
231
+ "--control-plane", default=os.environ.get(CONTROL_PLANE_ENV), help="control plane URL"
232
+ )
233
+ login.add_argument("--temporal-plaintext", action="store_true", help=argparse.SUPPRESS)
234
+ login.add_argument("--no-start", action="store_true", help="write the agent, do not start")
235
+ login.add_argument("--no-browser", action="store_true", help="show the link, do not open it")
236
+ login.add_argument("--root", default=None, type=Path, help="state root (default per OS)")
237
+
238
+ connect = subcommands.add_parser(
239
+ "connect", help="connect your Runner to an Organisation you belong to"
240
+ )
241
+ connect.add_argument(
242
+ "organisation", nargs="?", help="the Organisation's name or id; omitted lists yours"
243
+ )
244
+ connect.add_argument("--name", default=None, help=_INSTALLED_NAME_HELP)
245
+ connect.add_argument("--root", default=None, type=Path, help=argparse.SUPPRESS)
246
+
211
247
  for service_verb in ("start", "stop"):
212
248
  driven = subcommands.add_parser(
213
249
  service_verb, help=f"{service_verb} one workstation Runner's login agent"
@@ -380,6 +416,10 @@ def main(argv: Sequence[str] | None = None) -> int:
380
416
  )
381
417
  return 2
382
418
  return asyncio.run(service.run(attestation=_image_attestation(), install=_run_install()))
419
+ if arguments.command == "login":
420
+ return _login(arguments)
421
+ if arguments.command == "connect":
422
+ return _connect(arguments)
383
423
  if arguments.command in {"install", "start", "stop", "credential"}:
384
424
  return _workstation(arguments)
385
425
  if arguments.command == "restart":
@@ -597,6 +637,101 @@ def _sign_in(arguments: argparse.Namespace) -> int:
597
637
  return asyncio.run(service.sign_in(arguments.runtime, store=store, method=arguments.method))
598
638
 
599
639
 
640
+ def _login(arguments: argparse.Namespace) -> int:
641
+ """``login [name]``: a workstation Runner gets its login agent, as ``install`` gives one;
642
+ in a container the identity goes to the state directory ``run`` reads."""
643
+
644
+ if not arguments.control_plane:
645
+ print(f"--control-plane (or {CONTROL_PLANE_ENV}) is required", file=sys.stderr)
646
+ return 2
647
+
648
+ def say(line: str) -> None:
649
+ print(line, flush=True)
650
+
651
+ def open_browser(url: str) -> object:
652
+ return None if arguments.no_browser or _in_container() else webbrowser.open(url)
653
+
654
+ try:
655
+ if _in_container():
656
+ name = arguments.name or _container_name()
657
+ _, outcome = asyncio.run(
658
+ service.login(
659
+ state_dir=_state_dir(),
660
+ control_plane=arguments.control_plane,
661
+ name=workstation.validate_name(name) if name else None,
662
+ say=say,
663
+ open_browser=open_browser,
664
+ )
665
+ )
666
+ print(outcome)
667
+ return 0
668
+ paths = workstation.RunnerPaths(
669
+ root=arguments.root or workstation.default_root(),
670
+ name=workstation.validate_name(arguments.name or DEFAULT_LOGIN_NAME),
671
+ )
672
+ settings = workstation.WorkstationSettings(
673
+ control_plane_url=arguments.control_plane,
674
+ temporal_tls=not arguments.temporal_plaintext,
675
+ path=os.environ.get("PATH", ""),
676
+ install_channel=workstation.install_channel(),
677
+ acp_cli_kinds=_acp_cli_kinds(None, paths),
678
+ )
679
+ lines = asyncio.run(
680
+ workstation.login(
681
+ paths,
682
+ settings,
683
+ say=say,
684
+ open_browser=open_browser,
685
+ start_service=not arguments.no_start,
686
+ )
687
+ )
688
+ except (
689
+ workstation.CliMissingError,
690
+ RunnerRegistrationError,
691
+ RuntimeError,
692
+ ValueError,
693
+ ) as error:
694
+ print(f"login refused: {error}", file=sys.stderr)
695
+ return 1
696
+ for line in lines:
697
+ print(line)
698
+ return 0
699
+
700
+
701
+ def _connect(arguments: argparse.Namespace) -> int:
702
+ if arguments.name or not _in_container():
703
+ try:
704
+ paths = _runner_paths(arguments, how="name one with --name")
705
+ except ValueError as error:
706
+ print(error, file=sys.stderr)
707
+ return 2
708
+ state_dir = paths.state_dir
709
+ control_plane = workstation.WorkstationSettings.load(paths).control_plane_url
710
+ else:
711
+ state_dir = _state_dir()
712
+ control_plane = os.environ.get(CONTROL_PLANE_ENV, "").strip()
713
+ try:
714
+ return asyncio.run(
715
+ service.connect(
716
+ state_dir=state_dir,
717
+ control_plane=control_plane,
718
+ organisation=arguments.organisation,
719
+ ask=input,
720
+ say=lambda line: print(line, file=sys.stderr),
721
+ )
722
+ )
723
+ except RunnerRegistrationError as error:
724
+ print(f"connect refused ({error.reason}): {error}", file=sys.stderr)
725
+ return 1
726
+
727
+
728
+ def _container_name() -> str | None:
729
+ """The name a container Runner reports: ``RUNNER_NAME``, else none for the control plane
730
+ to choose, since a container id is no name a person picked."""
731
+
732
+ return os.environ.get("RUNNER_NAME", "").strip() or None
733
+
734
+
600
735
  def _install(arguments: argparse.Namespace, paths: workstation.RunnerPaths) -> int:
601
736
  if not arguments.control_plane:
602
737
  print(f"--control-plane (or {CONTROL_PLANE_ENV}) is required", file=sys.stderr)
@@ -0,0 +1,315 @@
1
+ """One machine, many connections (Spec: Runners rev 14).
2
+
3
+ A person's Runner takes work from every Organisation they connected it to: one Temporal
4
+ Worker per connection, each polling ``runner.{runner_id}`` in its own namespace with its own
5
+ Runner Token, all reconciled from the heartbeat ack. The machine has one concurrency limit,
6
+ from its own config, shared by all of them: the first work in takes a free slot.
7
+
8
+ **The shared limit.** Each Worker gets a :class:`ConnectionSlots`, a custom slot supplier
9
+ over one :class:`MachineSlots`. Temporal reserves a slot *before* it polls, and an idle
10
+ poll holds its reservation for up to a minute; counting reservations against the machine
11
+ would let one idle connection's poll starve every other. So a reservation is granted while
12
+ the slots in use on the machine, plus this connection's own unused reservations, are below
13
+ the limit. One connection alone therefore never exceeds the limit, exactly as
14
+ ``max_concurrent_activities`` did; across connections, polls already outstanding can each
15
+ bring an activity in the same instant, so N connections can briefly run limit + N - 1. The
16
+ spec puts the machine-wide limit in routing ("Routing counts the Runner's load across all
17
+ its connections ... so least-loaded and the limit see the whole machine"): the control
18
+ plane does not send a Directive to a full machine. This is the backstop.
19
+
20
+ **Pause.** A paused connection makes no new reservation, so its Worker stops polling once
21
+ the polls already outstanding return, while what it already runs finishes. The control
22
+ plane routes nothing to a paused connection, so those polls come back empty.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import contextlib
29
+ import logging
30
+ import threading
31
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
32
+ from dataclasses import dataclass, field
33
+ from typing import Any, Protocol
34
+ from uuid import UUID
35
+
36
+ from temporalio.worker import (
37
+ CustomSlotSupplier,
38
+ FixedSizeSlotSupplier,
39
+ SlotMarkUsedContext,
40
+ SlotPermit,
41
+ SlotReleaseContext,
42
+ SlotReserveContext,
43
+ WorkerTuner,
44
+ )
45
+
46
+ from agentic_runner_contracts.runner_registration import ContractLlmTerms, RunnerConnection
47
+
48
+ __all__ = [
49
+ "ConnectionSlots",
50
+ "ConnectionWorkers",
51
+ "MachineSlots",
52
+ "contract_refusal",
53
+ ]
54
+
55
+ _logger = logging.getLogger(__name__)
56
+
57
+
58
+ def contract_refusal(
59
+ organisation: UUID, terms: Sequence[ContractLlmTerms], contract_id: UUID | None
60
+ ) -> str | None:
61
+ """Why a connection to ``organisation`` must not serve ``contract_id``, or None.
62
+
63
+ One process holds every connected Organisation's credentials, harness roots and
64
+ Workspaces, so a task delivered in one Organisation's namespace is served only under a
65
+ Contract the ack places in that Organisation. Only the ack can say whose a Contract is:
66
+ one it does not name, or names without an Organisation, is refused too.
67
+ """
68
+
69
+ served = next((term for term in terms if term.contract_id == contract_id), None)
70
+ if served is None or served.organisation_id is None:
71
+ return "contract_not_served"
72
+ if served.organisation_id != organisation:
73
+ return "contract_of_another_organisation"
74
+ return None
75
+
76
+
77
+ class MachineSlots:
78
+ """The machine's one concurrency limit, counted across every connection's Worker.
79
+
80
+ Mutated from Temporal's callbacks, which need not run on the event loop's thread, so
81
+ the counts sit behind a lock and a waiting reservation is woken thread-safely.
82
+ """
83
+
84
+ def __init__(self, limit: int) -> None:
85
+ self.limit = limit
86
+ self._used = 0
87
+ self._lock = threading.Lock()
88
+ self._waiters: set[tuple[asyncio.AbstractEventLoop, asyncio.Event]] = set()
89
+
90
+ @property
91
+ def used(self) -> int:
92
+ return self._used
93
+
94
+ def connection(self) -> ConnectionSlots:
95
+ return ConnectionSlots(self)
96
+
97
+ def tuner(self, slots: ConnectionSlots) -> WorkerTuner:
98
+ """A Worker's tuner: its activity slots from ``slots``. The Runner hosts no
99
+ workflow, so the other kinds get the smallest fixed supply."""
100
+
101
+ return WorkerTuner.create_composite(
102
+ workflow_supplier=FixedSizeSlotSupplier(1),
103
+ activity_supplier=slots,
104
+ local_activity_supplier=FixedSizeSlotSupplier(1),
105
+ nexus_supplier=FixedSizeSlotSupplier(1),
106
+ )
107
+
108
+ def _try_reserve(self, slots: ConnectionSlots) -> bool:
109
+ with self._lock:
110
+ if slots.paused or self._used + slots._reserved >= self.limit:
111
+ return False
112
+ slots._reserved += 1
113
+ return True
114
+
115
+ def _mark_used(self, slots: ConnectionSlots) -> None:
116
+ with self._lock:
117
+ slots._reserved -= 1
118
+ self._used += 1
119
+
120
+ def _release(self, slots: ConnectionSlots, *, used: bool) -> None:
121
+ with self._lock:
122
+ if used:
123
+ self._used -= 1
124
+ else:
125
+ slots._reserved -= 1
126
+ self._wake()
127
+
128
+ def _wake(self) -> None:
129
+ with self._lock:
130
+ waiters = list(self._waiters)
131
+ for loop, event in waiters:
132
+ with contextlib.suppress(RuntimeError): # a loop already closed waits for nothing
133
+ loop.call_soon_threadsafe(event.set)
134
+
135
+ async def _reserve(self, slots: ConnectionSlots) -> None:
136
+ waiter = (asyncio.get_running_loop(), asyncio.Event())
137
+ with self._lock:
138
+ self._waiters.add(waiter)
139
+ try:
140
+ while not self._try_reserve(slots):
141
+ await waiter[1].wait()
142
+ waiter[1].clear()
143
+ finally:
144
+ with self._lock:
145
+ self._waiters.discard(waiter)
146
+
147
+
148
+ class _Permit(SlotPermit):
149
+ def __init__(self) -> None:
150
+ self.used = False
151
+
152
+
153
+ class ConnectionSlots(CustomSlotSupplier):
154
+ """One connection's Worker's activity slots, drawn from the machine's."""
155
+
156
+ def __init__(self, machine: MachineSlots) -> None:
157
+ self._machine = machine
158
+ self._reserved = 0
159
+ self._paused = False
160
+
161
+ @property
162
+ def paused(self) -> bool:
163
+ return self._paused
164
+
165
+ @paused.setter
166
+ def paused(self, value: bool) -> None:
167
+ self._paused = value
168
+ self._machine._wake()
169
+
170
+ async def reserve_slot(self, ctx: SlotReserveContext) -> SlotPermit:
171
+ await self._machine._reserve(self)
172
+ return _Permit()
173
+
174
+ def try_reserve_slot(self, ctx: SlotReserveContext) -> SlotPermit | None:
175
+ return _Permit() if self._machine._try_reserve(self) else None
176
+
177
+ def mark_slot_used(self, ctx: SlotMarkUsedContext) -> None:
178
+ permit = ctx.permit
179
+ if isinstance(permit, _Permit) and not permit.used:
180
+ permit.used = True
181
+ self._machine._mark_used(self)
182
+
183
+ def release_slot(self, ctx: SlotReleaseContext) -> None:
184
+ permit = ctx.permit
185
+ self._machine._release(self, used=isinstance(permit, _Permit) and permit.used)
186
+
187
+
188
+ class GracefulWorker(Protocol):
189
+ async def run(self) -> None: ...
190
+ async def shutdown(self) -> None: ...
191
+
192
+
193
+ class TemporalClient(Protocol):
194
+ api_key: str | None
195
+
196
+
197
+ # Opens a Temporal client for one namespace with one Runner Token.
198
+ ConnectClient = Callable[[str, str], Awaitable[TemporalClient]]
199
+ # Builds one connection's Worker from its client, the connection and its slot supplier.
200
+ BuildWorker = Callable[[TemporalClient, RunnerConnection, ConnectionSlots], GracefulWorker]
201
+
202
+
203
+ @dataclass
204
+ class _Running:
205
+ connection: RunnerConnection
206
+ client: TemporalClient
207
+ slots: ConnectionSlots
208
+ worker: GracefulWorker
209
+ task: asyncio.Task[None]
210
+
211
+
212
+ @dataclass
213
+ class ConnectionWorkers:
214
+ """The Workers this Runner runs, one per connection the last ack named.
215
+
216
+ :meth:`reconcile` starts a Worker for a new connection, hands a renewed Runner Token to
217
+ a running one's client, pauses or resumes it, and drains one whose connection is gone:
218
+ it stops polling and lets what it runs finish, as a SIGTERM drains the process.
219
+ """
220
+
221
+ machine: MachineSlots
222
+ connect_client: ConnectClient
223
+ build_worker: BuildWorker
224
+ _running: dict[UUID, _Running] = field(default_factory=dict)
225
+ _draining: set[asyncio.Task[None]] = field(default_factory=set)
226
+ failed: asyncio.Event = field(default_factory=asyncio.Event)
227
+ error: BaseException | None = None
228
+ _closed: bool = False
229
+
230
+ @property
231
+ def polled(self) -> Mapping[UUID, RunnerConnection]:
232
+ return {key: running.connection for key, running in self._running.items()}
233
+
234
+ async def reconcile(self, connections: Sequence[RunnerConnection]) -> None:
235
+ """Follow one ack. A connection that cannot open is logged and retried on the next
236
+ beat; it never holds back the others, whose tokens must keep being renewed."""
237
+
238
+ if self._closed:
239
+ # Beats go on through a drain; their acks start nothing new.
240
+ return
241
+ wanted = {connection.organisation_id: connection for connection in connections}
242
+ for key in set(self._running) - set(wanted):
243
+ self._drain(key)
244
+ for key, connection in wanted.items():
245
+ if (running := self._running.get(key)) is not None:
246
+ self._update(running, connection)
247
+ for key, connection in wanted.items():
248
+ # A connection's first ack always carries its token; one that did not is retried
249
+ # on the next beat rather than dialled without one.
250
+ if key not in self._running and connection.runner_token is not None:
251
+ try:
252
+ await self._start(connection)
253
+ except Exception as error: # noqa: BLE001 - retried on the next beat
254
+ _logger.warning("cannot poll %s yet: %s", connection.temporal_namespace, error)
255
+
256
+ def _update(self, running: _Running, connection: RunnerConnection) -> None:
257
+ if connection.runner_token is not None and (
258
+ connection.runner_token != running.client.api_key
259
+ ):
260
+ running.client.api_key = connection.runner_token
261
+ if connection.paused != running.slots.paused:
262
+ running.slots.paused = connection.paused
263
+ running.connection = connection
264
+
265
+ async def shutdown(self) -> None:
266
+ """Drain every Worker; returns once each has finished what it was running."""
267
+
268
+ self._closed = True
269
+ for key in list(self._running):
270
+ self._drain(key)
271
+ if self._draining:
272
+ await asyncio.gather(*self._draining)
273
+
274
+ async def _start(self, connection: RunnerConnection) -> None:
275
+ assert connection.runner_token is not None
276
+ client = await self.connect_client(connection.temporal_namespace, connection.runner_token)
277
+ if self._closed:
278
+ # Shut down while the client opened: a Worker started now would never drain.
279
+ return
280
+ slots = self.machine.connection()
281
+ slots.paused = connection.paused
282
+ worker = self.build_worker(client, connection, slots)
283
+ task = asyncio.create_task(worker.run())
284
+ task.add_done_callback(self._watch)
285
+ self._running[connection.organisation_id] = _Running(
286
+ connection=connection, client=client, slots=slots, worker=worker, task=task
287
+ )
288
+ _logger.info("polling %s in %s", connection.task_queue, connection.temporal_namespace)
289
+
290
+ def _drain(self, key: UUID) -> None:
291
+ running = self._running.pop(key)
292
+ _logger.info("draining %s: its connection is gone", running.connection.temporal_namespace)
293
+
294
+ async def drain() -> None:
295
+ await running.worker.shutdown()
296
+ try:
297
+ await running.task
298
+ except Exception:
299
+ # Gone already, so nothing waits on it; the drain is still the process's
300
+ # own, and the reason must reach its log.
301
+ _logger.exception(
302
+ "the Worker for %s failed while draining", running.connection.temporal_namespace
303
+ )
304
+
305
+ task = asyncio.create_task(drain())
306
+ self._draining.add(task)
307
+ task.add_done_callback(self._draining.discard)
308
+
309
+ def _watch(self, task: asyncio.Task[Any]) -> None:
310
+ # A Worker that stops on its own -- not drained -- ends the process, as the one
311
+ # Worker's `run()` returning or raising did before connections.
312
+ if task.cancelled() or all(r.task is not task for r in self._running.values()):
313
+ return
314
+ self.error = task.exception()
315
+ self.failed.set()