agentic-runner 2.7.0__tar.gz → 3.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/PKG-INFO +4 -1
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/pyproject.toml +11 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/__init__.py +1 -1
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/activities.py +187 -14
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/host_store.py +6 -10
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/integrations/git/evidence.py +27 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/lifecycle.py +4 -5
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/message_store.py +7 -7
- agentic_runner-3.0.0/src/agentic_runner/private_state.py +138 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/registration.py +8 -10
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/sealed_box.py +6 -8
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/service.py +68 -10
- agentic_runner-3.0.0/src/agentic_runner/testing/__init__.py +18 -0
- agentic_runner-3.0.0/src/agentic_runner/testing/__main__.py +32 -0
- agentic_runner-3.0.0/src/agentic_runner/testing/control_plane.py +441 -0
- agentic_runner-3.0.0/src/agentic_runner/testing/plugin.py +178 -0
- agentic_runner-3.0.0/src/agentic_runner/testing/test_conformance.py +163 -0
- agentic_runner-3.0.0/src/agentic_runner/testing/workflow.py +37 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/workers/_runtime_support.py +31 -2
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/workers/agent_runtime.py +23 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/workers/claude_runtime.py +26 -3
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/workers/codex_runtime.py +40 -3
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/workers/contract_isolation.py +13 -12
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/.gitignore +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/LICENSE +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/README.md +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/attempts.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/callback.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/child_watcher.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/cli.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/config.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/credentials.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/device_login_activities.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/egress.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/heartbeat_link.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/hooks.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/integrations/__init__.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/integrations/github/auth.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/llm_proxy.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/mcp.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/py.typed +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/recipient_key_secret.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/runtime/__init__.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/tiny_http.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/triage_activities.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/user_sources.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/workers/__init__.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/workers/command_policy.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/workers/harness_usage.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/workers/mcp_config.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/workers/settings.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/workers/skills.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/workstation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agentic-runner
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: The Agentic OS Runner: an activity-only Temporal executor for workspaces, Agent Runtimes and verb seams
|
|
5
5
|
License-Expression: AGPL-3.0-only
|
|
6
6
|
License-File: LICENSE
|
|
@@ -12,6 +12,9 @@ Requires-Dist: pydantic-settings<3.0,>=2.0
|
|
|
12
12
|
Requires-Dist: pydantic<3.0,>=2.0
|
|
13
13
|
Requires-Dist: pyjwt[crypto]>=2.13.0
|
|
14
14
|
Requires-Dist: temporalio<2.0,>=1.10
|
|
15
|
+
Provides-Extra: testing
|
|
16
|
+
Requires-Dist: pytest-asyncio<1.0,>=0.24; extra == 'testing'
|
|
17
|
+
Requires-Dist: pytest<9.0,>=8.0; extra == 'testing'
|
|
15
18
|
Description-Content-Type: text/markdown
|
|
16
19
|
|
|
17
20
|
# agentic-runner
|
|
@@ -19,9 +19,20 @@ dependencies = [
|
|
|
19
19
|
"temporalio>=1.10,<2.0",
|
|
20
20
|
]
|
|
21
21
|
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
# The conformance kit (`agentic_runner.testing`, docs/conformance.md): the fake control
|
|
24
|
+
# plane needs nothing beyond the dependencies above; the scenario suite needs these.
|
|
25
|
+
testing = [
|
|
26
|
+
"pytest>=8.0,<9.0",
|
|
27
|
+
"pytest-asyncio>=0.24,<1.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
22
30
|
[project.scripts]
|
|
23
31
|
agentic-runner = "agentic_runner.cli:main"
|
|
24
32
|
|
|
33
|
+
[project.entry-points.pytest11]
|
|
34
|
+
agentic_runner = "agentic_runner.testing.plugin"
|
|
35
|
+
|
|
25
36
|
[build-system]
|
|
26
37
|
requires = ["hatchling>=1.27,<2.0"]
|
|
27
38
|
build-backend = "hatchling.build"
|
|
@@ -38,7 +38,12 @@ from uuid import UUID
|
|
|
38
38
|
from temporalio import activity
|
|
39
39
|
from temporalio.exceptions import ApplicationError
|
|
40
40
|
|
|
41
|
-
from agentic_runner.attempts import
|
|
41
|
+
from agentic_runner.attempts import (
|
|
42
|
+
AttemptRecords,
|
|
43
|
+
PriorAttemptAliveError,
|
|
44
|
+
fence_work_record,
|
|
45
|
+
refuse_if_prior_attempt_alive,
|
|
46
|
+
)
|
|
42
47
|
from agentic_runner.callback import (
|
|
43
48
|
CALLBACK_TOKEN_ENV,
|
|
44
49
|
AnnotateRequest,
|
|
@@ -86,6 +91,7 @@ from agentic_runner.integrations.git.contracts import (
|
|
|
86
91
|
PrepareVerifierWorkspaceRequest,
|
|
87
92
|
PushBranchRequest,
|
|
88
93
|
)
|
|
94
|
+
from agentic_runner.integrations.git.evidence import read_head_commit
|
|
89
95
|
from agentic_runner.integrations.git.workspace import (
|
|
90
96
|
GitWorkspacePolicyError,
|
|
91
97
|
contract_workspace_path,
|
|
@@ -115,6 +121,7 @@ from agentic_runner.workers.agent_runtime import (
|
|
|
115
121
|
AuthModel,
|
|
116
122
|
DirectiveRequest,
|
|
117
123
|
DirectiveResult,
|
|
124
|
+
ResumableAgentRuntime,
|
|
118
125
|
)
|
|
119
126
|
from agentic_runner.workers.contract_isolation import (
|
|
120
127
|
NO_CONTRACT,
|
|
@@ -332,8 +339,87 @@ _APPROVAL_POLLS_PER_ACTIVITY = 10
|
|
|
332
339
|
_APPROVAL_ACTIVITY_TIME_BUDGET_SECONDS = 360.0
|
|
333
340
|
|
|
334
341
|
|
|
342
|
+
@dataclass(frozen=True, slots=True)
|
|
343
|
+
class HarnessSession:
|
|
344
|
+
"""The harness session a Directive attempt started, and where it ran (local-agents 16).
|
|
345
|
+
|
|
346
|
+
A retry may resume it only when the Contract, the Workspace path and the head all
|
|
347
|
+
match exactly (ADR-0007, amendment 2026-10-03).
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
session_id: str
|
|
351
|
+
contract_id: str | None
|
|
352
|
+
workspace_path: str
|
|
353
|
+
head: str | None
|
|
354
|
+
|
|
355
|
+
@classmethod
|
|
356
|
+
def from_heartbeat(cls, details: object) -> "HarnessSession | None":
|
|
357
|
+
if not isinstance(details, Mapping):
|
|
358
|
+
return None
|
|
359
|
+
session_id = details.get("session_id")
|
|
360
|
+
contract_id = details.get("contract_id")
|
|
361
|
+
workspace_path = details.get("workspace_path")
|
|
362
|
+
head = details.get("head")
|
|
363
|
+
if (
|
|
364
|
+
not isinstance(session_id, str)
|
|
365
|
+
or not isinstance(workspace_path, str)
|
|
366
|
+
or not isinstance(contract_id, str | None)
|
|
367
|
+
or not isinstance(head, str | None)
|
|
368
|
+
):
|
|
369
|
+
return None
|
|
370
|
+
return cls(session_id, contract_id, workspace_path, head)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _why_not_resume(
|
|
374
|
+
prior: HarnessSession | None,
|
|
375
|
+
here: HarnessSession,
|
|
376
|
+
agent_runtime: AgentRuntime,
|
|
377
|
+
sandbox: DirectiveSandbox | None,
|
|
378
|
+
) -> str | None:
|
|
379
|
+
if prior is None:
|
|
380
|
+
return "no_prior_session"
|
|
381
|
+
if prior.contract_id != here.contract_id:
|
|
382
|
+
return "contract_changed"
|
|
383
|
+
if prior.workspace_path != here.workspace_path:
|
|
384
|
+
return "workspace_changed"
|
|
385
|
+
if here.head is None or prior.head != here.head:
|
|
386
|
+
return "head_changed"
|
|
387
|
+
if not isinstance(agent_runtime, ResumableAgentRuntime):
|
|
388
|
+
return "runtime_cannot_resume"
|
|
389
|
+
if not agent_runtime.has_session(prior.session_id, sandbox):
|
|
390
|
+
return "session_not_found"
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class _Liveness:
|
|
395
|
+
"""What one attempt's heartbeats carry: its first start, and its harness session.
|
|
396
|
+
|
|
397
|
+
Both ride in the Temporal heartbeat details rather than in the Runner's process
|
|
398
|
+
(ADR-0013 §3), so the retry that follows a lost pod still finds them.
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
def __init__(
|
|
402
|
+
self, *, first_started: float, now: float, prior_session: HarnessSession | None
|
|
403
|
+
) -> None:
|
|
404
|
+
self.earlier_attempts_seconds = max(0.0, now - first_started)
|
|
405
|
+
self.prior_session = prior_session
|
|
406
|
+
self._first_started = first_started
|
|
407
|
+
self._session: HarnessSession | None = None
|
|
408
|
+
|
|
409
|
+
def details(self) -> tuple[object, ...]:
|
|
410
|
+
if self._session is None:
|
|
411
|
+
return (self._first_started,)
|
|
412
|
+
return (self._first_started, asdict(self._session))
|
|
413
|
+
|
|
414
|
+
def record_session(self, session: HarnessSession) -> None:
|
|
415
|
+
self._session = session
|
|
416
|
+
# At once, not at the next beat: the attempt may be lost before then.
|
|
417
|
+
if activity.in_activity():
|
|
418
|
+
activity.heartbeat(*self.details())
|
|
419
|
+
|
|
420
|
+
|
|
335
421
|
@contextlib.asynccontextmanager
|
|
336
|
-
async def _liveness_heartbeats() -> AsyncIterator[
|
|
422
|
+
async def _liveness_heartbeats() -> AsyncIterator[_Liveness]:
|
|
337
423
|
"""Emit periodic activity heartbeats from a background task while the body runs.
|
|
338
424
|
|
|
339
425
|
Async-activity heartbeats are delivered on the worker event loop, so the wrapped
|
|
@@ -345,24 +431,29 @@ async def _liveness_heartbeats() -> AsyncIterator[float]:
|
|
|
345
431
|
47, 23 item 4). Each beat carries the first attempt's wall-clock start as its
|
|
346
432
|
heartbeat details, so the retry that follows a laptop's sleep -- the attempt Temporal
|
|
347
433
|
failed on its heartbeat timeout -- charges the Work Record's wall-clock from there:
|
|
348
|
-
sleep is an outage, and the Budget keeps running through it.
|
|
434
|
+
sleep is an outage, and the Budget keeps running through it. Once the Agent Runtime
|
|
435
|
+
names its session, the beats carry that too (local-agents 16).
|
|
349
436
|
"""
|
|
437
|
+
now = time.time()
|
|
350
438
|
if not activity.in_activity():
|
|
351
|
-
yield
|
|
439
|
+
yield _Liveness(first_started=now, now=now, prior_session=None)
|
|
352
440
|
return
|
|
353
441
|
|
|
354
|
-
now = time.time()
|
|
355
442
|
details = activity.info().heartbeat_details
|
|
356
|
-
|
|
443
|
+
liveness = _Liveness(
|
|
444
|
+
first_started=float(details[0]) if details else now,
|
|
445
|
+
now=now,
|
|
446
|
+
prior_session=HarnessSession.from_heartbeat(details[1]) if len(details) > 1 else None,
|
|
447
|
+
)
|
|
357
448
|
|
|
358
449
|
async def beat() -> None:
|
|
359
450
|
while True:
|
|
360
|
-
activity.heartbeat(
|
|
451
|
+
activity.heartbeat(*liveness.details())
|
|
361
452
|
await asyncio.sleep(_HEARTBEAT_INTERVAL_SECONDS)
|
|
362
453
|
|
|
363
454
|
heartbeater = asyncio.create_task(beat())
|
|
364
455
|
try:
|
|
365
|
-
yield
|
|
456
|
+
yield liveness
|
|
366
457
|
finally:
|
|
367
458
|
heartbeater.cancel()
|
|
368
459
|
with contextlib.suppress(asyncio.CancelledError):
|
|
@@ -1337,6 +1428,7 @@ def wipe_contract_residue(
|
|
|
1337
1428
|
# Where a Runner-side routing refusal lands in the trail (PRD issue 42, 17 A11).
|
|
1338
1429
|
_ROUTING_EVIDENCE_SOURCE = "runner.routing"
|
|
1339
1430
|
PRIOR_ATTEMPT_ALIVE_EVIDENCE_SOURCE = "runner.prior_attempt_alive"
|
|
1431
|
+
HARNESS_SESSION_EVIDENCE_SOURCE = "runner.harness_session"
|
|
1340
1432
|
DIRECTIVE_TOKEN_EVIDENCE_SOURCE = "runner.directive_token"
|
|
1341
1433
|
AGENT_RUNTIME_EVIDENCE_SOURCE = "runner.agent_runtime"
|
|
1342
1434
|
SKILLS_EVIDENCE_SOURCE = "runner.skills"
|
|
@@ -1613,6 +1705,63 @@ class RunnerRalphActivities:
|
|
|
1613
1705
|
},
|
|
1614
1706
|
)
|
|
1615
1707
|
|
|
1708
|
+
async def _harness_session(
|
|
1709
|
+
self,
|
|
1710
|
+
liveness: _Liveness,
|
|
1711
|
+
*,
|
|
1712
|
+
work_record_id: str,
|
|
1713
|
+
directive_number: int,
|
|
1714
|
+
agent_runtime: AgentRuntime,
|
|
1715
|
+
contract_id: str | None,
|
|
1716
|
+
workspace_path: Path,
|
|
1717
|
+
sandbox: DirectiveSandbox | None,
|
|
1718
|
+
) -> tuple[str | None, Callable[[str], None]]:
|
|
1719
|
+
"""Resume the session an earlier attempt of this Directive started, or start fresh.
|
|
1720
|
+
|
|
1721
|
+
Returns the session to resume, if any, and the hook that files a fresh one in the
|
|
1722
|
+
heartbeat details. Every retry writes one Evidence event saying which, and why --
|
|
1723
|
+
never what the session holds (local-agents 16, ADR-0007 amendment 2026-10-03).
|
|
1724
|
+
"""
|
|
1725
|
+
|
|
1726
|
+
head = read_head_commit(
|
|
1727
|
+
workspace_path=workspace_path, workspace_root=self._require_workspace_root()
|
|
1728
|
+
)
|
|
1729
|
+
here = HarnessSession(
|
|
1730
|
+
session_id="",
|
|
1731
|
+
contract_id=contract_id,
|
|
1732
|
+
workspace_path=str(workspace_path),
|
|
1733
|
+
head=head,
|
|
1734
|
+
)
|
|
1735
|
+
|
|
1736
|
+
def start_fresh(session_id: str) -> None:
|
|
1737
|
+
liveness.record_session(replace(here, session_id=session_id))
|
|
1738
|
+
|
|
1739
|
+
attempt = activity.info().attempt if activity.in_activity() else 1
|
|
1740
|
+
if attempt == 1:
|
|
1741
|
+
return None, start_fresh
|
|
1742
|
+
# Made explicit rather than left to the spawn: the session to resume must not be
|
|
1743
|
+
# one a still-running harness is writing (RR-05). A refusal raises into `_fenced`.
|
|
1744
|
+
refuse_if_prior_attempt_alive()
|
|
1745
|
+
prior = liveness.prior_session
|
|
1746
|
+
reason = _why_not_resume(prior, here, agent_runtime, sandbox)
|
|
1747
|
+
await self._fastapi_client.append_evidence(
|
|
1748
|
+
work_record_id,
|
|
1749
|
+
source=HARNESS_SESSION_EVIDENCE_SOURCE,
|
|
1750
|
+
actor=self._actor(),
|
|
1751
|
+
payload={
|
|
1752
|
+
"event": "directive.harness_session",
|
|
1753
|
+
"work_record_id": work_record_id,
|
|
1754
|
+
"directive_number": directive_number,
|
|
1755
|
+
"attempt": attempt,
|
|
1756
|
+
"decision": "fresh" if reason else "resumed",
|
|
1757
|
+
"reason": reason or "contract_workspace_and_head_match",
|
|
1758
|
+
},
|
|
1759
|
+
)
|
|
1760
|
+
if reason is not None or prior is None:
|
|
1761
|
+
return None, start_fresh
|
|
1762
|
+
liveness.record_session(prior)
|
|
1763
|
+
return prior.session_id, start_fresh
|
|
1764
|
+
|
|
1616
1765
|
@activity.defn(name="create_or_update_branch_pr")
|
|
1617
1766
|
async def create_or_update_branch_pr(
|
|
1618
1767
|
self,
|
|
@@ -1652,7 +1801,7 @@ class RunnerRalphActivities:
|
|
|
1652
1801
|
asked: list[QuestionAsked],
|
|
1653
1802
|
) -> BranchPullRequestOutput:
|
|
1654
1803
|
async with (
|
|
1655
|
-
_liveness_heartbeats() as
|
|
1804
|
+
_liveness_heartbeats() as liveness,
|
|
1656
1805
|
contextlib.AsyncExitStack() as attempt_stack,
|
|
1657
1806
|
):
|
|
1658
1807
|
if (
|
|
@@ -1670,7 +1819,7 @@ class RunnerRalphActivities:
|
|
|
1670
1819
|
pr_created=False,
|
|
1671
1820
|
)
|
|
1672
1821
|
|
|
1673
|
-
directive_started = self._monotonic() - earlier_attempts_seconds
|
|
1822
|
+
directive_started = self._monotonic() - liveness.earlier_attempts_seconds
|
|
1674
1823
|
await self._advance_work_record_lifecycle(
|
|
1675
1824
|
request.work_record_id,
|
|
1676
1825
|
to_state="IN_PROGRESS",
|
|
@@ -1798,10 +1947,22 @@ class RunnerRalphActivities:
|
|
|
1798
1947
|
)
|
|
1799
1948
|
prompt_notes: list[str] = []
|
|
1800
1949
|
await attempt.hooks.phase(HookName.PRE_RUNTIME)
|
|
1950
|
+
sandbox = self._directive_sandbox(runtime_state, checkout_workspace)
|
|
1951
|
+
resume_session_id, on_session_started = await self._harness_session(
|
|
1952
|
+
liveness,
|
|
1953
|
+
work_record_id=request.work_record_id,
|
|
1954
|
+
directive_number=request.directive_number,
|
|
1955
|
+
agent_runtime=agent_runtime,
|
|
1956
|
+
contract_id=contract_id,
|
|
1957
|
+
workspace_path=checkout_workspace,
|
|
1958
|
+
sandbox=sandbox,
|
|
1959
|
+
)
|
|
1801
1960
|
codex_result = await agent_runtime.execute_directive(
|
|
1802
1961
|
DirectiveRequest(
|
|
1803
|
-
sandbox=
|
|
1962
|
+
sandbox=sandbox,
|
|
1804
1963
|
workspace_path=checkout_workspace,
|
|
1964
|
+
resume_session_id=resume_session_id,
|
|
1965
|
+
on_session_started=on_session_started,
|
|
1805
1966
|
# The attempt's callback socket and bearer, plus whatever the
|
|
1806
1967
|
# `environment` hook exported. The env allow-list gains these names
|
|
1807
1968
|
# and nothing else (PRD issue 09's invariant, issue 45's amendment).
|
|
@@ -4500,7 +4661,7 @@ class RunnerRalphActivities:
|
|
|
4500
4661
|
"""
|
|
4501
4662
|
|
|
4502
4663
|
async with (
|
|
4503
|
-
_liveness_heartbeats() as
|
|
4664
|
+
_liveness_heartbeats() as liveness,
|
|
4504
4665
|
contextlib.AsyncExitStack() as attempt_stack,
|
|
4505
4666
|
):
|
|
4506
4667
|
if (
|
|
@@ -4516,7 +4677,7 @@ class RunnerRalphActivities:
|
|
|
4516
4677
|
branch_head_sha="",
|
|
4517
4678
|
summary="directive unavailable: runtime dependencies missing",
|
|
4518
4679
|
)
|
|
4519
|
-
directive_started = self._monotonic() - earlier_attempts_seconds
|
|
4680
|
+
directive_started = self._monotonic() - liveness.earlier_attempts_seconds
|
|
4520
4681
|
# Loop back into the working state for another runtime turn.
|
|
4521
4682
|
await self._advance_work_record_lifecycle(
|
|
4522
4683
|
request.work_record_id,
|
|
@@ -4584,10 +4745,22 @@ class RunnerRalphActivities:
|
|
|
4584
4745
|
)
|
|
4585
4746
|
prompt_notes: list[str] = []
|
|
4586
4747
|
await attempt.hooks.phase(HookName.PRE_RUNTIME)
|
|
4748
|
+
sandbox = self._directive_sandbox(state, state.workspace_path)
|
|
4749
|
+
resume_session_id, on_session_started = await self._harness_session(
|
|
4750
|
+
liveness,
|
|
4751
|
+
work_record_id=request.work_record_id,
|
|
4752
|
+
directive_number=request.directive_number,
|
|
4753
|
+
agent_runtime=agent_runtime,
|
|
4754
|
+
contract_id=state.contract_id,
|
|
4755
|
+
workspace_path=state.workspace_path,
|
|
4756
|
+
sandbox=sandbox,
|
|
4757
|
+
)
|
|
4587
4758
|
codex_result = await agent_runtime.execute_directive(
|
|
4588
4759
|
DirectiveRequest(
|
|
4589
|
-
sandbox=
|
|
4760
|
+
sandbox=sandbox,
|
|
4590
4761
|
workspace_path=state.workspace_path,
|
|
4762
|
+
resume_session_id=resume_session_id,
|
|
4763
|
+
on_session_started=on_session_started,
|
|
4591
4764
|
extra_env=attempt.env,
|
|
4592
4765
|
mcp_servers=attempt.mcp_servers,
|
|
4593
4766
|
egress_allow_list=attempt.egress_allow_list,
|
|
@@ -19,7 +19,6 @@ operator's act, through ``agentic-runner credential set``.
|
|
|
19
19
|
|
|
20
20
|
from __future__ import annotations
|
|
21
21
|
|
|
22
|
-
import os
|
|
23
22
|
import shutil
|
|
24
23
|
import subprocess
|
|
25
24
|
import sys
|
|
@@ -28,6 +27,7 @@ from pathlib import Path
|
|
|
28
27
|
from typing import Protocol
|
|
29
28
|
|
|
30
29
|
from agentic_runner.credentials import DirectoryCredentialStore
|
|
30
|
+
from agentic_runner.private_state import private_read, private_write
|
|
31
31
|
from agentic_runner_contracts.runner_registration import StoreKind
|
|
32
32
|
|
|
33
33
|
__all__ = [
|
|
@@ -66,16 +66,12 @@ class FileCredentialStore(DirectoryCredentialStore):
|
|
|
66
66
|
super().__init__(root)
|
|
67
67
|
self.root = root
|
|
68
68
|
|
|
69
|
+
def get(self, reference: str) -> str | None:
|
|
70
|
+
raw = private_read(self.root / _single_segment(reference))
|
|
71
|
+
return None if raw is None else raw.decode("utf-8").strip()
|
|
72
|
+
|
|
69
73
|
def put(self, reference: str, value: str) -> None:
|
|
70
|
-
self.root
|
|
71
|
-
self.root.chmod(0o700)
|
|
72
|
-
path = self.root / _single_segment(reference)
|
|
73
|
-
# Created 0600 rather than chmod-ed after: there is no instant at which the value
|
|
74
|
-
# sits in a file another uid can open.
|
|
75
|
-
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
76
|
-
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
77
|
-
handle.write(value)
|
|
78
|
-
path.chmod(0o600)
|
|
74
|
+
private_write(self.root / _single_segment(reference), value.encode("utf-8"))
|
|
79
75
|
|
|
80
76
|
|
|
81
77
|
class KeychainStore:
|
{agentic_runner-2.7.0 → agentic_runner-3.0.0}/src/agentic_runner/integrations/git/evidence.py
RENAMED
|
@@ -117,6 +117,33 @@ def collect_git_evidence(
|
|
|
117
117
|
)
|
|
118
118
|
|
|
119
119
|
|
|
120
|
+
def read_head_commit(
|
|
121
|
+
*,
|
|
122
|
+
workspace_path: Path,
|
|
123
|
+
workspace_root: Path,
|
|
124
|
+
command_timeout_seconds: int = 10,
|
|
125
|
+
) -> str | None:
|
|
126
|
+
"""The Workspace's HEAD commit, or None when it has none (no clone, an unborn branch).
|
|
127
|
+
|
|
128
|
+
Reads the tree directly rather than through the workspace adapter: an in-place
|
|
129
|
+
Directive retried on a restarted Runner finds its clone on disk but not in the
|
|
130
|
+
adapter's registry.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
resolved_workspace = resolve_workspace_path(workspace_root, workspace_path)
|
|
134
|
+
if not os.path.lexists(resolved_workspace / ".git"):
|
|
135
|
+
return None
|
|
136
|
+
require_runner_owned_git_dir(resolved_workspace)
|
|
137
|
+
head = _run_git_evidence_command(
|
|
138
|
+
["rev-parse", "--verify", "--quiet", "HEAD"],
|
|
139
|
+
cwd=resolved_workspace,
|
|
140
|
+
output_limit_bytes=256,
|
|
141
|
+
command_timeout_seconds=command_timeout_seconds,
|
|
142
|
+
)
|
|
143
|
+
commit = head.stdout.strip()
|
|
144
|
+
return commit if head.returncode == 0 and commit else None
|
|
145
|
+
|
|
146
|
+
|
|
120
147
|
def build_git_evidence(
|
|
121
148
|
*,
|
|
122
149
|
workspace_path: Path,
|
|
@@ -13,6 +13,7 @@ from pathlib import Path
|
|
|
13
13
|
|
|
14
14
|
from pydantic import TypeAdapter
|
|
15
15
|
|
|
16
|
+
from agentic_runner.private_state import private_read, private_write
|
|
16
17
|
from agentic_runner_contracts.runner_registration import LifecycleEvent, LifecycleKind
|
|
17
18
|
|
|
18
19
|
__all__ = ["LIFECYCLE_FILENAME", "LifecycleOutbox"]
|
|
@@ -33,9 +34,8 @@ class LifecycleOutbox:
|
|
|
33
34
|
self._write(events[-_KEEP:])
|
|
34
35
|
|
|
35
36
|
def pending(self) -> list[LifecycleEvent]:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return _EVENTS.validate_json(self._path.read_bytes())
|
|
37
|
+
raw = private_read(self._path)
|
|
38
|
+
return [] if raw is None else _EVENTS.validate_json(raw)
|
|
39
39
|
|
|
40
40
|
def acknowledge(self, sent: list[LifecycleEvent]) -> None:
|
|
41
41
|
"""Drop what an acknowledged heartbeat carried; keep anything added meanwhile."""
|
|
@@ -44,5 +44,4 @@ class LifecycleOutbox:
|
|
|
44
44
|
self._write(remaining)
|
|
45
45
|
|
|
46
46
|
def _write(self, events: list[LifecycleEvent]) -> None:
|
|
47
|
-
self._path.
|
|
48
|
-
self._path.write_bytes(_EVENTS.dump_json(events))
|
|
47
|
+
private_write(self._path, _EVENTS.dump_json(events))
|
|
@@ -32,6 +32,7 @@ from uuid import UUID, uuid4
|
|
|
32
32
|
|
|
33
33
|
from temporalio.client import Client
|
|
34
34
|
|
|
35
|
+
from agentic_runner.private_state import private_write
|
|
35
36
|
from agentic_runner_contracts.channel_messages import (
|
|
36
37
|
PENDING_MESSAGES_SIGNAL,
|
|
37
38
|
MessageEnvelope,
|
|
@@ -134,9 +135,11 @@ class MessageStore:
|
|
|
134
135
|
)
|
|
135
136
|
path = self._path(contract_id, work_record_id)
|
|
136
137
|
_ensure_dir(path.parent)
|
|
137
|
-
|
|
138
|
+
descriptor = os.open(
|
|
139
|
+
path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, _FILE_MODE
|
|
140
|
+
)
|
|
141
|
+
with os.fdopen(descriptor, "a", encoding="utf-8") as handle:
|
|
138
142
|
handle.write(envelope.model_dump_json() + "\n")
|
|
139
|
-
path.chmod(_FILE_MODE)
|
|
140
143
|
return envelope
|
|
141
144
|
|
|
142
145
|
def read(
|
|
@@ -187,9 +190,7 @@ class MessageStore:
|
|
|
187
190
|
cursors = self._cursors(contract_id, work_record_id)
|
|
188
191
|
cursors[agent_id] = str(through.message_id)
|
|
189
192
|
path = self._cursor_path(contract_id, work_record_id)
|
|
190
|
-
|
|
191
|
-
path.write_text(json.dumps(cursors), encoding="utf-8")
|
|
192
|
-
path.chmod(_FILE_MODE)
|
|
193
|
+
private_write(path, json.dumps(cursors).encode("utf-8"))
|
|
193
194
|
|
|
194
195
|
def transcript(self, request: TranscriptRequest) -> TranscriptDelivery:
|
|
195
196
|
"""The pull-through's answer: every body, or the Contract state that seals them."""
|
|
@@ -222,8 +223,7 @@ class MessageStore:
|
|
|
222
223
|
if not stores:
|
|
223
224
|
return 0
|
|
224
225
|
marker = contract_dir / SEALED_MARKER
|
|
225
|
-
marker
|
|
226
|
-
marker.chmod(_FILE_MODE)
|
|
226
|
+
private_write(marker, json.dumps({"contract_state": contract_state}).encode("utf-8"))
|
|
227
227
|
return len(stores)
|
|
228
228
|
|
|
229
229
|
def sealed_state(self, contract_id: str) -> str | None:
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""The state directory's one writer and one reader (runner-repo issue 08).
|
|
2
|
+
|
|
3
|
+
The state directory holds the Runner's private identity key and the Recipient Key, so
|
|
4
|
+
nothing in it may be readable by another uid for even an instant, and no crash may leave a
|
|
5
|
+
half-written file where the identity was. Every file the Runner writes there goes through
|
|
6
|
+
:func:`private_write` and every file it reads through :func:`private_read`; both refuse a
|
|
7
|
+
directory or file that is a symlink, belongs to another uid or carries a group or other
|
|
8
|
+
permission bit, rather than tightening it -- a state directory in that shape was put there
|
|
9
|
+
by someone else, and the Runner cannot know what they read while it was open.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import errno
|
|
16
|
+
import os
|
|
17
|
+
import secrets
|
|
18
|
+
import stat
|
|
19
|
+
from collections.abc import Callable, Iterable
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"UnsafeStateError",
|
|
24
|
+
"ensure_private_dir",
|
|
25
|
+
"private_read",
|
|
26
|
+
"private_write",
|
|
27
|
+
"require_private_files",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
_DIR_MODE = 0o700
|
|
31
|
+
_FILE_MODE = 0o600
|
|
32
|
+
_GROUP_OTHER_BITS = 0o077
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UnsafeStateError(RuntimeError):
|
|
36
|
+
"""The state directory, or a file in it, is not this Runner's alone."""
|
|
37
|
+
|
|
38
|
+
reason = "unsafe_state_dir"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def ensure_private_dir(path: Path) -> None:
|
|
42
|
+
"""Create ``path`` 0700 if it is missing, else refuse it unless it is private already."""
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
os.mkdir(path, _DIR_MODE)
|
|
46
|
+
except FileExistsError:
|
|
47
|
+
pass
|
|
48
|
+
except FileNotFoundError:
|
|
49
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
os.mkdir(path, _DIR_MODE)
|
|
51
|
+
_require_private(path, os.lstat(path), stat.S_ISDIR, "directory", "chmod 700")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def require_private_files(directory: Path, names: Iterable[str]) -> None:
|
|
55
|
+
"""Refuse any of ``directory``'s ``names`` that exists and is not private; skip the rest."""
|
|
56
|
+
|
|
57
|
+
for name in names:
|
|
58
|
+
path = directory / name
|
|
59
|
+
try:
|
|
60
|
+
status = os.lstat(path)
|
|
61
|
+
except FileNotFoundError:
|
|
62
|
+
continue
|
|
63
|
+
_require_private(path, status, stat.S_ISREG, "file", "chmod 600")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def private_write(path: Path, data: bytes) -> None:
|
|
67
|
+
"""Replace ``path`` with ``data``: 0600 from the first byte, and all-or-nothing.
|
|
68
|
+
|
|
69
|
+
A temp file beside the target, created exclusively and without following a planted
|
|
70
|
+
symlink, then renamed over it: a reader sees the old content or the new, never a
|
|
71
|
+
truncated identity, and the rename is made durable by syncing the directory too.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
ensure_private_dir(path.parent)
|
|
75
|
+
temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
|
76
|
+
descriptor = os.open(
|
|
77
|
+
temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, _FILE_MODE
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
81
|
+
handle.write(data)
|
|
82
|
+
handle.flush()
|
|
83
|
+
os.fsync(handle.fileno())
|
|
84
|
+
os.replace(temporary, path)
|
|
85
|
+
except BaseException:
|
|
86
|
+
with contextlib.suppress(FileNotFoundError):
|
|
87
|
+
os.unlink(temporary)
|
|
88
|
+
raise
|
|
89
|
+
directory = os.open(path.parent, os.O_RDONLY)
|
|
90
|
+
try:
|
|
91
|
+
os.fsync(directory)
|
|
92
|
+
finally:
|
|
93
|
+
os.close(directory)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def private_read(path: Path) -> bytes | None:
|
|
97
|
+
"""``path``'s content, or ``None`` if it does not exist; refused unless private."""
|
|
98
|
+
|
|
99
|
+
parent = path.parent
|
|
100
|
+
try:
|
|
101
|
+
parent_status = os.lstat(parent)
|
|
102
|
+
except FileNotFoundError:
|
|
103
|
+
return None
|
|
104
|
+
_require_private(parent, parent_status, stat.S_ISDIR, "directory", "chmod 700")
|
|
105
|
+
try:
|
|
106
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
107
|
+
except FileNotFoundError:
|
|
108
|
+
return None
|
|
109
|
+
except OSError as error:
|
|
110
|
+
if error.errno == errno.ELOOP:
|
|
111
|
+
raise UnsafeStateError(f"{path} is a symlink; refusing to read through it") from error
|
|
112
|
+
raise
|
|
113
|
+
with os.fdopen(descriptor, "rb") as handle:
|
|
114
|
+
_require_private(path, os.fstat(handle.fileno()), stat.S_ISREG, "file", "chmod 600")
|
|
115
|
+
return handle.read()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _require_private(
|
|
119
|
+
path: Path,
|
|
120
|
+
status: os.stat_result,
|
|
121
|
+
is_kind: Callable[[int], bool],
|
|
122
|
+
kind: str,
|
|
123
|
+
remedy: str,
|
|
124
|
+
) -> None:
|
|
125
|
+
if stat.S_ISLNK(status.st_mode):
|
|
126
|
+
raise UnsafeStateError(f"{path} is a symlink; the state {kind} must be a real {kind}")
|
|
127
|
+
if not is_kind(status.st_mode):
|
|
128
|
+
raise UnsafeStateError(f"{path} is not a {kind}")
|
|
129
|
+
euid = os.geteuid()
|
|
130
|
+
if status.st_uid != euid:
|
|
131
|
+
raise UnsafeStateError(
|
|
132
|
+
f"{path} is owned by uid {status.st_uid}, not this Runner's uid {euid}"
|
|
133
|
+
)
|
|
134
|
+
if status.st_mode & _GROUP_OTHER_BITS:
|
|
135
|
+
raise UnsafeStateError(
|
|
136
|
+
f"{path} is mode {stat.S_IMODE(status.st_mode):04o}, open to group or others; "
|
|
137
|
+
f"`{remedy} {path}` if nobody else could have read it"
|
|
138
|
+
)
|
|
@@ -33,6 +33,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
|
33
33
|
from pydantic import BaseModel, ConfigDict
|
|
34
34
|
|
|
35
35
|
from agentic_runner import __version__ as runner_version
|
|
36
|
+
from agentic_runner.private_state import private_read, private_write
|
|
36
37
|
from agentic_runner_contracts import __version__ as contracts_version
|
|
37
38
|
from agentic_runner_contracts.channel_messages import TRANSCRIPT_DELIVERIES_PATH, TranscriptDelivery
|
|
38
39
|
from agentic_runner_contracts.runner_registration import (
|
|
@@ -64,6 +65,10 @@ DIRECTIVE_TOKEN_PATH = "/api/runner/v1/runners/directive-token"
|
|
|
64
65
|
INTAKE_WORK_RECORDS_PATH = "/api/runner/v1/runners/intake/work-records"
|
|
65
66
|
INTAKE_IGNORED_PATH = "/api/runner/v1/runners/intake/ignored"
|
|
66
67
|
|
|
68
|
+
# The refusal a revoked Runner's heartbeat earns (06 §2: revocation *is* the refused
|
|
69
|
+
# heartbeat). The one reason this process stops on rather than retries.
|
|
70
|
+
RUNNER_REVOKED_REASON = "runner_revoked"
|
|
71
|
+
|
|
67
72
|
# What the state directory holds after a successful bootstrap. One file, mode 0600: the
|
|
68
73
|
# private key is in it, and a Runner that loses it re-bootstraps rather than recovering.
|
|
69
74
|
STATE_FILENAME = "runner-identity.json"
|
|
@@ -208,19 +213,12 @@ def _version(value: str) -> tuple[int, ...]:
|
|
|
208
213
|
def load_state(state_dir: Path) -> RunnerState | None:
|
|
209
214
|
"""The persisted identity, or ``None`` on a first boot."""
|
|
210
215
|
|
|
211
|
-
|
|
212
|
-
if
|
|
213
|
-
return None
|
|
214
|
-
return RunnerState.model_validate_json(path.read_text(encoding="utf-8"))
|
|
216
|
+
raw = private_read(state_dir / STATE_FILENAME)
|
|
217
|
+
return None if raw is None else RunnerState.model_validate_json(raw)
|
|
215
218
|
|
|
216
219
|
|
|
217
220
|
def save_state(state_dir: Path, state: RunnerState) -> None:
|
|
218
|
-
state_dir.
|
|
219
|
-
path = state_dir / STATE_FILENAME
|
|
220
|
-
path.write_text(state.model_dump_json(), encoding="utf-8")
|
|
221
|
-
# The private key is in this file; a shared PVC or a workstation home is not a place
|
|
222
|
-
# to leave it group-readable.
|
|
223
|
-
path.chmod(0o600)
|
|
221
|
+
private_write(state_dir / STATE_FILENAME, state.model_dump_json().encode("utf-8"))
|
|
224
222
|
|
|
225
223
|
|
|
226
224
|
class RunnerRegistrationClient:
|