LemonCore.ACPClientBridge (lemon_core v0.1.0)

View Source

Direct request/reply channel from a run's tool execution to the ACP client handler that owns the client connection for that run.

This replaces the former :acp_client_request Bus broadcast. That event was RPC wearing an event's clothes: it carried a live pid and a reference() over a cluster-wide pub/sub topic to fake a synchronous request/reply. Unlike every other bus event it could not be serialised, persisted or replayed, and under Phoenix.PubSub it fanned the pid out to every node only for a single process to answer. This module makes the exchange what it always was: a direct call to one registered process.

Roles

  • The handler is the process serving an ACP session/prompt turn (LemonControlPlane.ACP). It owns the JSON-RPC connection to the ACP client and calls register/1 for the run's id while it waits, then services {:acp_client_request, payload} messages from its own receive loop.
  • The requester is a coding-agent filesystem tool (CodingAgent.Tools.ACPFileBridge) that needs the client to read or write a file on its side. It calls request/4 and blocks for the reply.

Both apps depend on lemon_core and neither on the other, so this shared rendezvous lives here rather than in either endpoint.

Failure contract

request/4 answers rather than raises, mirroring LemonCore.RouterBridge. Three failure modes, deliberately distinguished so the caller can log the cause even though the consequence is the same (the file operation did not happen):

  • {:error, :no_client} — no handler is registered for the run. Under the old broadcast this presented as a full-timeout hang, because a broadcast to an empty topic is silently dropped; now it fails fast.
  • {:error, :client_down} — a handler was registered but its process died before replying. Caught via monitor so the requester is not left waiting.
  • {:error, :timeout} — the handler was reachable but did not reply within the caller's timeout.

Node locality

The registry is node-local. The ACP bridge is a single-connection JSON-RPC preview where the handler owns the client socket and the run it submits is serviced in the same BEAM, so a local registry is sufficient and correct. Making this cross-node would mean a distributed registry behind the same API; it is deliberately out of scope, and the pid/ref-over-broadcast pattern this replaces was never a sound way to do it either.

Summary

Functions

Child spec for the rendezvous registry. Added to LemonCore.Application's supervision tree so request/4 and register/1 are always available.

Register the calling process as the ACP client handler for run_id.

Make a synchronous client request against the handler registered for run_id.

Unregister the calling process as the handler for run_id. Safe to call even if nothing is registered; the handler calls it from an after block.

Return the pid of the handler registered for run_id, or nil.

Functions

child_spec(opts)

Child spec for the rendezvous registry. Added to LemonCore.Application's supervision tree so request/4 and register/1 are always available.

register(run_id)

@spec register(binary()) :: :ok | {:error, {:already_registered, pid()}}

Register the calling process as the ACP client handler for run_id.

Idempotent per process: a second call from the same process is a no-op. A call from a different process while one is still registered returns {:error, {:already_registered, pid}} rather than crashing the caller — a run is submitted and waited exactly once, so this should not happen, but a handler should not die trying to register.

request(run_id, method, params, timeout_ms)

@spec request(binary(), String.t(), map(), timeout()) ::
  {:ok, term()} | {:error, :no_client | :client_down | :timeout}

Make a synchronous client request against the handler registered for run_id.

Sends {:acp_client_request, %{method:, params:, reply_to:, ref:}} to the handler and blocks for {:acp_client_response, ref, response}. The payload shape matches what the handler's receive loop already expects, so the handler side is unchanged apart from receiving a direct message instead of a bus event.

unregister(run_id)

@spec unregister(binary()) :: :ok

Unregister the calling process as the handler for run_id. Safe to call even if nothing is registered; the handler calls it from an after block.

whereis(run_id)

@spec whereis(binary()) :: pid() | nil

Return the pid of the handler registered for run_id, or nil.