Skip to content

fix(sdk/python): add lock timeouts + offload blocking requests fallback (#620) - #904

Merged
AbirAbbas merged 11 commits into
Agent-Field:mainfrom
7vignesh:fix/blocking-requests-lock-timeouts-620
Aug 12, 2026
Merged

fix(sdk/python): add lock timeouts + offload blocking requests fallback (#620)#904
AbirAbbas merged 11 commits into
Agent-Field:mainfrom
7vignesh:fix/blocking-requests-lock-timeouts-620

Conversation

@7vignesh

Copy link
Copy Markdown
Contributor

Summary

Prevents indefinite hangs from contended locks and offloads the remaining blocking HTTP call inside an async function. This is slice 4 (final slice) of #620.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Docs only
  • Tests only
  • CI / tooling
  • Breaking change

What changed

Lock timeouts

New agentfield/lock_utils.py provides timed_lock() — a drop-in context manager replacement for bare with self._lock: that acquires with a configurable timeout (default 30s, env var AGENTFIELD_LOCK_TIMEOUT_SECONDS) and raises LockTimeoutError with diagnostic info instead of hanging forever.

Applied to all lock sites in:

  • result_cache.py (11 sites)
  • cost_tracker.py (8 sites)
  • node_logs.py (7 sites, including module-level _follow_lock)

Blocking request offload

memory_events.py history() — the blocking requests.get() fallback inside this async def is now offloaded to loop.run_in_executor() so it doesn't freeze the event loop. Removed the ASYNC210 per-file-ignore for this file (violation resolved).

Running-loop guard

client.execute_sync() now emits a RuntimeWarning when called from within a running event loop, directing users to await client.execute() instead. This catches the dangerous pattern before it causes a hang.

Test plan

  • cd sdk/python && python -m pytest tests/test_lock_timeout.py -v (7 tests: timeout, reentrant, cross-thread, error attributes, warning)
  • cd sdk/python && python -m pytest tests/test_result_cache.py tests/test_result_cache_deadlock.py tests/test_run_async.py tests/test_agent_core.py tests/test_client.py tests/test_client_execution_paths.py (63 passed)
  • cd sdk/python && ruff check . clean

Test coverage

  • I ran tests for the surface(s) I changed locally.
  • New code paths are covered by tests in this PR (no bare additions).
  • If I removed code, I updated coverage-baseline.json — N/A

Checklist

  • I have read CONTRIBUTING.md (if present) and docs/DEVELOPMENT.md.
  • Commits are signed and follow conventional-commits style.
  • I have linked any related issues.

Related issues / PRs

Part of #620 (final slice)
Follows:

github-actions Bot and others added 6 commits July 28, 2026 23:30
…ck (Agent-Field#620)

Slice 4 of Agent-Field#620: prevents indefinite hangs from contended locks and
offloads the remaining blocking HTTP call in an async function.

Lock timeouts:
- New agentfield/lock_utils.py: timed_lock() context manager that
  acquires with a configurable timeout (default 30s, env var
  AGENTFIELD_LOCK_TIMEOUT_SECONDS) and raises LockTimeoutError with
  diagnostic info instead of hanging.
- Applied to all lock sites in result_cache.py (11), cost_tracker.py (8),
  node_logs.py (7) — the 26 highest-contention acquisitions.

Blocking request offload:
- memory_events.py history() fallback: the blocking requests.get() in
  the async function's ImportError path is now offloaded to
  loop.run_in_executor() so it doesn't freeze the event loop.
- Removed the ASYNC210 per-file-ignore for memory_events.py (resolved).

Running-loop guard:
- client.execute_sync() now emits a RuntimeWarning when called from
  within a running event loop, directing users to await execute() instead.

Tests: 7 tests in test_lock_timeout.py covering timeout behaviour,
reentrant locks, cross-thread contention, error attributes, and the
execute_sync warning. 63 tests pass across the affected test surface.

Part of Agent-Field#620.
@7vignesh
7vignesh requested review from a team and AbirAbbas as code owners August 10, 2026 14:39
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Performance

SDK Memory Δ Latency Δ Tests Status
Python 9.0 KB - 0.29 µs -17%

✓ No regressions detected

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 87.10% 87.40% ↓ -0.30 pp 🟡
sdk-go 92.70% 92.00% ↑ +0.70 pp 🟢
sdk-python 94.20% 93.73% ↑ +0.47 pp 🟢
sdk-typescript 91.17% 90.42% ↑ +0.75 pp 🟢
web-ui 84.76% 84.79% ↓ -0.03 pp 🟡
aggregate 85.62% 85.75% ↓ -0.13 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 0 ➖ no changes
sdk-go 0 ➖ no changes
sdk-python 19 100.00%
sdk-typescript 0 ➖ no changes
web-ui 0 ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

AbirAbbas
AbirAbbas previously approved these changes Aug 12, 2026

@AbirAbbas AbirAbbas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified this locally on the CI-exact gates: ruff 0.15.22 clean and the full run_pytest.sh suite green on both 3.10 and 3.12 (1933 passed each), plus the websockets-compat matrix. Anti-tautology probes check out — patching timed_lock back to a bare acquire() makes the timeout test genuinely hang, and reverting client.py fails the running-loop-warning test, so those guards are real. Lock-site conversion is exact (11/8/7, zero bare with …_lock stragglers, remaining ones are async with on asyncio locks and correctly out of scope), and the RLock reentrancy in result_cache survives acquire(timeout=).

Three things inline, none of which I'd hold the merge for — the env-var parsing one is the one I'd fix first since it can fail import agentfield outright. One micro-nit not worth a thread: the tests/test_lock_timeout.py module docstring claims a memory_events.history() non-blocking test that doesn't exist (that change is guarded by the ASYNC210 ruff gate instead, which does fail on revert — I checked).

Comment thread sdk/python/agentfield/lock_utils.py Outdated
Comment thread sdk/python/agentfield/lock_utils.py Outdated
Comment thread sdk/python/tests/test_lock_timeout.py Outdated
AbirAbbas and others added 3 commits August 12, 2026 11:30
DEFAULT_LOCK_TIMEOUT was resolved with a bare float() at import time, so a
malformed value took down `import agentfield` altogether. The empty-string
case is the common one: `AGENTFIELD_LOCK_TIMEOUT_SECONDS=` in a compose
`env:` block makes float("") raise from __init__.py -> result_cache.py ->
lock_utils.py. A negative value imported fine but broke every lock op, since
lock.acquire(timeout=-5) raises ValueError.

Parsing now falls back to 30s for missing, empty, non-numeric, non-positive
and non-finite values, warning through the module logger for the cases that
look like a misconfiguration. Tests drive a fresh interpreter per value so
the import-time path is the one under test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On Python 3.11+ asyncio.TimeoutError is TimeoutError, so deriving from it
made LockTimeoutError catchable by every `except asyncio.TimeoutError` up the
stack. Agent.call wraps client.execute in asyncio.wait_for and that path goes
through the result cache, so a real lock deadlock surfaced as "Execute call
timed out" and the holder/wait diagnostics were lost.

Deriving from RuntimeError instead keeps the message intact on all matrix
versions. Nothing in the repo catches LockTimeoutError, so no call sites
change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…string

The __new__-based client only avoided real I/O by accident: execution died on
a missing caller_agent_id attribute inside `except Exception: pass`. Give
caller_agent_id a value and stub _submit_execution_sync with a sentinel, so
the test asserts the RuntimeWarning and proves nothing was submitted — rather
than depending on a crash that a class-level default would silence, turning
the test into a live POST to localhost:8080 plus a polling loop.

The module docstring also advertised a memory_events.history() test that was
never written; say where that change is actually guarded instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@AbirAbbas AbirAbbas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving at 37babd1 (my push dismissed the earlier stamp). All three review follow-ups are in as focused commits; independent verification probed them live: import succeeds with empty/garbage/negative env values (30s fallback, warning logged), LockTimeoutError no longer matches except asyncio.TimeoutError while keeping its diagnostic attributes, and the warning test runs with no control plane and no sockets. Full gates green locally and on CI (3.10–3.12 + websockets matrix). Note in the thread about the wait_for_result exception-type change — intended, but observable.

@AbirAbbas
AbirAbbas added this pull request to the merge queue Aug 12, 2026
Merged via the queue into Agent-Field:main with commit 25821fc Aug 12, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants