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.
- agentic_runner/__init__.py +12 -0
- agentic_runner/activities.py +4918 -0
- agentic_runner/callback.py +342 -0
- agentic_runner/child_watcher.py +66 -0
- agentic_runner/cli.py +416 -0
- agentic_runner/config.py +105 -0
- agentic_runner/credentials.py +252 -0
- agentic_runner/device_login_activities.py +79 -0
- agentic_runner/egress.py +243 -0
- agentic_runner/heartbeat_link.py +249 -0
- agentic_runner/hooks.py +455 -0
- agentic_runner/host_store.py +295 -0
- agentic_runner/integrations/__init__.py +0 -0
- agentic_runner/integrations/git/__init__.py +1 -0
- agentic_runner/integrations/git/contracts.py +198 -0
- agentic_runner/integrations/git/evidence.py +442 -0
- agentic_runner/integrations/git/fake_workspace.py +339 -0
- agentic_runner/integrations/git/workspace.py +921 -0
- agentic_runner/integrations/github/__init__.py +53 -0
- agentic_runner/integrations/github/auth.py +171 -0
- agentic_runner/integrations/github/fake_client.py +494 -0
- agentic_runner/integrations/github/gh_client.py +944 -0
- agentic_runner/lifecycle.py +48 -0
- agentic_runner/llm_proxy.py +937 -0
- agentic_runner/mcp.py +342 -0
- agentic_runner/message_store.py +341 -0
- agentic_runner/py.typed +0 -0
- agentic_runner/recipient_key_secret.py +134 -0
- agentic_runner/registration.py +363 -0
- agentic_runner/runtime/__init__.py +0 -0
- agentic_runner/runtime/verifier_command.py +344 -0
- agentic_runner/sealed_box.py +509 -0
- agentic_runner/service.py +1068 -0
- agentic_runner/tiny_http.py +133 -0
- agentic_runner/triage_activities.py +113 -0
- agentic_runner/user_sources.py +546 -0
- agentic_runner/workers/__init__.py +1 -0
- agentic_runner/workers/_runtime_support.py +388 -0
- agentic_runner/workers/agent_runtime.py +93 -0
- agentic_runner/workers/claude_runtime.py +226 -0
- agentic_runner/workers/codex_runtime.py +311 -0
- agentic_runner/workers/command_policy.py +250 -0
- agentic_runner/workers/contract_device_login.py +211 -0
- agentic_runner/workers/contract_isolation.py +500 -0
- agentic_runner/workers/fastapi_client.py +396 -0
- agentic_runner/workers/harness_usage.py +65 -0
- agentic_runner/workers/mcp_config.py +111 -0
- agentic_runner/workers/settings.py +314 -0
- agentic_runner/workstation.py +687 -0
- agentic_runner-2.6.0.dist-info/METADATA +49 -0
- agentic_runner-2.6.0.dist-info/RECORD +54 -0
- agentic_runner-2.6.0.dist-info/WHEEL +4 -0
- agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|