agentic-runner 2.7.0__tar.gz → 2.8.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 (62) hide show
  1. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/PKG-INFO +4 -1
  2. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/pyproject.toml +11 -0
  3. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/__init__.py +1 -1
  4. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/registration.py +4 -0
  5. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/service.py +35 -4
  6. agentic_runner-2.8.0/src/agentic_runner/testing/__init__.py +18 -0
  7. agentic_runner-2.8.0/src/agentic_runner/testing/__main__.py +32 -0
  8. agentic_runner-2.8.0/src/agentic_runner/testing/control_plane.py +441 -0
  9. agentic_runner-2.8.0/src/agentic_runner/testing/plugin.py +178 -0
  10. agentic_runner-2.8.0/src/agentic_runner/testing/test_conformance.py +163 -0
  11. agentic_runner-2.8.0/src/agentic_runner/testing/workflow.py +37 -0
  12. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/.gitignore +0 -0
  13. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/LICENSE +0 -0
  14. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/README.md +0 -0
  15. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/activities.py +0 -0
  16. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/attempts.py +0 -0
  17. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/callback.py +0 -0
  18. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/child_watcher.py +0 -0
  19. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/cli.py +0 -0
  20. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/config.py +0 -0
  21. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/credentials.py +0 -0
  22. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/device_login_activities.py +0 -0
  23. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/egress.py +0 -0
  24. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/heartbeat_link.py +0 -0
  25. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/hooks.py +0 -0
  26. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/host_store.py +0 -0
  27. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/__init__.py +0 -0
  28. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  29. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  30. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  31. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  32. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  33. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  34. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  35. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  36. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  37. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/lifecycle.py +0 -0
  38. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/llm_proxy.py +0 -0
  39. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/mcp.py +0 -0
  40. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/message_store.py +0 -0
  41. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/py.typed +0 -0
  42. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  43. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/runtime/__init__.py +0 -0
  44. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  45. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/sealed_box.py +0 -0
  46. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/tiny_http.py +0 -0
  47. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/triage_activities.py +0 -0
  48. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/user_sources.py +0 -0
  49. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/__init__.py +0 -0
  50. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
  51. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/agent_runtime.py +0 -0
  52. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
  53. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
  54. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/command_policy.py +0 -0
  55. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
  56. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
  57. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
  58. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  59. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  60. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/settings.py +0 -0
  61. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/skills.py +0 -0
  62. {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workstation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-runner
3
- Version: 2.7.0
3
+ Version: 2.8.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
@@ -12,6 +12,9 @@ Requires-Dist: pydantic-settings<3.0,>=2.0
12
12
  Requires-Dist: pydantic<3.0,>=2.0
13
13
  Requires-Dist: pyjwt[crypto]>=2.13.0
14
14
  Requires-Dist: temporalio<2.0,>=1.10
15
+ Provides-Extra: testing
16
+ Requires-Dist: pytest-asyncio<1.0,>=0.24; extra == 'testing'
17
+ Requires-Dist: pytest<9.0,>=8.0; extra == 'testing'
15
18
  Description-Content-Type: text/markdown
16
19
 
17
20
  # agentic-runner
@@ -19,9 +19,20 @@ dependencies = [
19
19
  "temporalio>=1.10,<2.0",
20
20
  ]
21
21
 
22
+ [project.optional-dependencies]
23
+ # The conformance kit (`agentic_runner.testing`, docs/conformance.md): the fake control
24
+ # plane needs nothing beyond the dependencies above; the scenario suite needs these.
25
+ testing = [
26
+ "pytest>=8.0,<9.0",
27
+ "pytest-asyncio>=0.24,<1.0",
28
+ ]
29
+
22
30
  [project.scripts]
23
31
  agentic-runner = "agentic_runner.cli:main"
24
32
 
33
+ [project.entry-points.pytest11]
34
+ agentic_runner = "agentic_runner.testing.plugin"
35
+
25
36
  [build-system]
26
37
  requires = ["hatchling>=1.27,<2.0"]
27
38
  build-backend = "hatchling.build"
@@ -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__ = "2.7.0"
10
+ __version__ = "2.8.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -64,6 +64,10 @@ DIRECTIVE_TOKEN_PATH = "/api/runner/v1/runners/directive-token"
64
64
  INTAKE_WORK_RECORDS_PATH = "/api/runner/v1/runners/intake/work-records"
65
65
  INTAKE_IGNORED_PATH = "/api/runner/v1/runners/intake/ignored"
66
66
 
67
+ # The refusal a revoked Runner's heartbeat earns (06 §2: revocation *is* the refused
68
+ # heartbeat). The one reason this process stops on rather than retries.
69
+ RUNNER_REVOKED_REASON = "runner_revoked"
70
+
67
71
  # What the state directory holds after a successful bootstrap. One file, mode 0600: the
68
72
  # private key is in it, and a Runner that loses it re-bootstraps rather than recovering.
69
73
  STATE_FILENAME = "runner-identity.json"
@@ -64,6 +64,7 @@ from agentic_runner.llm_proxy import CeilingStore, LlmProxy, SlotStore, UsageOut
64
64
  from agentic_runner.mcp import ToolServerHealthLog
65
65
  from agentic_runner.message_store import MESSAGES_DIR, MessageStore, TemporalWorkflowSignaller
66
66
  from agentic_runner.registration import (
67
+ RUNNER_REVOKED_REASON,
67
68
  RunnerRegistrationClient,
68
69
  RunnerRegistrationError,
69
70
  RunnerState,
@@ -890,6 +891,8 @@ async def _serve_registered(
890
891
  await link.exchange()
891
892
  break
892
893
  except Exception as error: # noqa: BLE001 - keep trying until the control plane answers
894
+ if _revoked(error):
895
+ return 1
893
896
  _logger.warning("heartbeat failed before start: %s", error)
894
897
  if stop is not None and stop.is_set():
895
898
  return 1
@@ -931,7 +934,11 @@ async def _serve_registered(
931
934
  graceful_shutdown_timeout=graceful_shutdown_timeout(settings),
932
935
  max_concurrent_activities=state.max_concurrent_directives,
933
936
  )
934
- beat = asyncio.create_task(_heartbeat_forever(link, stream, client, interval))
937
+ stop = stop or asyncio.Event()
938
+ revoked = asyncio.Event()
939
+ beat = asyncio.create_task(
940
+ _heartbeat_forever(link, stream, client, interval, stop=stop, revoked=revoked)
941
+ )
935
942
  poll = asyncio.create_task(_poll_sources_forever(stream.sources, _source_poll_seconds()))
936
943
  readiness.ready = True
937
944
  await run_runner_lifecycle_hook(hooks, HookName.RUNNER_STARTUP)
@@ -945,7 +952,20 @@ async def _serve_registered(
945
952
  await task
946
953
  await run_runner_lifecycle_hook(hooks, HookName.RUNNER_SHUTDOWN)
947
954
  await _report_stop(link, lifecycle)
948
- return 0
955
+ return 1 if revoked.is_set() else 0
956
+
957
+
958
+ def _revoked(error: Exception) -> bool:
959
+ """Whether a failed heartbeat was the control plane revoking this Runner.
960
+
961
+ Every other failure is a lost beat and the next tick retries it; a revocation is
962
+ final, so retrying would only dial a control plane that has already said no.
963
+ """
964
+
965
+ if isinstance(error, RunnerRegistrationError) and error.reason == RUNNER_REVOKED_REASON:
966
+ print(f"revoked by the control plane: {error}", flush=True)
967
+ return True
968
+ return False
949
969
 
950
970
 
951
971
  async def _report_stop(link: HeartbeatLink, lifecycle: LifecycleOutbox) -> None:
@@ -959,13 +979,20 @@ async def _report_stop(link: HeartbeatLink, lifecycle: LifecycleOutbox) -> None:
959
979
 
960
980
 
961
981
  async def _heartbeat_forever(
962
- link: HeartbeatLink, stream: ControlPlaneStream, client: Client, interval: float
982
+ link: HeartbeatLink,
983
+ stream: ControlPlaneStream,
984
+ client: Client,
985
+ interval: float,
986
+ *,
987
+ stop: asyncio.Event,
988
+ revoked: asyncio.Event,
963
989
  ) -> None:
964
990
  """Every 30 s, for as long as the process lives; a missed beat is logged, not fatal.
965
991
 
966
992
  Staleness is the link's own property (issue 44): after three minutes of failures the
967
993
  verb seams refuse on their own, so there is nothing for this loop to do about a
968
- failure except try again on the next tick.
994
+ failure except try again on the next tick. A revocation is the exception: it drains
995
+ the Worker exactly as SIGTERM would, and the process exits non-zero.
969
996
  """
970
997
 
971
998
  while True:
@@ -974,6 +1001,10 @@ async def _heartbeat_forever(
974
1001
  try:
975
1002
  await link.exchange()
976
1003
  except Exception as error: # noqa: BLE001 - any failed exchange is a lost beat
1004
+ if _revoked(error):
1005
+ revoked.set()
1006
+ stop.set()
1007
+ return
977
1008
  _logger.warning("heartbeat failed: %s", error)
978
1009
  continue
979
1010
  if stream.runner_token is not None and stream.runner_token != token_before:
@@ -0,0 +1,18 @@
1
+ """The Runner conformance kit (runner-repo issue 07, ``docs/conformance.md``).
2
+
3
+ A fake control plane (:class:`FakeControlPlane`) and a scenario suite that proves a Runner
4
+ build works without the platform: ``pip install 'agentic-runner[testing]'`` and then
5
+ ``pytest --pyargs agentic_runner.testing``. The fake itself needs only the Runner's own
6
+ dependencies, so ``python -m agentic_runner.testing`` serves it from any Runner install,
7
+ the image included; pytest and the scenario fixtures come with the extra.
8
+ """
9
+
10
+ from agentic_runner.testing.control_plane import (
11
+ DEFAULT_NAMESPACE,
12
+ RUNNER_PREFIX,
13
+ Evidence,
14
+ FakeControlPlane,
15
+ Refusal,
16
+ )
17
+
18
+ __all__ = ["DEFAULT_NAMESPACE", "RUNNER_PREFIX", "Evidence", "FakeControlPlane", "Refusal"]
@@ -0,0 +1,32 @@
1
+ """``python -m agentic_runner.testing [port]``: serve the fake control plane until killed.
2
+
3
+ What the chart, Docker and workstation tests register a Runner against; ``GET /stats``
4
+ reports what it saw.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+
11
+ from agentic_runner.testing.control_plane import DEFAULT_NAMESPACE, FakeControlPlane
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser(prog="python -m agentic_runner.testing")
16
+ parser.add_argument("port", type=int, nargs="?", default=8000)
17
+ parser.add_argument("--host", default="0.0.0.0")
18
+ parser.add_argument("--namespace", default=DEFAULT_NAMESPACE)
19
+ parser.add_argument("--host-party", default="organisation")
20
+ arguments = parser.parse_args()
21
+
22
+ plane = FakeControlPlane(namespace=arguments.namespace, host_party=arguments.host_party)
23
+ server = plane.server(arguments.host, arguments.port)
24
+ print(
25
+ f"fake control plane on :{server.server_address[1]}, namespace {arguments.namespace}",
26
+ flush=True,
27
+ )
28
+ server.serve_forever()
29
+
30
+
31
+ if __name__ == "__main__":
32
+ main()
@@ -0,0 +1,441 @@
1
+ """The fake control plane: the Runner's side of the platform, answered the way it answers.
2
+
3
+ One oracle for every test that needs a control plane (runner-repo issue 07): the unit
4
+ tests mount it as an ``httpx.MockTransport``, the chart, Docker and workstation tests
5
+ serve it over HTTP from the Runner's own install (``python -m agentic_runner.testing``),
6
+ and the conformance scenarios do both. Standard library plus the Runner's own
7
+ dependencies, so it runs anywhere a Runner does.
8
+
9
+ It serves the public Runner surface only -- the ``/api/runner/v1`` prefix of issue 85
10
+ (ADR-0016) -- and refuses anything else, so a Runner build that reaches for a platform
11
+ route outside that surface fails here before it fails in production. What it checks is
12
+ what the platform checks on that surface: the bootstrap body, the Ed25519 signature of
13
+ every signed request against the identity it handed out, revocation, and the hosted
14
+ queue. What it answers is the same shape the platform answers, refusals included
15
+ (``{"detail": {"reason", "detail"}}``). Everything it saw is kept for the test to read,
16
+ and ``GET /stats`` -- the one route outside the prefix, and the fake's own -- summarises
17
+ it for a shell script.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import base64
24
+ import binascii
25
+ import json
26
+ import threading
27
+ import time
28
+ from collections.abc import Callable, Iterator, Mapping
29
+ from contextlib import contextmanager
30
+ from dataclasses import dataclass, field
31
+ from datetime import UTC, datetime, timedelta
32
+ from email.utils import formatdate
33
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
34
+ from typing import Any
35
+ from uuid import UUID, uuid4
36
+
37
+ import httpx
38
+ from cryptography.exceptions import InvalidSignature
39
+ from cryptography.hazmat.primitives import serialization
40
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
41
+ from pydantic import BaseModel, ValidationError
42
+
43
+ from agentic_runner.registration import (
44
+ BOOTSTRAP_PATH,
45
+ DIRECTIVE_TOKEN_PATH,
46
+ HEARTBEAT_PATH,
47
+ RUNNER_REVOKED_REASON,
48
+ )
49
+ from agentic_runner_contracts import __version__ as contracts_version
50
+ from agentic_runner_contracts.public_metadata import runner_task_queue
51
+ from agentic_runner_contracts.runner_registration import (
52
+ BootstrapRequest,
53
+ BootstrapResponse,
54
+ DirectiveTokenRequest,
55
+ DirectiveTokenResponse,
56
+ FloorState,
57
+ HeartbeatAck,
58
+ HeartbeatEnvelope,
59
+ RunnerIdentityMaterial,
60
+ )
61
+ from agentic_runner_contracts.runner_signature import (
62
+ RUNNER_ID_HEADER,
63
+ SIGNATURE_HEADER,
64
+ SIGNED_AT_HEADER,
65
+ canonical_request,
66
+ )
67
+
68
+ __all__ = [
69
+ "DEFAULT_NAMESPACE",
70
+ "RUNNER_PREFIX",
71
+ "Evidence",
72
+ "FakeControlPlane",
73
+ "Refusal",
74
+ ]
75
+
76
+ RUNNER_PREFIX = "/api/runner/v1"
77
+ STATS_PATH = "/stats"
78
+ # The namespace the chart, Docker and workstation tests start their Temporal dev server
79
+ # with: one Organisation, whose id is the first non-nil UUID.
80
+ DEFAULT_NAMESPACE = "org-00000000-0000-0000-0000-000000000001"
81
+ _EVIDENCE_PATH_SUFFIX = "/evidence"
82
+ _WORK_RECORDS_PATH = f"{RUNNER_PREFIX}/work-records/"
83
+ _TOKEN_TTL = timedelta(hours=1)
84
+ _DIRECTIVE_TOKEN_TTL = timedelta(minutes=15)
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Evidence:
89
+ """One Evidence write as received; ``credential`` is ``directive`` or ``signed``."""
90
+
91
+ work_record_id: str
92
+ actor: str
93
+ source: str
94
+ payload: dict[str, Any]
95
+ credential: str
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class Refusal:
100
+ method: str
101
+ path: str
102
+ status: int
103
+ reason: str
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class _Answer:
108
+ status: int
109
+ body: dict[str, Any] | None = None
110
+
111
+
112
+ class _RefusedError(Exception):
113
+ def __init__(self, status: int, reason: str, detail: str) -> None:
114
+ super().__init__(detail)
115
+ self.status = status
116
+ self.reason = reason
117
+
118
+
119
+ @dataclass
120
+ class _Registered:
121
+ public_key: Ed25519PublicKey
122
+ revoked: bool = False
123
+ tokens: int = 0
124
+
125
+
126
+ @dataclass
127
+ class FakeControlPlane:
128
+ """Bootstrap, heartbeat, Directive token and Evidence, answered the way the platform does.
129
+
130
+ ``heartbeat_interval_seconds`` is the cadence bootstrap hands out; a scenario that
131
+ waits on beats sets it low rather than waiting 30 s per beat.
132
+ """
133
+
134
+ namespace: str = DEFAULT_NAMESPACE
135
+ host_party: str = "organisation"
136
+ heartbeat_interval_seconds: int = 30
137
+ contracts_floor: str = contracts_version
138
+
139
+ bootstraps: list[BootstrapRequest] = field(default_factory=list)
140
+ runner_ids: list[UUID] = field(default_factory=list)
141
+ heartbeats: list[HeartbeatEnvelope] = field(default_factory=list)
142
+ heartbeat_runner_ids: list[UUID] = field(default_factory=list)
143
+ directive_tokens: list[str] = field(default_factory=list)
144
+ evidence: list[Evidence] = field(default_factory=list)
145
+ refusals: list[Refusal] = field(default_factory=list)
146
+
147
+ def __post_init__(self) -> None:
148
+ self._lock = threading.Lock()
149
+ self._registered: dict[UUID, _Registered] = {}
150
+ self._minted: set[str] = set()
151
+
152
+ # ---------------------------------------------------------------- the test's levers
153
+
154
+ def revoke(self, runner_id: UUID) -> None:
155
+ """Revoke a Runner: from now on its signed requests are refused ``runner_revoked``."""
156
+
157
+ with self._lock:
158
+ self._registered[runner_id].revoked = True
159
+
160
+ async def wait_for(self, condition: Callable[[], bool], *, timeout: float = 10.0) -> None:
161
+ """Poll ``condition`` until it holds; works whether the fake is mounted or served."""
162
+
163
+ deadline = time.monotonic() + timeout
164
+ while not condition():
165
+ if time.monotonic() > deadline:
166
+ raise TimeoutError(f"fake control plane: condition not met in {timeout}s")
167
+ await asyncio.sleep(0.02)
168
+
169
+ def stats(self) -> dict[str, Any]:
170
+ with self._lock:
171
+ return {
172
+ "bootstraps": len(self.bootstraps),
173
+ "runner_ids": [str(runner_id) for runner_id in self.runner_ids],
174
+ "recipient_key_ids": sorted({b.recipient_key.key_id for b in self.bootstraps}),
175
+ "isolation_modes": sorted({b.isolation_mode.value for b in self.bootstraps}),
176
+ "heartbeats": len(self.heartbeats),
177
+ "heartbeat_runner_ids": sorted({str(r) for r in self.heartbeat_runner_ids}),
178
+ "directive_tokens": len(self.directive_tokens),
179
+ "evidence": len(self.evidence),
180
+ "refusals": [f"{r.method} {r.path} {r.status} {r.reason}" for r in self.refusals],
181
+ }
182
+
183
+ # ---------------------------------------------------------------- the two mounts
184
+
185
+ def transport(self) -> httpx.MockTransport:
186
+ """In-process: hand this to ``httpx.AsyncClient(transport=...)``."""
187
+
188
+ def handle(request: httpx.Request) -> httpx.Response:
189
+ answer = self.handle(
190
+ request.method,
191
+ request.url.raw_path.decode("ascii"),
192
+ request.headers,
193
+ request.content,
194
+ )
195
+ return httpx.Response(
196
+ answer.status,
197
+ json=answer.body,
198
+ headers={"date": formatdate(usegmt=True)},
199
+ )
200
+
201
+ return httpx.MockTransport(handle)
202
+
203
+ @contextmanager
204
+ def serving(self, host: str = "127.0.0.1", port: int = 0) -> Iterator[str]:
205
+ """Over HTTP, on a background thread; yields the base URL. Port 0 picks a free one."""
206
+
207
+ server = self.server(host, port)
208
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
209
+ thread.start()
210
+ try:
211
+ yield f"http://{host}:{server.server_address[1]}"
212
+ finally:
213
+ server.shutdown()
214
+ server.server_close()
215
+ thread.join()
216
+
217
+ def server(self, host: str, port: int) -> ThreadingHTTPServer:
218
+ plane = self
219
+
220
+ class Handler(BaseHTTPRequestHandler):
221
+ def _answer(self) -> None:
222
+ length = int(self.headers.get("content-length", "0") or 0)
223
+ answer = plane.handle(
224
+ self.command, self.path, dict(self.headers), self.rfile.read(length)
225
+ )
226
+ raw = b"" if answer.body is None else json.dumps(answer.body).encode()
227
+ # send_response adds the `Date` header the Runner's clock-skew reading uses.
228
+ self.send_response(answer.status)
229
+ self.send_header("content-type", "application/json")
230
+ self.send_header("content-length", str(len(raw)))
231
+ self.end_headers()
232
+ self.wfile.write(raw)
233
+
234
+ do_GET = _answer # noqa: N815 - http.server's contract
235
+ do_POST = _answer # noqa: N815 - http.server's contract
236
+ do_PUT = _answer # noqa: N815 - http.server's contract
237
+ do_DELETE = _answer # noqa: N815 - http.server's contract
238
+
239
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002
240
+ print(f"fake control plane: {format % args}", flush=True)
241
+
242
+ return ThreadingHTTPServer((host, port), Handler)
243
+
244
+ # ---------------------------------------------------------------- the routes
245
+
246
+ def handle(self, method: str, target: str, headers: Mapping[str, str], body: bytes) -> _Answer:
247
+ """One request, as received: the raw target (query included) and the exact body."""
248
+
249
+ path = target.partition("?")[0]
250
+ if method == "GET" and path == STATS_PATH:
251
+ return _Answer(200, self.stats())
252
+ headers = {key.lower(): value for key, value in headers.items()}
253
+ try:
254
+ with self._lock:
255
+ return self._route(method, target, path, headers, body)
256
+ except _RefusedError as refused:
257
+ with self._lock:
258
+ self.refusals.append(Refusal(method, path, refused.status, refused.reason))
259
+ return _Answer(
260
+ refused.status, {"detail": {"reason": refused.reason, "detail": str(refused)}}
261
+ )
262
+
263
+ def _route(
264
+ self, method: str, target: str, path: str, headers: dict[str, str], body: bytes
265
+ ) -> _Answer:
266
+ if not path.startswith(f"{RUNNER_PREFIX}/"):
267
+ raise _RefusedError(
268
+ 404,
269
+ "outside_runner_prefix",
270
+ f"{path} is not on the Runner surface ({RUNNER_PREFIX}); a Runner calls "
271
+ "nothing else",
272
+ )
273
+ if method == "POST" and path == BOOTSTRAP_PATH:
274
+ return self._bootstrap(body)
275
+ if method == "POST" and path == HEARTBEAT_PATH:
276
+ return self._heartbeat(method, target, headers, body)
277
+ if method == "POST" and path == DIRECTIVE_TOKEN_PATH:
278
+ return self._directive_token(method, target, headers, body)
279
+ if (
280
+ method == "POST"
281
+ and path.startswith(_WORK_RECORDS_PATH)
282
+ and path.endswith(_EVIDENCE_PATH_SUFFIX)
283
+ ):
284
+ work_record_id = path[len(_WORK_RECORDS_PATH) : -len(_EVIDENCE_PATH_SUFFIX)]
285
+ return self._evidence(work_record_id, method, target, headers, body)
286
+ raise _RefusedError(
287
+ 404, "not_served", f"the fake control plane does not serve {method} {path}"
288
+ )
289
+
290
+ def _bootstrap(self, body: bytes) -> _Answer:
291
+ request = _parse(BootstrapRequest, body)
292
+ runner_id = uuid4()
293
+ key = Ed25519PrivateKey.generate()
294
+ self._registered[runner_id] = _Registered(public_key=key.public_key())
295
+ self.bootstraps.append(request)
296
+ self.runner_ids.append(runner_id)
297
+ print(f"bootstrap {runner_id} isolation={request.isolation_mode.value}", flush=True)
298
+ response = BootstrapResponse(
299
+ identity=RunnerIdentityMaterial(
300
+ runner_id=runner_id,
301
+ identity_id=f"identity-{runner_id}",
302
+ private_key_pem=key.private_bytes(
303
+ serialization.Encoding.PEM,
304
+ serialization.PrivateFormat.PKCS8,
305
+ serialization.NoEncryption(),
306
+ ).decode("ascii"),
307
+ ),
308
+ temporal_namespace=self.namespace,
309
+ task_queue=runner_task_queue(runner_id),
310
+ runner_token=f"runner-token-{runner_id}-0",
311
+ runner_token_expires_at=datetime.now(UTC) + _TOKEN_TTL,
312
+ tag_set_version=1,
313
+ floor_state=FloorState.OK,
314
+ contracts_floor=self.contracts_floor,
315
+ host_party=self.host_party,
316
+ heartbeat_interval_seconds=self.heartbeat_interval_seconds,
317
+ )
318
+ return _Answer(200, response.model_dump(mode="json"))
319
+
320
+ def _heartbeat(self, method: str, target: str, headers: dict[str, str], body: bytes) -> _Answer:
321
+ runner_id, runner = self._signed(method, target, headers, body)
322
+ envelope = _parse(HeartbeatEnvelope, body)
323
+ expected = runner_task_queue(runner_id)
324
+ if envelope.hosted_task_queue != expected:
325
+ raise _RefusedError(
326
+ 409,
327
+ "hosted_queue_mismatch",
328
+ f"Runner {runner_id} reports polling {envelope.hosted_task_queue!r}; "
329
+ f"Directives are dispatched to {expected!r}",
330
+ )
331
+ self.heartbeats.append(envelope)
332
+ self.heartbeat_runner_ids.append(runner_id)
333
+ runner.tokens += 1
334
+ ack = HeartbeatAck(
335
+ runner_id=runner_id,
336
+ runner_token=f"runner-token-{runner_id}-{runner.tokens}",
337
+ runner_token_expires_at=datetime.now(UTC) + _TOKEN_TTL,
338
+ floor_state=FloorState.OK,
339
+ contracts_floor=self.contracts_floor,
340
+ tag_set_version=envelope.tag_set_version,
341
+ accepts_new_directives=True,
342
+ # Every Usage Record committed, so the Runner's outbox drops exactly these.
343
+ usage_accepted=[f"{u.directive_id}:{u.sequence}" for u in envelope.usage],
344
+ )
345
+ return _Answer(200, ack.model_dump(mode="json"))
346
+
347
+ def _directive_token(
348
+ self, method: str, target: str, headers: dict[str, str], body: bytes
349
+ ) -> _Answer:
350
+ self._signed(method, target, headers, body)
351
+ request = _parse(DirectiveTokenRequest, body)
352
+ token = f"directive-token-{len(self.directive_tokens) + 1}"
353
+ self._minted.add(token)
354
+ self.directive_tokens.append(request.directive_id)
355
+ response = DirectiveTokenResponse(
356
+ directive_id=request.directive_id,
357
+ token=token,
358
+ expires_at=datetime.now(UTC) + _DIRECTIVE_TOKEN_TTL,
359
+ )
360
+ return _Answer(200, response.model_dump(mode="json"))
361
+
362
+ def _evidence(
363
+ self,
364
+ work_record_id: str,
365
+ method: str,
366
+ target: str,
367
+ headers: dict[str, str],
368
+ body: bytes,
369
+ ) -> _Answer:
370
+ # Either credential the platform accepts on this route: the Directive token the
371
+ # activity pulled, or -- before one exists -- the signed Runner envelope.
372
+ scheme, _, token = headers.get("authorization", "").partition(" ")
373
+ if scheme.lower() == "bearer" and token in self._minted:
374
+ credential = "directive"
375
+ else:
376
+ self._signed(method, target, headers, body)
377
+ credential = "signed"
378
+ try:
379
+ payload = json.loads(body)
380
+ entry = Evidence(
381
+ work_record_id=work_record_id,
382
+ actor=str(payload["actor"]),
383
+ source=str(payload["source"]),
384
+ payload=dict(payload["payload"]),
385
+ credential=credential,
386
+ )
387
+ except (ValueError, KeyError, TypeError) as error:
388
+ raise _RefusedError(
389
+ 422, "evidence_invalid", f"not an Evidence write: {error}"
390
+ ) from None
391
+ self.evidence.append(entry)
392
+ return _Answer(
393
+ 201,
394
+ {
395
+ "evidence_event_id": str(uuid4()),
396
+ "work_record_id": work_record_id,
397
+ "sequence": len(self.evidence),
398
+ "created": True,
399
+ "idempotency_key": payload.get("idempotency_key"),
400
+ },
401
+ )
402
+
403
+ def _signed(
404
+ self, method: str, target: str, headers: dict[str, str], body: bytes
405
+ ) -> tuple[UUID, _Registered]:
406
+ """The signed Runner envelope, checked the way the platform checks it (issue 71)."""
407
+
408
+ try:
409
+ runner_id = UUID(headers.get(RUNNER_ID_HEADER.lower(), ""))
410
+ except ValueError:
411
+ raise _RefusedError(
412
+ 401, "signature_invalid", "no Runner id on a signed request"
413
+ ) from None
414
+ runner = self._registered.get(runner_id)
415
+ if runner is None:
416
+ raise _RefusedError(404, "runner_not_found", f"Runner {runner_id} not found")
417
+ try:
418
+ signature = base64.b64decode(headers.get(SIGNATURE_HEADER.lower(), ""), validate=True)
419
+ runner.public_key.verify(
420
+ signature,
421
+ canonical_request(
422
+ method=method,
423
+ path=target,
424
+ signed_at=headers.get(SIGNED_AT_HEADER.lower(), ""),
425
+ body=body,
426
+ ),
427
+ )
428
+ except (binascii.Error, InvalidSignature):
429
+ raise _RefusedError(
430
+ 401, "signature_invalid", f"Runner {runner_id}: signature does not verify"
431
+ ) from None
432
+ if runner.revoked:
433
+ raise _RefusedError(403, RUNNER_REVOKED_REASON, f"Runner {runner_id} is revoked")
434
+ return runner_id, runner
435
+
436
+
437
+ def _parse[Model: BaseModel](model: type[Model], body: bytes) -> Model:
438
+ try:
439
+ return model.model_validate_json(body)
440
+ except ValidationError as error:
441
+ raise _RefusedError(422, "request_invalid", str(error)) from None
@@ -0,0 +1,178 @@
1
+ """The conformance kit's pytest plugin (``pytest11`` entry point ``agentic_runner``).
2
+
3
+ Fixtures a fork's own tests can use as well as the scenarios in ``test_conformance.py``:
4
+ ``fake_control_plane`` serves :class:`FakeControlPlane` on a free port, and
5
+ ``runner_launcher`` starts the *installed* Runner -- ``agentic-runner run``, a real
6
+ process -- against it, so a scenario sees exactly what an operator's process would do.
7
+
8
+ Imports nothing beyond pytest and the Runner's own dependencies: the entry point loads
9
+ this module into every pytest session of an environment that has the Runner installed,
10
+ with or without the ``testing`` extra.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import os
17
+ import shutil
18
+ import signal
19
+ import socket
20
+ import subprocess
21
+ import sys
22
+ import time
23
+ import urllib.error
24
+ import urllib.request
25
+ from collections.abc import Iterator
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+
29
+ import pytest
30
+ from temporalio.testing import WorkflowEnvironment
31
+
32
+ from agentic_runner.testing.control_plane import DEFAULT_NAMESPACE, FakeControlPlane
33
+
34
+ # A conformance Runner never runs a Directive that needs isolation or a credential, so it
35
+ # runs `none`, unprivileged, with every path under the test's own directory.
36
+ AGENT_TOKEN = "conformance-agent-token-0123456789"
37
+ _RUN = "import sys; from agentic_runner.cli import main; sys.exit(main(['run']))"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ServedControlPlane:
42
+ plane: FakeControlPlane
43
+ url: str
44
+
45
+
46
+ @dataclass
47
+ class RunnerProcess:
48
+ """One ``agentic-runner run`` process; its stdout and stderr go to ``log``."""
49
+
50
+ process: subprocess.Popen[bytes]
51
+ log: Path
52
+ readiness_port: int
53
+
54
+ def ready(self) -> bool:
55
+ """The readiness probe the chart points Kubernetes at: registered, heard, polling."""
56
+
57
+ url = f"http://127.0.0.1:{self.readiness_port}/healthz"
58
+ try:
59
+ with urllib.request.urlopen(url, timeout=1) as response:
60
+ return bool(response.status == 200)
61
+ except (urllib.error.URLError, OSError):
62
+ return False
63
+
64
+ def output(self) -> str:
65
+ return self.log.read_text(encoding="utf-8", errors="replace")
66
+
67
+ def running(self) -> bool:
68
+ return self.process.poll() is None
69
+
70
+ def wait(self, timeout: float) -> int:
71
+ return self.process.wait(timeout=timeout)
72
+
73
+ def terminate(self, timeout: float = 60) -> int:
74
+ """SIGTERM -- what Kubernetes, ``docker stop`` and launchd send -- then wait."""
75
+
76
+ self.process.send_signal(signal.SIGTERM)
77
+ return self.wait(timeout)
78
+
79
+
80
+ @dataclass
81
+ class RunnerLauncher:
82
+ """Starts Runners that share one state directory, so a restart is the same Runner."""
83
+
84
+ root: Path
85
+ started: list[RunnerProcess] = field(default_factory=list)
86
+
87
+ def start(self, *, control_plane_url: str, temporal_address: str) -> RunnerProcess:
88
+ state = self.root / "state"
89
+ readiness_port = _free_port()
90
+ environment = {
91
+ **os.environ,
92
+ "AGENTIC_CONTROL_PLANE_URL": control_plane_url,
93
+ "AGENTIC_AGENT_TOKEN": AGENT_TOKEN,
94
+ "AGENTIC_RUNNER_ISOLATION": "none",
95
+ "AGENTIC_RUNNER_STATE_DIR": str(state),
96
+ "AGENTIC_RUNNER_WORKSPACE_ROOT": str(self.root / "workspaces"),
97
+ "AGENTIC_RUNNER_SOCKET_DIR": str(self.root / "sockets"),
98
+ "AGENTIC_RUNNER_CONFIG": str(self.root / "absent-config.toml"),
99
+ "AGENTIC_RUNNER_READINESS_PORT": str(readiness_port),
100
+ "TEMPORAL_ADDRESS": temporal_address,
101
+ "AGENTIC_TEMPORAL_TLS": "false",
102
+ "WORKSPACE_ROOT": str(self.root / "workspaces"),
103
+ "WORKER_STATE_DIR": str(state),
104
+ "CODEX_HOME": str(self.root / "codex"),
105
+ }
106
+ log = self.root / f"runner-{len(self.started)}.log"
107
+ with log.open("wb") as sink:
108
+ process = subprocess.Popen(
109
+ [sys.executable, "-c", _RUN],
110
+ env=environment,
111
+ stdout=sink,
112
+ stderr=subprocess.STDOUT,
113
+ )
114
+ runner = RunnerProcess(process=process, log=log, readiness_port=readiness_port)
115
+ self.started.append(runner)
116
+ return runner
117
+
118
+ def close(self) -> None:
119
+ for runner in self.started:
120
+ if runner.running():
121
+ runner.process.kill()
122
+ runner.process.wait()
123
+ print(f"--- {runner.log.name} (exit {runner.process.returncode})")
124
+ print(runner.output())
125
+
126
+
127
+ async def start_temporal(namespace: str = DEFAULT_NAMESPACE) -> WorkflowEnvironment:
128
+ """A local Temporal dev server holding ``namespace``.
129
+
130
+ Uses a ``temporal`` CLI already on ``PATH`` when there is one; otherwise the SDK
131
+ downloads the dev server on first use.
132
+ """
133
+
134
+ return await WorkflowEnvironment.start_local(
135
+ namespace=namespace, dev_server_existing_path=shutil.which("temporal")
136
+ )
137
+
138
+
139
+ async def until_ready(served: ServedControlPlane, runner: RunnerProcess, beats: int = 1) -> None:
140
+ """Wait until the Runner is ready and the fake has heard ``beats`` heartbeats.
141
+
142
+ Ready, not merely registered: the drain handler is installed once the Worker polls,
143
+ and a scenario that signals before then is testing start-up, not the drain. Fails at
144
+ once -- with the Runner's output -- if the process dies first.
145
+ """
146
+
147
+ plane = served.plane
148
+ deadline = time.monotonic() + 120
149
+ while len(plane.heartbeats) < beats or not runner.ready():
150
+ if not runner.running():
151
+ pytest.fail(f"the Runner exited {runner.process.returncode}:\n{runner.output()}")
152
+ if time.monotonic() > deadline:
153
+ pytest.fail(f"not ready in 120s; the fake saw {plane.stats()}:\n{runner.output()}")
154
+ await asyncio.sleep(0.1)
155
+
156
+
157
+ def _free_port() -> int:
158
+ with socket.socket() as probe:
159
+ probe.bind(("127.0.0.1", 0))
160
+ return int(probe.getsockname()[1])
161
+
162
+
163
+ @pytest.fixture
164
+ def fake_control_plane() -> Iterator[ServedControlPlane]:
165
+ """The fake, served over HTTP, handing out a one-second heartbeat cadence."""
166
+
167
+ plane = FakeControlPlane(heartbeat_interval_seconds=1)
168
+ with plane.serving() as url:
169
+ yield ServedControlPlane(plane=plane, url=url)
170
+
171
+
172
+ @pytest.fixture
173
+ def runner_launcher(tmp_path: Path) -> Iterator[RunnerLauncher]:
174
+ launcher = RunnerLauncher(root=tmp_path)
175
+ try:
176
+ yield launcher
177
+ finally:
178
+ launcher.close()
@@ -0,0 +1,163 @@
1
+ """The conformance scenarios: ``pytest --pyargs agentic_runner.testing``.
2
+
3
+ Each one starts the installed Runner as a process against the fake control plane and a
4
+ local Temporal dev server, and checks one thing an operator relies on: it registers once,
5
+ it heartbeats signed, it runs a Directive dispatched to its own queue, it stops when
6
+ revoked, and it drains on SIGTERM. A fork that passes these has a Runner the platform can
7
+ drive; ``docs/conformance.md`` says what they do not cover.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from uuid import uuid4
13
+
14
+ import pytest
15
+ from temporalio.worker import Worker
16
+ from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner, SandboxRestrictions
17
+
18
+ from agentic_runner import __version__ as runner_version
19
+ from agentic_runner.registration import RUNNER_REVOKED_REASON
20
+ from agentic_runner.testing.plugin import (
21
+ RunnerLauncher,
22
+ ServedControlPlane,
23
+ start_temporal,
24
+ until_ready,
25
+ )
26
+ from agentic_runner.testing.workflow import RoundTripWorkflow
27
+ from agentic_runner_contracts import __version__ as contracts_version
28
+ from agentic_runner_contracts.activity_io import ContractResidueInput
29
+ from agentic_runner_contracts.public_metadata import runner_task_queue
30
+ from agentic_runner_contracts.routing import DirectiveRouting
31
+ from agentic_runner_contracts.runner_registration import IsolationMode, LifecycleKind
32
+
33
+
34
+ @pytest.mark.asyncio
35
+ async def test_a_runner_registers_once_and_a_restart_is_the_same_runner(
36
+ fake_control_plane: ServedControlPlane, runner_launcher: RunnerLauncher
37
+ ) -> None:
38
+ plane = fake_control_plane.plane
39
+ async with await start_temporal(plane.namespace) as temporal:
40
+ address = temporal.client.service_client.config.target_host
41
+ first = runner_launcher.start(
42
+ control_plane_url=fake_control_plane.url, temporal_address=address
43
+ )
44
+ await until_ready(fake_control_plane, first)
45
+
46
+ [bootstrap] = plane.bootstraps
47
+ assert bootstrap.isolation_mode is IsolationMode.NONE
48
+ assert bootstrap.runner_version == runner_version
49
+ assert bootstrap.contracts_version == contracts_version
50
+ assert first.terminate() == 0
51
+
52
+ beats = len(plane.heartbeats)
53
+ second = runner_launcher.start(
54
+ control_plane_url=fake_control_plane.url, temporal_address=address
55
+ )
56
+ await until_ready(fake_control_plane, second, beats=beats + 1)
57
+ assert second.terminate() == 0
58
+
59
+ # The identity is read back from the state directory: no second registration.
60
+ assert len(plane.bootstraps) == 1
61
+ assert set(plane.heartbeat_runner_ids) == set(plane.runner_ids)
62
+
63
+
64
+ @pytest.mark.asyncio
65
+ async def test_a_runner_heartbeats_signed_from_its_own_queue(
66
+ fake_control_plane: ServedControlPlane, runner_launcher: RunnerLauncher
67
+ ) -> None:
68
+ plane = fake_control_plane.plane
69
+ async with await start_temporal(plane.namespace) as temporal:
70
+ runner = runner_launcher.start(
71
+ control_plane_url=fake_control_plane.url,
72
+ temporal_address=temporal.client.service_client.config.target_host,
73
+ )
74
+ await until_ready(fake_control_plane, runner, beats=3)
75
+ assert runner.terminate() == 0
76
+
77
+ [runner_id] = plane.runner_ids
78
+ assert set(plane.heartbeat_runner_ids) == {runner_id}
79
+ # Every beat verified against the identity the fake handed out; none was refused.
80
+ assert plane.refusals == []
81
+ assert {beat.hosted_task_queue for beat in plane.heartbeats} == {runner_task_queue(runner_id)}
82
+ assert {beat.contracts_version for beat in plane.heartbeats} == {contracts_version}
83
+
84
+
85
+ @pytest.mark.asyncio
86
+ async def test_a_directive_dispatched_to_the_runner_queue_comes_back_answered(
87
+ fake_control_plane: ServedControlPlane, runner_launcher: RunnerLauncher
88
+ ) -> None:
89
+ plane = fake_control_plane.plane
90
+ async with await start_temporal(plane.namespace) as temporal:
91
+ runner = runner_launcher.start(
92
+ control_plane_url=fake_control_plane.url,
93
+ temporal_address=temporal.client.service_client.config.target_host,
94
+ )
95
+ await until_ready(fake_control_plane, runner)
96
+ [runner_id] = plane.runner_ids
97
+ contract_id = str(uuid4())
98
+ request = ContractResidueInput(
99
+ contract_id=contract_id,
100
+ routing=DirectiveRouting(runner_id=str(runner_id), host_party=plane.host_party),
101
+ )
102
+
103
+ # The workflow is this suite's, not the platform's; the kit's own modules pass
104
+ # through the sandbox rather than being re-imported inside it.
105
+ async with Worker(
106
+ temporal.client,
107
+ task_queue="conformance-workflows",
108
+ workflows=[RoundTripWorkflow],
109
+ workflow_runner=SandboxedWorkflowRunner(
110
+ restrictions=SandboxRestrictions.default.with_passthrough_modules(
111
+ "agentic_runner", "agentic_runner_contracts"
112
+ )
113
+ ),
114
+ ):
115
+ answer = await temporal.client.execute_workflow(
116
+ RoundTripWorkflow.run,
117
+ args=[runner_task_queue(runner_id), request],
118
+ id=f"conformance-{uuid4()}",
119
+ task_queue="conformance-workflows",
120
+ )
121
+ assert runner.terminate() == 0
122
+
123
+ assert answer.contract_id == contract_id
124
+ assert answer.workspaces_wiped == 0
125
+
126
+
127
+ @pytest.mark.asyncio
128
+ async def test_a_revoked_runner_stops(
129
+ fake_control_plane: ServedControlPlane, runner_launcher: RunnerLauncher
130
+ ) -> None:
131
+ plane = fake_control_plane.plane
132
+ async with await start_temporal(plane.namespace) as temporal:
133
+ runner = runner_launcher.start(
134
+ control_plane_url=fake_control_plane.url,
135
+ temporal_address=temporal.client.service_client.config.target_host,
136
+ )
137
+ await until_ready(fake_control_plane, runner)
138
+ [runner_id] = plane.runner_ids
139
+
140
+ plane.revoke(runner_id)
141
+ exit_code = runner.wait(timeout=60)
142
+
143
+ assert exit_code != 0
144
+ assert "revoked" in runner.output()
145
+ assert {refusal.reason for refusal in plane.refusals} == {RUNNER_REVOKED_REASON}
146
+
147
+
148
+ @pytest.mark.asyncio
149
+ async def test_sigterm_drains_to_a_clean_exit_and_reports_the_stop(
150
+ fake_control_plane: ServedControlPlane, runner_launcher: RunnerLauncher
151
+ ) -> None:
152
+ plane = fake_control_plane.plane
153
+ async with await start_temporal(plane.namespace) as temporal:
154
+ runner = runner_launcher.start(
155
+ control_plane_url=fake_control_plane.url,
156
+ temporal_address=temporal.client.service_client.config.target_host,
157
+ )
158
+ await until_ready(fake_control_plane, runner)
159
+
160
+ assert runner.terminate() == 0
161
+
162
+ # The last beat is the one the drain sends on its way out.
163
+ assert LifecycleKind.STOP in {event.kind for event in plane.heartbeats[-1].lifecycle}
@@ -0,0 +1,37 @@
1
+ """The control plane's half of a Directive round trip, as small as one can be.
2
+
3
+ The platform's workflows live in the platform; this one stands in for them in the
4
+ conformance suite. It dispatches one Runner activity to ``runner.{runner_id}`` -- the
5
+ queue a real workflow routes to -- and returns what the Runner answered. The activity is
6
+ ``wipe_contract_residue``: it touches no workspace, no Agent Runtime and no GitHub, so its
7
+ answer depends only on the Runner having picked the task up off its own queue.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from datetime import timedelta
13
+
14
+ from temporalio import workflow
15
+ from temporalio.common import RetryPolicy
16
+
17
+ with workflow.unsafe.imports_passed_through():
18
+ from agentic_runner_contracts.activity_io import ContractResidueInput, ContractResidueOutput
19
+
20
+ ROUND_TRIP_ACTIVITY = "wipe_contract_residue"
21
+
22
+
23
+ @workflow.defn(name="agentic_runner_conformance_round_trip")
24
+ class RoundTripWorkflow:
25
+ @workflow.run
26
+ async def run(self, task_queue: str, request: ContractResidueInput) -> ContractResidueOutput:
27
+ answer: ContractResidueOutput = await workflow.execute_activity(
28
+ ROUND_TRIP_ACTIVITY,
29
+ request,
30
+ task_queue=task_queue,
31
+ result_type=ContractResidueOutput,
32
+ start_to_close_timeout=timedelta(seconds=30),
33
+ # One attempt: a Runner that cannot answer should fail the scenario, not
34
+ # retry it into the test's timeout.
35
+ retry_policy=RetryPolicy(maximum_attempts=1),
36
+ )
37
+ return answer
File without changes
File without changes