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.
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/PKG-INFO +2 -2
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/pyproject.toml +1 -1
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/__init__.py +1 -1
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/activities.py +65 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/cli.py +135 -0
- agentic_runner-3.31.1/src/agentic_runner/connections.py +315 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/registration.py +61 -2
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/service.py +358 -54
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/sign_in.py +55 -8
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/testing/__main__.py +9 -2
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/testing/control_plane.py +285 -18
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/testing/plugin.py +6 -3
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/testing/test_conformance.py +93 -3
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/triage_activities.py +21 -1
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workstation.py +95 -18
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/.gitignore +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/LICENSE +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/README.md +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/attempts.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/auth_mode.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/build.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/callback.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/child_watcher.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/cli_floor.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/config.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/credentials.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/egress.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/harness_self_test.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/heartbeat_link.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/hooks.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/host_store.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/__init__.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/git/__init__.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/git/contracts.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/git/evidence.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/git/workspace.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/github/__init__.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/github/auth.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/github/fake_client.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/github/gh_client.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/oauth/__init__.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/integrations/oauth/fake_provider.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/launch.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/lifecycle.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/llm_contracts.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/llm_proxy.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/mcp.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/message_store.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/oauth_connectors.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/organisation_name.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/pool_key.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/private_state.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/py.typed +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/recipient_key_secret.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/runner_limit.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/runtime/__init__.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/runtime/verifier_command.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/sealed_box.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/skill.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/skills/agentic-runner-setup/SKILL.md +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/testing/__init__.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/testing/workflow.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/tiny_http.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/usage_windows.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/user_sources.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/__init__.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/_runtime_support.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/acp_runtime.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/agent_runtime.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/claude_runtime.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/claude_sign_in.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/codex_runtime.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/command_policy.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/contract_isolation.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/fastapi_client.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/harness_logins.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/harness_outcome.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/harness_usage.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/mcp_config.py +0 -0
- {agentic_runner-3.30.4 → agentic_runner-3.31.1}/src/agentic_runner/workers/settings.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
@@ -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()
|