Skip to content

Commit 3c68f5c

Browse files
nficanocursoragent
andcommitted
fix: bounded grace + audit logging for lease expiry (§14) (#90)
Add a configurable grace window (lease_expiry_grace_sec, default 1s) to expires_at enforcement in validate_lease_op and the watchdog so small clock skew does not spuriously expire a lease, and emit a structured "lease_expired" audit log when the watchdog terminates a job. Fixes #90 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 86ed024 commit 3c68f5c

5 files changed

Lines changed: 120 additions & 2 deletions

File tree

src/arcp/_runtime/_job_runner.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,14 +201,18 @@ async def _revoke_with_retry(runtime: ARCPRuntime, credential_id: str) -> bool:
201201

202202
async def _lease_watchdog(runtime: ARCPRuntime, job: Job, expires_at_iso: str) -> None:
203203
expiry = _parse_iso_utc(expires_at_iso)
204-
delay = (expiry - dt.datetime.now(dt.UTC)).total_seconds()
204+
# Apply the same bounded grace (§14) the op-check uses so the proactive
205+
# watchdog does not fire ahead of an authority-bearing op-time check.
206+
delay = (expiry - dt.datetime.now(dt.UTC)).total_seconds() + runtime.lease_expiry_grace_sec
205207
if delay > 0:
206208
try:
207209
await asyncio.sleep(delay)
208210
except asyncio.CancelledError:
209211
return
210212
if job.state != "running":
211213
return
214+
# §14: log lease expirations for audit.
215+
runtime.logger.info("lease_expired", job_id=job.job_id, expires_at=expires_at_iso)
212216
await job.emit_error(
213217
JobErrorPayload(
214218
code="LEASE_EXPIRED",

src/arcp/_runtime/job.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ def authorize(
306306
LeaseOpContext(capability=capability, target=target, cost=cost, now=now),
307307
constraints=self.lease_constraints,
308308
budget=self.job.budget if "cost.budget" in self.lease else None,
309+
grace_sec=self.runtime.lease_expiry_grace_sec,
309310
)
310311

311312
def authorize_model(self, model_id: str, *, now: dt.datetime | None = None) -> None:
@@ -314,6 +315,7 @@ def authorize_model(self, model_id: str, *, now: dt.datetime | None = None) -> N
314315
self.lease,
315316
LeaseOpContext(capability="model.use", target=model_id, now=now),
316317
constraints=self.lease_constraints,
318+
grace_sec=self.runtime.lease_expiry_grace_sec,
317319
)
318320

319321
async def rotate_credential(self, credential_id: str, new_value: str) -> None:

src/arcp/_runtime/lease.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,17 +209,27 @@ def _glob_lang_subset(child: str, parent: str) -> bool:
209209
return True
210210

211211

212+
DEFAULT_EXPIRY_GRACE_SEC: float = 1.0
213+
"""Bounded grace window applied to `expires_at` enforcement (§14).
214+
215+
`expires_at` is an absolute ISO timestamp, so its comparison is inherently
216+
wall-clock; deployments rely on NTP discipline. The grace absorbs small clock
217+
skew (e.g. an NTP step) so a barely-unexpired lease is not spuriously rejected.
218+
"""
219+
220+
212221
def validate_lease_op(
213222
lease: Lease,
214223
ctx: LeaseOpContext,
215224
*,
216225
constraints: LeaseConstraints | None = None,
217226
budget: dict[str, Decimal] | None = None,
227+
grace_sec: float = DEFAULT_EXPIRY_GRACE_SEC,
218228
) -> None:
219229
"""Authorize an op against the lease: pattern match, expiry, budget. Raise on violation."""
220230
if constraints is not None and constraints.expires_at is not None:
221231
n = ctx.now or dt.datetime.now(dt.UTC)
222-
expiry = _parse_iso_utc(constraints.expires_at)
232+
expiry = _parse_iso_utc(constraints.expires_at) + dt.timedelta(seconds=grace_sec)
223233
if n >= expiry:
224234
raise LeaseExpiredError("lease has expired")
225235

src/arcp/_runtime/server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ class ARCPRuntime:
109109
idempotency_ttl_sec: TTL for entries in the idempotency store.
110110
max_concurrent_jobs: Cap on simultaneous running agent tasks.
111111
chunk_size_cap: Per-`result_chunk` size cap (spec §14 SHOULD).
112+
lease_expiry_grace_sec: Bounded grace window (§14) added to
113+
`expires_at` enforcement to absorb small clock skew.
112114
job_authorization_policy: Hook controlling list/subscribe/cancel
113115
visibility (defaults to same-principal).
114116
event_log: Storage for replayable envelopes. Defaults to
@@ -134,6 +136,7 @@ def __init__( # noqa: PLR0913
134136
idempotency_ttl_sec: float = 24 * 60 * 60,
135137
max_concurrent_jobs: int = 100,
136138
chunk_size_cap: int = 1024 * 1024,
139+
lease_expiry_grace_sec: float = 1.0,
137140
job_authorization_policy: JobAuthorizationPolicy | None = None,
138141
event_log: EventLog | None = None,
139142
credential_provisioner: CredentialProvisioner | None = None,
@@ -159,6 +162,7 @@ def __init__( # noqa: PLR0913
159162
self.resume_window_sec = resume_window_sec
160163
self.max_concurrent_jobs = max_concurrent_jobs
161164
self.chunk_size_cap = chunk_size_cap
165+
self.lease_expiry_grace_sec = lease_expiry_grace_sec
162166
self.idempotency = IdempotencyStore(ttl_sec=idempotency_ttl_sec)
163167
self.event_log: EventLog = event_log if event_log is not None else InMemoryEventLog()
164168
self.policy = job_authorization_policy or _default_authz_policy
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""#90 — bounded grace on expires_at enforcement and lease-expiry audit logging (§14)."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import contextlib
7+
import datetime as dt
8+
from typing import Any
9+
10+
import pytest
11+
12+
from arcp import (
13+
Capabilities,
14+
ClientInfo,
15+
LeaseConstraints,
16+
LeaseExpiredError,
17+
RuntimeInfo,
18+
pair_memory_transports,
19+
)
20+
from arcp._messages.execution import LeaseConstraints as LC
21+
from arcp._runtime.lease import LeaseOpContext, validate_lease_op
22+
from arcp.client import ARCPClient
23+
from arcp.runtime import ARCPRuntime, StaticBearerVerifier
24+
25+
26+
def test_expiry_grace_window_is_applied_and_configurable() -> None:
27+
now = dt.datetime.now(dt.UTC)
28+
# expires_at 0.5s in the past relative to `now`.
29+
expires = (now - dt.timedelta(seconds=0.5)).isoformat().replace("+00:00", "Z")
30+
constraints = LC(expires_at=expires)
31+
lease = {"fs.read": ["*"]}
32+
ctx = LeaseOpContext(capability="fs.read", target="f", now=now)
33+
34+
# Within a 1s grace window -> still authorized.
35+
validate_lease_op(lease, ctx, constraints=constraints, grace_sec=1.0)
36+
37+
# Grace disabled -> expired.
38+
with pytest.raises(LeaseExpiredError):
39+
validate_lease_op(lease, ctx, constraints=constraints, grace_sec=0.0)
40+
41+
42+
class _CapturingLogger:
43+
def __init__(self) -> None:
44+
self.records: list[tuple[str, dict[str, Any]]] = []
45+
46+
def bind(self, **_kw: Any) -> _CapturingLogger:
47+
return self
48+
49+
def info(self, event: str, **kw: Any) -> None:
50+
self.records.append((event, kw))
51+
52+
def warning(self, *_a: Any, **_k: Any) -> None: ...
53+
def error(self, *_a: Any, **_k: Any) -> None: ...
54+
def debug(self, *_a: Any, **_k: Any) -> None: ...
55+
def exception(self, *_a: Any, **_k: Any) -> None: ...
56+
57+
58+
async def test_lease_expiration_is_logged_for_audit() -> None:
59+
logger = _CapturingLogger()
60+
rt = ARCPRuntime(
61+
runtime=RuntimeInfo(name="r", version="1"),
62+
bearer=StaticBearerVerifier({"tok": "p1"}),
63+
heartbeat_interval_sec=None,
64+
lease_expiry_grace_sec=0.1,
65+
logger=logger,
66+
)
67+
68+
async def slow(input_value, ctx):
69+
await asyncio.sleep(5.0)
70+
return "never"
71+
72+
rt.register_agent("slow", slow)
73+
74+
server_t, client_t = pair_memory_transports()
75+
accept_task = asyncio.create_task(rt.accept(server_t))
76+
client = ARCPClient(
77+
client=ClientInfo(name="c", version="1"),
78+
token="tok",
79+
capabilities=Capabilities(features=rt.capabilities.features),
80+
)
81+
await client.connect(client_t)
82+
try:
83+
expiry = (dt.datetime.now(dt.UTC) + dt.timedelta(milliseconds=100)).isoformat()
84+
handle = await client.submit(
85+
agent="slow",
86+
lease_request={"fs.read": ["/tmp/*"]},
87+
lease_constraints=LeaseConstraints(expires_at=expiry.replace("+00:00", "Z")),
88+
)
89+
with pytest.raises(LeaseExpiredError):
90+
await asyncio.wait_for(handle.done, timeout=3.0)
91+
assert any(event == "lease_expired" for event, _ in logger.records), logger.records
92+
finally:
93+
with contextlib.suppress(Exception):
94+
await client.close()
95+
accept_task.cancel()
96+
with contextlib.suppress(asyncio.CancelledError, Exception):
97+
await accept_task
98+
await rt.close()

0 commit comments

Comments
 (0)