agentic-runner 2.6.0__py3-none-any.whl

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 (54) hide show
  1. agentic_runner/__init__.py +12 -0
  2. agentic_runner/activities.py +4918 -0
  3. agentic_runner/callback.py +342 -0
  4. agentic_runner/child_watcher.py +66 -0
  5. agentic_runner/cli.py +416 -0
  6. agentic_runner/config.py +105 -0
  7. agentic_runner/credentials.py +252 -0
  8. agentic_runner/device_login_activities.py +79 -0
  9. agentic_runner/egress.py +243 -0
  10. agentic_runner/heartbeat_link.py +249 -0
  11. agentic_runner/hooks.py +455 -0
  12. agentic_runner/host_store.py +295 -0
  13. agentic_runner/integrations/__init__.py +0 -0
  14. agentic_runner/integrations/git/__init__.py +1 -0
  15. agentic_runner/integrations/git/contracts.py +198 -0
  16. agentic_runner/integrations/git/evidence.py +442 -0
  17. agentic_runner/integrations/git/fake_workspace.py +339 -0
  18. agentic_runner/integrations/git/workspace.py +921 -0
  19. agentic_runner/integrations/github/__init__.py +53 -0
  20. agentic_runner/integrations/github/auth.py +171 -0
  21. agentic_runner/integrations/github/fake_client.py +494 -0
  22. agentic_runner/integrations/github/gh_client.py +944 -0
  23. agentic_runner/lifecycle.py +48 -0
  24. agentic_runner/llm_proxy.py +937 -0
  25. agentic_runner/mcp.py +342 -0
  26. agentic_runner/message_store.py +341 -0
  27. agentic_runner/py.typed +0 -0
  28. agentic_runner/recipient_key_secret.py +134 -0
  29. agentic_runner/registration.py +363 -0
  30. agentic_runner/runtime/__init__.py +0 -0
  31. agentic_runner/runtime/verifier_command.py +344 -0
  32. agentic_runner/sealed_box.py +509 -0
  33. agentic_runner/service.py +1068 -0
  34. agentic_runner/tiny_http.py +133 -0
  35. agentic_runner/triage_activities.py +113 -0
  36. agentic_runner/user_sources.py +546 -0
  37. agentic_runner/workers/__init__.py +1 -0
  38. agentic_runner/workers/_runtime_support.py +388 -0
  39. agentic_runner/workers/agent_runtime.py +93 -0
  40. agentic_runner/workers/claude_runtime.py +226 -0
  41. agentic_runner/workers/codex_runtime.py +311 -0
  42. agentic_runner/workers/command_policy.py +250 -0
  43. agentic_runner/workers/contract_device_login.py +211 -0
  44. agentic_runner/workers/contract_isolation.py +500 -0
  45. agentic_runner/workers/fastapi_client.py +396 -0
  46. agentic_runner/workers/harness_usage.py +65 -0
  47. agentic_runner/workers/mcp_config.py +111 -0
  48. agentic_runner/workers/settings.py +314 -0
  49. agentic_runner/workstation.py +687 -0
  50. agentic_runner-2.6.0.dist-info/METADATA +49 -0
  51. agentic_runner-2.6.0.dist-info/RECORD +54 -0
  52. agentic_runner-2.6.0.dist-info/WHEEL +4 -0
  53. agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
  54. agentic_runner-2.6.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,1068 @@
1
+ """``agentic-runner run``: the Runner process itself (PRD issue 46, ADR-0013 §8).
2
+
3
+ One process, one identity, many concurrent Directives. It registers once (or re-reads
4
+ the identity a previous boot persisted), heartbeats every 30 s over the outbound-only
5
+ stream, polls its own ``runner.{runner_id}`` queue for the activities the locality rule
6
+ assigns it, and serves its Directives the relocated LLM proxy and the callback socket.
7
+ Nothing here is platform code: the loop, the policy authority and the Evidence sink stay
8
+ where ADR-0013 §1 put them, and this module imports none of them.
9
+
10
+ Start-up fails closed before anything registers (17 A2): a Runner declared
11
+ ``contract_uid`` that cannot change uid exits non-zero naming ``CAP_SETUID``, and its
12
+ readiness probe never turns green. Auto-detecting ``none`` instead was rejected for
13
+ exactly the case that would make it tempting -- a ``securityContext`` someone edited.
14
+
15
+ The composition helpers below (``build_*``) are the single place a concrete runtime,
16
+ store or isolation is named.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import contextlib
23
+ import logging
24
+ import os
25
+ import shutil
26
+ import signal
27
+ import tempfile
28
+ import time
29
+ from collections.abc import Awaitable, Callable, Sequence
30
+ from dataclasses import dataclass, field
31
+ from datetime import UTC, datetime, timedelta
32
+ from pathlib import Path
33
+ from typing import Any, Protocol
34
+
35
+ import httpx
36
+ from temporalio import activity
37
+ from temporalio.client import Client
38
+ from temporalio.worker import (
39
+ ActivityInboundInterceptor,
40
+ ExecuteActivityInput,
41
+ Interceptor,
42
+ Worker,
43
+ )
44
+
45
+ from agentic_runner import __version__ as runner_version
46
+ from agentic_runner.activities import RunnerRalphActivities
47
+ from agentic_runner.child_watcher import install_stop_tolerant_child_watcher
48
+ from agentic_runner.config import RunnerConfig
49
+ from agentic_runner.config import load as load_config
50
+ from agentic_runner.credentials import (
51
+ CredentialResolver,
52
+ DirectoryCredentialStore,
53
+ EmptyCredentialStore,
54
+ HostCredentialStore,
55
+ )
56
+ from agentic_runner.device_login_activities import ContractDeviceLoginActivities
57
+ from agentic_runner.heartbeat_link import WAKE_DIVERGENCE, HeartbeatLink
58
+ from agentic_runner.hooks import AttemptFacts, HookName, HookRunner
59
+ from agentic_runner.integrations.git.workspace import LocalGitWorkspace
60
+ from agentic_runner.integrations.github.gh_client import GitHubAppClient
61
+ from agentic_runner.lifecycle import LifecycleOutbox
62
+ from agentic_runner.llm_proxy import CeilingStore, LlmProxy, SlotStore, UsageOutbox
63
+ from agentic_runner.mcp import ToolServerHealthLog
64
+ from agentic_runner.message_store import MESSAGES_DIR, MessageStore, TemporalWorkflowSignaller
65
+ from agentic_runner.registration import (
66
+ RunnerRegistrationClient,
67
+ RunnerRegistrationError,
68
+ RunnerState,
69
+ SignedIntakeStream,
70
+ can_separate_uids,
71
+ load_state,
72
+ require_isolation_supported,
73
+ save_state,
74
+ )
75
+ from agentic_runner.sealed_box import RecipientKeyStore, SealedCredentialStream
76
+ from agentic_runner.tiny_http import HttpRequest, read_request, write_json
77
+ from agentic_runner.triage_activities import RunnerTriageActivities
78
+ from agentic_runner.user_sources import ProxyTriage, UserSourcePoller
79
+ from agentic_runner.workers.agent_runtime import AgentRuntime
80
+ from agentic_runner.workers.claude_runtime import ClaudeRuntime
81
+ from agentic_runner.workers.codex_runtime import CodexRuntime
82
+ from agentic_runner.workers.contract_isolation import ContractIsolation
83
+ from agentic_runner.workers.fastapi_client import DirectiveTokenSource, RunnerFastApiClient
84
+ from agentic_runner.workers.settings import WorkerSettings, get_worker_settings
85
+ from agentic_runner_contracts import __version__ as contracts_version
86
+ from agentic_runner_contracts.channel_messages import TranscriptDelivery
87
+ from agentic_runner_contracts.routing import RunnerRoutingIdentity
88
+ from agentic_runner_contracts.runner_registration import (
89
+ RECENT_OUTCOMES_MAX,
90
+ CliVersion,
91
+ DirectiveOutcome,
92
+ EgressPosture,
93
+ HarnessVersion,
94
+ HeartbeatAck,
95
+ HeartbeatEnvelope,
96
+ HostAttestation,
97
+ IsolationMode,
98
+ LifecycleKind,
99
+ RecipientKey,
100
+ ResourcePressure,
101
+ )
102
+ from agentic_runner_contracts.runtime_context import WorkerRuntimeContextResolver
103
+ from agentic_runner_contracts.sealed_credential import key_fingerprint
104
+
105
+ __all__ = [
106
+ "AGENT_TOKEN_ENV",
107
+ "CONTROL_PLANE_ENV",
108
+ "EGRESS_POSTURE_ENV",
109
+ "ISOLATION_ENV",
110
+ "READINESS_PORT_ENV",
111
+ "RECIPIENT_KEY_ID_ENV",
112
+ "RECIPIENT_PUBLIC_KEY_ENV",
113
+ "TAGS_ENV",
114
+ "TEMPORAL_TLS_ENV",
115
+ "ControlPlaneStream",
116
+ "GracefulWorker",
117
+ "Readiness",
118
+ "build_agent_runtimes",
119
+ "build_contract_isolation",
120
+ "build_credential_resolver",
121
+ "build_hook_runner",
122
+ "build_socket_dir",
123
+ "graceful_shutdown_timeout",
124
+ "parse_tags",
125
+ "recipient_key",
126
+ "register",
127
+ "run",
128
+ "run_runner_lifecycle_hook",
129
+ "run_worker_until_terminated",
130
+ ]
131
+
132
+ _logger = logging.getLogger(__name__)
133
+
134
+ # The Helm values and the workstation installer both land here as environment (ADR-0013
135
+ # §8). The Agent Token is read from the environment and never from a flag: a flag would
136
+ # put it in the process table for every other uid on the host to read.
137
+ AGENT_TOKEN_ENV = "AGENTIC_AGENT_TOKEN"
138
+ CONTROL_PLANE_ENV = "AGENTIC_CONTROL_PLANE_URL"
139
+ TAGS_ENV = "AGENTIC_RUNNER_TAGS"
140
+ ISOLATION_ENV = "AGENTIC_RUNNER_ISOLATION"
141
+ EGRESS_POSTURE_ENV = "AGENTIC_RUNNER_EGRESS_POSTURE"
142
+ MAX_CONCURRENT_DIRECTIVES_ENV = "AGENTIC_RUNNER_MAX_CONCURRENT_DIRECTIVES"
143
+ READINESS_PORT_ENV = "AGENTIC_RUNNER_READINESS_PORT"
144
+ TEMPORAL_TLS_ENV = "AGENTIC_TEMPORAL_TLS"
145
+ # How often a user-connected Source is read (PRD issue 50). A minute is how often a person
146
+ # glances at their inbox; anything tighter spends IMAP logins, not attention.
147
+ SOURCE_POLL_SECONDS_ENV = "AGENTIC_RUNNER_SOURCE_POLL_SECONDS"
148
+ DEFAULT_SOURCE_POLL_SECONDS = 60.0
149
+ # The Recipient Key is normally *generated here*, once per installation, and kept in the
150
+ # state directory (PRD issue 48, 22 A4). These two exist for an installer that mints it
151
+ # outside this process and are read only when both are set; the Helm chart takes the
152
+ # other route -- `recipient-key ensure-secret` installs the release's key into the
153
+ # state directory before this process starts, so the store below finds it.
154
+ RECIPIENT_KEY_ID_ENV = "AGENTIC_RECIPIENT_KEY_ID"
155
+ RECIPIENT_PUBLIC_KEY_ENV = "AGENTIC_RECIPIENT_PUBLIC_KEY"
156
+
157
+ # The verifier's own Temporal activity ceiling (the platform's workflows/ralph.py,
158
+ # _LONG_RUNNING_ACTIVITY_TIMEOUTS["run_verifier"]). A constant rather than an import:
159
+ # it bounds a *process* shutdown budget, and the Runner imports no workflow module.
160
+ _VERIFIER_ACTIVITY_BUDGET_SECONDS = 20 * 60
161
+ # Margin so the Temporal SDK's own graceful-shutdown timer expires and Worker.run()
162
+ # returns before Kubernetes SIGKILLs the pod at terminationGracePeriodSeconds (the
163
+ # chart's value and this must move together).
164
+ _SHUTDOWN_HEADROOM_SECONDS = 60
165
+
166
+ # What `agentic-runner status <org>` reads (PRD issue 47): the live pid, and a file the
167
+ # process touches on every acknowledged heartbeat, whose mtime is the heartbeat age.
168
+ PIDFILE_NAME = "runner.pid"
169
+ HEARTBEAT_STAMP_NAME = "last-heartbeat"
170
+
171
+
172
+ # ------------------------------------------------------------------ environment
173
+
174
+
175
+ def parse_tags(raw: str) -> dict[str, str]:
176
+ """``region=eu-west-1,gpu=none`` -> a Runner Tag set (25 §9).
177
+
178
+ Free-form by design, and sent exactly once at bootstrap: a tag never enters a queue
179
+ name, a Search Attribute or a heartbeat body.
180
+ """
181
+
182
+ tags: dict[str, str] = {}
183
+ for pair in raw.split(","):
184
+ if not pair.strip():
185
+ continue
186
+ key, separator, value = pair.partition("=")
187
+ if not separator:
188
+ raise ValueError(f"Runner Tag {pair!r} is not key=value")
189
+ tags[key.strip()] = value.strip()
190
+ return tags
191
+
192
+
193
+ def isolation_mode() -> IsolationMode:
194
+ return IsolationMode(os.environ.get(ISOLATION_ENV, "").strip() or IsolationMode.CONTRACT_UID)
195
+
196
+
197
+ def recipient_key(state_dir: Path) -> tuple[RecipientKey, str]:
198
+ """This installation's Recipient Key and its fingerprint (22 A4).
199
+
200
+ Generated on first call and never again: the private half stays in the state directory
201
+ at 0600 and the platform only ever relays the public one. An operator-supplied pair
202
+ (both env vars) wins, because an installer that minted the key elsewhere is stating
203
+ which key this installation is.
204
+ """
205
+
206
+ key_id = os.environ.get(RECIPIENT_KEY_ID_ENV, "").strip()
207
+ public_key = os.environ.get(RECIPIENT_PUBLIC_KEY_ENV, "").strip()
208
+ if key_id and public_key:
209
+ return RecipientKey(key_id=key_id, public_key=public_key), key_fingerprint(public_key)
210
+ generated = RecipientKeyStore(state_dir).current()
211
+ return (
212
+ RecipientKey(key_id=generated.key_id, public_key=generated.public_key),
213
+ generated.fingerprint,
214
+ )
215
+
216
+
217
+ def _require_env(name: str) -> str:
218
+ value = os.environ.get(name, "").strip()
219
+ if not value:
220
+ raise RunnerRegistrationError("configuration_missing", f"{name} is not set")
221
+ return value
222
+
223
+
224
+ def _max_concurrent_directives() -> int:
225
+ return int(os.environ.get(MAX_CONCURRENT_DIRECTIVES_ENV, "").strip() or 1)
226
+
227
+
228
+ async def register(
229
+ *,
230
+ state_dir: Path,
231
+ can_separate_uids: bool,
232
+ client: httpx.AsyncClient | None = None,
233
+ control_plane: str | None = None,
234
+ agent_token: str | None = None,
235
+ isolation: IsolationMode | None = None,
236
+ ) -> tuple[RunnerState, str]:
237
+ """First boot (PRD issue 41): exchange the Agent Token for this process's identity.
238
+
239
+ Idempotent by state file -- a restart re-reads what is already there rather than
240
+ registering a second Runner against the Organisation's cap (21 A7). The keyword
241
+ overrides are the workstation ``install`` (issue 47), which takes the Agent Token
242
+ pasted at a prompt rather than from a Helm-set environment.
243
+ """
244
+
245
+ existing = load_state(state_dir)
246
+ if existing is not None:
247
+ return existing, f"already registered {existing.runner_id} on {existing.task_queue}"
248
+ recipient, fingerprint = recipient_key(state_dir)
249
+ registration = RunnerRegistrationClient(
250
+ base_url=control_plane or _require_env(CONTROL_PLANE_ENV), client=client
251
+ )
252
+ state, response = await registration.bootstrap(
253
+ agent_token=agent_token or _require_env(AGENT_TOKEN_ENV),
254
+ tags=parse_tags(os.environ.get(TAGS_ENV, "")),
255
+ isolation_mode=isolation or isolation_mode(),
256
+ recipient_key=recipient,
257
+ max_concurrent_directives=_max_concurrent_directives(),
258
+ can_separate_uids=can_separate_uids,
259
+ )
260
+ save_state(state_dir, state)
261
+ return state, (
262
+ f"registered {state.runner_id} in namespace {state.temporal_namespace} "
263
+ f"on {state.task_queue}; contracts floor {response.contracts_floor} "
264
+ f"({response.floor_state.value}); Recipient Key {fingerprint}"
265
+ )
266
+
267
+
268
+ # ------------------------------------------------------------------ composition
269
+
270
+
271
+ def build_contract_isolation(
272
+ settings: WorkerSettings, *, can_separate_uids: bool | None = None
273
+ ) -> ContractIsolation:
274
+ """The Runner's uid allocator and Contract directory owner (ADR-0015 §1-§3)."""
275
+
276
+ return ContractIsolation(
277
+ workspace_root=_setting(settings, "WORKSPACE_ROOT", Path("/var/lib/agentic-os/workspaces")),
278
+ state_dir=_setting(settings, "WORKER_STATE_DIR", Path("/var/lib/agentic-os/state")),
279
+ uid_min=_setting(settings, "CONTRACT_UID_MIN", 60_000),
280
+ uid_max=_setting(settings, "CONTRACT_UID_MAX", 60_999),
281
+ max_processes=_setting(settings, "CONTRACT_MAX_PROCESSES", 512),
282
+ memory_limit_bytes=_setting(settings, "CONTRACT_MEMORY_LIMIT_BYTES", 4 * 1024**3),
283
+ can_separate_uids=can_separate_uids,
284
+ )
285
+
286
+
287
+ def _setting(settings: object, name: str, default: Any) -> Any:
288
+ return getattr(settings, name, default)
289
+
290
+
291
+ def build_hook_runner(config: RunnerConfig) -> HookRunner:
292
+ """Load the Runner Hooks once, at the composition root (PRD issue 45).
293
+
294
+ Once, not per Directive: `load_hooks` refuses a filename that is not a catalogue slot,
295
+ and an operator's typo should stop the Runner starting rather than fail one Work
296
+ Record at a time, hours later, on whichever pod happened to pick it up.
297
+ """
298
+
299
+ return HookRunner(hooks_path=config.hooks_path)
300
+
301
+
302
+ def build_credential_resolver(
303
+ config: RunnerConfig, *, host_store: HostCredentialStore | None = None
304
+ ) -> CredentialResolver:
305
+ """The host store a Directive resolves its Credential References from (22 A1).
306
+
307
+ No store configured resolves to `EmptyCredentialStore` rather than to no resolver at
308
+ all -- "nothing is installed" is the fail-closed reading. Values a funder sealed land
309
+ in this same resolver, under the same reference name, from the heartbeat stream.
310
+ """
311
+
312
+ store: HostCredentialStore = host_store or (
313
+ DirectoryCredentialStore(config.credential_store)
314
+ if config.credential_store is not None
315
+ else EmptyCredentialStore()
316
+ )
317
+ return CredentialResolver(store=store)
318
+
319
+
320
+ def build_socket_dir(config: RunnerConfig) -> Path:
321
+ """Where per-attempt callback sockets live, resolved once.
322
+
323
+ ``sun_path`` is ~104 bytes, so the sockets need a short root of their own. A Runner
324
+ that cannot create the configured one (a read-only `/run`, a developer's laptop)
325
+ falls back to a private temp directory here, at start-up, so a callback socket is
326
+ never the reason a Work Record fails.
327
+ """
328
+
329
+ try:
330
+ config.socket_dir.mkdir(parents=True, exist_ok=True)
331
+ except OSError:
332
+ fallback = Path(tempfile.mkdtemp(prefix="agentic-runner-sockets-"))
333
+ # 0755, not mkdtemp's 0700: the per-attempt subdirectory below is chowned to the
334
+ # Contract's uid, and that uid cannot traverse a parent only the Runner may enter.
335
+ fallback.chmod(0o755)
336
+ _logger.warning("callback socket dir %s unusable; using %s", config.socket_dir, fallback)
337
+ return fallback
338
+ return config.socket_dir
339
+
340
+
341
+ _RUNTIMES: dict[str, Callable[[WorkerSettings], AgentRuntime]] = {
342
+ "claude_code": lambda settings: ClaudeRuntime(settings=settings),
343
+ "codex_cli": lambda settings: CodexRuntime(settings=settings),
344
+ }
345
+
346
+
347
+ def build_agent_runtimes(
348
+ settings: WorkerSettings, clis: Sequence[CliVersion]
349
+ ) -> dict[str, AgentRuntime]:
350
+ """Every Agent Runtime this process serves, by the Profile's `cli_kind` (ADR-0006).
351
+
352
+ The one place a concrete runtime is named; everything downstream depends only on the
353
+ port. One process serves every CLI it found that clears its floor, and each Directive
354
+ picks from here by the Work Record's `cli_kind` (local-agents 01) -- there is no
355
+ process-wide choice. The same `clis` ride the heartbeat's attestation, so what this
356
+ Runner serves and what routing believes it serves are one list.
357
+ """
358
+
359
+ return {
360
+ cli.cli_kind: _RUNTIMES[cli.cli_kind](settings)
361
+ for cli in clis
362
+ if cli.meets_floor and cli.cli_kind in _RUNTIMES
363
+ }
364
+
365
+
366
+ async def run_runner_lifecycle_hook(hooks: HookRunner, name: HookName) -> None:
367
+ """``runner_startup`` / ``runner_shutdown``: the two slots outside a Directive.
368
+
369
+ They run as the Runner's own uid and in its own working directory, because there is
370
+ no Contract yet and no Workspace to stand in. A non-zero exit is logged, never fatal:
371
+ refusing to start a Runner on a bad cleanup script would take an Organisation's whole
372
+ fleet offline.
373
+ """
374
+
375
+ run = await hooks.run(name, cwd=Path.cwd(), facts=AttemptFacts())
376
+ if run is not None:
377
+ _logger.info("%s hook exited %d in %dms", name.value, run.exit_code, run.duration_ms)
378
+
379
+
380
+ def graceful_shutdown_timeout(settings: WorkerSettings) -> timedelta:
381
+ """One Directive's worst case: the CLI execution ceiling plus the verifier budget.
382
+
383
+ Sized so ``Worker.shutdown()`` (issue 02) waits out a real in-flight Directive
384
+ instead of cancelling it once this timer lapses.
385
+ """
386
+
387
+ cli_timeout_seconds = max(
388
+ settings.CODEX_CLI_TIMEOUT_SECONDS, settings.CLAUDE_CLI_TIMEOUT_SECONDS
389
+ )
390
+ return timedelta(
391
+ seconds=cli_timeout_seconds + _VERIFIER_ACTIVITY_BUDGET_SECONDS - _SHUTDOWN_HEADROOM_SECONDS
392
+ )
393
+
394
+
395
+ class GracefulWorker(Protocol):
396
+ """The subset of ``temporalio.worker.Worker`` the shutdown drain depends on."""
397
+
398
+ async def run(self) -> None: ...
399
+ async def shutdown(self) -> None: ...
400
+
401
+
402
+ async def run_worker_until_terminated(
403
+ worker: GracefulWorker, *, stop: asyncio.Event | None = None
404
+ ) -> None:
405
+ """Poll until SIGTERM (or ``stop``), then drain: stop polling, let the running
406
+ activity finish.
407
+
408
+ Python's default SIGTERM disposition kills the process outright, discarding whatever
409
+ Directive is mid-run. The chart's ``preStop`` hook (issue 02) signals termination as
410
+ early as possible; this handler is what turns that into ``Worker.shutdown()`` -- stop
411
+ polling for new work, wait for the in-flight activity, then let ``run()`` return.
412
+ """
413
+
414
+ loop = asyncio.get_running_loop()
415
+ stop_event = stop or asyncio.Event()
416
+ loop.add_signal_handler(signal.SIGTERM, stop_event.set)
417
+ try:
418
+ run_task = asyncio.ensure_future(worker.run())
419
+ stop_task = asyncio.ensure_future(stop_event.wait())
420
+ await asyncio.wait({run_task, stop_task}, return_when=asyncio.FIRST_COMPLETED)
421
+ if not stop_task.done():
422
+ stop_task.cancel()
423
+ if not run_task.done():
424
+ _logger.info("Worker received SIGTERM: draining in-flight activity before exit")
425
+ await worker.shutdown()
426
+ await run_task
427
+ finally:
428
+ loop.remove_signal_handler(signal.SIGTERM)
429
+
430
+
431
+ # ------------------------------------------------------------------ the stream
432
+
433
+
434
+ class _LoadInterceptor(Interceptor):
435
+ """Counts the Directives in flight and keeps the last-N outcomes (06 §4)."""
436
+
437
+ def __init__(self) -> None:
438
+ self.in_flight = 0
439
+ self.outcomes: list[DirectiveOutcome] = []
440
+
441
+ def intercept_activity(self, next: ActivityInboundInterceptor) -> ActivityInboundInterceptor:
442
+ return _LoadInbound(next, self)
443
+
444
+
445
+ class _LoadInbound(ActivityInboundInterceptor):
446
+ def __init__(self, next: ActivityInboundInterceptor, load: _LoadInterceptor) -> None:
447
+ super().__init__(next)
448
+ self._load = load
449
+
450
+ async def execute_activity(self, input: ExecuteActivityInput) -> Any:
451
+ self._load.in_flight += 1
452
+ name = activity.info().activity_type
453
+ try:
454
+ result = await self.next.execute_activity(input)
455
+ except Exception as error:
456
+ self._record(name, "failed", type(error).__name__)
457
+ raise
458
+ else:
459
+ self._record(name, "succeeded", None)
460
+ return result
461
+ finally:
462
+ self._load.in_flight -= 1
463
+
464
+ def _record(self, name: str, status: str, exception_type: str | None) -> None:
465
+ self._load.outcomes.append(
466
+ DirectiveOutcome(activity=name, status=status, exception_type=exception_type)
467
+ )
468
+ del self._load.outcomes[:-RECENT_OUTCOMES_MAX]
469
+
470
+
471
+ def resource_pressure(path: Path) -> ResourcePressure:
472
+ """Enough to answer "wedged or merely busy" (06 §4), from what the OS offers."""
473
+
474
+ cpu = 0.0
475
+ if hasattr(os, "getloadavg"):
476
+ cpu = min(100.0, os.getloadavg()[0] / (os.cpu_count() or 1) * 100.0)
477
+ memory = 0.0
478
+ try:
479
+ fields = dict(line.split(":", 1) for line in Path("/proc/meminfo").read_text().splitlines())
480
+ total = float(fields["MemTotal"].split()[0])
481
+ available = float(fields["MemAvailable"].split()[0])
482
+ memory = max(0.0, min(100.0, (1 - available / total) * 100.0))
483
+ except (OSError, KeyError, ValueError, ZeroDivisionError):
484
+ pass
485
+ try:
486
+ usage = shutil.disk_usage(path)
487
+ disk = usage.used / usage.total * 100.0 if usage.total else 0.0
488
+ except OSError:
489
+ disk = 0.0
490
+ return ResourcePressure(cpu_percent=cpu, memory_percent=memory, disk_percent=disk)
491
+
492
+
493
+ @dataclass
494
+ class ControlPlaneStream:
495
+ """One heartbeat exchange, as ``HeartbeatLink`` drives it (PRD issues 41, 43, 44, 48).
496
+
497
+ Everything that rides the stream is assembled here from the stores that produced it:
498
+ the proxy's Usage Records and slot fields, the sealed values opened since the last
499
+ beat, the applied Grant snapshots the link hands in. What comes back is applied in
500
+ the same place -- usage acknowledged, sealed values opened into the resolver, the
501
+ refreshed Runner Token kept for the Temporal connection.
502
+ """
503
+
504
+ client: RunnerRegistrationClient
505
+ state: RunnerState
506
+ isolation: IsolationMode
507
+ proxy: LlmProxy
508
+ sealed: SealedCredentialStream
509
+ credentials: CredentialResolver
510
+ load: _LoadInterceptor
511
+ egress_posture: EgressPosture = EgressPosture.UNRESTRICTED
512
+ tag_set_version: int = 1
513
+ runner_token: str | None = None
514
+ runner_token_expires_at: datetime | None = None
515
+ clock: Callable[[], datetime] = field(default=lambda: datetime.now(UTC))
516
+ # Issue 47: the self-reported build and host facts, the lifecycle facts waiting for an
517
+ # ack, and where `status` reads the heartbeat age from.
518
+ attestation: HostAttestation | None = None
519
+ lifecycle: LifecycleOutbox | None = None
520
+ heartbeat_stamp: Path | None = None
521
+ monotonic: Callable[[], float] = time.monotonic
522
+ last_sleep_at: datetime | None = None
523
+ last_wake_at: datetime | None = None
524
+ # PRD issue 50: the user-connected Sources this Runner reads, re-assigned every ack.
525
+ sources: UserSourcePoller | None = None
526
+ # PRD issue 52: where a transcript pull-through relayed on the ack is answered from.
527
+ messages: MessageStore | None = None
528
+ # Console-v2 issue 29: the latest start of each Runner-hosted Tool Server.
529
+ tool_servers: ToolServerHealthLog | None = None
530
+ _delivered: set[tuple[str, str]] = field(default_factory=set)
531
+ _last_seen: tuple[float, datetime] | None = None
532
+
533
+ def _observe_sleep(self, now: datetime) -> None:
534
+ """A wall clock that ran ahead of the monotonic one is a suspend (23 item 4).
535
+
536
+ Recorded, never acted on here: the attempt a sleep outlived is failed by its own
537
+ Temporal heartbeat timeout, and the verb seams re-heartbeat before acting (44).
538
+ """
539
+
540
+ monotonic = self.monotonic()
541
+ if self._last_seen is not None:
542
+ seen_monotonic, seen_wall = self._last_seen
543
+ awake = monotonic - seen_monotonic
544
+ if (now - seen_wall).total_seconds() - awake > WAKE_DIVERGENCE:
545
+ self.last_sleep_at = seen_wall + timedelta(seconds=awake)
546
+ self.last_wake_at = now
547
+ if self.lifecycle is not None:
548
+ self.lifecycle.add(LifecycleKind.WAKE, now, slept_at=self.last_sleep_at)
549
+ self._last_seen = (monotonic, now)
550
+
551
+ def _clock_skew(self, now: datetime) -> float:
552
+ server = self.client.server_date
553
+ return 0.0 if server is None else round((now - server).total_seconds(), 1)
554
+
555
+ def _harnesses(self) -> list[HarnessVersion]:
556
+ """Per Contract: the CLI's version and whether it runs on a delivered key (22 A8)
557
+ or on the login its funder created in the harness root -- read off the slot, never
558
+ off the root, which the Runner does not open."""
559
+
560
+ if self.attestation is None:
561
+ return []
562
+ versions = {cli.cli_kind: cli.version for cli in self.attestation.clis}
563
+ return [
564
+ HarnessVersion(
565
+ contract_id=slot.contract_id,
566
+ cli_kind=slot.runtime_kind,
567
+ version=versions.get(slot.runtime_kind, "unknown"),
568
+ auth_mode="api_key" if slot.present else "login",
569
+ )
570
+ for slot in self.proxy.slots.statuses()
571
+ ]
572
+
573
+ async def directive_token(self, directive_id: str) -> str:
574
+ """One activity execution's control-plane token, by Directive id (PRD issue 63)."""
575
+
576
+ return (await self.client.request_directive_token(self.state, directive_id)).token
577
+
578
+ async def exchange(self, applied: Sequence[Any]) -> HeartbeatAck:
579
+ now = self.clock()
580
+ self._observe_sleep(now)
581
+ lifecycle = self.lifecycle.pending() if self.lifecycle is not None else []
582
+ renewed, resealed = self.sealed.renew(now)
583
+ envelope = HeartbeatEnvelope(
584
+ runner_version=runner_version,
585
+ contracts_version=contracts_version,
586
+ resource_pressure=resource_pressure(Path.cwd()),
587
+ max_concurrent_directives=self.state.max_concurrent_directives,
588
+ current_load=self.load.in_flight,
589
+ recent_outcomes=list(self.load.outcomes),
590
+ slots=self.proxy.slots.statuses(),
591
+ usage=self.proxy.outbox.pending(),
592
+ egress_posture=self.egress_posture,
593
+ isolation_mode=self.isolation,
594
+ tag_set_version=self.tag_set_version,
595
+ hosted_task_queue=self.state.task_queue,
596
+ recipient_key=renewed,
597
+ resealed=resealed,
598
+ opened=self.sealed.take_opened(),
599
+ applied_snapshots=list(applied),
600
+ harnesses=self._harnesses(),
601
+ attestation=self.attestation,
602
+ lifecycle=lifecycle,
603
+ last_sleep_at=self.last_sleep_at,
604
+ last_wake_at=self.last_wake_at,
605
+ clock_skew_seconds=self._clock_skew(now),
606
+ source_status=self.sources.statuses() if self.sources is not None else [],
607
+ tool_servers=self.tool_servers.latest() if self.tool_servers is not None else [],
608
+ )
609
+ ack = await self.client.heartbeat(self.state, envelope)
610
+ if self.sources is not None:
611
+ self.sources.assign(ack.user_sources)
612
+ await self._deliver_transcripts(ack)
613
+ if self.lifecycle is not None:
614
+ self.lifecycle.acknowledge(lifecycle)
615
+ if self.heartbeat_stamp is not None:
616
+ self.heartbeat_stamp.touch()
617
+ self.proxy.outbox.acknowledge(ack.usage_accepted)
618
+ for refused in self.sealed.apply(ack.sealed_credentials):
619
+ _logger.warning(
620
+ "sealed credential %s/%s could not be opened with this installation's key",
621
+ refused.contract_id,
622
+ refused.slot,
623
+ )
624
+ self._sync_delivered()
625
+ if ack.runner_token is not None:
626
+ self.runner_token = ack.runner_token
627
+ self.runner_token_expires_at = ack.runner_token_expires_at
628
+ self.tag_set_version = ack.tag_set_version
629
+ if ack.hold_reason:
630
+ _logger.warning("control plane holds new Directives: %s", ack.hold_reason)
631
+ return ack
632
+
633
+ async def _deliver_transcripts(self, ack: HeartbeatAck) -> None:
634
+ """The pull-through's Runner half (PRD issue 52, map ticket 10).
635
+
636
+ The bodies go back over this same signed stream and nowhere else; a request this
637
+ Runner cannot answer (no store, a sealed Contract) is answered with the reason so
638
+ the console can say so rather than time out.
639
+ """
640
+
641
+ for request in ack.transcript_requests:
642
+ delivery = (
643
+ self.messages.transcript(request)
644
+ if self.messages is not None
645
+ else TranscriptDelivery(
646
+ request_id=request.request_id,
647
+ work_record_id=request.work_record_id,
648
+ refused="no_store",
649
+ )
650
+ )
651
+ try:
652
+ await self.client.deliver_transcript(self.state, delivery)
653
+ except Exception as error: # noqa: BLE001 - a lost delivery is the console's timeout
654
+ _logger.warning(
655
+ "transcript %s could not be delivered: %s", request.request_id, error
656
+ )
657
+
658
+ def _sync_delivered(self) -> None:
659
+ """The resolver holds exactly what the last ack let this installation open."""
660
+
661
+ plaintext = self.sealed.plaintext
662
+ for (contract_id, slot), value in plaintext.items():
663
+ self.credentials.deliver(contract_id, slot, value)
664
+ for contract_id, slot in self._delivered - set(plaintext):
665
+ self.credentials.drop(contract_id, slot)
666
+ self._delivered = set(plaintext)
667
+
668
+
669
+ # ------------------------------------------------------------------ readiness
670
+
671
+
672
+ class Readiness:
673
+ """``GET /healthz``: 503 until this Runner is registered, heard and polling."""
674
+
675
+ def __init__(self, port: int) -> None:
676
+ self._port = port
677
+ self.ready = False
678
+ self._server: asyncio.AbstractServer | None = None
679
+
680
+ @property
681
+ def port(self) -> int:
682
+ return self._port
683
+
684
+ async def start(self) -> None:
685
+ self._server = await asyncio.start_server(self._serve, host="0.0.0.0", port=self._port)
686
+ self._port = self._server.sockets[0].getsockname()[1]
687
+
688
+ async def close(self) -> None:
689
+ if self._server is not None:
690
+ self._server.close()
691
+ await self._server.wait_closed()
692
+
693
+ async def _serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
694
+ try:
695
+ request = await read_request(reader)
696
+ if not isinstance(request, HttpRequest):
697
+ await write_json(writer, *request)
698
+ elif self.ready:
699
+ await write_json(writer, 200, {"ready": True})
700
+ else:
701
+ await write_json(writer, 503, {"ready": False})
702
+ finally:
703
+ writer.close()
704
+ with contextlib.suppress(Exception):
705
+ await writer.wait_closed()
706
+
707
+
708
+ # ------------------------------------------------------------------ the process
709
+
710
+ Connector = Callable[..., Awaitable[Client]]
711
+ WorkerFactory = Callable[..., GracefulWorker]
712
+
713
+
714
+ def _temporal_worker(client: Client, **kwargs: Any) -> Worker:
715
+ return Worker(client, **kwargs)
716
+
717
+
718
+ async def run(
719
+ *,
720
+ http_client: httpx.AsyncClient | None = None,
721
+ connect: Connector = Client.connect,
722
+ worker_factory: WorkerFactory = _temporal_worker,
723
+ can_change_uid: bool | None = None,
724
+ stop: asyncio.Event | None = None,
725
+ heartbeat_interval: float | None = None,
726
+ readiness: Readiness | None = None,
727
+ host_store: HostCredentialStore | None = None,
728
+ attestation: HostAttestation | None = None,
729
+ ) -> int:
730
+ """The Runner process. Returns the exit code.
731
+
732
+ The seams are injectable for the test that stands two of these up against a fake
733
+ control plane: the HTTP transport, the Temporal connection, the Worker and the
734
+ capability check. Production passes none of them. ``host_store`` and
735
+ ``attestation`` are the workstation's (issue 47): its OS credential store and what
736
+ it says about itself. ``heartbeat_interval`` defaults to the cadence bootstrap gave.
737
+ """
738
+
739
+ install_stop_tolerant_child_watcher(asyncio.get_running_loop())
740
+ config = load_config()
741
+ mode = isolation_mode()
742
+ can = can_separate_uids() if can_change_uid is None else can_change_uid
743
+ try:
744
+ require_isolation_supported(mode, can_separate_uids=can)
745
+ control_plane = _require_env(CONTROL_PLANE_ENV)
746
+ except RunnerRegistrationError as error:
747
+ print(f"refusing to start ({error.reason}): {error}", flush=True)
748
+ return 1
749
+ # The control plane the activities call back is the one this process registered
750
+ # with, unless the operator points them at an internal address explicitly.
751
+ os.environ.setdefault("INTERNAL_FASTAPI_BASE_URL", control_plane)
752
+ settings = get_worker_settings()
753
+ state_dir = config.state_dir
754
+
755
+ readiness = readiness or Readiness(int(os.environ.get(READINESS_PORT_ENV, "").strip() or 0))
756
+ await readiness.start()
757
+ try:
758
+ return await _serve(
759
+ config=config,
760
+ settings=settings,
761
+ mode=mode,
762
+ can_change_uid=can,
763
+ control_plane=control_plane,
764
+ state_dir=state_dir,
765
+ readiness=readiness,
766
+ http_client=http_client,
767
+ connect=connect,
768
+ worker_factory=worker_factory,
769
+ stop=stop,
770
+ heartbeat_interval=heartbeat_interval,
771
+ host_store=host_store,
772
+ attestation=attestation,
773
+ )
774
+ finally:
775
+ await readiness.close()
776
+
777
+
778
+ async def _serve(
779
+ *,
780
+ config: RunnerConfig,
781
+ settings: WorkerSettings,
782
+ mode: IsolationMode,
783
+ can_change_uid: bool,
784
+ control_plane: str,
785
+ state_dir: Path,
786
+ readiness: Readiness,
787
+ http_client: httpx.AsyncClient | None,
788
+ connect: Connector,
789
+ worker_factory: WorkerFactory,
790
+ stop: asyncio.Event | None,
791
+ heartbeat_interval: float | None,
792
+ host_store: HostCredentialStore | None,
793
+ attestation: HostAttestation | None,
794
+ ) -> int:
795
+ try:
796
+ state, outcome = await register(
797
+ state_dir=state_dir, can_separate_uids=can_change_uid, client=http_client
798
+ )
799
+ except RunnerRegistrationError as error:
800
+ print(f"registration refused ({error.reason}): {error}", flush=True)
801
+ return 1
802
+ print(outcome, flush=True)
803
+ interval = (
804
+ heartbeat_interval if heartbeat_interval is not None else state.heartbeat_interval_seconds
805
+ )
806
+ # `status` reads these two (issue 47); the process is the only writer of either.
807
+ pidfile = state_dir / PIDFILE_NAME
808
+ pidfile.write_text(str(os.getpid()), encoding="utf-8")
809
+ lifecycle = LifecycleOutbox(state_dir)
810
+ lifecycle.add(LifecycleKind.START, datetime.now(UTC))
811
+ try:
812
+ return await _serve_registered(
813
+ config=config,
814
+ settings=settings,
815
+ state=state,
816
+ mode=mode,
817
+ can_change_uid=can_change_uid,
818
+ control_plane=control_plane,
819
+ state_dir=state_dir,
820
+ readiness=readiness,
821
+ http_client=http_client,
822
+ connect=connect,
823
+ worker_factory=worker_factory,
824
+ stop=stop,
825
+ interval=interval,
826
+ host_store=host_store,
827
+ attestation=attestation,
828
+ lifecycle=lifecycle,
829
+ )
830
+ finally:
831
+ pidfile.unlink(missing_ok=True)
832
+
833
+
834
+ async def _serve_registered(
835
+ *,
836
+ config: RunnerConfig,
837
+ settings: WorkerSettings,
838
+ state: RunnerState,
839
+ mode: IsolationMode,
840
+ can_change_uid: bool,
841
+ control_plane: str,
842
+ state_dir: Path,
843
+ readiness: Readiness,
844
+ http_client: httpx.AsyncClient | None,
845
+ connect: Connector,
846
+ worker_factory: WorkerFactory,
847
+ stop: asyncio.Event | None,
848
+ interval: float,
849
+ host_store: HostCredentialStore | None,
850
+ attestation: HostAttestation | None,
851
+ lifecycle: LifecycleOutbox,
852
+ ) -> int:
853
+ load = _LoadInterceptor()
854
+ proxy = LlmProxy(slots=SlotStore(), ceilings=CeilingStore(), outbox=UsageOutbox())
855
+ credentials = build_credential_resolver(config, host_store=host_store)
856
+ stream = ControlPlaneStream(
857
+ client=RunnerRegistrationClient(base_url=control_plane, client=http_client),
858
+ state=state,
859
+ isolation=mode,
860
+ proxy=proxy,
861
+ sealed=SealedCredentialStream(RecipientKeyStore(state_dir)),
862
+ credentials=credentials,
863
+ load=load,
864
+ egress_posture=EgressPosture(
865
+ os.environ.get(EGRESS_POSTURE_ENV, "").strip() or EgressPosture.UNRESTRICTED
866
+ ),
867
+ attestation=attestation,
868
+ lifecycle=lifecycle,
869
+ heartbeat_stamp=state_dir / HEARTBEAT_STAMP_NAME,
870
+ )
871
+ stream.sources = UserSourcePoller(
872
+ stream=SignedIntakeStream(stream.client, state),
873
+ store=credentials.host_store,
874
+ triage=ProxyTriage(
875
+ proxy=proxy,
876
+ model=settings.TRIAGE_MODEL,
877
+ timeout_seconds=settings.TRIAGE_DIRECTIVE_TIMEOUT_SECONDS,
878
+ ),
879
+ )
880
+ messages = MessageStore(state_dir / MESSAGES_DIR)
881
+ stream.messages = messages
882
+ tool_servers = ToolServerHealthLog()
883
+ stream.tool_servers = tool_servers
884
+ link = HeartbeatLink(stream, ceilings=proxy.ceilings)
885
+ # The first beat is the one that hands back a Runner Token: a persisted identity has
886
+ # none, and the Temporal connection below cannot be opened without it.
887
+ while True:
888
+ try:
889
+ await link.exchange()
890
+ break
891
+ except Exception as error: # noqa: BLE001 - keep trying until the control plane answers
892
+ _logger.warning("heartbeat failed before start: %s", error)
893
+ if stop is not None and stop.is_set():
894
+ return 1
895
+ await asyncio.sleep(interval)
896
+
897
+ client = await connect(
898
+ settings.TEMPORAL_ADDRESS,
899
+ namespace=state.temporal_namespace,
900
+ api_key=stream.runner_token,
901
+ tls=os.environ.get(TEMPORAL_TLS_ENV, "").strip().lower() in {"1", "true", "yes"},
902
+ )
903
+ hooks = build_hook_runner(config)
904
+ async with (
905
+ proxy,
906
+ RunnerFastApiClient(str(settings.INTERNAL_FASTAPI_BASE_URL), state=state) as fastapi,
907
+ ):
908
+ worker = worker_factory(
909
+ client,
910
+ task_queue=state.task_queue,
911
+ activities=_activities(
912
+ fastapi,
913
+ settings=settings,
914
+ config=config,
915
+ state=state,
916
+ can_change_uid=can_change_uid,
917
+ hooks=hooks,
918
+ proxy=proxy,
919
+ credentials=credentials,
920
+ link=link,
921
+ attestation=attestation,
922
+ token_source=stream,
923
+ messages=messages,
924
+ tool_servers=tool_servers,
925
+ # The wake signal rides the connection this process polls with: one
926
+ # namespace, the Organisation's own (PRD issue 52, map ticket 15 §2).
927
+ signaller=TemporalWorkflowSignaller(client),
928
+ ),
929
+ interceptors=[load],
930
+ graceful_shutdown_timeout=graceful_shutdown_timeout(settings),
931
+ max_concurrent_activities=state.max_concurrent_directives,
932
+ )
933
+ beat = asyncio.create_task(_heartbeat_forever(link, stream, client, interval))
934
+ poll = asyncio.create_task(_poll_sources_forever(stream.sources, _source_poll_seconds()))
935
+ readiness.ready = True
936
+ await run_runner_lifecycle_hook(hooks, HookName.RUNNER_STARTUP)
937
+ try:
938
+ await run_worker_until_terminated(worker, stop=stop)
939
+ finally:
940
+ readiness.ready = False
941
+ for task in (beat, poll):
942
+ task.cancel()
943
+ with contextlib.suppress(asyncio.CancelledError):
944
+ await task
945
+ await run_runner_lifecycle_hook(hooks, HookName.RUNNER_SHUTDOWN)
946
+ await _report_stop(link, lifecycle)
947
+ return 0
948
+
949
+
950
+ async def _report_stop(link: HeartbeatLink, lifecycle: LifecycleOutbox) -> None:
951
+ """One last beat carrying ``stop``; if it cannot land, the next start carries it."""
952
+
953
+ lifecycle.add(LifecycleKind.STOP, datetime.now(UTC))
954
+ try:
955
+ await asyncio.wait_for(link.exchange(), timeout=10)
956
+ except Exception as error: # noqa: BLE001 - the fact stays queued in the state dir
957
+ _logger.warning("stop not reported yet (%s); the next start carries it", error)
958
+
959
+
960
+ async def _heartbeat_forever(
961
+ link: HeartbeatLink, stream: ControlPlaneStream, client: Client, interval: float
962
+ ) -> None:
963
+ """Every 30 s, for as long as the process lives; a missed beat is logged, not fatal.
964
+
965
+ Staleness is the link's own property (issue 44): after three minutes of failures the
966
+ verb seams refuse on their own, so there is nothing for this loop to do about a
967
+ failure except try again on the next tick.
968
+ """
969
+
970
+ while True:
971
+ await asyncio.sleep(interval)
972
+ token_before = stream.runner_token
973
+ try:
974
+ await link.exchange()
975
+ except Exception as error: # noqa: BLE001 - any failed exchange is a lost beat
976
+ _logger.warning("heartbeat failed: %s", error)
977
+ continue
978
+ if stream.runner_token is not None and stream.runner_token != token_before:
979
+ client.api_key = stream.runner_token
980
+
981
+
982
+ def _source_poll_seconds() -> float:
983
+ raw = os.environ.get(SOURCE_POLL_SECONDS_ENV, "").strip()
984
+ return float(raw) if raw else DEFAULT_SOURCE_POLL_SECONDS
985
+
986
+
987
+ async def _poll_sources_forever(poller: UserSourcePoller, interval: float) -> None:
988
+ """Read every assigned user-connected Source once per interval (PRD issue 50).
989
+
990
+ Nothing to read until the first ack assigns something; a failed poll is the poller's
991
+ own business (it records the Source's status), never this loop's.
992
+ """
993
+
994
+ while True:
995
+ await asyncio.sleep(interval)
996
+ await poller.poll()
997
+
998
+
999
+ def _activities(
1000
+ fastapi: RunnerFastApiClient,
1001
+ *,
1002
+ settings: WorkerSettings,
1003
+ config: RunnerConfig,
1004
+ state: RunnerState,
1005
+ can_change_uid: bool,
1006
+ hooks: HookRunner,
1007
+ proxy: LlmProxy,
1008
+ credentials: CredentialResolver,
1009
+ link: HeartbeatLink,
1010
+ attestation: HostAttestation | None,
1011
+ token_source: DirectiveTokenSource | None = None,
1012
+ messages: MessageStore | None = None,
1013
+ signaller: TemporalWorkflowSignaller | None = None,
1014
+ tool_servers: ToolServerHealthLog | None = None,
1015
+ ) -> list[Callable[..., Any]]:
1016
+ github_client = GitHubAppClient(
1017
+ app_id=os.getenv("GITHUB_APP_ID"),
1018
+ installation_id=os.getenv("GITHUB_APP_INSTALLATION_ID"),
1019
+ private_key_pem=os.getenv("GITHUB_APP_PRIVATE_KEY_PEM"),
1020
+ api_base_url=os.getenv("GITHUB_API_BASE_URL", "https://api.github.com"),
1021
+ )
1022
+ static_git_token = os.getenv("AGENTIC_OS_GIT_TOKEN") or None
1023
+ isolation = build_contract_isolation(settings, can_separate_uids=can_change_uid)
1024
+ ralph = RunnerRalphActivities(
1025
+ fastapi,
1026
+ runtime_context_resolver=WorkerRuntimeContextResolver(fastapi),
1027
+ agent_runtimes=build_agent_runtimes(
1028
+ settings, attestation.clis if attestation is not None else []
1029
+ ),
1030
+ git_workspace=LocalGitWorkspace(
1031
+ git_token=static_git_token,
1032
+ git_token_provider=(
1033
+ None if static_git_token else github_client.installation_token_provider()
1034
+ ),
1035
+ command_timeout_seconds=settings.CODEX_CLI_TIMEOUT_SECONDS,
1036
+ output_limit_bytes=settings.CODEX_CLI_OUTPUT_LIMIT_BYTES,
1037
+ ),
1038
+ github_client=github_client,
1039
+ workspace_root=settings.WORKSPACE_ROOT,
1040
+ contract_isolation=isolation,
1041
+ # The host party is the control plane's to assign (17 A3); a state file written
1042
+ # before issue 42 carries none, and such a process makes no routing assertion.
1043
+ routing_identity=(
1044
+ RunnerRoutingIdentity(
1045
+ runner_id=str(state.runner_id), host_party=state.host_party, tags=dict(state.tags)
1046
+ )
1047
+ if state.host_party
1048
+ else None
1049
+ ),
1050
+ hooks=hooks,
1051
+ socket_dir=build_socket_dir(config),
1052
+ llm_proxy=proxy,
1053
+ credentials=credentials,
1054
+ heartbeat_link=link,
1055
+ token_source=token_source,
1056
+ message_store=messages,
1057
+ workflow_signaller=signaller,
1058
+ tool_server_health=tool_servers,
1059
+ )
1060
+ return [
1061
+ *ralph.activity_callables(),
1062
+ *ContractDeviceLoginActivities(contract_isolation=isolation).activity_callables(),
1063
+ *RunnerTriageActivities(
1064
+ proxy=proxy,
1065
+ model=settings.TRIAGE_MODEL,
1066
+ timeout_seconds=settings.TRIAGE_DIRECTIVE_TIMEOUT_SECONDS,
1067
+ ).activity_callables(),
1068
+ ]