Skip to content

refactor(server): split into service layer, add agentic protocol, adopt task_group - #437

Merged
16bit-ykiko merged 12 commits into
mainfrom
feat/agentic-protocol
May 1, 2026
Merged

refactor(server): split into service layer, add agentic protocol, adopt task_group#437
16bit-ykiko merged 12 commits into
mainfrom
feat/agentic-protocol

Conversation

@16bit-ykiko

@16bit-ykiko 16bit-ykiko commented Apr 26, 2026

Copy link
Copy Markdown
Member

Summary

  • Restructure src/server/ into subdirectories (service/, compiler/, worker/, workspace/, protocol/) to separate concerns: transport/session management, compilation, worker orchestration, and persistent workspace state.
  • Decouple MasterServer from transport: MasterServer no longer holds a JsonPeer& reference or registers handlers itself. New LSPClient and AgentClient classes own their peer references and register protocol handlers, accessing MasterServer internals via friend class.
  • Add agentic protocol: A TCP-based side channel (agentic/compileCommand) that lets external tools (AI agents, build systems) query compile commands from a running clice server. Includes a CLI client mode (--mode agentic --port N --path FILE), server-side listener when --port is specified in pipe mode, and integration tests for happy path, fallback, concurrency, and connection-refused.
  • Replace fire-and-forget loop.schedule() with kota::task_group: Compiler compile tasks, Indexer background indexing + resource monitor, WorkerPool worker monitors, and socket accept loops now use structured concurrency. This eliminates manual alive_count_/generation counters and ensures all spawned tasks are joined on shutdown.
  • Fix flaky integration test: CliceClient.initialize() now always sets cache_dir to a workspace-local .clice/ directory, preventing stale PCH artifacts from the global ~/.cache/clice/ from polluting test runs.

Details

Compiler peer lifetime: Compiler and Indexer previously took JsonPeer& in their constructors, coupling them to a single connection. They now store a JsonPeer* set via set_peer(), with null checks before sending diagnostics/progress. This supports the multi-connection model where agentic clients don't need diagnostics.

Socket mode single-LSP enforcement: accept_connections() takes a register_lsp flag; when true, only the first connection gets an LSPClient. All connections get an AgentClient. This prevents multiple LSP sessions from racing on shared server state.

Structured shutdown: Compiler::stop() cancels in-flight compile tasks and joins them. WorkerPool::stop() signals workers and joins the monitor task group. Indexer uses a cancellation_source to stop its resource monitor when a background indexing run completes.

Pin kotatsu: Changed from GIT_TAG main + GIT_SHALLOW TRUE to an exact commit hash for reproducible builds.

16bit-ykiko and others added 2 commits April 26, 2026 18:26
…tic protocol

Reorganize the flat src/server/ directory into four subdirectories:
- workspace/ (workspace, config)
- compiler/ (compiler, compile_graph, indexer, protocol)
- worker/ (worker_pool, stateful/stateless_worker, worker_common)
- lsp/ (master_server, session)

Add initial agentic protocol support:
- --agentic flag enables a TCP listener (reusing --host/--port) for
  agent connections alongside the LSP pipe, with validation that it
  is only allowed in pipe mode
- Multi-peer support: agent connections/disconnections are handled
  independently without affecting the LSP session
- First agentic request: agentic/compileCommand returns the compile
  command (file, directory, arguments) for a given filesystem path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…s to src/protocol/

Reorganize server architecture: extract LSP handler registration into
LSPClient and agentic handlers into AgentClient, keeping MasterServer
as pure state. Move all protocol definitions (worker, extension, agentic)
into src/protocol/. Add TCP listener for agentic connections in pipe mode
and stub --mode agentic for future CLI agent client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 26, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Refactors server architecture by reorganizing header locations into modular subdirectories, decoupling peer initialization from Compiler/Indexer construction, extracting LSP handler logic into a dedicated LSPClient class, introducing AgentClient for agentic protocol support, and adding a new agentic command mode for remote compilation queries via JSON-RPC.

Changes

Cohort / File(s) Summary
CLI Routing and Agentic Mode
src/clice.cc
Switches pipe/socket mode routing from bespoke code to shared run_server_mode() call. Adds new agentic mode with --path option, validates port and path requirements, and routes to run_agentic_mode().
New Protocol Definitions
src/protocol/agentic.h, src/protocol/extension.h
Introduces agentic protocol with CompileCommandParams and CompileCommandResult request/response types. Defines extension protocol for context operations (query, current, switch) with corresponding params and results.
Protocol Migration
src/protocol/worker.h
Removes context-switching and context-querying types (ContextItem, QueryContextParams, etc.) moved to new extension.h header; removes unused includes.
Server Architecture Refactoring
src/server/service/master_server.h, src/server/service/master_server.cpp, src/server/service/lsp_client.h, src/server/service/lsp_client.cpp, src/server/service/agent_client.h, src/server/service/agent_client.cpp, src/server/master_server.h (deleted)
Deletes old monolithic master_server.h. Introduces new modular API with MasterServer constructor/initialization logic, ServerOptions configuration struct, run_server_mode() entrypoint supporting pipe/socket modes with TCP accept loop and per-connection handling. Extracts LSP handler registration into LSPClient with deferred peer initialization. Adds AgentClient to handle agentic compile-command protocol requests.
Compiler and Indexer Peer Decoupling
src/server/compiler/compiler.h, src/server/compiler/compiler.cpp, src/server/compiler/indexer.h, src/server/compiler/indexer.cpp
Removes JsonPeer& parameter from Compiler constructor, introduces set_peer() method for deferred initialization. Updates indexer includes to new header locations.
Header Location Migration
src/server/compiler/compile_graph.cpp, src/server/workspace/config.cpp, src/server/workspace/workspace.h, src/server/workspace/workspace.cpp, src/server/service/session.h, src/server/worker/worker_pool.h, src/server/worker/worker_pool.cpp, src/server/worker/stateful_worker.cpp, src/server/worker/stateless_worker.cpp
Reorganizes header dependencies to reflect new directory structure: server/compile_graph.hserver/compiler/compile_graph.h, server/config.hserver/workspace/config.h, server/workspace.hserver/workspace/workspace.h, server/worker_pool.hserver/worker/worker_pool.h, server/protocol.hprotocol/worker.h.
Agentic Mode Implementation
src/server/service/agentic.h, src/server/service/agentic.cpp
Introduces run_agentic_mode() function that connects to an agentic server via TCP, sends CompileCommandParams request, deserializes and prints JSON response, handles errors gracefully, and returns exit code.
Test Fixture and Integration Tests
tests/conftest.py, tests/integration/agentic/test_agentic.py
Adds agentic pytest fixture spawning a server in pipe mode with allocated port. Updates client fixture to apply --host uniformly across modes. Introduces three integration tests verifying agentic protocol with valid/invalid paths and repeated requests.
Unit Test Header Updates
tests/unit/server/compile_graph_*.cpp, tests/unit/server/config_tests.cpp, tests/unit/server/*_worker_tests.cpp, tests/unit/server/worker_test_helpers.h
Updates test includes to match new header locations (server/compiler/compile_graph.h, server/workspace/config.h, protocol/worker.h).

Sequence Diagram(s)

sequenceDiagram
    participant AgenticCLI as Agentic CLI<br/>(client)
    participant TCP as TCP Socket
    participant MasterServer as MasterServer<br/>(listener)
    participant JsonPeer
    participant Compiler

    AgenticCLI->>TCP: Connect to host:port
    activate TCP
    MasterServer->>TCP: Accept connection
    TCP->>JsonPeer: Wrap in StreamTransport/JsonPeer
    deactivate TCP
    
    AgenticCLI->>JsonPeer: Send CompileCommandParams {path}
    JsonPeer->>AgentClient: Route to registered handler
    AgentClient->>Compiler: fill_compile_args(path)
    Compiler-->>AgentClient: {directory, arguments} or error
    AgentClient->>JsonPeer: co_return CompileCommandResult or Error
    JsonPeer->>AgenticCLI: Send JSON response
    
    AgenticCLI->>TCP: Close connection
    AgenticCLI->>AgenticCLI: Print JSON to stdout, exit(0 or 1)
Loading
sequenceDiagram
    participant Main as main()
    participant EventLoop as kota::event_loop
    participant MasterServer as MasterServer
    participant Workspace
    participant Compiler
    participant Indexer

    Main->>MasterServer: new MasterServer(loop, argv[0])
    activate MasterServer
    MasterServer->>Workspace: construct
    MasterServer->>Compiler: construct (no peer)
    MasterServer->>Indexer: construct (no peer)
    deactivate MasterServer
    
    Main->>MasterServer: initialize(workspace_root)
    activate MasterServer
    MasterServer->>Workspace: load config, create cache dirs
    MasterServer->>Compiler: load compile_commands.json
    MasterServer->>Workspace: scan dependency graph
    MasterServer->>Indexer: build module map, load index
    deactivate MasterServer
    
    Main->>+Main: run_server_mode(ServerOptions)
    Main->>EventLoop: setup TCP listen or stdio transport
    loop Accept connections
        EventLoop->>JsonPeer: new JsonPeer(stream)
        JsonPeer->>Compiler: set_peer(peer)
        JsonPeer->>Indexer: set_peer(peer)
        JsonPeer->>LSPClient: new LSPClient(server, peer)
        JsonPeer->>AgentClient: new AgentClient(server, peer)
        EventLoop->>EventLoop: run async connection handler
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

The diff encompasses significant architectural refactoring with heterogeneous changes across many files: new protocol definitions and server service classes with non-trivial logic (MasterServer initialization, connection handling, peer decoupling), extraction of LSP handlers into a new class, header reorganization across multiple directories, and new agentic mode implementation. While some changes are repetitive (header updates), the core logic density in server initialization, connection dispatch, and LSP/agentic client implementations, combined with the variety of concerns (protocols, services, tests, CLI), demands careful cross-file reasoning.

Possibly related PRs

Poem

🐰 Hop! The server splits its seams with grace,
Protocol and peer part ways in space,
Agentic whispers through the TCP stream,
While LSP clients dream their modular dream!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main refactoring: splitting the server into a service layer, adding agentic protocol support, and adopting task_group.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/agentic-protocol

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (4)
src/server/service/agent_client.cpp (1)

22-27: Pass Session* when the path is an open document.

fill_compile_args accepts an optional Session* for header-context fallback on open files (per compiler.h Lines 65-70). Calling it without a session means an agent query for a header that's currently open in the editor will skip the header-context fallback and may fail or return suboptimal arguments, while the same query via LSP would succeed. Consider resolving the path to an open Session (via workspace.path_pool and server.sessions) and passing it through.

♻️ Sketch
-            std::string directory;
-            std::vector<std::string> arguments;
-            if(!this->server.compiler.fill_compile_args(params.path, directory, arguments)) {
+            std::string directory;
+            std::vector<std::string> arguments;
+            // Resolve to an open Session so header-context fallback works for
+            // headers currently open in the editor.
+            Session* session = nullptr;
+            auto path_id = this->server.workspace.path_pool.find(params.path);
+            if(path_id) {
+                if(auto it = this->server.sessions.find(*path_id);
+                   it != this->server.sessions.end()) {
+                    session = &it->second;
+                }
+            }
+            if(!this->server.compiler.fill_compile_args(params.path,
+                                                        directory, arguments,
+                                                        session)) {
                 co_return kota::outcome_error(
                     kota::ipc::Error{std::format("no compile command found for {}", params.path)});
             }

(Adapt to the actual path_pool::find / sessions accessors — may require friending or a small accessor on MasterServer.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/service/agent_client.cpp` around lines 22 - 27, When calling
compiler.fill_compile_args in agent_client.cpp, detect if params.path
corresponds to an open document and pass the associated Session* so
header-context fallback is used; resolve the path via the workspace.path_pool
(e.g., path_pool.find or equivalent) and look up the Session in server.sessions
(or expose a small accessor on MasterServer if necessary), then call
fill_compile_args(params.path, directory, arguments, sessionPtr) instead of the
current call without a Session to ensure open-file header contexts are
considered.
src/server/compiler/compiler.h (1)

57-59: Document the deferred peer-wiring contract.

Switching peer from a reference to a nullable pointer enables MasterServer to construct Compiler before any client connection exists, but it also means any peer-using code (e.g., publish_diagnostics, clear_diagnostics) silently no-ops until set_peer is called. A short comment here would help future maintainers avoid surprises (especially in --mode agentic where no LSP peer ever attaches).

📝 Suggested doc comment
+    /// Wire up the LSP peer used to publish diagnostics. Must be called
+    /// before any compile path that emits diagnostics; until set, diagnostic
+    /// notifications are dropped (intentional for agentic-only mode).
     void set_peer(kota::ipc::JsonPeer* p) {
         peer = p;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/compiler/compiler.h` around lines 57 - 59, Add a brief doc comment
for the deferred peer-wiring contract on the Compiler class: explain that the
former reference was changed to a nullable pointer named peer and that any
methods using the peer (e.g., publish_diagnostics, clear_diagnostics) will
silently no-op until set_peer(kota::ipc::JsonPeer* p) is called; note that
MasterServer may construct Compiler before any client attaches and in --mode
agentic no LSP peer may ever be provided, so callers should not assume peer is
non-null.
src/server/service/agent_client.h (1)

9-16: Minor: document lifetime contract between AgentClient and peer.

The constructor (in agent_client.cpp) registers a handler that captures this. So AgentClient must outlive the registered handler on peer, i.e., peer must be destroyed (or its handlers cleared) before AgentClient. A short comment on the class would make this contract explicit for future maintainers wiring up connection lifecycles.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/service/agent_client.h` around lines 9 - 16, Add a brief lifetime
contract comment to AgentClient explaining that the constructor registers a
handler on kota::ipc::JsonPeer which captures this, so AgentClient must outlive
the registered handler (i.e., peer handlers should be cleared or peer destroyed
before destroying AgentClient); place this comment above the class declaration
in agent_client.h and reference AgentClient, its constructor, and
kota::ipc::JsonPeer to make the ownership/teardown expectation explicit for
maintainers.
src/server/service/lsp_client.cpp (1)

189-189: Redundant indexer.set_peer call.

server.indexer.set_peer(&peer) is already invoked in the LSPClient constructor (line 41), so calling it again here on initialized is dead code. Recommend removing for clarity — the constructor is the single, well-defined wiring point.

♻️ Proposed cleanup
-        srv.indexer.set_peer(&this->peer);
         srv.indexer.set_max_concurrency(cfg.stateless_worker_count.value);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/service/lsp_client.cpp` at line 189, Remove the redundant call to
srv.indexer.set_peer(&this->peer) from the initialized method: the LSPClient
constructor already wires the peer via indexer.set_peer(&peer), so delete the
duplicate srv.indexer.set_peer(&this->peer) line in the initialized function to
avoid dead code and keep wiring confined to the constructor.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/protocol/extension.h`:
- Around line 20-40: QueryContextResult::total and SwitchContextResult::success
are left uninitialized which can produce indeterminate values;
default-initialize these trivial scalar fields (set total = 0 and success =
false) in their struct definitions so any partially constructed or
early-returned instances are safe by default, updating the declarations for
QueryContextResult::total and SwitchContextResult::success accordingly.

In `@src/server/service/agent_client.cpp`:
- Around line 19-35: Validate and authorize incoming agent requests before
calling server.compiler.fill_compile_args: ensure CompileCommandParams::path is
non-empty, absolute, and canonicalize it then check it lies inside the server
workspace root (use this->server.workspace_root or equivalent) before
proceeding; if out-of-bounds return a generic kota::ipc::Error without echoing
the raw path. Additionally, gate this endpoint behind connection restrictions
and authentication: make the peer listener bind to loopback by default and
require a token/credential check on the peer (or validate an initial auth
header) before executing the peer.on_request handler. Finally, remove verbatim
path interpolation in the error message returned from the lambda (currently
creating kota::ipc::Error via std::format) and replace with a sanitized or
generic message.

In `@src/server/service/lsp_client.cpp`:
- Around line 39-41: LSPClient stores &peer into server.compiler and
server.indexer but never clears them, leaving dangling
Compiler::peer/Indexer::peer when an LSPClient is destroyed; add a LSPClient
destructor (~LSPClient) in lsp_client.h/cpp that resets those pointers by
calling server.compiler.set_peer(nullptr) and server.indexer.set_peer(nullptr)
only if they still reference this LSPClient's peer (i.e., guard so you don't
clear a peer that another LSPClient has since set), to prevent subsequent
publish_diagnostics/clear_diagnostics dereferencing freed memory.

In `@src/server/service/master_server.cpp`:
- Around line 220-225: The code currently ignores failure from kota::tcp::listen
when creating acceptor, so add a visible warning log when listen returns
empty/false; specifically, after calling auto acceptor =
kota::tcp::listen(opts.host, opts.port, {}, loop); check the falsy case and emit
a warning via LOG_WARN or LOG_ERROR indicating the agentic listener failed to
bind on opts.host:opts.port and include errno/strerror or the underlying error
message, otherwise proceed with the existing block that logs success and
schedules accept_connections(loop, server, std::move(*acceptor), false,
connections).
- Around line 175-194: accept_connections currently constructs an LSPClient for
every TCP connection, and LSPClient's constructor writes raw &peer into
Compiler::peer and Indexer::peer causing latest-wins overwrites and
use-after-free when the Connection is erased; fix by preventing multiple
concurrent LSP clients: gate LSPClient construction in accept_connections (or
inside LSPClient factory) on server.lifecycle == ServerLifecycle::Uninitialized
or on a single-client flag and reject additional connections instead of creating
LSPClient, and implement LSPClient::~LSPClient to clear Compiler::peer and
Indexer::peer on teardown; additionally, in the agent listener path (pipe mode)
log a warning (LOG_WARN) if listen fails so a busy port is visible.

---

Nitpick comments:
In `@src/server/compiler/compiler.h`:
- Around line 57-59: Add a brief doc comment for the deferred peer-wiring
contract on the Compiler class: explain that the former reference was changed to
a nullable pointer named peer and that any methods using the peer (e.g.,
publish_diagnostics, clear_diagnostics) will silently no-op until
set_peer(kota::ipc::JsonPeer* p) is called; note that MasterServer may construct
Compiler before any client attaches and in --mode agentic no LSP peer may ever
be provided, so callers should not assume peer is non-null.

In `@src/server/service/agent_client.cpp`:
- Around line 22-27: When calling compiler.fill_compile_args in
agent_client.cpp, detect if params.path corresponds to an open document and pass
the associated Session* so header-context fallback is used; resolve the path via
the workspace.path_pool (e.g., path_pool.find or equivalent) and look up the
Session in server.sessions (or expose a small accessor on MasterServer if
necessary), then call fill_compile_args(params.path, directory, arguments,
sessionPtr) instead of the current call without a Session to ensure open-file
header contexts are considered.

In `@src/server/service/agent_client.h`:
- Around line 9-16: Add a brief lifetime contract comment to AgentClient
explaining that the constructor registers a handler on kota::ipc::JsonPeer which
captures this, so AgentClient must outlive the registered handler (i.e., peer
handlers should be cleared or peer destroyed before destroying AgentClient);
place this comment above the class declaration in agent_client.h and reference
AgentClient, its constructor, and kota::ipc::JsonPeer to make the
ownership/teardown expectation explicit for maintainers.

In `@src/server/service/lsp_client.cpp`:
- Line 189: Remove the redundant call to srv.indexer.set_peer(&this->peer) from
the initialized method: the LSPClient constructor already wires the peer via
indexer.set_peer(&peer), so delete the duplicate
srv.indexer.set_peer(&this->peer) line in the initialized function to avoid dead
code and keep wiring confined to the constructor.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 76b3ad22-158f-4397-8d17-1a247b82da9f

📥 Commits

Reviewing files that changed from the base of the PR and between 939ab6d and ed2b20c.

📒 Files selected for processing (37)
  • src/clice.cc
  • src/protocol/agentic.h
  • src/protocol/extension.h
  • src/protocol/worker.h
  • src/server/compiler/compile_graph.cpp
  • src/server/compiler/compile_graph.h
  • src/server/compiler/compiler.cpp
  • src/server/compiler/compiler.h
  • src/server/compiler/indexer.cpp
  • src/server/compiler/indexer.h
  • src/server/master_server.h
  • src/server/service/agent_client.cpp
  • src/server/service/agent_client.h
  • src/server/service/lsp_client.cpp
  • src/server/service/lsp_client.h
  • src/server/service/master_server.cpp
  • src/server/service/master_server.h
  • src/server/service/session.h
  • src/server/worker/stateful_worker.cpp
  • src/server/worker/stateful_worker.h
  • src/server/worker/stateless_worker.cpp
  • src/server/worker/stateless_worker.h
  • src/server/worker/worker_common.h
  • src/server/worker/worker_pool.cpp
  • src/server/worker/worker_pool.h
  • src/server/workspace/config.cpp
  • src/server/workspace/config.h
  • src/server/workspace/workspace.cpp
  • src/server/workspace/workspace.h
  • tests/unit/server/compile_graph_integration_tests.cpp
  • tests/unit/server/compile_graph_tests.cpp
  • tests/unit/server/config_tests.cpp
  • tests/unit/server/module_worker_tests.cpp
  • tests/unit/server/pch_worker_tests.cpp
  • tests/unit/server/stateful_worker_tests.cpp
  • tests/unit/server/stateless_worker_tests.cpp
  • tests/unit/server/worker_test_helpers.h
💤 Files with no reviewable changes (2)
  • src/protocol/worker.h
  • src/server/master_server.h

Comment thread src/server/protocol/extension.h
Comment thread src/server/service/agent_client.cpp
Comment thread src/server/service/lsp_client.cpp
Comment thread src/server/service/master_server.cpp
Comment thread src/server/service/master_server.cpp Outdated
Add AgenticClient utility for JSON-RPC over TCP and three integration
tests for agentic/compileCommand: known file, unknown file fallback,
and multiple sequential requests. Update conftest to pass --port with
a dynamic free port so the agentic listener starts in pipe mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/conftest.py (1)

168-173: ⚠️ Potential issue | 🟠 Major

make_client not updated with --host/--port; will break multi-session tests.

The agentic TCP listener is unconditionally started in pipe mode (lines 203-228 of master_server.cpp) and defaults to 127.0.0.1:50051 when --host/--port are not provided. The client fixture was updated to pass dynamic --host/--port arguments, but make_client still only passes --mode pipe. When multi-session tests (e.g., test_persistent_cache.py) call make_client multiple times sequentially, the second call will fail to bind because the first server is already holding port 50051.

🛡️ Proposed fix
-async def make_client(executable: Path, workspace: Path) -> CliceClient:
+async def make_client(executable: Path, workspace: Path) -> CliceClient:
     """Spawn a fresh clice server and initialize it. For multi-session tests."""
     c = CliceClient()
-    await c.start_io(str(executable), "--mode", "pipe")
+    port = _find_free_port()
+    await c.start_io(
+        str(executable), "--mode", "pipe", "--host", "127.0.0.1", "--port", str(port)
+    )
     await c.initialize(workspace)
     return c
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/conftest.py` around lines 168 - 173, The make_client helper currently
always starts the server in pipe mode and omits dynamic host/port, causing port
collisions in multi-session tests; update the async function
make_client(executable: Path, workspace: Path) -> CliceClient to accept host and
port parameters (e.g., host: str, port: int) and forward them into the start_io
invocation (call CliceClient.start_io with the existing "--mode", "pipe" plus
"--host", host, "--port", str(port)); ensure any callers (tests/fixtures that
previously relied on make_client) pass the dynamic host/port values so each
spawned server binds to the intended address instead of the default
127.0.0.1:50051.
🧹 Nitpick comments (6)
tests/integration/utils/agentic_client.py (3)

51-63: Consider raising on missing Content-Length instead of silently returning None.

When the header lacks Content-Length, _read_message returns None, which then trips request()'s "connection closed before response" assertion — a misleading message for what is actually a protocol violation. Distinguishing the two cases would make protocol regressions easier to debug.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/utils/agentic_client.py` around lines 51 - 63, The
_read_message function currently returns None when the header lacks a
Content-Length, causing downstream code (like request()) to misinterpret it as a
closed connection; change _read_message to raise a clear exception (e.g.,
ValueError or a custom ProtocolError) when the Content-Length header is missing
so callers can distinguish a protocol violation from a closed connection; update
the exception message to include the raw header and reference the _read_message
function so logs/tests can pinpoint the malformed header case, and ensure
request() or its callers either let the exception propagate or handle it to
produce a distinct error for protocol violations.

28-49: request() swallows JSON-RPC error responses, leading to confusing test failures.

The current implementation only asserts id correspondence and returns the raw response. If the server returns a JSON-RPC error (e.g. the kota::outcome_error path in agent_client.cpp for unknown files), callers blindly do resp["result"] and raise an opaque KeyError instead of a clear failure. Consider either raising on error here, or at minimum returning the parsed object after asserting one of result/error is present so callers can branch.

         response = await asyncio.wait_for(self._read_message(), timeout=timeout)
         assert response is not None, "connection closed before response"
         assert response.get("id") == msg_id
+        assert "result" in response or "error" in response, f"malformed response: {response}"
         return response
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/utils/agentic_client.py` around lines 28 - 49, The
request() method currently returns raw JSON-RPC responses which hides
server-side errors; update request() (which calls _read_message()) to check the
parsed response for an "error" field after verifying the id and, if present,
raise a clear exception (e.g., a RuntimeError or a small JSONRPCError) that
includes the error object/message and the original response id so tests fail
with a meaningful message; alternatively, if you prefer not to raise, assert
that either "result" or "error" is present and return a normalized dict
containing one of those keys to force callers to handle errors explicitly.

16-26: Replace asyncio.get_event_loop() with asyncio.get_running_loop() inside the async coroutine.

asyncio.get_event_loop() is deprecated inside coroutines in Python 3.12+ and emits a DeprecationWarning. The official asyncio documentation explicitly recommends asyncio.get_running_loop() as the replacement for coroutines and callbacks, as it guarantees the loop is running and raises RuntimeError otherwise.

♻️ Proposed change
     `@classmethod`
     async def connect(cls, host: str, port: int, *, timeout: float = 10.0):
-        deadline = asyncio.get_event_loop().time() + timeout
+        loop = asyncio.get_running_loop()
+        deadline = loop.time() + timeout
         while True:
             try:
                 reader, writer = await asyncio.open_connection(host, port)
                 return cls(reader, writer)
             except (ConnectionRefusedError, OSError):
-                if asyncio.get_event_loop().time() >= deadline:
+                if loop.time() >= deadline:
                     raise
                 await asyncio.sleep(0.1)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/utils/agentic_client.py` around lines 16 - 26, The connect
classmethod uses asyncio.get_event_loop() which is deprecated inside coroutines;
replace both calls to asyncio.get_event_loop().time() with
asyncio.get_running_loop().time() inside AgenticClient.connect (the classmethod
named connect) so the deadline calculation and the loop time check use the
running loop; keep the retry/sleep logic unchanged (await asyncio.sleep(0.1))
and ensure you import nothing new.
tests/integration/agentic/test_agentic.py (1)

6-13: Strengthen the arguments assertion.

assert len(result["arguments"]) > 0 will pass even if the server returns a junk single-element list. Since the workspace's compile_commands.json is generated with clang++ -std=c++17 -fsyntax-only main.cpp (per _generate_test_data_cdbs in conftest.py), you can pin down a few invariants cheaply:

     assert result["file"] == main_cpp
     assert result["directory"] == str(workspace)
     assert len(result["arguments"]) > 0
+    assert any("-std=c++17" in a for a in result["arguments"])
+    assert main_cpp in result["arguments"] or any(main_cpp in a for a in result["arguments"])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/agentic/test_agentic.py` around lines 6 - 13, The current
assertion on result["arguments"] is too weak; update test_compile_command to
assert concrete invariants from the generated compile_commands.json: verify that
the command name (e.g., "clang++") appears in result["arguments"] (check
result["arguments"][0] or that any arg contains "clang++"), that "-std=c++17"
and "-fsyntax-only" are present in result["arguments"], and that the source file
argument equals or ends with "main.cpp" (compare to the main_cpp variable) so
the arguments must contain those expected tokens rather than just having a
non-empty list.
tests/conftest.py (2)

96-104: Free-port lookup has an unavoidable TOCTOU window.

_find_free_port binds-then-closes-then-returns the port, leaving a race where another process (or another parallel pytest worker, e.g. under pytest-xdist) can claim it before the server binds. For local CI this is usually fine, but if you start seeing flaky Address already in use failures, switch to SO_REUSEADDR and have the server inherit the listening socket, or pass a port range and retry. Mentioning here so it's on the radar — no action required if tests run serially.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/conftest.py` around lines 96 - 104, The free-port helper
_find_free_port (used by agentic_port fixture) opens, binds and closes a socket
which creates a TOCTOU race where another process can claim the port before the
test server binds; fix by changing the approach to avoid closing the listening
socket early—either create and set SO_REUSEADDR and pass the open listening
socket into the server (have the server inherit the socket created by
_find_free_port) or replace the helper with a port-range+retry strategy that
attempts bind, catches Address already in use, and retries until success; update
_find_free_port (and any code that consumes agentic_port) to use the chosen
approach so the socket remains reserved until the server binds.

138-143: Unused client parameter — make the dependency explicit.

The client fixture is requested only to ensure the server is up before the agentic connect; it isn't otherwise referenced. That's fine, but a brief comment will save future readers from "remove unused parameter" refactors that would silently break ordering.

 `@pytest.fixture`
 async def agentic(agentic_port: int, client) -> AgenticClient:
-    """Connect to the agentic TCP endpoint of a running server."""
+    """Connect to the agentic TCP endpoint of a running server.
+
+    The ``client`` fixture is required (even though unused here) to ensure the
+    server process is started and listening before we attempt to connect.
+    """
     ac = await AgenticClient.connect("127.0.0.1", agentic_port)
     yield ac
     await ac.close()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/conftest.py` around lines 138 - 143, The agentic fixture currently
takes an unused parameter client only to ensure server startup order; update the
Agentic fixture (agentic) to document this by adding a brief comment or note in
its docstring that the client fixture is intentionally required to guarantee the
server is running before AgenticClient.connect is called, so future editors know
not to remove the dependency; reference the fixture names agentic and client in
the comment to make the intent explicit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/integration/agentic/test_agentic.py`:
- Around line 16-22: The test expects a fallback for unknown paths but the
server returns an error when fill_compile_args (compiler.cpp) fails
(resolve_header_context returns nullopt) and the agent handler in
agent_client.cpp returns an outcome error; update test_compile_command_fallback
to assert an error response instead of accessing resp["result"] — e.g., call
agentic.request("agentic/compileCommand", {"path": "/nonexistent/file.cpp"}),
assert that "error" is present in resp (or that resp.get("result") is None) and
check the error code/message matches the agent handler's error, and remove the
assertion that result["file"] == "/nonexistent/file.cpp".

---

Outside diff comments:
In `@tests/conftest.py`:
- Around line 168-173: The make_client helper currently always starts the server
in pipe mode and omits dynamic host/port, causing port collisions in
multi-session tests; update the async function make_client(executable: Path,
workspace: Path) -> CliceClient to accept host and port parameters (e.g., host:
str, port: int) and forward them into the start_io invocation (call
CliceClient.start_io with the existing "--mode", "pipe" plus "--host", host,
"--port", str(port)); ensure any callers (tests/fixtures that previously relied
on make_client) pass the dynamic host/port values so each spawned server binds
to the intended address instead of the default 127.0.0.1:50051.

---

Nitpick comments:
In `@tests/conftest.py`:
- Around line 96-104: The free-port helper _find_free_port (used by agentic_port
fixture) opens, binds and closes a socket which creates a TOCTOU race where
another process can claim the port before the test server binds; fix by changing
the approach to avoid closing the listening socket early—either create and set
SO_REUSEADDR and pass the open listening socket into the server (have the server
inherit the socket created by _find_free_port) or replace the helper with a
port-range+retry strategy that attempts bind, catches Address already in use,
and retries until success; update _find_free_port (and any code that consumes
agentic_port) to use the chosen approach so the socket remains reserved until
the server binds.
- Around line 138-143: The agentic fixture currently takes an unused parameter
client only to ensure server startup order; update the Agentic fixture (agentic)
to document this by adding a brief comment or note in its docstring that the
client fixture is intentionally required to guarantee the server is running
before AgenticClient.connect is called, so future editors know not to remove the
dependency; reference the fixture names agentic and client in the comment to
make the intent explicit.

In `@tests/integration/agentic/test_agentic.py`:
- Around line 6-13: The current assertion on result["arguments"] is too weak;
update test_compile_command to assert concrete invariants from the generated
compile_commands.json: verify that the command name (e.g., "clang++") appears in
result["arguments"] (check result["arguments"][0] or that any arg contains
"clang++"), that "-std=c++17" and "-fsyntax-only" are present in
result["arguments"], and that the source file argument equals or ends with
"main.cpp" (compare to the main_cpp variable) so the arguments must contain
those expected tokens rather than just having a non-empty list.

In `@tests/integration/utils/agentic_client.py`:
- Around line 51-63: The _read_message function currently returns None when the
header lacks a Content-Length, causing downstream code (like request()) to
misinterpret it as a closed connection; change _read_message to raise a clear
exception (e.g., ValueError or a custom ProtocolError) when the Content-Length
header is missing so callers can distinguish a protocol violation from a closed
connection; update the exception message to include the raw header and reference
the _read_message function so logs/tests can pinpoint the malformed header case,
and ensure request() or its callers either let the exception propagate or handle
it to produce a distinct error for protocol violations.
- Around line 28-49: The request() method currently returns raw JSON-RPC
responses which hides server-side errors; update request() (which calls
_read_message()) to check the parsed response for an "error" field after
verifying the id and, if present, raise a clear exception (e.g., a RuntimeError
or a small JSONRPCError) that includes the error object/message and the original
response id so tests fail with a meaningful message; alternatively, if you
prefer not to raise, assert that either "result" or "error" is present and
return a normalized dict containing one of those keys to force callers to handle
errors explicitly.
- Around line 16-26: The connect classmethod uses asyncio.get_event_loop() which
is deprecated inside coroutines; replace both calls to
asyncio.get_event_loop().time() with asyncio.get_running_loop().time() inside
AgenticClient.connect (the classmethod named connect) so the deadline
calculation and the loop time check use the running loop; keep the retry/sleep
logic unchanged (await asyncio.sleep(0.1)) and ensure you import nothing new.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 401686bb-be6a-441e-8c09-21f060a2cb59

📥 Commits

Reviewing files that changed from the base of the PR and between ed2b20c and e736eb3.

📒 Files selected for processing (4)
  • tests/conftest.py
  • tests/integration/agentic/__init__.py
  • tests/integration/agentic/test_agentic.py
  • tests/integration/utils/agentic_client.py

Comment thread tests/integration/agentic/test_agentic.py Outdated
…free

Temporary lambdas used as coroutines have their captures accessed through
a dangling this pointer after the lambda is destroyed. Pass values as
function parameters instead (copied into the coroutine frame by the
standard). Also make the agentic TCP listener opt-in (--port > 0) to
avoid acceptor leaks in smoke tests, add LSPClient destructor to reset
peer pointers, and default-initialize extension protocol scalars.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/server/service/lsp_client.cpp (1)

414-427: ⚠️ Potential issue | 🟡 Minor

Empty PCH-link array produces malformed JSON.

The merge path assumes pch_json has at least one element. If pch_json is "[]" (or, more generally, any array of length 2), then pop_back() + , + append(pch_json.begin() + 1, pch_json.end()) produces "[..,]" — invalid JSON that the client will reject. The outer guard only checks !document_links_json.empty(), not size() > 2.

🛡️ Proposed fix
-            if(!links.data.empty() && links.data != "null" && links.data.size() > 2) {
+            if(pch_json.size() <= 2) {
+                // PCH contributes no links; keep the original result as-is.
+            } else if(!links.data.empty() && links.data != "null" && links.data.size() > 2) {
                 links.data.pop_back();
                 links.data += ',';
                 links.data.append(pch_json.begin() + 1, pch_json.end());
             } else {
                 links.data = pch_json;
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/service/lsp_client.cpp` around lines 414 - 427, The merge logic
for PCH document links can produce malformed JSON when pch_json is an empty
array ("[]") because the code assumes pch_json has more than just the brackets;
update the condition around the merge to ensure pch_json.size() > 2 (and keep
the existing checks for links.data size) before doing pop_back()/add
comma/append of pch_json.begin() + 1; otherwise assign links.data = pch_json or
skip the comma/append path. Refer to sit2, sit2->second.pch_ref, pch_it (from
srv.workspace.pch_cache), pch_json, and links.data when locating and fixing the
code.
♻️ Duplicate comments (1)
src/server/service/master_server.cpp (1)

175-178: ⚠️ Potential issue | 🟠 Major

Multi-LSP-client hazard in socket mode remains unmitigated despite the destructor fix.

The added LSPClient::~LSPClient (lsp_client.cpp:778-781) closes the dangling-pointer hole on disconnect, but in socket mode register_lsp=true still means every accepted TCP connection constructs a fresh LSPClient that overwrites server.compiler.peer / server.indexer.peer (lsp_client.cpp:40-41). As long as ≥ 2 LSP clients are connected concurrently, the second connection's constructor silently redirects diagnostics/progress for client A to client B's transport, and the second initialize request is only rejected after the peer pointers have already been clobbered.

Recommend gating the LSPClient construction so socket mode admits at most one LSP client, refusing extra LSP attempts at the connection layer rather than at the protocol layer:

🛡️ Suggested guard
         std::unique_ptr<LSPClient> lsp;
-        if(register_lsp)
-            lsp = std::make_unique<LSPClient>(server, *peer);
+        if(register_lsp) {
+            if(server.lifecycle == ServerLifecycle::Uninitialized) {
+                lsp = std::make_unique<LSPClient>(server, *peer);
+            } else {
+                LOG_WARN("Refusing additional LSP client; server already initialized");
+            }
+        }
         auto agent = std::make_unique<AgentClient>(server, *peer);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/service/master_server.cpp` around lines 175 - 178, Current code in
master_server.cpp always constructs a new LSPClient when register_lsp is true,
which lets a second socket-mode client overwrite server.compiler.peer /
server.indexer.peer; before creating the LSPClient (the
std::make_unique<LSPClient> call) add a gate that checks whether an existing LSP
is already bound (e.g., test server.compiler.peer and/or server.indexer.peer or
an explicit server.has_lsp flag) and if one exists refuse the new connection at
the accept/connection layer (close the socket or send a connection-refused
response) and skip constructing LSPClient, otherwise proceed to construct and
set the peer; ensure this check is done prior to any code that might mutate
server.compiler.peer/server.indexer.peer so the second client cannot clobber the
first.
🧹 Nitpick comments (3)
tests/integration/utils/agentic_client.py (1)

16-26: Use asyncio.get_running_loop() instead of asyncio.get_event_loop() in this coroutine.

asyncio.get_event_loop() emits a DeprecationWarning in Python 3.12+ and is not recommended inside async functions. The connect method is always awaited, so asyncio.get_running_loop() is a direct replacement with clearer semantics.

♻️ Proposed refactor
     `@classmethod`
     async def connect(cls, host: str, port: int, *, timeout: float = 10.0):
-        deadline = asyncio.get_event_loop().time() + timeout
+        loop = asyncio.get_running_loop()
+        deadline = loop.time() + timeout
         while True:
             try:
                 reader, writer = await asyncio.open_connection(host, port)
                 return cls(reader, writer)
             except (ConnectionRefusedError, OSError):
-                if asyncio.get_event_loop().time() >= deadline:
+                if loop.time() >= deadline:
                     raise
                 await asyncio.sleep(0.1)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/utils/agentic_client.py` around lines 16 - 26, The connect
classmethod uses asyncio.get_event_loop() inside an async coroutine which
triggers DeprecationWarnings; replace both asyncio.get_event_loop() calls with
asyncio.get_running_loop() in the connect method so the deadline calculation and
time checks use the running event loop (i.e., update the deadline =
asyncio.get_event_loop().time() and the subsequent
asyncio.get_event_loop().time() comparisons inside the connect coroutine to use
asyncio.get_running_loop().time()).
src/server/service/lsp_client.cpp (1)

188-192: Redundant set_peer on the indexer.

server.indexer.set_peer(&peer) was already called in the constructor at line 41. Calling it again here in the initialized notification handler is redundant — the peer has not changed. Also note the inconsistency: the constructor wires both compiler and indexer, but the initialized handler only re-wires indexer.

If this second call is intentional (e.g., to re-arm progress reporting after some teardown), please add a comment; otherwise, drop it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/service/lsp_client.cpp` around lines 188 - 192, The call to
srv.indexer.set_peer(&this->peer) inside the initialized notification handler is
redundant because set_peer(&peer) is already done in the constructor (where both
compiler and indexer are wired); remove the duplicate
srv.indexer.set_peer(&this->peer) from the initialized handler unless there is
an intentional reason to re-arm progress reporting—if intentional, replace the
call with a brief inline comment explaining why the indexer needs re-wiring here
and also ensure symmetry with the compiler wiring (i.e., document why only
indexer is re-wired while compiler is not) so readers understand the difference;
leave srv.load_workspace() as-is.
src/server/service/master_server.cpp (1)

64-77: Silent fallthrough when a configured path is a directory without compile_commands.json.

In the directory branch, if candidate does not exist, we fall through to the next configured entry without a warning — only the "path doesn't exist at all" branch logs. Users who misconfigure a directory entry get no diagnostic and end up with the auto-discovery message at line 102 (or none if discovery succeeds elsewhere), making misconfiguration hard to spot.

♻️ Proposed refinement
         if(llvm::sys::fs::is_directory(configured)) {
             auto candidate = path::join(configured, "compile_commands.json");
             if(llvm::sys::fs::exists(candidate)) {
                 cdb_path = std::move(candidate);
                 break;
             }
+            LOG_WARN("No compile_commands.json under configured directory: {}", configured);
         } else if(llvm::sys::fs::exists(configured)) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server/service/master_server.cpp` around lines 64 - 77, The loop over
cfg.compile_commands_paths silently ignores directory entries that lack a
compile_commands.json, making misconfiguration hard to detect; update the branch
inside the for loop (the llvm::sys::fs::is_directory(configured) case) to check
if candidate exists and if not call LOG_WARN (similar to the else branch) with a
clear message mentioning the configured directory and that compile_commands.json
was not found, so users see a diagnostic when candidate does not exist before
falling through to auto-discovery; ensure you reference candidate and configured
in the log and leave the existing cdb_path assignment and breaks unchanged when
a match is found.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/server/service/lsp_client.cpp`:
- Around line 414-427: The merge logic for PCH document links can produce
malformed JSON when pch_json is an empty array ("[]") because the code assumes
pch_json has more than just the brackets; update the condition around the merge
to ensure pch_json.size() > 2 (and keep the existing checks for links.data size)
before doing pop_back()/add comma/append of pch_json.begin() + 1; otherwise
assign links.data = pch_json or skip the comma/append path. Refer to sit2,
sit2->second.pch_ref, pch_it (from srv.workspace.pch_cache), pch_json, and
links.data when locating and fixing the code.

---

Duplicate comments:
In `@src/server/service/master_server.cpp`:
- Around line 175-178: Current code in master_server.cpp always constructs a new
LSPClient when register_lsp is true, which lets a second socket-mode client
overwrite server.compiler.peer / server.indexer.peer; before creating the
LSPClient (the std::make_unique<LSPClient> call) add a gate that checks whether
an existing LSP is already bound (e.g., test server.compiler.peer and/or
server.indexer.peer or an explicit server.has_lsp flag) and if one exists refuse
the new connection at the accept/connection layer (close the socket or send a
connection-refused response) and skip constructing LSPClient, otherwise proceed
to construct and set the peer; ensure this check is done prior to any code that
might mutate server.compiler.peer/server.indexer.peer so the second client
cannot clobber the first.

---

Nitpick comments:
In `@src/server/service/lsp_client.cpp`:
- Around line 188-192: The call to srv.indexer.set_peer(&this->peer) inside the
initialized notification handler is redundant because set_peer(&peer) is already
done in the constructor (where both compiler and indexer are wired); remove the
duplicate srv.indexer.set_peer(&this->peer) from the initialized handler unless
there is an intentional reason to re-arm progress reporting—if intentional,
replace the call with a brief inline comment explaining why the indexer needs
re-wiring here and also ensure symmetry with the compiler wiring (i.e., document
why only indexer is re-wired while compiler is not) so readers understand the
difference; leave srv.load_workspace() as-is.

In `@src/server/service/master_server.cpp`:
- Around line 64-77: The loop over cfg.compile_commands_paths silently ignores
directory entries that lack a compile_commands.json, making misconfiguration
hard to detect; update the branch inside the for loop (the
llvm::sys::fs::is_directory(configured) case) to check if candidate exists and
if not call LOG_WARN (similar to the else branch) with a clear message
mentioning the configured directory and that compile_commands.json was not
found, so users see a diagnostic when candidate does not exist before falling
through to auto-discovery; ensure you reference candidate and configured in the
log and leave the existing cdb_path assignment and breaks unchanged when a match
is found.

In `@tests/integration/utils/agentic_client.py`:
- Around line 16-26: The connect classmethod uses asyncio.get_event_loop()
inside an async coroutine which triggers DeprecationWarnings; replace both
asyncio.get_event_loop() calls with asyncio.get_running_loop() in the connect
method so the deadline calculation and time checks use the running event loop
(i.e., update the deadline = asyncio.get_event_loop().time() and the subsequent
asyncio.get_event_loop().time() comparisons inside the connect coroutine to use
asyncio.get_running_loop().time()).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 52013694-f6d0-4c35-8fb0-91a71786881c

📥 Commits

Reviewing files that changed from the base of the PR and between e736eb3 and c83ba7e.

📒 Files selected for processing (8)
  • src/clice.cc
  • src/protocol/extension.h
  • src/server/service/lsp_client.cpp
  • src/server/service/lsp_client.h
  • src/server/service/master_server.cpp
  • src/server/service/master_server.h
  • tests/conftest.py
  • tests/integration/utils/agentic_client.py
✅ Files skipped from review due to trivial changes (1)
  • src/protocol/extension.h
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/server/service/master_server.h
  • tests/conftest.py
  • src/clice.cc

16bit-ykiko and others added 2 commits April 26, 2026 22:36
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…odule

- Rewrite `--mode agentic` as a one-shot TCP client that connects to a
  running server's agentic port, sends a compileCommand request, prints
  the JSON result to stdout, and exits.
- Extract agentic client code into src/server/service/agentic.cpp/h.
- Replace inline coroutine lambdas with named functions and when_all.
- Rewrite integration tests to use subprocess.run instead of a custom
  JSON-RPC client; delete agentic_client.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
tests/conftest.py (1)

125-128: _find_free_port has a TOCTOU race; consider passing port=0 to the server instead.

Binding to port 0 then closing and handing the number to a freshly spawned clice is racy — under CI load another process can claim the freed port before clice calls listen. The cleanest fix is to let clice bind port 0 itself and report back the chosen port, but that requires server-side support. As a pragmatic interim, set SO_REUSEADDR or accept that occasional flakes are possible.

def _find_free_port() -> int:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]

Not a blocker — this is a common pytest pattern — but worth noting if the agentic suite ever flakes in CI.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/conftest.py` around lines 125 - 128, _find_free_port suffers a TOCTOU
race because it binds then closes the socket before the test server (clice)
binds; either change test harness so the server binds to port 0 and reports the
chosen port back, or as a pragmatic interim set SO_REUSEADDR on the temporary
socket to reduce races: update the _find_free_port function to call
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) before s.bind, or
preferably modify clice/server startup to accept port=0 and return the bound
port so callers do not rely on a transient free-port probe.
src/clice.cc (1)

28-31: --port help text and validation are inconsistent across modes.

--port is consumed by socket mode (line 249 of src/server/service/master_server.cpp) as the LSP listen port and by agentic mode as the connect target, but the help string only describes the "Agentic TCP port (0 = disabled)" case. Additionally, socket mode does not validate port > 0 the way agentic mode does at line 146 — passing --mode socket with no --port falls through to kota::tcp::listen(host, 0, ...), which (if it succeeds) binds an ephemeral port the user has no way to discover.

🛠️ Suggested adjustments
     DecoKV(style = KVStyle::JoinedOrSeparate,
-           help = "Agentic TCP port (0 = disabled)",
+           help = "TCP port: required for socket/agentic modes; 0 disables agent listener in pipe mode",
            required = false)
     <int> port = 0;
     if(mode == "pipe" || mode == "socket") {
+        if(mode == "socket" && opts.port.value_or(0) <= 0) {
+            LOG_ERROR("--port is required for socket mode");
+            return 1;
+        }
         clice::ServerOptions server_opts;

Also applies to: 132-140

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/clice.cc` around lines 28 - 31, The --port DecoKV (port variable declared
with DecoKV style = KVStyle::JoinedOrSeparate) has a help string that only
describes agentic mode and lacks validation for socket mode; update the help
text to describe both uses (agentic: connect target where 0 disables; socket:
LSP listen port) and change behavior so socket mode validates port > 0 before
calling kota::tcp::listen (mirror the existing agentic-mode port > 0 check),
returning a clear error if missing/zero; keep the existing agentic-mode
validation intact and ensure any error messages reference the port variable and
mode to make debugging obvious.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/clice.cc`:
- Around line 28-31: The --port DecoKV (port variable declared with DecoKV style
= KVStyle::JoinedOrSeparate) has a help string that only describes agentic mode
and lacks validation for socket mode; update the help text to describe both uses
(agentic: connect target where 0 disables; socket: LSP listen port) and change
behavior so socket mode validates port > 0 before calling kota::tcp::listen
(mirror the existing agentic-mode port > 0 check), returning a clear error if
missing/zero; keep the existing agentic-mode validation intact and ensure any
error messages reference the port variable and mode to make debugging obvious.

In `@tests/conftest.py`:
- Around line 125-128: _find_free_port suffers a TOCTOU race because it binds
then closes the socket before the test server (clice) binds; either change test
harness so the server binds to port 0 and reports the chosen port back, or as a
pragmatic interim set SO_REUSEADDR on the temporary socket to reduce races:
update the _find_free_port function to call s.setsockopt(socket.SOL_SOCKET,
socket.SO_REUSEADDR, 1) before s.bind, or preferably modify clice/server startup
to accept port=0 and return the bound port so callers do not rely on a transient
free-port probe.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 20fca714-1515-436e-8979-10e7dfc13d0d

📥 Commits

Reviewing files that changed from the base of the PR and between 7f9c743 and 06070a2.

📒 Files selected for processing (7)
  • src/clice.cc
  • src/server/service/agentic.cpp
  • src/server/service/agentic.h
  • src/server/service/master_server.cpp
  • src/server/service/master_server.h
  • tests/conftest.py
  • tests/integration/agentic/test_agentic.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/server/service/master_server.h

16bit-ykiko and others added 6 commits April 27, 2026 01:48
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ic client

Move JsonPeer ownership to the caller via unique_ptr& out-parameter so
the peer outlives the when_all scope and avoids use-after-free.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Compiler: detached compile tasks now spawn into a task_group member,
  with stop() for graceful shutdown instead of raw loop.schedule()
- MasterServer: connection handlers use a local task_group inside
  accept_connections instead of fire-and-forget scheduling
- Indexer: replace hand-written inflight/finished/completion_event
  counters with task_group; monitor_resources uses cancellation_token
  via with_token instead of generation counter polling
- WorkerPool: monitor tasks use task_group, removing manual
  alive_count_/all_exited_ tracking; stop() simplified to join()
- Agentic client: remove unnecessary loop parameter, use
  event_loop::current() inside coroutine

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pin to e024f3b which includes the variant dispatch fix (#129).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove `friend class LSPClient/AgentClient` from MasterServer, replace
  with explicit public accessors and coordination methods (open_session,
  close_session, find_session, on_file_saved, schedule_shutdown, etc.)
- Enforce single LSP connection in socket mode
- Delete dead MasterServer::initialize() and duplicate set_peer call
- Unify lambda capture style in lsp_client.cpp (this-> consistently)
- Fix stale doc reference from src/server/protocol.h to src/server/protocol/
- Add agentic error-path integration tests (connection_refused, concurrent)
- Fix flaky test_touch_without_content_change_skips_recompile: default
  cache_dir to workspace/.clice in CliceClient.initialize() to prevent
  stale PCH from global cache causing silent compilation failures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…or comments

Trivial getters/setters on MasterServer added no value over direct member
access via friend — revert to `friend class LSPClient; friend class AgentClient;`.
Also remove all `// --- ... ---` decorative separator comments project-wide.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@16bit-ykiko 16bit-ykiko changed the title feat(server): split MasterServer into service layer and add agentic protocol refactor(server): split into service layer, add agentic protocol, adopt task_group May 1, 2026
@16bit-ykiko
16bit-ykiko merged commit 75b9ea0 into main May 1, 2026
20 checks passed
@16bit-ykiko
16bit-ykiko deleted the feat/agentic-protocol branch May 1, 2026 17:06
16bit-ykiko added a commit that referenced this pull request Jul 3, 2026
## Background

Server-side error feedback is the operability foundation for v1.0
(roadmap: server task 3 "error handling & logging"). Before this PR,
internal failures died silently in logs (or as silent `null` responses),
users got no signal when their setup was broken, and there was no
structured way to profile the server on real codebases. This PR
supersedes #436 (reimplemented on current main rather than rebased — it
predates the #437/#447/#449 restructures).

## Feedback channels

**Anomaly (soft assertions, `src/support/anomaly.h`)** —
`LOG_ANOMALY(id, fmt, ...)` for internal states that must be unreachable
when clice works correctly. Debug builds abort after logging (the CI
Debug matrix and local dev catch bugs earliest; `CLICE_ANOMALY_NO_TRAP`
exists for tests); Release logs `[anomaly:<id>]`, pushes
`window/logMessage` (master) and continues. Per-ID rate limit with a
final suppression notice; the gate runs **before** format arguments are
evaluated (lazy contract locked by unit tests with side-effecting args).
IDs: `PCHBuildFail`, `PCMBuildFail`, `CompileFail`, `WorkerRequestFail`,
`WorkerCrash`, `WorkerSpawnFail`, `PositionMapFail` (markers are the
enumerator names, rendered by the reflective enum formatter and pinned
by `MarkerNamesStable`).

Situations reachable by user input or normal operation are deliberately
**not** anomalies. That split is structural: worker build failures carry
`BuildResult.has_user_errors`, and master-side dispatch failures carry
`dispatch_errc` codes (`cancelled` for memory-pressure preemption,
`worker_unavailable` for crash/restart windows) so
`is_operational_error()` keeps them out of the anomaly channel.

**Guidance** — `LOG_GUIDANCE(...)` (`[guidance]` + Warning logMessage)
for user-actionable situations: no compile_commands.json, invalid
initializationOptions, config problems.

**LSP errors instead of silent null** — feature requests on closed
documents → `Document not open`; unresolvable call/type hierarchy items
→ error; reversed ranges → `InvalidParams`; empty
hierarchy/workspace-symbol results return `[]` instead of `null`.
Out-of-range positions **clamp** per the LSP spec (character → line end,
line → end of content) instead of erroring.

**Guidance diagnostics** — `fill_compile_args()` returns a
`CommandSource` (`CDBExact`/`IncludeGraph`/`Inferred`
(reserved)/`Fallback`) and emits a per-file decision log (tiers tried,
tier hit, args hash). When a *guessed* command produces file-not-found
errors, the publish merges a file-top Warning explaining it (code
`inferred-compile-command`, linking the quick-start guide). Exact CDB
matches never get it. Config rule appends now reach synthesized fallback
commands, so `-I` rules work without a CDB.

**clice.toml diagnostics** — parse/type errors (Error, defaults apply)
and unknown keys (Warning via a strict second decode pass, config still
applies) are published on the config file's URI. Line/column ranges wait
for the kotatsu TOML location feature (FIXMEs mark the re-enable
points); until then diagnostics anchor at the file top.

## Logging system

- **Design doc in `logging.h`**: channel selection is decision-oriented
— pick by *who must act and whether it is clice's fault*. Levels, perf
topics and process ownership are specified there.
- **`LOG_PERF(topic, ...)`** emits greppable `[perf:<topic>] key=value`
lines for profiling on real codebases: `startup` (CDB load, dep scan,
index load), `index` (per-file index/merge timings, run summary, save),
`cache` (PCH/PCM hit/miss with reason, evictions), `request`
(per-request `wait_ms`/`total_ms` for query/build/format/compile).
- **Per-process log files**: workers log only to their own
`<session>/<worker>.log` (no stderr mirror), so the master log no longer
duplicates worker output. Worker stderr is reserved for unexpected
third-party output (sanitizers, libc asserts), relayed line-by-line into
the master log by the pool.
- **Crash backtraces** land in the owning process's log file
(`install_crash_handler`, covers SIGABRT so `LOG_FATAL` and Debug
anomaly traps too); a worker's backtrace goes *only* to its own log,
verified by an integration test that SIGABRTs a worker and asserts the
trace stays out of the master log.
- **Startup flow** is fully logged: master banner (pid/mode/workspace),
config source, session log dir, worker pool summary,
CDB/dep-scan/index-load timings.

## Notable fixes uncovered along the way

- `CompilationDatabase::lookup()` synthesizes a command for unknown
files, so the old `results.empty()` checks were dead: the automatic
include-graph header-context tier was unreachable, and headers without
entries silently compiled as bare `clang`. Tier selection now uses
`has_entry()`; background indexing skips Fallback-sourced files.
- initializationOptions now overlay **before** `apply_defaults()`: a
client-provided `cache_dir` previously didn't propagate into the derived
`logging_dir`/`index_dir`.
- Six unchecked `*map.to_range()` dereferences (empty-optional UB) in
feature code converted to checked helpers reporting `PositionMapFail`.
- Anomaly notify/trap hooks are mutex-synchronized (copy under lock,
invoke outside), making reporting safe from any thread.

## Test infrastructure

- `CliceClient` records `window/logMessage`; **`assert_no_anomaly()`
runs in every integration teardown** (all fixtures and `make_client`
sessions), scanning notifications and master/worker log files even when
shutdown fails. `tests/replay.py` fails a smoke trace on any anomaly
push.
- E2E: guidance-diagnostic lifecycle (no CDB → appears; add CDB +
restart → gone while the include error stays), fallback rule-append (no
CDB + clice.toml `-I` rule → clean compile), clice.toml
error/unknown-key/clear scenarios, worker SIGABRT → `WorkerCrash`
anomaly + backtrace ownership, closed/unknown documents via
`pytest.raises`, position/range clamping.
- Unit triggers for `PositionMapFail`, `WorkerCrash`, `WorkerSpawnFail`;
`dispatch_errc` classification; fallback append; lazy-evaluation and
rate-limit contracts.
- Named timing constants
(`MTIME_GRANULARITY`/`SETTLE_TIME`/`IDLE_TIMEOUT`) replace hardcoded
sleeps; yield-based workspace cleanup; log dump on test failure.

## Out of scope

Module-cycle guidance diagnostics wait for the module refactor; kotatsu
TOML line/column re-enable after the kotatsu-side PR merges; worker
logMessage forwarding and request-correlation IDs belong to the
worker-pool follow-up.

## Test plan

- [x] RelWithDebInfo: 732 unit / 187 integration / 3 smoke — all green
locally
- [x] Debug: 732 unit / 187 integration / 3 smoke — all green locally
(anomalies abort in Debug, so this run is the "zero anomalies on regular
paths" acceptance)
- [x] `pixi run format` clean; two rounds of 3 parallel review subagents
(correctness / style / tests) — all findings fixed or triaged
- [x] Full CI matrix green (Linux/macOS/Windows native,
aarch64/x64/arm64 cross, editor test)

Closes #436 (superseded).
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