agentic-runner 2.6.1__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.6.1 → agentic_runner-2.8.0}/PKG-INFO +4 -1
  2. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/pyproject.toml +11 -0
  3. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/__init__.py +1 -1
  4. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/activities.py +106 -3
  5. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/registration.py +4 -0
  6. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/service.py +35 -4
  7. agentic_runner-2.8.0/src/agentic_runner/testing/__init__.py +18 -0
  8. agentic_runner-2.8.0/src/agentic_runner/testing/__main__.py +32 -0
  9. agentic_runner-2.8.0/src/agentic_runner/testing/control_plane.py +441 -0
  10. agentic_runner-2.8.0/src/agentic_runner/testing/plugin.py +178 -0
  11. agentic_runner-2.8.0/src/agentic_runner/testing/test_conformance.py +163 -0
  12. agentic_runner-2.8.0/src/agentic_runner/testing/workflow.py +37 -0
  13. agentic_runner-2.8.0/src/agentic_runner/workers/skills.py +164 -0
  14. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/.gitignore +0 -0
  15. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/LICENSE +0 -0
  16. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/README.md +0 -0
  17. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/attempts.py +0 -0
  18. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/callback.py +0 -0
  19. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/child_watcher.py +0 -0
  20. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/cli.py +0 -0
  21. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/config.py +0 -0
  22. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/credentials.py +0 -0
  23. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/device_login_activities.py +0 -0
  24. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/egress.py +0 -0
  25. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/heartbeat_link.py +0 -0
  26. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/hooks.py +0 -0
  27. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/host_store.py +0 -0
  28. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/integrations/__init__.py +0 -0
  29. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  30. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  31. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  32. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  33. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  34. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  35. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  36. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  37. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  38. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/lifecycle.py +0 -0
  39. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/llm_proxy.py +0 -0
  40. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/mcp.py +0 -0
  41. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/message_store.py +0 -0
  42. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/py.typed +0 -0
  43. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  44. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/runtime/__init__.py +0 -0
  45. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  46. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/sealed_box.py +0 -0
  47. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/tiny_http.py +0 -0
  48. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/triage_activities.py +0 -0
  49. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/user_sources.py +0 -0
  50. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/workers/__init__.py +0 -0
  51. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
  52. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/workers/agent_runtime.py +0 -0
  53. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
  54. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
  55. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/workers/command_policy.py +0 -0
  56. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
  57. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
  58. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
  59. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  60. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  61. {agentic_runner-2.6.1 → agentic_runner-2.8.0}/src/agentic_runner/workers/settings.py +0 -0
  62. {agentic_runner-2.6.1 → 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.6.1
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.6.1"
10
+ __version__ = "2.8.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -129,6 +129,14 @@ from agentic_runner.workers.fastapi_client import (
129
129
  )
130
130
  from agentic_runner.workers.harness_usage import extract_claude_result, extract_codex_turn_usage
131
131
  from agentic_runner.workers.mcp_config import McpServerEntry
132
+ from agentic_runner.workers.skills import (
133
+ SkillDelivery,
134
+ delivery_for,
135
+ first_digest_mismatch,
136
+ remove_skills,
137
+ skills_preamble,
138
+ write_skills,
139
+ )
132
140
  from agentic_runner_contracts.activity_io import (
133
141
  LEARNER_CHILDREN_BUDGET_BYTES,
134
142
  LEARNING_FAILED,
@@ -238,6 +246,7 @@ from agentic_runner_contracts.routing import (
238
246
  from agentic_runner_contracts.runtime_context import (
239
247
  EGRESS_ALLOW_LIST_KEY,
240
248
  McpServerSpec,
249
+ SkillVersionSpec,
241
250
  WorkerRuntimeContext,
242
251
  WorkerRuntimeContextResolver,
243
252
  work_branch_name,
@@ -491,6 +500,8 @@ class _RuntimeContextState:
491
500
  experience: str = ""
492
501
  experience_lesson_ids: tuple[str, ...] = ()
493
502
  experience_truncated: bool = False
503
+ # Console-v2 issue 28: the Skill versions attached to the Agent.
504
+ skills: tuple[SkillVersionSpec, ...] = ()
494
505
  workspace_path: Path | None = None
495
506
  branch_head_sha: str | None = None
496
507
  # The Autonomy Policy's tier source (PRD issue 14) -- see WorkerRuntimeContext for
@@ -533,6 +544,9 @@ class _DirectiveAttempt:
533
544
  # PRD issue 58: what config assembly wrote for the CLI, and where it may reach.
534
545
  mcp_servers: tuple[McpServerEntry, ...] | None = None
535
546
  egress_allow_list: tuple[str, ...] = ()
547
+ # Console-v2 issue 28: the attached Skills, for a runtime with no skills directory to
548
+ # write them into. Prefixed to the Directive prompt; empty when there is none.
549
+ skills_preamble: str = ""
536
550
 
537
551
  async def run_environment_hook(self) -> None:
538
552
  self.extra_env.update(await self.hooks.environment())
@@ -1325,6 +1339,7 @@ _ROUTING_EVIDENCE_SOURCE = "runner.routing"
1325
1339
  PRIOR_ATTEMPT_ALIVE_EVIDENCE_SOURCE = "runner.prior_attempt_alive"
1326
1340
  DIRECTIVE_TOKEN_EVIDENCE_SOURCE = "runner.directive_token"
1327
1341
  AGENT_RUNTIME_EVIDENCE_SOURCE = "runner.agent_runtime"
1342
+ SKILLS_EVIDENCE_SOURCE = "runner.skills"
1328
1343
 
1329
1344
 
1330
1345
  class _EvidenceWriter(Protocol):
@@ -1793,7 +1808,8 @@ class RunnerRalphActivities:
1793
1808
  extra_env=attempt.env,
1794
1809
  mcp_servers=attempt.mcp_servers,
1795
1810
  egress_allow_list=attempt.egress_allow_list,
1796
- prompt=_directive_prompt(
1811
+ prompt=attempt.skills_preamble
1812
+ + _directive_prompt(
1797
1813
  completion_criteria=runtime_state.completion_criteria,
1798
1814
  pr_body=request.pr_body,
1799
1815
  persona_preamble=_persona_prompt_preamble(
@@ -2258,6 +2274,7 @@ class RunnerRalphActivities:
2258
2274
  work_record_id, source=source, payload=dict(payload)
2259
2275
  )
2260
2276
 
2277
+ await self._refuse_skill_digest_mismatch(work_record_id, state)
2261
2278
  credentials = await self._resolve_credentials(work_record_id, state)
2262
2279
  if credentials is not None:
2263
2280
  # Names only, at the moment they resolved (22 A1's Evidence): a support read
@@ -2321,6 +2338,13 @@ class RunnerRalphActivities:
2321
2338
  credentials=credentials,
2322
2339
  env=callback_env,
2323
2340
  )
2341
+ delivered_preamble = await self._deliver_skills(
2342
+ stack,
2343
+ work_record_id=work_record_id,
2344
+ state=state,
2345
+ sandbox=sandbox,
2346
+ directive_number=directive_number,
2347
+ )
2324
2348
  session = await stack.enter_async_context(
2325
2349
  DirectiveHookSession(
2326
2350
  hooks=self._hooks,
@@ -2342,8 +2366,85 @@ class RunnerRalphActivities:
2342
2366
  credentials=credentials,
2343
2367
  mcp_servers=mcp_servers,
2344
2368
  egress_allow_list=egress_allow_list,
2369
+ skills_preamble=delivered_preamble,
2345
2370
  )
2346
2371
 
2372
+ async def _refuse_skill_digest_mismatch(
2373
+ self, work_record_id: str, state: _RuntimeContextState
2374
+ ) -> None:
2375
+ """A Skill whose body is not the version that was published fails the Directive
2376
+ non-retryably (console-v2 issue 28): a retry would fetch the same body, and
2377
+ running on it would hand the Agent prompt material no person reviewed."""
2378
+
2379
+ mismatch = first_digest_mismatch(state.skills)
2380
+ if mismatch is None:
2381
+ return
2382
+ await _raise_recorded(
2383
+ ApplicationError(
2384
+ f"Skill {mismatch.slug} version {mismatch.version} does not match its sha256",
2385
+ non_retryable=True,
2386
+ ),
2387
+ self._fastapi_client,
2388
+ work_record_id,
2389
+ source=SKILLS_EVIDENCE_SOURCE,
2390
+ actor=self._actor(),
2391
+ payload={
2392
+ "event": "skills.digest_mismatch",
2393
+ "agent_id": state.agent_id,
2394
+ "slug": mismatch.slug,
2395
+ "version": mismatch.version,
2396
+ "sha256": mismatch.sha256,
2397
+ },
2398
+ )
2399
+
2400
+ async def _deliver_skills(
2401
+ self,
2402
+ stack: contextlib.AsyncExitStack,
2403
+ *,
2404
+ work_record_id: str,
2405
+ state: _RuntimeContextState,
2406
+ sandbox: DirectiveSandbox | None,
2407
+ directive_number: int,
2408
+ ) -> str:
2409
+ """Put the attached Skills where this runtime reads them; return any preamble.
2410
+
2411
+ Written files are removed on the attempt's exit, success or failure. A Runner
2412
+ with no Contract sandbox has no harness root it owns, so it falls back to the
2413
+ preamble rather than writing into a shared ``CODEX_HOME``.
2414
+ """
2415
+
2416
+ if not state.skills:
2417
+ return ""
2418
+ delivery = delivery_for(state.cli_kind)
2419
+ if delivery is SkillDelivery.DIRECTORY and sandbox is None:
2420
+ delivery = SkillDelivery.PROMPT_PREAMBLE
2421
+ preamble = ""
2422
+ if delivery is SkillDelivery.DIRECTORY:
2423
+ assert sandbox is not None
2424
+ # Registered before the write, so a write that fails half-way is cleaned too.
2425
+ stack.push_async_callback(
2426
+ remove_skills, sandbox.harness_config_dir, state.skills, uid=sandbox.uid
2427
+ )
2428
+ await write_skills(sandbox.harness_config_dir, state.skills, uid=sandbox.uid)
2429
+ else:
2430
+ preamble = skills_preamble(state.skills)
2431
+ await self._fastapi_client.append_evidence(
2432
+ work_record_id,
2433
+ source=SKILLS_EVIDENCE_SOURCE,
2434
+ payload={
2435
+ "event": "skills.delivered",
2436
+ "directive_number": directive_number,
2437
+ "agent_id": state.agent_id,
2438
+ "cli_kind": state.cli_kind,
2439
+ "delivery": delivery.value,
2440
+ "skills": [
2441
+ {"slug": skill.slug, "version": skill.version, "sha256": skill.sha256}
2442
+ for skill in state.skills
2443
+ ],
2444
+ },
2445
+ )
2446
+ return preamble
2447
+
2347
2448
  async def _plan_mcp(
2348
2449
  self,
2349
2450
  work_record_id: str,
@@ -2995,6 +3096,7 @@ class RunnerRalphActivities:
2995
3096
  experience=runtime_context.experience,
2996
3097
  experience_lesson_ids=tuple(runtime_context.experience_lesson_ids),
2997
3098
  experience_truncated=runtime_context.experience_truncated,
3099
+ skills=tuple(runtime_context.skills),
2998
3100
  workspace_path=resolved_workspace,
2999
3101
  branch_head_sha=branch_head_sha,
3000
3102
  product_id=runtime_context.product_id,
@@ -4253,7 +4355,8 @@ class RunnerRalphActivities:
4253
4355
  extra_env=attempt.env,
4254
4356
  mcp_servers=attempt.mcp_servers,
4255
4357
  egress_allow_list=attempt.egress_allow_list,
4256
- prompt=_learning_prompt(
4358
+ prompt=attempt.skills_preamble
4359
+ + _learning_prompt(
4257
4360
  request,
4258
4361
  base_branch=state.base_branch or request.base_ref,
4259
4362
  transcript=self._learner_transcript(state, request.work_record_id),
@@ -4488,7 +4591,7 @@ class RunnerRalphActivities:
4488
4591
  extra_env=attempt.env,
4489
4592
  mcp_servers=attempt.mcp_servers,
4490
4593
  egress_allow_list=attempt.egress_allow_list,
4491
- prompt=await kind.prompt(state, prompt_notes),
4594
+ prompt=attempt.skills_preamble + await kind.prompt(state, prompt_notes),
4492
4595
  base_branch=request.base_ref,
4493
4596
  work_branch=state.work_branch,
4494
4597
  )
@@ -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()