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,249 @@
|
|
|
1
|
+
"""The Runner's pushed Grant state and its liveness clock (PRD issue 44, ADR-0011 §11).
|
|
2
|
+
|
|
3
|
+
Issue 09 fetched a Grant snapshot at every Directive boundary, so a narrowing bit at the
|
|
4
|
+
*next Directive*. Here the control plane pushes a snapshot whenever a link changes and the
|
|
5
|
+
Runner applies it on receipt, so a narrowing bites at the **next verb** -- and a Runner
|
|
6
|
+
that has lost the control plane stops executing privileged verbs whether or not anything
|
|
7
|
+
changed.
|
|
8
|
+
|
|
9
|
+
Three things live together because they are one fact about "may this verb run now":
|
|
10
|
+
|
|
11
|
+
* **The per-Agent store.** Snapshots are keyed by Agent and read only by that Agent's verb
|
|
12
|
+
evaluations (17's structural rule): a Runner shared by many Contracts never mixes them,
|
|
13
|
+
and a push *replaces* an Agent's snapshot rather than merging into it.
|
|
14
|
+
* **The acknowledgement.** Each snapshot carries a version, and every heartbeat says which
|
|
15
|
+
versions this Runner holds, which is how the control plane tells "applied" from "sent".
|
|
16
|
+
* **The liveness clock.** Staleness is a property of the *link*, not a per-snapshot TTL
|
|
17
|
+
(map ticket 03): three minutes without an acknowledged exchange refuses every privileged
|
|
18
|
+
verb, because a Runner that cannot hear a narrowing must not act on what it last heard.
|
|
19
|
+
|
|
20
|
+
This is the one place the Runner keeps state between activities, and it is a deliberate
|
|
21
|
+
exception to ADR-0013 §3: what is cached is not a fetch result the activity could carry on
|
|
22
|
+
its own input, it *is* the control plane's current word, delivered out of band. Everything
|
|
23
|
+
else an activity learns still travels on its input and output.
|
|
24
|
+
|
|
25
|
+
A Protected Path (ADR-0011 §13): the seams read their snapshot from here, so a PR touching
|
|
26
|
+
this module always hits a human gate.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import time
|
|
32
|
+
from collections import OrderedDict
|
|
33
|
+
from collections.abc import Callable, Sequence
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from typing import Any, Protocol
|
|
36
|
+
from uuid import UUID
|
|
37
|
+
|
|
38
|
+
from agentic_runner.llm_proxy import Ceilings, CeilingStore
|
|
39
|
+
from agentic_runner_contracts.grants import GrantSnapshot
|
|
40
|
+
from agentic_runner_contracts.runner_registration import (
|
|
41
|
+
HEARTBEAT_INTERVAL,
|
|
42
|
+
HEARTBEAT_STALE_AFTER,
|
|
43
|
+
SNAPSHOT_PUSH_MAX,
|
|
44
|
+
AppliedSnapshot,
|
|
45
|
+
HeartbeatAck,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"HEARTBEAT_STALE_REASON",
|
|
50
|
+
"SNAPSHOT_MISSING_REASON",
|
|
51
|
+
"HeartbeatLink",
|
|
52
|
+
"HeartbeatStream",
|
|
53
|
+
"SeamUnavailableError",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
HEARTBEAT_STALE_REASON = "heartbeat_stale"
|
|
57
|
+
SNAPSHOT_MISSING_REASON = "snapshot_missing"
|
|
58
|
+
|
|
59
|
+
# How far the wall clock may run ahead of the monotonic clock before this process decides
|
|
60
|
+
# it was suspended. `time.monotonic()` does not advance across a suspend on Linux, so a
|
|
61
|
+
# closed lid shows up as exactly this divergence; one heartbeat interval of slack keeps a
|
|
62
|
+
# leap second or an NTP step from reading as a wake (23 item 4).
|
|
63
|
+
WAKE_DIVERGENCE = HEARTBEAT_INTERVAL.total_seconds()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SeamUnavailableError(RuntimeError):
|
|
67
|
+
"""A privileged verb cannot be decided right now; ``reason`` is the stable code.
|
|
68
|
+
|
|
69
|
+
Not a ``deny``: the chain did not refuse this verb, the Runner is in no position to
|
|
70
|
+
ask it. The activity fails the attempt and Temporal retries it on the same
|
|
71
|
+
``runner.{runner_id}`` queue (ADR-0013 §8) once the heartbeat is back -- which is why
|
|
72
|
+
this is a plain exception and never a ``WorkerFastApiClientRejectionError``, the type
|
|
73
|
+
the retry policy declares non-retryable.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, reason: str, message: str) -> None:
|
|
77
|
+
super().__init__(message)
|
|
78
|
+
self.reason = reason
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class HeartbeatStream(Protocol):
|
|
82
|
+
"""One heartbeat exchange: what this Runner holds out, what it is pushed back.
|
|
83
|
+
|
|
84
|
+
The transport is the Runner process's (``registration.RunnerRegistrationClient``
|
|
85
|
+
signs an envelope over HTTPS); the link only needs the acknowledgement in and the ack
|
|
86
|
+
out, which is also what makes it drivable by a fake in a test.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
async def exchange(self, applied: Sequence[AppliedSnapshot]) -> HeartbeatAck: ...
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class _Held:
|
|
94
|
+
version: str
|
|
95
|
+
payload: dict[str, Any]
|
|
96
|
+
snapshot: GrantSnapshot
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class HeartbeatLink:
|
|
100
|
+
"""What the control plane last pushed, and whether it can still be heard."""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
stream: HeartbeatStream,
|
|
105
|
+
*,
|
|
106
|
+
ceilings: CeilingStore | None = None,
|
|
107
|
+
clock: Callable[[], tuple[float, float]] | None = None,
|
|
108
|
+
stale_after: float = HEARTBEAT_STALE_AFTER.total_seconds(),
|
|
109
|
+
) -> None:
|
|
110
|
+
self._stream = stream
|
|
111
|
+
self._ceilings = ceilings
|
|
112
|
+
# (monotonic, wall). One callable rather than two so a test moves both together
|
|
113
|
+
# -- a clock that only advances one of them is precisely a suspend, and that must
|
|
114
|
+
# be deliberate on both sides.
|
|
115
|
+
self._clock = clock or (lambda: (time.monotonic(), time.time()))
|
|
116
|
+
self._stale_after = stale_after
|
|
117
|
+
# Least-recently-read first, capped at what one envelope may acknowledge: a
|
|
118
|
+
# store larger than `SNAPSHOT_PUSH_MAX` would build an `applied()` list that
|
|
119
|
+
# `HeartbeatEnvelope` rejects, and the heartbeat could then never be sent again.
|
|
120
|
+
# Eviction is by read rather than by push because an Agent whose Work Record
|
|
121
|
+
# finished is never read again, while one under a running Directive is read at
|
|
122
|
+
# every verb -- and `max_concurrent_directives` is itself bounded by this number.
|
|
123
|
+
self._held: OrderedDict[str, _Held] = OrderedDict()
|
|
124
|
+
self._last_ack: float | None = None
|
|
125
|
+
self._seen_monotonic, self._seen_wall = self._clock()
|
|
126
|
+
self._woke = False
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------------ the store
|
|
129
|
+
|
|
130
|
+
def snapshot_for(self, agent_id: str | None) -> GrantSnapshot | None:
|
|
131
|
+
"""This Agent's pushed snapshot, or ``None`` if none was ever pushed."""
|
|
132
|
+
|
|
133
|
+
held = self._touch(agent_id)
|
|
134
|
+
return held.snapshot if held else None
|
|
135
|
+
|
|
136
|
+
def payload_for(self, agent_id: str | None) -> dict[str, Any]:
|
|
137
|
+
"""The snapshot exactly as the control plane wrote it (ADR-0013 §3)."""
|
|
138
|
+
|
|
139
|
+
held = self._touch(agent_id)
|
|
140
|
+
return dict(held.payload) if held else {}
|
|
141
|
+
|
|
142
|
+
def _touch(self, agent_id: str | None) -> _Held | None:
|
|
143
|
+
if agent_id is None:
|
|
144
|
+
return None
|
|
145
|
+
held = self._held.get(agent_id)
|
|
146
|
+
if held is not None:
|
|
147
|
+
self._held.move_to_end(agent_id)
|
|
148
|
+
return held
|
|
149
|
+
|
|
150
|
+
def applied(self) -> list[AppliedSnapshot]:
|
|
151
|
+
return [
|
|
152
|
+
AppliedSnapshot(agent_id=UUID(agent_id), version=held.version)
|
|
153
|
+
for agent_id, held in sorted(self._held.items())
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
def apply(self, ack: HeartbeatAck) -> None:
|
|
157
|
+
"""Apply one ack's pushes: snapshots per Agent, ceilings per Contract.
|
|
158
|
+
|
|
159
|
+
Replacement, never a merge. A narrowed Grant that merged into what it narrowed
|
|
160
|
+
would leave the wider entry standing, which is the one mistake this whole layer
|
|
161
|
+
exists to prevent.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
for push in ack.grant_pushes:
|
|
165
|
+
agent_id = str(push.agent_id)
|
|
166
|
+
self._held[agent_id] = _Held(
|
|
167
|
+
version=push.version,
|
|
168
|
+
payload=dict(push.snapshot),
|
|
169
|
+
snapshot=GrantSnapshot.from_payload(push.snapshot),
|
|
170
|
+
)
|
|
171
|
+
self._held.move_to_end(agent_id)
|
|
172
|
+
while len(self._held) > SNAPSHOT_PUSH_MAX:
|
|
173
|
+
self._held.popitem(last=False)
|
|
174
|
+
if self._ceilings is None:
|
|
175
|
+
return
|
|
176
|
+
for ceiling in ack.ceiling_pushes:
|
|
177
|
+
self._ceilings.push(
|
|
178
|
+
ceiling.contract_id,
|
|
179
|
+
Ceilings(
|
|
180
|
+
contract_limit=ceiling.contract_limit,
|
|
181
|
+
contract_used=ceiling.contract_used,
|
|
182
|
+
organisation_limit=ceiling.organisation_limit,
|
|
183
|
+
organisation_used=ceiling.organisation_used,
|
|
184
|
+
org_funded=ceiling.org_funded,
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# ------------------------------------------------------------------ the link
|
|
189
|
+
|
|
190
|
+
async def exchange(self) -> HeartbeatAck:
|
|
191
|
+
"""One heartbeat: acknowledge what is held, apply what comes back, stamp liveness."""
|
|
192
|
+
|
|
193
|
+
ack = await self._stream.exchange(self.applied())
|
|
194
|
+
self.apply(ack)
|
|
195
|
+
self._last_ack, self._seen_wall = self._clock()
|
|
196
|
+
self._seen_monotonic = self._last_ack
|
|
197
|
+
self._woke = False
|
|
198
|
+
return ack
|
|
199
|
+
|
|
200
|
+
async def require_ready(self, agent_id: str | None, *, verb: str) -> None:
|
|
201
|
+
"""Refuse a privileged verb this Runner is in no position to decide.
|
|
202
|
+
|
|
203
|
+
Order matters: a wake is answered with a heartbeat *before* staleness is read, so
|
|
204
|
+
a laptop that slept re-hears the control plane instead of acting on a snapshot
|
|
205
|
+
from before the sleep (23 item 4); and a Runner holding no snapshot for an Agent
|
|
206
|
+
refuses rather than running it unattenuated -- absence is never permission.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
if self._observe_wake():
|
|
210
|
+
try:
|
|
211
|
+
await self.exchange()
|
|
212
|
+
except Exception as error: # noqa: BLE001 - any failed exchange is a lost link
|
|
213
|
+
raise SeamUnavailableError(
|
|
214
|
+
HEARTBEAT_STALE_REASON,
|
|
215
|
+
f"{verb!r} is refused: this Runner woke and could not reach the "
|
|
216
|
+
f"control plane ({error})",
|
|
217
|
+
) from error
|
|
218
|
+
age = self._age()
|
|
219
|
+
if age is None or age > self._stale_after:
|
|
220
|
+
raise SeamUnavailableError(
|
|
221
|
+
HEARTBEAT_STALE_REASON,
|
|
222
|
+
f"{verb!r} is refused: the last acknowledged heartbeat is "
|
|
223
|
+
f"{'none' if age is None else f'{age:.0f}s'} old, past the "
|
|
224
|
+
f"{self._stale_after:.0f}s liveness bound",
|
|
225
|
+
)
|
|
226
|
+
if agent_id is not None and agent_id not in self._held:
|
|
227
|
+
raise SeamUnavailableError(
|
|
228
|
+
SNAPSHOT_MISSING_REASON,
|
|
229
|
+
f"{verb!r} is refused: no Grant snapshot has been pushed for Agent {agent_id}",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def _age(self) -> float | None:
|
|
233
|
+
if self._last_ack is None:
|
|
234
|
+
return None
|
|
235
|
+
monotonic, _ = self._clock()
|
|
236
|
+
return monotonic - self._last_ack
|
|
237
|
+
|
|
238
|
+
def _observe_wake(self) -> bool:
|
|
239
|
+
"""Whether this process was suspended since the last look.
|
|
240
|
+
|
|
241
|
+
Sticky until an exchange succeeds: a wake whose heartbeat failed must go on
|
|
242
|
+
refusing, or a retry would quietly proceed on the pre-sleep snapshot.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
monotonic, wall = self._clock()
|
|
246
|
+
divergence = (wall - self._seen_wall) - (monotonic - self._seen_monotonic)
|
|
247
|
+
self._seen_monotonic, self._seen_wall = monotonic, wall
|
|
248
|
+
self._woke = self._woke or divergence > WAKE_DIVERGENCE
|
|
249
|
+
return self._woke
|