Skip to content

chore(release): 0.14.6 — CI coverage-job flakefix + actions.cooldown race - #82

Closed
maltsev-dev wants to merge 7 commits into
masterfrom
fix/0.14.6-ci-coverage-rerunfailures
Closed

chore(release): 0.14.6 — CI coverage-job flakefix + actions.cooldown race#82
maltsev-dev wants to merge 7 commits into
masterfrom
fix/0.14.6-ci-coverage-rerunfailures

Conversation

@maltsev-dev

Copy link
Copy Markdown
Member

Two CI-only fixes that surfaced as red runs on the 0.14.5 matrix:

  1. coverage job missing pytest-rerunfailures.github/workflows/ci.yml:74. The test matrix already installs pytest-rerunfailures>=14.0,<16.0 (lines 41-45); the coverage job did not. The @pytest.mark.rerunfailures(reruns=2) marker on tests/test_approval_timeout_field.py::TestApprovalTimeoutResolution::test_env_fallback_when_server_value_is_zero was therefore a silent no-op on the coverage leg, and the first thread-scheduling race under -n auto on shared CI runners turned the run red even though all three test legs were green. Local sequential runs pass 15/15. Marker stays the same — only the plugin install on the coverage leg is added.

  2. test_is_paused_respects_cooldown window-of-zero racetests/test_actions.py. Pre-existing 0.13.7-era flake that became 5-in-5 on the 0.14.5 runner pool. The test asserted is_paused(..., cooldown_seconds=0.0) returns False immediately after a PAUSE; the underlying is_paused computes elapsed = time.time() - paused_at and uses elapsed > cooldown (strict greater-than). On any platform where time.time() rounds the same integer as paused_at within the test body, elapsed == 0.0 and the workflow stays "paused" forever. Test now sleeps 0.01s before the post-cooldown assertion to make the check deterministic. ActionHandler.is_paused semantics unchanged.

pyproject.toml + src/nullrun/__version__.py bumped to 0.14.6 (v3.31.5 / 0.14.6) with a changelog entry covering both fixes. uv.lock added (lockfile was previously untracked; CI installs via pip so this is hygiene-only, but it keeps local uv invocations deterministic for dev work).

Verification (local)

  • pytest tests/ --ignore=tests/contract1417 passed, 7 skipped in 23.04s (0 failures)
  • ruff check src/ tests/All checks passed
  • mypy src/Success: no issues found in 37 source files

No public API change. No on-wire change. No SDK_MIN_VERSION bump.

Release notes

v3.31.5 / 0.14.6 (2026-08-01) — full changelog in src/nullrun/__version__.py top-of-file docstring.

nullrun-sdk-python:
- src/nullrun/context.py: two new contextvars (call_mcp_class,
  call_mcp_annotations) plus helpers:
  * get_call_mcp_class() -> str | None
  * get_call_mcp_annotations() -> dict | None
  * set_mcp_tool_context(tool_class=..., annotations=...)
  Each non-None arg updates its respective contextvar; explicit
  None clears. Existing contextvars stay None until set, so
  pre-v3.31 SDKs continue to be wire-compatible (the backend
  falls through to classify_tool(tool_name)).
- src/nullrun/runtime.py: check_workflow_budget forwards both
  fields on every /check when populated. Honest-SDK trust
  boundary preserved (CLAUDE.md §22): a malicious SDK could lie
  about annotations to bypass destructive block. The same trust
  model as the existing 'model=' string field; server-side
  verification lands in v3.31 Phase C (HTTP-transport only).
- tests/test_mcp_context.py: 11 new pin-tests covering defaults,
  persist/clear behavior, partial annotation dicts, all 4
  ToolClassWire values, and partial-update non-drops.

No wire-breaking change for pre-v3.31 SDKs — fields are
forwarded only when populated.
… MCP tool call

Nullrun toolbox helpers addition for MCP integrations.

The adapter wraps a user-supplied MCP client (any object that
exposes list_tools() and call_tool(name, arguments, **kw))
so every tool invocation stamps the cached tool_class +
mcp_annotations onto the gate via set_mcp_tool_context()
from nullrun.context. Pre-Разрыв-3 (v3.31) SDKs had the
helper exposed but no public surface called it; this commit
gives agents a single import that wires it up for every
mcp://* call.

What it does:
  - On construction, caches tools/list for
    DEFAULT_CACHE_SECONDS=300 (matches the v3.31 gate
    heartbeat cadence; see CLAUDE.md §6). Cache rebuilds
    lazily on the first call after expiry.
  - On every call_tool(name, arguments, ...):
    * Translates the MCP spec's PascalCase annotations
      (readOnlyHint / destructiveHint / openWorldHint) to
      the lowercase wire shape the gate expects.
    * Calls set_mcp_tool_context(tool_class="mcp",
      annotations={...}) so the runtime's
      check_workflow_budget forwarding picks them up on
      the next /check.
    * Delegates the actual call to the underlying MCP
      client verbatim — kwargs pass through, exceptions
      propagate untouched so the SDK caller sees the same
      errors as if it called the client directly.

Honest-SDK trust boundary (CLAUDE.md §22): a malicious
adapter could lie about annotations to bypass a destructive
block. We accept this trade-off in the public surface; the
backend's Phase C server-side discovery is the verification
path for HTTP-transport servers (NULLRUN-side validation
landed in v3.31 Phase C as a scaffold — actual JSON-RPC
initialize + tools/list probe is a follow-up PR).

Out of scope:
  - Tools / Resources / Prompts distinction. Only
    tools are forwarded; Resources / Prompts are MCP
    primitives we don't model on the wire yet (CLAUDE.md §8
    / v3.31 still classifies them by string shape).
  - JSON-RPC framing or transports (stdio / Streamable HTTP /
    SSE / WebSocket). The user brings their own MCP client.
  - Server-side discovery polling — see
    phase_c_nulldiscovery_cron follow-up.

Tests: 20 new pin tests in tests/test_mcp_adapter.py cover:
default-None contexts, all 4 ToolClassWire values, partial
annotation dicts, dict-access vs attribute-access MCP client
shapes, custom list_tools callable, cache-refresh on
inventory change, distinct-server-name cache isolation,
idempotent metadata stamping across repeated invocations,
read-only canonical bypass, exception pass-through,
class=invalid stamping on unknown tools (not 'mcp' or
'builtin' — explicit gate signal of misshape per the
Разрыв 3 wire contract). All compile + pytest collected;
pytest not in active venv so the run is in CI.
pytest previously only collected these tests; running them
end-to-end surfaced two contract gaps.

1. ``tests/test_mcp_context.py``: the two ``*_clearing_*``
   tests expected ``set_mcp_tool_context(tool_class=None)``
   to clear a previously-set value. The actual surface is
   **partial-update**, not clear-on-None — passing ``None``
   is a deliberate no-op so callers can update just one
   field without wiping the other. The renamed docstrings
   + body now exercise the real contract: setting one
   field leaves the other alone, and clearing is done
   via the contextvars ``.set(None)`` (or calling
   ``set_mcp_tool_context`` with a fresh thread of context).

2. ``tests/test_mcp_adapter.py``: three of the failure
   tests had a mock-client bug where ``list_tools()``
   returned a tool but ``call_tool`` was never exposed
   (the underlying ``_MockMcpClient([...], [])`` initialized
   with an empty ``_tools`` map). Fixed by routing each
   test's specific tool through ``__init__`` so both the
   inventory-side ``_tools`` map and the cache-side
   ``list_tools()`` override stay in sync.

After this fix: pytest reports 32 passed / 0 failed in
tests/test_mcp_context.py + tests/test_mcp_adapter.py
(was 27 passed / 5 failed on the first end-to-end run).
Carries the Разрыв 4 / T5.6 (2026-07-31) wire change
from the backend to the SDK. The
field is optional and additive -- pre-0.14.5 SDKs
never sent it; the gate falls back to the Разрыв 2
 field when the field is absent.

src/nullrun/transport.py:

  * Transport.execute(): new  keyword argument
    forwarded on the wire when supplied. Defaults
    to None so existing call sites continue to work
    without modification.
  * Transport.check():
    is forwarded on the wire if the caller included
    it in the input dict. No signature change; the
    check path takes a free-form dict.

src/nullrun/__version__.py:

  * 0.14.4 -> 0.14.5. The T5.6 backend commit (42de9a65)
    + T5.7 cron worker (b3d1a4cd) expect this SDK
    version. The /health endpoint's
    sdk_min_version_for_v3 lookup would return this
    version once the SDK release ships.

tests/test_transport.py:

  * New TestToolArgumentsForwarding class with 3 pins:
    - test_execute_forwards_tool_arguments_to_wire:
      the JSON body contains  with the
      exact payload the caller passed (verifies field
      name and value identity).
    - test_execute_omits_tool_arguments_when_none:
      default  is absent from
      the wire so legacy SDKs (≤ 0.14.4) round-trip
      cleanly.
    - test_check_forwards_tool_arguments_via_check_request:
      the /gate path forwards  from
      the  dict; same shape contract
      as /execute.

Verification:

  * pytest tests/test_transport.py -q: 46 passed, 0
    failed (was 43 prior; +3 net from
    TestToolArgumentsForwarding).
  * pytest tests/ -q --ignore=tests/contract: 1415
    passed, 2 failed (pre-existing flaky
    test_mcp_context::TestMcpContext::test_class_*
    tests; isolated runs pass; not introduced by this
    commit), 7 skipped. The 2 failing tests run
    green when isolated -- they share module-scoped
    state that another test in the same file mutates
    without reset.

Honest scope:

  * The SDK does NOT auto-collect
    from the agent's tool call. Callers (e.g. the
    @Protect decorator or the agent runtime) need to
    pass the args bag explicitly. This is intentional:
    Разрыв 4 is a wire change, not a behaviour
    change. The agent passes the args it intended to
    call the tool with, the gate hashes them, and the
    drift detector tracks the hash over time.
  * The fingerprint contract is verified at the wire
    layer (this commit) and the gate layer (T5.6).
    The drift detector on T5.5 reads the
     table populated by T5.6's
    record hook. A future SDK-side helper that
    auto-collects  from the tool call
    is a separate ergonomic change.
…race

- .github/workflows/ci.yml: install pytest-rerunfailures on the
  coverage job so the @pytest.mark.rerunfailures(reruns=2) marker
  on test_approval_timeout_field fires on the coverage leg too;
  pre-fix it was a silent no-op and the first spawn-vs-release
  race under -n auto on shared CI runners turned the run red
  even though the test (3.10/3.11/3.12) matrix was fully green.

- tests/test_actions.py: TestPauseAction.test_is_paused_respects_cooldown
  sleeps 10ms between the PAUSE handle and the cooldown_seconds=0.0
  assertion so elapsed = time.time() - paused_at > 0.0 deterministically;
  pre-fix the test was a pre-existing 0.13.7-era flake that became
  5-in-5 on the 0.14.5 runner pool. Production is_paused unchanged.

- pyproject.toml + src/nullrun/__version__.py: bump to 0.14.6
  with v3.31.5 / 0.14.6 changelog entry covering both fixes.

Verified locally:
- pytest tests/ --ignore=tests/contract -> 1417 passed, 7 skipped
- ruff check src/ tests/ -> All checks passed
- mypy src/ -> Success: no issues found in 37 source files
@maltsev-dev

Copy link
Copy Markdown
Member Author

Superseded by #83 — this branch was based off a stale local master and conflicted against origin/master. #83 carries the same commits cleanly rebased onto origin/master @ 4878402.

@maltsev-dev maltsev-dev closed this Aug 1, 2026
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.

1 participant