agentic-runner 2.6.0__tar.gz → 2.6.1__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.6.0 → agentic_runner-2.6.1}/PKG-INFO +1 -1
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/__init__.py +1 -1
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/activities.py +65 -23
- agentic_runner-2.6.1/src/agentic_runner/attempts.py +258 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/runtime/verifier_command.py +35 -5
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/service.py +6 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/_runtime_support.py +23 -8
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/.gitignore +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/LICENSE +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/README.md +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/pyproject.toml +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/callback.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/child_watcher.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/cli.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/config.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/credentials.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/device_login_activities.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/egress.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/heartbeat_link.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/hooks.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/host_store.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/__init__.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/git/__init__.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/git/contracts.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/git/evidence.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/git/workspace.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/github/__init__.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/github/auth.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/github/fake_client.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/github/gh_client.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/lifecycle.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/llm_proxy.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/mcp.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/message_store.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/py.typed +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/recipient_key_secret.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/registration.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/runtime/__init__.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/sealed_box.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/tiny_http.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/triage_activities.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/user_sources.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/__init__.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/agent_runtime.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/claude_runtime.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/codex_runtime.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/command_policy.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/contract_device_login.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/contract_isolation.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/fastapi_client.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/harness_usage.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/mcp_config.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/settings.py +0 -0
- {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workstation.py +0 -0
|
@@ -38,6 +38,7 @@ 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 AttemptRecords, PriorAttemptAliveError, fence_work_record
|
|
41
42
|
from agentic_runner.callback import (
|
|
42
43
|
CALLBACK_TOKEN_ENV,
|
|
43
44
|
AnnotateRequest,
|
|
@@ -1321,6 +1322,7 @@ def wipe_contract_residue(
|
|
|
1321
1322
|
|
|
1322
1323
|
# Where a Runner-side routing refusal lands in the trail (PRD issue 42, 17 A11).
|
|
1323
1324
|
_ROUTING_EVIDENCE_SOURCE = "runner.routing"
|
|
1325
|
+
PRIOR_ATTEMPT_ALIVE_EVIDENCE_SOURCE = "runner.prior_attempt_alive"
|
|
1324
1326
|
DIRECTIVE_TOKEN_EVIDENCE_SOURCE = "runner.directive_token"
|
|
1325
1327
|
AGENT_RUNTIME_EVIDENCE_SOURCE = "runner.agent_runtime"
|
|
1326
1328
|
|
|
@@ -1435,6 +1437,7 @@ class RunnerRalphActivities:
|
|
|
1435
1437
|
mcp_spawner: Spawner | None = None,
|
|
1436
1438
|
egress_resolver: Resolver | None = None,
|
|
1437
1439
|
tool_server_health: ToolServerHealthLog | None = None,
|
|
1440
|
+
attempt_records: AttemptRecords | None = None,
|
|
1438
1441
|
) -> None:
|
|
1439
1442
|
self._fastapi_client = fastapi_client
|
|
1440
1443
|
# Wall-clock source for per-Directive runtime, accrued against the Budget's
|
|
@@ -1494,6 +1497,9 @@ class RunnerRalphActivities:
|
|
|
1494
1497
|
self._mcp_spawner = mcp_spawner
|
|
1495
1498
|
self._egress_resolver = egress_resolver
|
|
1496
1499
|
self._tool_server_health = tool_server_health
|
|
1500
|
+
# The retry fence (runner-repo 05). None fences nothing: a process that never
|
|
1501
|
+
# registered has no state directory to keep the records in.
|
|
1502
|
+
self._attempt_records = attempt_records
|
|
1497
1503
|
|
|
1498
1504
|
def activity_callables(self) -> list[Callable[..., Any]]:
|
|
1499
1505
|
"""Return decorated activity callables for Temporal worker registration."""
|
|
@@ -1561,6 +1567,37 @@ class RunnerRalphActivities:
|
|
|
1561
1567
|
else "runner"
|
|
1562
1568
|
)
|
|
1563
1569
|
|
|
1570
|
+
@contextlib.asynccontextmanager
|
|
1571
|
+
async def _fenced(self, work_record_id: str) -> AsyncIterator[None]:
|
|
1572
|
+
"""Refuse this attempt's spawns while an earlier attempt's group lives (RR-05).
|
|
1573
|
+
|
|
1574
|
+
The refusal is retryable on purpose: the earlier attempt either finishes or is
|
|
1575
|
+
killed, and the next retry then finds the Workspace free.
|
|
1576
|
+
"""
|
|
1577
|
+
|
|
1578
|
+
if self._attempt_records is None:
|
|
1579
|
+
yield
|
|
1580
|
+
return
|
|
1581
|
+
attempt = activity.info().attempt if activity.in_activity() else 1
|
|
1582
|
+
try:
|
|
1583
|
+
with fence_work_record(self._attempt_records, work_record_id, attempt=attempt):
|
|
1584
|
+
yield
|
|
1585
|
+
except PriorAttemptAliveError as refusal:
|
|
1586
|
+
await _raise_recorded(
|
|
1587
|
+
refusal,
|
|
1588
|
+
self._fastapi_client,
|
|
1589
|
+
work_record_id,
|
|
1590
|
+
source=PRIOR_ATTEMPT_ALIVE_EVIDENCE_SOURCE,
|
|
1591
|
+
actor=self._actor(),
|
|
1592
|
+
payload={
|
|
1593
|
+
"event": "directive.prior_attempt_alive",
|
|
1594
|
+
"work_record_id": work_record_id,
|
|
1595
|
+
"attempt": attempt,
|
|
1596
|
+
"prior_attempt": refusal.prior.attempt,
|
|
1597
|
+
"prior_pgid": refusal.prior.pgid,
|
|
1598
|
+
},
|
|
1599
|
+
)
|
|
1600
|
+
|
|
1564
1601
|
@activity.defn(name="create_or_update_branch_pr")
|
|
1565
1602
|
async def create_or_update_branch_pr(
|
|
1566
1603
|
self,
|
|
@@ -1570,7 +1607,9 @@ class RunnerRalphActivities:
|
|
|
1570
1607
|
await self._assert_routed(request)
|
|
1571
1608
|
asked: list[QuestionAsked] = []
|
|
1572
1609
|
try:
|
|
1573
|
-
|
|
1610
|
+
async with self._fenced(request.work_record_id):
|
|
1611
|
+
ran = await self._run_branch_pr_directive(request, asked)
|
|
1612
|
+
return _with_question(ran, asked)
|
|
1574
1613
|
except HookRefusedError:
|
|
1575
1614
|
# A Runner Hook at or before `pre_runtime` exited non-zero, `pre_directive`
|
|
1576
1615
|
# being the reject gate (PRD issue 45). Refusing the Directive is the point,
|
|
@@ -3811,7 +3850,7 @@ class RunnerRalphActivities:
|
|
|
3811
3850
|
async def run_verifier(self, request: VerifierRunInput) -> VerifierRunOutput:
|
|
3812
3851
|
"""Run product-owned verifier config and record bounded redacted evidence."""
|
|
3813
3852
|
await self._assert_routed(request)
|
|
3814
|
-
async with _liveness_heartbeats():
|
|
3853
|
+
async with _liveness_heartbeats(), self._fenced(request.work_record_id):
|
|
3815
3854
|
if self._runtime_dependencies_ready():
|
|
3816
3855
|
state = await self._runtime_state(
|
|
3817
3856
|
request.work_record_id, workspace_path=request.workspace_path
|
|
@@ -3909,7 +3948,7 @@ class RunnerRalphActivities:
|
|
|
3909
3948
|
sandbox=verifier_sandbox,
|
|
3910
3949
|
)
|
|
3911
3950
|
try:
|
|
3912
|
-
# Run in a thread:
|
|
3951
|
+
# Run in a thread: waiting on the verifier inline would block the worker
|
|
3913
3952
|
# event loop for up to the verifier timeout, starving the liveness
|
|
3914
3953
|
# heartbeats above (and every other activity on this pod).
|
|
3915
3954
|
result = await asyncio.to_thread(
|
|
@@ -3981,7 +4020,8 @@ class RunnerRalphActivities:
|
|
|
3981
4020
|
repair a verifier failure, then re-push. Exactly one runtime turn (ADR-0007)."""
|
|
3982
4021
|
await self._assert_routed(request)
|
|
3983
4022
|
try:
|
|
3984
|
-
|
|
4023
|
+
async with self._fenced(request.work_record_id):
|
|
4024
|
+
return await self._run_fix_directive(request)
|
|
3985
4025
|
except HookRefusedError as refusal:
|
|
3986
4026
|
# Same posture as the first Directive: a hook refusal ends the loop
|
|
3987
4027
|
# attributably instead of raising into an endless retry (PRD issue 45).
|
|
@@ -4071,25 +4111,26 @@ class RunnerRalphActivities:
|
|
|
4071
4111
|
)
|
|
4072
4112
|
|
|
4073
4113
|
try:
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4114
|
+
async with self._fenced(request.work_record_id):
|
|
4115
|
+
ran = await self._run_in_place_directive(
|
|
4116
|
+
request,
|
|
4117
|
+
_InPlaceDirective(
|
|
4118
|
+
lifecycle_source="ralph.execute_member_directive",
|
|
4119
|
+
run_source="ralph.member_directive.codex_run",
|
|
4120
|
+
push_source="ralph.member_directive.git_commit_push",
|
|
4121
|
+
commit_message=(
|
|
4122
|
+
f"feat(task): directive {request.directive_number} by Agent "
|
|
4123
|
+
f"{request.agent_id} for work record {request.work_record_id}"
|
|
4124
|
+
),
|
|
4125
|
+
push_summary=(
|
|
4126
|
+
f"Push Directive {request.directive_number} ({request.role or 'member'}"
|
|
4127
|
+
f", woken by {request.wake_reason or 'pending messages'}) to "
|
|
4128
|
+
f"{request.repository}."
|
|
4129
|
+
),
|
|
4130
|
+
prompt=prompt,
|
|
4131
|
+
push_when_clean=False,
|
|
4088
4132
|
),
|
|
4089
|
-
|
|
4090
|
-
push_when_clean=False,
|
|
4091
|
-
),
|
|
4092
|
-
)
|
|
4133
|
+
)
|
|
4093
4134
|
except HookRefusedError as refusal:
|
|
4094
4135
|
return MemberDirectiveOutput(
|
|
4095
4136
|
work_record_id=request.work_record_id,
|
|
@@ -4163,7 +4204,8 @@ class RunnerRalphActivities:
|
|
|
4163
4204
|
summary="directive unavailable: runtime dependencies missing",
|
|
4164
4205
|
)
|
|
4165
4206
|
try:
|
|
4166
|
-
|
|
4207
|
+
async with self._fenced(request.work_record_id):
|
|
4208
|
+
return await self._run_learning_directive(request)
|
|
4167
4209
|
except HookRefusedError as refusal:
|
|
4168
4210
|
return self._learning_output(
|
|
4169
4211
|
request,
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""One live process group per Work Record, fenced across activity attempts (runner-repo 05).
|
|
2
|
+
|
|
3
|
+
A lost heartbeat makes Temporal schedule a retry while the first attempt's coroutine,
|
|
4
|
+
and the harness it spawned, may still be running in this same process. Both would then
|
|
5
|
+
write one Workspace, which ADR-0015 §2 and ADR-0012 assume never happens. So every process
|
|
6
|
+
group a Work Record's Directive, verifier run or Runner Hook spawns is recorded as
|
|
7
|
+
``{pgid, start_time, attempt}`` under the Runner's state directory, and the next spawn
|
|
8
|
+
for that Work Record is refused while the recorded group's leader is still alive.
|
|
9
|
+
|
|
10
|
+
Liveness is pid *plus* start time, as Paperclip fences its restarts: a pid alone is
|
|
11
|
+
recycled, and a record naming a recycled pid must not block a Work Record forever. The
|
|
12
|
+
record lives in the state directory, never the Workspace, because the Agent writes the
|
|
13
|
+
Workspace and could forge or delete its own fence there.
|
|
14
|
+
|
|
15
|
+
The binding is a context variable rather than a parameter: the spawn sites sit behind
|
|
16
|
+
the Agent Runtime port, the Runner Hook runner and the verifier's command runner, and
|
|
17
|
+
none of them knows which Work Record it serves. ``asyncio.to_thread`` copies the
|
|
18
|
+
context, so the verifier's thread sees the activity's binding too.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
from collections.abc import Iterator
|
|
30
|
+
from contextvars import ContextVar
|
|
31
|
+
from dataclasses import asdict, dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Final
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"ATTEMPTS_DIR",
|
|
37
|
+
"AttemptRecord",
|
|
38
|
+
"AttemptRecords",
|
|
39
|
+
"PriorAttemptAliveError",
|
|
40
|
+
"fence_work_record",
|
|
41
|
+
"process_start_time",
|
|
42
|
+
"recorded_group",
|
|
43
|
+
"refuse_if_prior_attempt_alive",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
ATTEMPTS_DIR: Final[str] = "attempts"
|
|
47
|
+
_DIR_MODE: Final[int] = 0o700
|
|
48
|
+
_FILE_MODE: Final[int] = 0o600
|
|
49
|
+
# A Work Record id names a file here, so it must not be able to name one anywhere else.
|
|
50
|
+
_SAFE_ID: Final[re.Pattern[str]] = re.compile(r"[A-Za-z0-9_-][A-Za-z0-9._-]*")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class AttemptRecord:
|
|
55
|
+
pgid: int
|
|
56
|
+
start_time: str
|
|
57
|
+
attempt: int
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PriorAttemptAliveError(Exception):
|
|
61
|
+
"""A spawn was refused: an earlier attempt's process group for this Work Record lives."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, work_record_id: str, prior: AttemptRecord) -> None:
|
|
64
|
+
super().__init__(
|
|
65
|
+
f"work record {work_record_id}: attempt {prior.attempt} still runs "
|
|
66
|
+
f"(process group {prior.pgid})"
|
|
67
|
+
)
|
|
68
|
+
self.work_record_id = work_record_id
|
|
69
|
+
self.prior = prior
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def process_start_time(pid: int) -> str | None:
|
|
73
|
+
"""When ``pid`` started, in the platform's own units, or None if no such process.
|
|
74
|
+
|
|
75
|
+
Only ever compared for equality with an earlier reading of the same pid on the same
|
|
76
|
+
host, so the units need not agree across platforms: clock ticks since boot on Linux,
|
|
77
|
+
the ``lstart`` text on macOS (one-second resolution, which a pid recycled within the
|
|
78
|
+
same second would defeat -- not a case a harness spawn produces).
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
if sys.platform == "linux":
|
|
82
|
+
return _linux_start_time(pid)
|
|
83
|
+
return _ps_start_time(pid)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _linux_start_time(pid: int) -> str | None:
|
|
87
|
+
try:
|
|
88
|
+
stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8", errors="replace")
|
|
89
|
+
except (FileNotFoundError, ProcessLookupError):
|
|
90
|
+
return None
|
|
91
|
+
# Field 2 is the command name in parentheses and may itself hold spaces or ')', so
|
|
92
|
+
# the fields are counted from after its *last* ')': field 3 is index 0, 22 is 19.
|
|
93
|
+
return stat[stat.rindex(")") + 2 :].split()[19]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _ps_start_time(pid: int) -> str | None:
|
|
97
|
+
completed = subprocess.run(
|
|
98
|
+
["ps", "-o", "lstart=", "-p", str(pid)],
|
|
99
|
+
capture_output=True,
|
|
100
|
+
text=True,
|
|
101
|
+
check=False,
|
|
102
|
+
)
|
|
103
|
+
started = completed.stdout.strip()
|
|
104
|
+
return started if completed.returncode == 0 and started else None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AttemptRecords:
|
|
108
|
+
"""The ``attempts/`` directory of one Runner's state directory."""
|
|
109
|
+
|
|
110
|
+
def __init__(self, state_dir: Path, *, workspace_root: Path) -> None:
|
|
111
|
+
self._dir = state_dir / ATTEMPTS_DIR
|
|
112
|
+
if self._dir.resolve().is_relative_to(workspace_root.resolve()):
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"attempt records at {self._dir} would sit under the workspace root "
|
|
115
|
+
f"{workspace_root}, where an Agent could forge them"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def path(self, work_record_id: str) -> Path:
|
|
119
|
+
if not _SAFE_ID.fullmatch(work_record_id):
|
|
120
|
+
raise ValueError(f"work record id is not a file name: {work_record_id!r}")
|
|
121
|
+
return self._dir / f"{work_record_id}.json"
|
|
122
|
+
|
|
123
|
+
def refuse_if_alive(self, work_record_id: str) -> None:
|
|
124
|
+
"""Raise if a recorded group for this Work Record lives; clear a stale record."""
|
|
125
|
+
|
|
126
|
+
path = self.path(work_record_id)
|
|
127
|
+
record = _read(path)
|
|
128
|
+
if record is None:
|
|
129
|
+
return
|
|
130
|
+
if _alive(record):
|
|
131
|
+
raise PriorAttemptAliveError(work_record_id, record)
|
|
132
|
+
_unlink_if(path, record)
|
|
133
|
+
|
|
134
|
+
def record(self, work_record_id: str, *, pgid: int, attempt: int) -> AttemptRecord | None:
|
|
135
|
+
"""Claim the Work Record for a just-spawned group; None if it already exited.
|
|
136
|
+
|
|
137
|
+
Exclusive create, so two attempts that both passed the pre-spawn check in the
|
|
138
|
+
same instant cannot both hold the claim: the second finds the first's record
|
|
139
|
+
and is refused.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
start_time = process_start_time(pgid)
|
|
143
|
+
if start_time is None:
|
|
144
|
+
return None
|
|
145
|
+
record = AttemptRecord(pgid=pgid, start_time=start_time, attempt=attempt)
|
|
146
|
+
path = self.path(work_record_id)
|
|
147
|
+
self._dir.mkdir(mode=_DIR_MODE, parents=True, exist_ok=True)
|
|
148
|
+
while True:
|
|
149
|
+
try:
|
|
150
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, _FILE_MODE)
|
|
151
|
+
except FileExistsError:
|
|
152
|
+
self.refuse_if_alive(work_record_id)
|
|
153
|
+
continue
|
|
154
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
155
|
+
json.dump(asdict(record), handle)
|
|
156
|
+
return record
|
|
157
|
+
|
|
158
|
+
def release(self, work_record_id: str, record: AttemptRecord) -> None:
|
|
159
|
+
"""Remove our own record once its group leader has exited.
|
|
160
|
+
|
|
161
|
+
Kept while the leader lives -- a cancel whose kill did not land -- so the next
|
|
162
|
+
attempt is still refused rather than let in beside it.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
if not _alive(record):
|
|
166
|
+
_unlink_if(self.path(work_record_id), record)
|
|
167
|
+
|
|
168
|
+
def sweep(self) -> None:
|
|
169
|
+
"""At Runner start: drop records whose process is gone, keep the live ones.
|
|
170
|
+
|
|
171
|
+
A workstation Runner restarted without its children leaves them running in the
|
|
172
|
+
Workspace; their records stay so the check still refuses until they exit.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
if not self._dir.is_dir():
|
|
176
|
+
return
|
|
177
|
+
for path in self._dir.glob("*.json"):
|
|
178
|
+
record = _read(path)
|
|
179
|
+
if record is None or not _alive(record):
|
|
180
|
+
path.unlink(missing_ok=True)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _read(path: Path) -> AttemptRecord | None:
|
|
184
|
+
try:
|
|
185
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
186
|
+
return AttemptRecord(
|
|
187
|
+
pgid=int(payload["pgid"]),
|
|
188
|
+
start_time=str(payload["start_time"]),
|
|
189
|
+
attempt=int(payload["attempt"]),
|
|
190
|
+
)
|
|
191
|
+
except FileNotFoundError:
|
|
192
|
+
return None
|
|
193
|
+
except (ValueError, KeyError, TypeError):
|
|
194
|
+
# Half-written by a Runner that died mid-write: it fences nothing it can name.
|
|
195
|
+
return AttemptRecord(pgid=0, start_time="", attempt=0)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _alive(record: AttemptRecord) -> bool:
|
|
199
|
+
return record.pgid > 0 and process_start_time(record.pgid) == record.start_time
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _unlink_if(path: Path, record: AttemptRecord) -> None:
|
|
203
|
+
# Another attempt may have replaced a stale record between our read and this unlink;
|
|
204
|
+
# only the record we judged is ours to remove.
|
|
205
|
+
if _read(path) == record:
|
|
206
|
+
path.unlink(missing_ok=True)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass(frozen=True, slots=True)
|
|
210
|
+
class _Fence:
|
|
211
|
+
records: AttemptRecords
|
|
212
|
+
work_record_id: str
|
|
213
|
+
attempt: int
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
_FENCE: ContextVar[_Fence | None] = ContextVar("agentic_runner_attempt_fence", default=None)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@contextlib.contextmanager
|
|
220
|
+
def fence_work_record(
|
|
221
|
+
records: AttemptRecords, work_record_id: str, *, attempt: int
|
|
222
|
+
) -> Iterator[None]:
|
|
223
|
+
"""Fence every process group spawned inside this block to ``work_record_id``."""
|
|
224
|
+
|
|
225
|
+
token = _FENCE.set(_Fence(records=records, work_record_id=work_record_id, attempt=attempt))
|
|
226
|
+
try:
|
|
227
|
+
yield
|
|
228
|
+
finally:
|
|
229
|
+
_FENCE.reset(token)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def refuse_if_prior_attempt_alive() -> None:
|
|
233
|
+
"""Called before a spawn; a no-op outside a fenced block."""
|
|
234
|
+
|
|
235
|
+
fence = _FENCE.get()
|
|
236
|
+
if fence is not None:
|
|
237
|
+
fence.records.refuse_if_alive(fence.work_record_id)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@contextlib.contextmanager
|
|
241
|
+
def recorded_group(pgid: int) -> Iterator[None]:
|
|
242
|
+
"""Hold the Work Record's record for a spawned group while the block waits on it.
|
|
243
|
+
|
|
244
|
+
Raises :class:`PriorAttemptAliveError` on entry if another attempt claimed the Work
|
|
245
|
+
Record since the pre-spawn check; the caller kills the group it just spawned.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
fence = _FENCE.get()
|
|
249
|
+
record = (
|
|
250
|
+
None
|
|
251
|
+
if fence is None
|
|
252
|
+
else fence.records.record(fence.work_record_id, pgid=pgid, attempt=fence.attempt)
|
|
253
|
+
)
|
|
254
|
+
try:
|
|
255
|
+
yield
|
|
256
|
+
finally:
|
|
257
|
+
if fence is not None and record is not None:
|
|
258
|
+
fence.records.release(fence.work_record_id, record)
|
{agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/runtime/verifier_command.py
RENAMED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
import hashlib
|
|
4
5
|
import json
|
|
5
6
|
import os
|
|
6
7
|
import re
|
|
8
|
+
import signal
|
|
7
9
|
import subprocess
|
|
8
10
|
import time
|
|
9
11
|
from collections.abc import Callable, Mapping
|
|
@@ -11,6 +13,11 @@ from dataclasses import dataclass
|
|
|
11
13
|
from pathlib import Path
|
|
12
14
|
from typing import Protocol
|
|
13
15
|
|
|
16
|
+
from agentic_runner.attempts import (
|
|
17
|
+
PriorAttemptAliveError,
|
|
18
|
+
recorded_group,
|
|
19
|
+
refuse_if_prior_attempt_alive,
|
|
20
|
+
)
|
|
14
21
|
from agentic_runner_contracts.redaction import redact_secret_like_text
|
|
15
22
|
|
|
16
23
|
DEFAULT_OUTPUT_LIMIT_BYTES = 16_384
|
|
@@ -91,21 +98,44 @@ def _subprocess_runner(
|
|
|
91
98
|
that cannot separate uids still verifies, and they arrive together from
|
|
92
99
|
``DirectiveSandbox.spawn_kwargs`` — the uid drop in subprocess's own C fork-exec path,
|
|
93
100
|
only the rlimits in the Python hook.
|
|
101
|
+
|
|
102
|
+
Its own session, killed as a group on timeout, like a Directive: the test suite
|
|
103
|
+
forks workers into the Workspace, and the attempt fence (``attempts.py``) records
|
|
104
|
+
the group by its pgid.
|
|
94
105
|
"""
|
|
95
106
|
|
|
96
|
-
|
|
107
|
+
refuse_if_prior_attempt_alive()
|
|
108
|
+
with subprocess.Popen(
|
|
97
109
|
list(argv),
|
|
98
110
|
cwd=cwd,
|
|
99
111
|
env=env,
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
capture_output=True,
|
|
112
|
+
stdout=subprocess.PIPE,
|
|
113
|
+
stderr=subprocess.PIPE,
|
|
103
114
|
text=True,
|
|
115
|
+
start_new_session=True,
|
|
104
116
|
preexec_fn=preexec_fn,
|
|
105
117
|
user=user,
|
|
106
118
|
group=group,
|
|
107
119
|
extra_groups=extra_groups,
|
|
108
|
-
)
|
|
120
|
+
) as process:
|
|
121
|
+
try:
|
|
122
|
+
with recorded_group(process.pid):
|
|
123
|
+
try:
|
|
124
|
+
stdout, stderr = process.communicate(timeout=timeout_seconds)
|
|
125
|
+
except subprocess.TimeoutExpired:
|
|
126
|
+
_kill_group(process)
|
|
127
|
+
raise
|
|
128
|
+
except PriorAttemptAliveError:
|
|
129
|
+
_kill_group(process)
|
|
130
|
+
raise
|
|
131
|
+
return subprocess.CompletedProcess(process.args, process.returncode, stdout, stderr)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _kill_group(process: subprocess.Popen[str]) -> None:
|
|
135
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
136
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
137
|
+
process.kill()
|
|
138
|
+
process.communicate()
|
|
109
139
|
|
|
110
140
|
|
|
111
141
|
def run(
|
|
@@ -44,6 +44,7 @@ from temporalio.worker import (
|
|
|
44
44
|
|
|
45
45
|
from agentic_runner import __version__ as runner_version
|
|
46
46
|
from agentic_runner.activities import RunnerRalphActivities
|
|
47
|
+
from agentic_runner.attempts import AttemptRecords
|
|
47
48
|
from agentic_runner.child_watcher import install_stop_tolerant_child_watcher
|
|
48
49
|
from agentic_runner.config import RunnerConfig
|
|
49
50
|
from agentic_runner.config import load as load_config
|
|
@@ -1021,6 +1022,10 @@ def _activities(
|
|
|
1021
1022
|
)
|
|
1022
1023
|
static_git_token = os.getenv("AGENTIC_OS_GIT_TOKEN") or None
|
|
1023
1024
|
isolation = build_contract_isolation(settings, can_separate_uids=can_change_uid)
|
|
1025
|
+
attempt_records = AttemptRecords(config.state_dir, workspace_root=settings.WORKSPACE_ROOT)
|
|
1026
|
+
# Only the dead go: a live record is a harness this process lost on restart, still
|
|
1027
|
+
# writing its Workspace, and the next attempt must keep being refused (RR-05).
|
|
1028
|
+
attempt_records.sweep()
|
|
1024
1029
|
ralph = RunnerRalphActivities(
|
|
1025
1030
|
fastapi,
|
|
1026
1031
|
runtime_context_resolver=WorkerRuntimeContextResolver(fastapi),
|
|
@@ -1056,6 +1061,7 @@ def _activities(
|
|
|
1056
1061
|
message_store=messages,
|
|
1057
1062
|
workflow_signaller=signaller,
|
|
1058
1063
|
tool_server_health=tool_servers,
|
|
1064
|
+
attempt_records=attempt_records,
|
|
1059
1065
|
)
|
|
1060
1066
|
return [
|
|
1061
1067
|
*ralph.activity_callables(),
|
{agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/_runtime_support.py
RENAMED
|
@@ -19,6 +19,11 @@ from dataclasses import dataclass
|
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
from typing import Final
|
|
21
21
|
|
|
22
|
+
from agentic_runner.attempts import (
|
|
23
|
+
PriorAttemptAliveError,
|
|
24
|
+
recorded_group,
|
|
25
|
+
refuse_if_prior_attempt_alive,
|
|
26
|
+
)
|
|
22
27
|
from agentic_runner.callback import CALLBACK_SOCKET_ENV, CALLBACK_TOKEN_ENV
|
|
23
28
|
from agentic_runner.egress import EGRESS_ENV_NAMES
|
|
24
29
|
from agentic_runner.llm_proxy import PROXY_ENV_NAMES
|
|
@@ -110,6 +115,7 @@ async def run_subprocess_exec(
|
|
|
110
115
|
output_limit_bytes: int,
|
|
111
116
|
sandbox: DirectiveSandbox | None = None,
|
|
112
117
|
) -> SubprocessResult:
|
|
118
|
+
refuse_if_prior_attempt_alive()
|
|
113
119
|
process = await asyncio.create_subprocess_exec(
|
|
114
120
|
*argv,
|
|
115
121
|
cwd=cwd,
|
|
@@ -121,14 +127,23 @@ async def run_subprocess_exec(
|
|
|
121
127
|
**({} if sandbox is None else sandbox.spawn_kwargs()),
|
|
122
128
|
)
|
|
123
129
|
try:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
with recorded_group(process.pid):
|
|
131
|
+
try:
|
|
132
|
+
(
|
|
133
|
+
stdout_bytes,
|
|
134
|
+
stderr_bytes,
|
|
135
|
+
stdout_truncated,
|
|
136
|
+
stderr_truncated,
|
|
137
|
+
) = await asyncio.wait_for(
|
|
138
|
+
_collect_process_output(
|
|
139
|
+
process, stdin=stdin, output_limit_bytes=output_limit_bytes
|
|
140
|
+
),
|
|
141
|
+
timeout=timeout_seconds,
|
|
142
|
+
)
|
|
143
|
+
except (TimeoutError, asyncio.CancelledError):
|
|
144
|
+
await _terminate_process_tree(process)
|
|
145
|
+
raise
|
|
146
|
+
except PriorAttemptAliveError:
|
|
132
147
|
await _terminate_process_tree(process)
|
|
133
148
|
raise
|
|
134
149
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/git/__init__.py
RENAMED
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/git/contracts.py
RENAMED
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/git/evidence.py
RENAMED
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/git/fake_workspace.py
RENAMED
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/git/workspace.py
RENAMED
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/github/__init__.py
RENAMED
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/github/auth.py
RENAMED
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/github/fake_client.py
RENAMED
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/github/gh_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/contract_device_login.py
RENAMED
|
File without changes
|
{agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/contract_isolation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|