|
Entropic 2.14.3
Local-first agentic inference engine
|
Unix socket MCP bridge for external client access. More...
#include <entropic/mcp/external_bridge.h>
Classes | |
| struct | AsyncTask |
| Async task state for background entropic.ask runs. More... | |
Public Member Functions | |
| ExternalBridge (entropic_handle_t handle, const ExternalMCPConfig &config, const std::filesystem::path &project_dir) | |
| Construct with engine handle and config. | |
| ~ExternalBridge () | |
| Destructor — stop if running. | |
| bool | start () |
| Start the background accept loop. | |
| void | stop () |
| Stop the accept loop and close the socket. | |
| const std::filesystem::path & | socket_path () const |
| Get the socket path (for logging/diagnostics). | |
| bool | ask_streaming () const |
| Whether entropic.ask routes through entropic_run_streaming. | |
| const ExternalMCPConfig & | config () const |
| The external MCP config this bridge was constructed with. | |
| std::string | dispatch (const std::string &request, int client_fd) |
| Dispatch one JSON-RPC request and return the response. | |
| bool | begin_turn_wait (long timeout_ms, long *waited_ms_out, const std::string &session_key="") |
| Take a ticket and wait for this caller's turn on the engine. | |
| void | end_turn_wait (const std::string &session_key="") |
| Hand the turn to the next ticket in this session's lane. | |
| void | fail_task_engine_busy (const std::string &task_id, long waited_ms) |
| Mark an async task failed because its turn never came (gh#144). | |
| void | commit_async_final_state (const std::string &task_id, const std::string &status, const std::string &phase, const std::string &text) |
| Record an async task's terminal state and write its sentinel. | |
| size_t | turn_queue_depth () const |
| How many callers are waiting behind the one(s) in flight. | |
| bool | turn_in_flight () const |
| Whether a turn is currently running on the engine. | |
| nlohmann::json | handle_ask_status (const nlohmann::json &args) |
| Handle entropic.ask_status — check async task state. | |
| void | attach_phase_observer (const std::string &task_id) |
| Run an async entropic.ask in a background thread. | |
| void | detach_phase_observer () |
| Clear the phase observer installed by attach_phase_observer. | |
| void | run_async_ask (const std::string &prompt, const std::string &task_id, int client_fd, const std::string &session_key="", const std::string &tier="") |
| Run an ask on a background worker and notify its owner. | |
| void | write_sentinel (const std::string &task_id, const std::string &status) |
| Write the sentinel file for an async task completion. | |
| std::filesystem::path | async_sentinel_dir () const |
| Sentinel directory (lazy: returns empty path until the engine's log_dir is configured). | |
| void | set_async_sentinel_root (const std::filesystem::path &root) |
| Override the async sentinel root directory. | |
| void | cleanup_expired_tasks () |
| Remove tasks older than TTL from the registry. | |
| std::unordered_map< std::string, AsyncTask > & | tasks_for_cancel () |
| Mutable accessor to the task registry. | |
| const std::string & | active_task_id_for_observer () const |
| Return the currently-active async task_id (if any). | |
| void | subscribe (int fd) |
| Add a connected fd to the subscriber set. | |
| void | unsubscribe (int fd) |
| Remove an fd from the subscriber set. | |
| void | broadcast_notification (const nlohmann::json ¬if) |
| Write a JSON-RPC notification to every subscribed fd. | |
| void | notify_client (int fd, const nlohmann::json ¬if) |
| Write a notification to ONE subscribed client (v2.14.0). | |
| size_t | subscriber_count () const |
| Current subscriber count (for diagnostics / testing). | |
| void | update_task_phase (const std::string &task_id, const std::string &status, const std::string &phase) |
| Update status/phase for a tracked task atomically. | |
| bool | observer_call_is_stale () const |
| Test whether an in-flight observer callback is stale. | |
Public Attributes | |
| std::mutex | tasks_mutex_ |
| Async task mutex (public for dispatch_tool access). | |
Unix socket MCP bridge for external client access.
Listens on a unix domain socket and dispatches JSON-RPC requests to the engine handle it was constructed with. Lifetime is tied to the engine handle — created in configure_common, destroyed before engine teardown.
Issue #12 (v2.1.4): also writes per-task sentinel files at <log_dir>/async/<task_id>.{done,failed,cancelled} so external monitors can use inotify rather than parsing log output.
gh#145 (v2.12.0): exposes config() so the tool-name handlers can read the consumer's configured namespace. gh#144 (v2.12.0): owns the FIFO turn queue that serialises every caller reaching the engine. v2.14.2: owns its async workers (joined by stop()) instead of detaching them.
@dg_internal
Definition at line 88 of file external_bridge.h.
| entropic::ExternalBridge::ExternalBridge | ( | entropic_handle_t | handle, |
| const ExternalMCPConfig & | config, | ||
| const std::filesystem::path & | project_dir | ||
| ) |
Construct with engine handle and config.
| handle | Engine handle (must outlive the bridge). |
| config | External MCP configuration. |
| project_dir | Project directory (for socket path derivation). |
| handle | Engine handle (must outlive the bridge). |
| config | External MCP configuration. |
| project_dir | Project directory (for socket path derivation). @req REQ-BRIDGE-001 |
Definition at line 1288 of file external_bridge.cpp.
| entropic::ExternalBridge::~ExternalBridge | ( | ) |
Destructor — stop if running.
@req REQ-BRIDGE-001
Definition at line 1303 of file external_bridge.cpp.
|
inline |
Return the currently-active async task_id (if any).
Definition at line 417 of file external_bridge.h.
|
inline |
Whether entropic.ask routes through entropic_run_streaming.
When false, handle_ask uses entropic_run (no on_token callback), which is compatible with MTP and avoids the streaming-path 316 throw (gh#115, v2.9.12). Static-dispatch helper for file-scope functions that hold ExternalBridge*.
Definition at line 137 of file external_bridge.h.
| std::filesystem::path entropic::ExternalBridge::async_sentinel_dir | ( | ) | const |
Sentinel directory (lazy: returns empty path until the engine's log_dir is configured).
Resolve the async sentinel directory.
Issue #12 (v2.1.4). Lives at <log_dir>/async/ by default (or <override>/async/ if set_async_sentinel_root was called). The bridge does not own log_dir lifetime — the path is recomputed each call so working-dir swaps via configure_dir take effect on the next async task.
Override (set via set_async_sentinel_root) takes precedence; falls back to <handle_->config.log_dir>/async. Returns an empty path if neither source is configured. Issue #12 (v2.1.4).
Definition at line 2243 of file external_bridge.cpp.
Run an async entropic.ask in a background thread.
Install phase observer scoped to one task.
| prompt | User prompt. |
| task_id | Assigned task ID. |
| client_fd | Socket fd for completion notification. @dg_internal |
Install VERIFYING-state observer scoped to one task.
Registers a state-change observer on the handle and records task_id as the active task. The observer maps VERIFYING transitions onto phase="validating" (first) or "revising" (subsequent). Pair with detach_phase_observer(). (P1-5 follow-up, 2.0.6-rc16.2)
| task_id | Task whose phase will be updated. @dg_internal |
Increments observer_gen_ under tasks_mutex_ and captures it as attached_gen_ so phase_observer_cb can detect stale post-detach fires via a simple generation comparison. (E5+E6, 2.1.0)
| task_id | Task whose phase transitions the observer tracks. @req REQ-BRIDGE-001 |
Definition at line 1822 of file external_bridge.cpp.
| bool entropic::ExternalBridge::begin_turn_wait | ( | long | timeout_ms, |
| long * | waited_ms_out, | ||
| const std::string & | session_key = "" |
||
| ) |
Take a ticket and wait for this caller's turn on the engine.
The engine admits one turn at a time and refuses the loser with ENTROPIC_ERROR_ALREADY_RUNNING (gh#144 stage 1). A refusal is the right contract for a direct C API consumer, but the wrong one for a shared host: several MCP clients on one engine want to QUEUE, which is the whole point of one resident model. This serialises them here so the engine's refusal is never reached from the bridge.
FIFO by ticket rather than a bare mutex: a mutex gives no fairness guarantee (a caller can starve), no observable depth, and no way to bound the wait — and MCP clients have their own timeouts, so blocking one indefinitely is worse than telling it the truth.
| timeout_ms | Give up after this long and let the caller report a busy engine rather than hang. |
| waited_ms_out | Optional; receives the time actually spent waiting. |
| session_key | Session this turn runs under (v2.14.0). Callers of the SAME session queue FIFO; callers of different sessions wait only for their own lane, because the engine runs sessions concurrently (gh#158). When the handle has concurrent_sessions: false every session shares one lane, as before — the engine would refuse a second concurrent session. |
| timeout_ms | Give up after this long rather than hang the client. |
| waited_ms_out | Optional; receives time actually spent waiting. |
Definition at line 1180 of file external_bridge.cpp.
Write a JSON-RPC notification to every subscribed fd.
Broadcast a JSON-RPC notification to every subscriber.
Writes are serialized under subscribers_mutex_ so concurrent broadcasts do not interleave on the same fd. Fds that fail to write are removed from the set.
| notif | JSON-RPC notification object. @dg_internal (public for unit test access) |
Issue #4 (v2.1.2, part C): write is non-blocking via send(MSG_DONTWAIT). A subscriber whose recv buffer is full (slow / non-draining consumer) returns EAGAIN/EWOULDBLOCK and is dropped on the same path as EBADF/EPIPE, as is a partial write. Pre-2.1.2 the broadcast used blocking write(), which let one stalled consumer wedge the async-task thread (issue #4). The longer-term replacement is a per-subscriber outbound queue + writer thread (proposal P2-20260429-001).
v2.14.2: the sends happen under subscribers_mutex_. The old snapshot- then-send let a subscriber unsubscribe and close mid-broadcast, and a reused descriptor then received another client's notification (TSan). Non-blocking sends make holding the lock cheap, which is why the snapshot was never needed to avoid stalls.
| notif | JSON-RPC notification object. @req REQ-BRIDGE-001 |
Definition at line 2171 of file external_bridge.cpp.
| void entropic::ExternalBridge::cleanup_expired_tasks | ( | ) |
Remove tasks older than TTL from the registry.
Remove tasks older than 15 minutes from the registry, AND delete their sentinel files if present.
@dg_internal
Issue #12 (v2.1.4): sentinel files would otherwise accumulate in <log_dir>/async/ indefinitely. Cleanup uses the same TTL as the in-memory registry so an external monitor that consumed the sentinel within 15 minutes still sees a consistent picture.
@req REQ-BRIDGE-001
Definition at line 2209 of file external_bridge.cpp.
| void entropic::ExternalBridge::commit_async_final_state | ( | const std::string & | task_id, |
| const std::string & | status, | ||
| const std::string & | phase, | ||
| const std::string & | text | ||
| ) |
Record an async task's terminal state and write its sentinel.
| task_id | Task reaching a terminal state. |
| status | Terminal status string. |
| phase | Terminal phase string. |
| text | Result or error text. @utility |
Extracted from run_async_ask (gh#144, v2.12.0) to keep it inside the SLOC and ABC gates once the turn-queue wait was added.
Issue #12 (v2.1.4): the sentinel is written UNDER tasks_mutex_, before the MCP notification fires, so an external monitor reacting to the sentinel can immediately call the ask_status tool and see the same terminal state.
| task_id | Task reaching a terminal state. |
| status | Terminal status string. |
| phase | Terminal phase string. |
| text | Result or error text. @dg_internal |
Definition at line 1869 of file external_bridge.cpp.
|
inline |
The external MCP config this bridge was constructed with.
gh#145 (v2.12.0): file-scope handlers holding an ExternalBridge* need the configured tool_prefix and description overrides to build and resolve tool names. Same static-dispatch rationale as ask_streaming().
Definition at line 150 of file external_bridge.h.
| void entropic::ExternalBridge::detach_phase_observer | ( | ) |
Clear the phase observer installed by attach_phase_observer.
Clear phase observer and the active-task pointer.
@dg_internal
Increments observer_gen_ under the lock before clearing active_task_id_ — any in-flight phase_observer_cb will see a generation mismatch and return immediately without accessing stale state. entropic_set_state_observer(nullptr) is called after the lock is released. (E5+E6, 2.1.0)
@req REQ-BRIDGE-001
Definition at line 1843 of file external_bridge.cpp.
Dispatch one JSON-RPC request and return the response.
Dispatch a JSON-RPC request and return the response.
Public since gh#144 (v2.12.0) for the same reason handle_ask_status is: this is the bridge's protocol entry point, and the tool schemas it serves are otherwise observable only over a real unix socket. The class is @dg_internal and crosses no public ABI, so exposing it costs nothing a consumer can depend on.
| request | Raw JSON-RPC request line. |
| client_fd | Socket fd for streaming progress (-1 when none). |
Per JSON-RPC 2.0 spec, messages without an "id" field are notifications and MUST NOT receive a response. Returns empty string for notifications so the caller knows not to write back.
| request | Raw JSON-RPC request string. |
| client_fd | Socket fd for streaming progress (entropic.ask). |
Definition at line 1761 of file external_bridge.cpp.
Hand the turn to the next ticket in this session's lane.
Hand the turn to the next ticket in line.
| session_key | The key passed to the matching begin_turn_wait(). @req REQ-BRIDGE-001 |
@req REQ-BRIDGE-001
Definition at line 1218 of file external_bridge.cpp.
| void entropic::ExternalBridge::fail_task_engine_busy | ( | const std::string & | task_id, |
| long | waited_ms | ||
| ) |
Mark an async task failed because its turn never came (gh#144).
| task_id | Task that gave up waiting. |
| waited_ms | How long it waited before giving up. @utility |
The task stays "queued" until the ticket is actually held, so ask_status reports the truth rather than claiming to run while blocked. On timeout it becomes a terminal error with the wait recorded, and the sentinel is written under tasks_mutex_ like every other terminal transition.
| task_id | Task that gave up waiting. |
| waited_ms | How long it waited before giving up. @dg_internal |
Definition at line 1895 of file external_bridge.cpp.
| json entropic::ExternalBridge::handle_ask_status | ( | const nlohmann::json & | args | ) |
Handle entropic.ask_status — check async task state.
| args | Tool arguments (JSON with task_id). |
Returns coarse status + granular phase so pollers see progression through queued → running → running:<tier> → done|failed|cancelled.
| args | Tool arguments (must contain "task_id"). |
Definition at line 762 of file external_bridge.cpp.
Write a notification to ONE subscribed client (v2.14.0).
For anything that belongs to a single caller — an async task's completion above all. Nothing is sent when fd is negative or is not (or no longer) a subscriber; a failed write drops the subscriber, as broadcast_notification() does.
| fd | Owning client's fd. |
| notif | JSON-RPC notification object. @dg_internal (public for unit test access) |
v2.14.2: the membership check and the send happen under ONE hold of subscribers_mutex_. They were split, so a client could unsubscribe and close between them — and once the descriptor number was reused, the notification went to a different client (TSan; same class as gh#208). The send is non-blocking, so holding the lock across it stalls nobody; a client's thread unsubscribes before it closes, so a sender holding the lock never sees a closed or reused fd.
| fd | Owning client's fd; negative = nobody. |
| notif | JSON-RPC notification object. @dg_internal |
Definition at line 2141 of file external_bridge.cpp.
|
inline |
Test whether an in-flight observer callback is stale.
Called from phase_observer_cb (free function) under tasks_mutex_. Returns true if observer_gen_ has been bumped since this callback's attach point — indicating detach_phase_observer (or a re-attach for a different task) has run and the firing callback should exit without touching state. (E5+E6, 2.1.0)
Definition at line 506 of file external_bridge.h.
| void entropic::ExternalBridge::run_async_ask | ( | const std::string & | prompt, |
| const std::string & | task_id, | ||
| int | client_fd, | ||
| const std::string & | session_key = "", |
||
| const std::string & | tier = "" |
||
| ) |
Run an ask on a background worker and notify its owner.
Run an async entropic.ask in a detached background thread.
v2.14.0: the completion notification goes to the client that started the task (client_fd) and to no other — it used to be broadcast, so every connected client learned every other client's task ids and outcomes. tier locks the turn exactly as entropic_run_session_as().
| prompt | User prompt. |
| task_id | Assigned task id. |
| client_fd | Owning client; -1 for none (nobody is notified). |
| session_key | Session to run under ("" = default). |
| tier | Tier or identity to lock to ("" = route as usual). @req REQ-BRIDGE-001 |
Creates a task registry entry, runs the engine, stores the result, writes the per-task sentinel file (#12, v2.1.4), and broadcasts notifications/progress to subscribed clients.
| prompt | User prompt. |
| task_id | Assigned task ID. |
| client_fd | Socket fd for completion notification. @req REQ-BRIDGE-001 |
Definition at line 1952 of file external_bridge.cpp.
Override the async sentinel root directory.
Issue #12 (v2.1.4): primarily a testability hook so unit tests can exercise the sentinel write path without setting up a full engine handle. Production code receives the path from the engine handle's log_dir; passing an empty path here clears the override and restores log_dir resolution.
| root | New sentinel root (the bridge appends /async). @utility |
Issue #12 (v2.1.4). @req REQ-BRIDGE-001
Definition at line 2256 of file external_bridge.cpp.
|
inline |
Get the socket path (for logging/diagnostics).
Definition at line 123 of file external_bridge.h.
| bool entropic::ExternalBridge::start | ( | ) |
Start the background accept loop.
Definition at line 1448 of file external_bridge.cpp.
| void entropic::ExternalBridge::stop | ( | ) |
Stop the accept loop and close the socket.
Stop the accept loop, drain client threads, and close the socket.
Issue #4 (v2.1.2, part D): pre-2.1.2 stop() only joined the accept thread because serve_client ran inline. Now each connected client has its own thread blocking in read(); we wake them via shutdown(SHUT_RDWR) on the client fd (which causes the pending read to return EOF) and then join. Order matters:
@req REQ-BRIDGE-001
Definition at line 1524 of file external_bridge.cpp.
Add a connected fd to the subscriber set.
Remove tasks older than 15 minutes from the registry.
| fd | Connected client socket fd. @dg_internal (public for unit test access) |
@dg_internal
Add a connected fd to the subscriber set.
| fd | Client socket fd. @req REQ-BRIDGE-001 |
Definition at line 2083 of file external_bridge.cpp.
|
inline |
Current subscriber count (for diagnostics / testing).
Definition at line 471 of file external_bridge.h.
|
inline |
Mutable accessor to the task registry.
Caller MUST hold tasks_mutex_. Used by the cancel-on-clear path to flip task statuses atomically with the mutex held. Contract verified against all call sites for v2.1.0 (E8). (P1-8, 2.0.6-rc16)
Definition at line 407 of file external_bridge.h.
| bool entropic::ExternalBridge::turn_in_flight | ( | ) | const |
Whether a turn is currently running on the engine.
Whether any lane holds a turn (v2.14.0).
Definition at line 1270 of file external_bridge.cpp.
| size_t entropic::ExternalBridge::turn_queue_depth | ( | ) | const |
How many callers are waiting behind the one(s) in flight.
Waiters summed over every lane (v2.14.0).
Definition at line 1257 of file external_bridge.cpp.
Remove an fd from the subscriber set.
| fd | Client socket fd being closed. @dg_internal (public for unit test access) |
| fd | Client socket fd being closed. @req REQ-BRIDGE-001 |
Definition at line 2094 of file external_bridge.cpp.
| void entropic::ExternalBridge::update_task_phase | ( | const std::string & | task_id, |
| const std::string & | status, | ||
| const std::string & | phase | ||
| ) |
Update status/phase for a tracked task atomically.
Update the status/phase of a tracked task.
Used by the async run thread to advance through queued → running → running:<tier> → done|failed|cancelled. Safe to call with an unknown task_id (no-op).
| task_id | Task identifier. |
| status | New coarse status string. |
| phase | New granular phase string. @utility |
| task_id | Task identifier. |
| status | New coarse status string. |
| phase | New granular phase string. @req REQ-BRIDGE-001 |
Definition at line 2187 of file external_bridge.cpp.
| void entropic::ExternalBridge::write_sentinel | ( | const std::string & | task_id, |
| const std::string & | status | ||
| ) |
Write the sentinel file for an async task completion.
Issue #12 (v2.1.4): persistent on-disk completion signal so external monitors (CLI scripts, watchdogs) can inotifywait on the async sentinel directory without parsing log output or subscribing to MCP notifications. Sentinel filename encodes the terminal status: <task_id>.done, .failed, .cancelled.
Caller MUST hold tasks_mutex_. The sentinel write happens under-lock together with the status update so that any external monitor reacting to the sentinel sees a consistent registry state on a follow-up entropic.ask_status query.
| task_id | Task identifier. |
| status | Terminal status string (done | error | cancelled). error maps to .failed. @utility |
Issue #12 (v2.1.4). Caller MUST hold tasks_mutex_ — this is invoked inside run_async_ask's terminal critical section so external monitors observe a consistent sentinel + registry pair.
Failure to create the parent directory or open the file is logged at WARN and swallowed; the MCP notification path remains the primary signal and a missing sentinel is non-fatal for callers that don't depend on it.
@req REQ-BRIDGE-001
Definition at line 2292 of file external_bridge.cpp.
|
mutable |
Async task mutex (public for dispatch_tool access).
Definition at line 375 of file external_bridge.h.