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,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