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.
Files changed (55) hide show
  1. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/PKG-INFO +1 -1
  2. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/__init__.py +1 -1
  3. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/activities.py +65 -23
  4. agentic_runner-2.6.1/src/agentic_runner/attempts.py +258 -0
  5. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/runtime/verifier_command.py +35 -5
  6. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/service.py +6 -0
  7. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/_runtime_support.py +23 -8
  8. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/.gitignore +0 -0
  9. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/LICENSE +0 -0
  10. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/README.md +0 -0
  11. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/pyproject.toml +0 -0
  12. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/callback.py +0 -0
  13. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/child_watcher.py +0 -0
  14. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/cli.py +0 -0
  15. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/config.py +0 -0
  16. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/credentials.py +0 -0
  17. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/device_login_activities.py +0 -0
  18. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/egress.py +0 -0
  19. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/heartbeat_link.py +0 -0
  20. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/hooks.py +0 -0
  21. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/host_store.py +0 -0
  22. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/__init__.py +0 -0
  23. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/git/__init__.py +0 -0
  24. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/git/contracts.py +0 -0
  25. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/git/evidence.py +0 -0
  26. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  27. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/git/workspace.py +0 -0
  28. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/github/__init__.py +0 -0
  29. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/github/auth.py +0 -0
  30. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  31. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  32. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/lifecycle.py +0 -0
  33. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/llm_proxy.py +0 -0
  34. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/mcp.py +0 -0
  35. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/message_store.py +0 -0
  36. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/py.typed +0 -0
  37. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/recipient_key_secret.py +0 -0
  38. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/registration.py +0 -0
  39. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/runtime/__init__.py +0 -0
  40. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/sealed_box.py +0 -0
  41. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/tiny_http.py +0 -0
  42. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/triage_activities.py +0 -0
  43. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/user_sources.py +0 -0
  44. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/__init__.py +0 -0
  45. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/agent_runtime.py +0 -0
  46. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/claude_runtime.py +0 -0
  47. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/codex_runtime.py +0 -0
  48. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/command_policy.py +0 -0
  49. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/contract_device_login.py +0 -0
  50. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/contract_isolation.py +0 -0
  51. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/fastapi_client.py +0 -0
  52. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/harness_usage.py +0 -0
  53. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/mcp_config.py +0 -0
  54. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workers/settings.py +0 -0
  55. {agentic_runner-2.6.0 → agentic_runner-2.6.1}/src/agentic_runner/workstation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-runner
3
- Version: 2.6.0
3
+ Version: 2.6.1
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
@@ -7,6 +7,6 @@ stay on the platform; this package imports none of them, and a CI partition test
7
7
  (``tests/test_package_partition.py``) fails the build if that changes.
8
8
  """
9
9
 
10
- __version__ = "2.6.0"
10
+ __version__ = "2.6.1"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -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
- return _with_question(await self._run_branch_pr_directive(request, asked), asked)
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: subprocess.run inline would block the worker
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
- return await self._run_fix_directive(request)
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
- ran = await self._run_in_place_directive(
4075
- request,
4076
- _InPlaceDirective(
4077
- lifecycle_source="ralph.execute_member_directive",
4078
- run_source="ralph.member_directive.codex_run",
4079
- push_source="ralph.member_directive.git_commit_push",
4080
- commit_message=(
4081
- f"feat(task): directive {request.directive_number} by Agent "
4082
- f"{request.agent_id} for work record {request.work_record_id}"
4083
- ),
4084
- push_summary=(
4085
- f"Push Directive {request.directive_number} ({request.role or 'member'}"
4086
- f", woken by {request.wake_reason or 'pending messages'}) to "
4087
- f"{request.repository}."
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
- prompt=prompt,
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
- return await self._run_learning_directive(request)
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)
@@ -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
- return subprocess.run(
107
+ refuse_if_prior_attempt_alive()
108
+ with subprocess.Popen(
97
109
  list(argv),
98
110
  cwd=cwd,
99
111
  env=env,
100
- timeout=timeout_seconds,
101
- check=False,
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(),
@@ -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
- stdout_bytes, stderr_bytes, stdout_truncated, stderr_truncated = await asyncio.wait_for(
125
- _collect_process_output(process, stdin=stdin, output_limit_bytes=output_limit_bytes),
126
- timeout=timeout_seconds,
127
- )
128
- except TimeoutError:
129
- await _terminate_process_tree(process)
130
- raise
131
- except asyncio.CancelledError:
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