gRPC Retirement — CXP moves to the cxpt direct transport

Branch: feat/cxp-direct-transport · Authorized: 2026-08-07 ("agreed merge, retire grpc, proceed as you recommend") · design of record: plans/grpc-retirement-design.md in the orchestrata-ultimate repo.

grpc.aio is gone from the CXP seam. cxpt — the vendored free-threaded direct transport — is now the only CXP transport, in prod and in every test. CXP_TRANSPORT=grpc is no longer a valid configuration; setting it raises at construction time instead of silently falling back to something else.

Why

Two independent drivers converged on the same removal:

  1. SPEC-023 (GIL-off). grpc.aio's EventEngine threads re-enable the process-wide GIL, which violates the project's free-threaded doctrine. cxpt exists specifically to replace it, and as of 2026-08-07 prod runs on it (env/prod.env: CXP_TRANSPORT=cxpt, both units verified serving — see Measured results below).
  2. The merge was blocked by it. MR !8's architecture-check CI job failed at uv sync --dev:
    failed to query metadata of file vendor/wheels/grpcio-1.82.1-cp314-cp314t-linux_x86_64.whl:
    No such file or directory
    
    The vendored free-threaded grpcio wheel lived under vendor/wheels/, which .gitignore excludes by design — the convention is "fresh clones rebuild it." CI has no wheel-build step, so any pinned [tool.uv.sources] path into vendor/wheels/ was an unresolvable dependency for a fresh clone. grpc was dead weight on the RPC path and an active CI blocker.

The key insight: protobuf and grpcio were never coupled

protobuf is the message codec (cxp_pb2.py, generated from cxp.proto). grpcio is the RPC runtime that used to carry those messages over HTTP/2. The two were separable all along — retiring gRPC meant the messages stayed and only the service stubs went.

The proof is CXPServicer's own contract: every RPC method's signature carried a context: grpc.aio.ServicerContext annotation, and that was grpc's only point of contact with the servicer. A grep 'context\.' over the original cxp/grpc_server.py returned zero hits — nothing in the servicer ever called a method on context. That single fact is why the carve-out was a move, not a rewrite: no behaviour changed hands. It also explains why direct_cxp_adapter.py had already been able to pass a bare sentinel object into CXPServicer in place of a real gRPC context — the cxpt path had effectively been running the "grpc" servicer in production all along, just without gRPC underneath it.

What moved, what was deleted

file grpc contact verdict
cxp/proto/cxp_pb2.py none — imports google.protobuf only kept as-is
cxp/grpc_server.py lines 1–504 (the servicer) grpc.aio.ServicerContext in annotations only extracted, made neutral
cxp/grpc_server.py lines 505–599 (CXPServer) grpc.aio.server(), servicer registration deleted
cxp/grpc_client.py GRPCClient 3 sites: aio.Channel, aio.insecure_channel, grpc.RpcError deleted
cxp/grpc_client.py converters + GRPCClientConfig none extracted, made neutral
cxp/proto/cxp_pb2_grpc.py generated stubs, import grpc deleted

New/changed modules in src/orchestrata_ultimate/cxp/:

Deleted outright: cxp/grpc_server.py, cxp/grpc_client.py, cxp/proto/cxp_pb2_grpc.py, scripts/build_ft_grpcio.py, scripts/build_ft_grpcio_tools.py, vendor/grpcio-ft/, vendor/grpcio-tools-ft/. Confirmed at HEAD: none of these paths exist, and grep -rn '^import grpc\|^from grpc' src/ tests/ returns zero hits.

grpcio and grpcio-tools are now entries in .claude/banned-libraries.txt, with the retirement rationale inline in the file so a future uv add grpcio gets caught by the no-banned-deps gate instead of silently reintroducing both the GIL violation and the unresolvable-dependency class.

Eleven call sites were rewired onto create_cxp_server/create_cxp_client or the Direct* classes directly: agents/runtime_main.py, cxp/transport.py (×2), cxp/direct_cxp_adapter.py (×2), kernel/cli_main.py, kernel/dispatcher.py, knowledge_graph/server.py, gateway/server.py, cxp/proto/__init__.py, scripts/start_test_services.py.

Six grpc-specific test modules were retired or repointed: tests/cxp/test_grpc.py, test_grpc_client.py, test_grpc_fields.py, test_grpc_kernel_endpoint.py, test_proto_grpc.py were deleted; tests/gateway/test_kernel_reply_consumer.py was repointed at the cxpt path rather than deleted (it still exists at HEAD).

Two production defects found and fixed during the cutover

Cutting cxpt over from "the alternative transport" to "the only transport" made two pre-existing gaps load-bearing. Both were found and fixed in the same change, not deferred.

1. Unary calls had no timeout. AsyncDirectClient.call() (cxp/direct_transport.py) awaited its reply future with no deadline. A peer that died — or simply never answered — after a request was submitted left that future pending forever; there is no connection-level event that completes it. Measured: a gateway talking to a kernel that had gone away blocked until pytest-timeout killed it at 120s, and would have blocked indefinitely in production. Fixed with _await_reply(), which wraps the future in asyncio.wait_for(fut, timeout=timeout) whenever a timeout is configured.

2. The client's retry loop never applied the configured timeout — timeout_seconds and max_retries were dead configuration. CXPClientConfig.timeout_seconds was read by nothing, and until this change DirectGRPCClient.send_packet() called the transport once and never retried, so max_retries (default 3) was also never honored. Every caller constructing a client expecting three attempts was silently getting one, turning every transient blip into a propagated failure. Fixed via _call_bounded(), which threads self._config.timeout_seconds through client.call(..., timeout=...), and a real retry loop in send_packet() that honors max_retries with escalating backoff (0.5 * (attempt + 1) seconds), matching what the retired grpc client's callers already assumed. max_retries=0 is honored as "make zero attempts," not "retry forever."

Both defects predate the cutover — they existed in direct_cxp_adapter.py and direct_transport.py before 2026-08-07 — but only became load-bearing once cxpt stopped being an alternate path and became the transport.

Behavioural difference worth recording: grpc.aio's insecure_channel() connects lazily — construction never fails, and a dead peer only surfaces on the first RPC. cxpt's AsyncDirectClient dials a real TCP (or Unix-domain) socket at connect() and raises OSError immediately if nothing is listening. Callers that assumed lazy-connect semantics from the grpc era need to expect that connect() itself can fail now, not just the first call().

Also worth recording: a raising handler on the servicer side (an exception in _packet_handler) produces a CXPReceipt(success=False, ...) — that is a successful round trip carrying a business-logic failure, and send_packet()'s retry loop correctly does not retry it. Retry is for transport failure (RpcError, TimeoutError, OSError) only; a successfully-delivered failure receipt is not one of those.

protoc / gencode contract

grpcio-tools regenerates cxp_pb2.py/cxp_pb2.pyi from cxp.proto (scripts/gen_cxp.py), and tests/scripts/test_gen_cxp_cli.py drives it through a real protoc subprocess (no mocks). So the tool couldn't just be dropped — but it is now deliberately not a project dependency.

Two independent reasons:

gen_cxp.py now fetches grpcio-tools on demand into a throwaway, GIL-enabled environment rather than depending on it at all:

uv run --isolated --no-project --python /usr/bin/python3.14 \
    --with grpcio-tools==1.80.0 \
    python -m grpc_tools.protoc ...

uv caches the wheels, so repeat runs are fast and offline. The version is pinned, and the pin is load-bearing: protoc's output carries a gencode version that the installed protobuf runtime validates at import, and a runtime older than the gencode is a hard VersionError. Measured 2026-08-07: the system /usr/bin/protoc 35.1 emits gencode 7.35.1 against this project's protobuf>=4.25 runtime and fails at import; grpcio-tools==1.80.0 emits gencode 6.31.1, which that runtime accepts. Bumping the pin means re-verifying that the regenerated cxp_pb2.py still imports.

cxp_pb2_grpc.py is no longer generated at all — there is no gRPC service to generate stubs for.

Measured production results

Cutover verified 2026-08-07: env/prod.env set CXP_TRANSPORT=cxpt, both units restarted together (kernel pid 1377133, gateway pid 1377190).

Revert path while grpc_server.py/grpc_client.py still existed: delete CXP_TRANSPORT=cxpt from env/prod.env and restart both units. After the servicer extraction landed there is no grpc transport left to revert to — reverting now means git revert of the retirement commit(s) plus rebuilding the vendored wheels.

Known open: the CXP WebSocket transport has no route to it

cxp/ws_client.py and cxp/ws_server.py (WSServer, WSServerConfig) have zero import sites anywhere else in src/. Verified this was not caused by the gRPC retirement: at HEAD, ws_server.py was already the only file in the tree defining WSServer, and neither deleted grpc module (grpc_server.py, grpc_client.py) ever referenced either WS module. This predates the retirement.

What the retirement did change is that cxp_transport() now recognizes exactly one transport (cxpt) and raises on anything else — so the WS modules are a complete, unreachable transport implementation with no selection path into it at all, not even a broken one. Decision pending: wire ws as a third selectable value in cxp_transport(), or delete the two modules. Not resolved as part of this retirement.

References