Skip to content

Commit ecd69b3

Browse files
committed
Add OTel tracing, runBatch streaming, on-demand handler init, and finecode-user.toml
- Add ITracingHooks protocol (finecode_jsonrpc) and JsonRpcTracingHooks implementations in WM and ER; wire into JsonRpcClient and JsonRpcServerSession for per-hop envelope spans. Add lsp_request_span, mcp_tool_span, attach_incoming_traceparent, add_span_event helpers. Scatter span events across the ER handler execution path. - Rewrite _handle_run_batch_with_partial_results to use the ER streaming path (run_with_partial_results) per action/project so ER partials are forwarded to the CLI as they arrive rather than after the full action completes. - Propagate result_formats through _TrackingPartialResultSender and partial_result_sender.schedule_sending to the ER. - Add deferred extension activators to Registry so handlers can be initialized on demand; expose initialize_all_handlers flag on add_dir and use it in CLI own-server mode to skip eager handler init. - Add read_project_user_config for finecode-user.toml: per-project user-local preset additions, forbidden from carrying [workspace] config. - Fix ServerStoppedError (typed exception replacing RuntimeError), follow_redirects=True in HttpSession, BaseRunnerRequestException message forwarded to super(), QueueShutDown handled in client send. Merge get_action_metadata into resolve_action_meta. Upgrade failed action import log from trace to warning. Add CLI status log lines.
1 parent 7f3deac commit ecd69b3

31 files changed

Lines changed: 1753 additions & 926 deletions

File tree

finecode_extension_api/src/finecode_extension_api/workspace_utils.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import pathlib
22

3+
from loguru import logger
4+
35

46
def group_files_by_project(
57
files: list[pathlib.Path],
@@ -13,8 +15,20 @@ def group_files_by_project(
1315
sorted_projects = sorted(project_paths, key=lambda p: len(p.parts), reverse=True)
1416
result: dict[pathlib.Path, list[pathlib.Path]] = {}
1517
for file in files:
18+
exists_on_disk = file.exists()
19+
matched = False
1620
for project in sorted_projects:
1721
if file.is_relative_to(project):
22+
logger.debug(
23+
f"group_files_by_project: assigned {file} to project {project}"
24+
f" (exists_on_disk={exists_on_disk})"
25+
)
1826
result.setdefault(project, []).append(file)
27+
matched = True
1928
break
29+
if not matched:
30+
logger.debug(
31+
f"group_files_by_project: {file} not under any known project"
32+
f" (exists_on_disk={exists_on_disk})"
33+
)
2034
return result

finecode_extension_runner/src/finecode_extension_runner/_services/run_action.py

Lines changed: 178 additions & 138 deletions
Large diffs are not rendered by default.

finecode_extension_runner/src/finecode_extension_runner/di/bootstrap.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,14 +154,19 @@ def bootstrap(
154154
)
155155

156156
svc_registry = service_registry.ServiceRegistry(di_registry=registry)
157-
_activate_extensions(handler_packages, svc_registry)
157+
all_eps, activated = _activate_extensions(handler_packages, svc_registry)
158158
_apply_user_service_config(service_declarations, svc_registry)
159159

160+
remaining = sorted(set(all_eps.keys()) - set(activated))
161+
if remaining:
162+
deferred = [_make_deferred_activator(pkg, all_eps[pkg], svc_registry) for pkg in remaining]
163+
registry.set_deferred_activators(deferred)
164+
160165

161166
def _activate_extensions(
162167
handler_packages: set[str],
163168
svc_registry: service_registry.ServiceRegistry,
164-
) -> None:
169+
) -> tuple[dict[str, importlib.metadata.EntryPoint], ordered_set.OrderedSet[str]]:
165170
all_eps = {
166171
ep.name: ep
167172
for ep in importlib.metadata.entry_points(group="finecode.activator")
@@ -183,6 +188,24 @@ def _activate_extensions(
183188
except Exception as e:
184189
logger.error(f"Failed to activate extension '{pkg_name}': {e}")
185190

191+
return all_eps, packages_to_activate
192+
193+
194+
def _make_deferred_activator(
195+
pkg_name: str,
196+
ep: importlib.metadata.EntryPoint,
197+
svc_registry: service_registry.ServiceRegistry,
198+
) -> Callable[[], None]:
199+
def activate() -> None:
200+
try:
201+
activator_cls = ep.load()
202+
activator_cls(registry=svc_registry).activate()
203+
logger.debug(f"On-demand activated extension '{pkg_name}'")
204+
except Exception as e:
205+
logger.error(f"Failed to on-demand activate extension '{pkg_name}': {e}")
206+
207+
return activate
208+
186209

187210
def _find_installed_packages_with_missing_eps(
188211
handler_packages: set[str],

finecode_extension_runner/src/finecode_extension_runner/di/registry.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ class Registry:
1212
def __init__(self) -> None:
1313
self._container: dict[type, Any] = {}
1414
self._factories: dict[type, Callable] = {}
15+
self._deferred_activators: list[Callable[[], None]] = []
16+
self._next_deferred: int = 0
17+
18+
def set_deferred_activators(self, activators: list[Callable[[], None]]) -> None:
19+
self._deferred_activators = activators
20+
self._next_deferred = 0
1521

1622
def register_instance(self, type_: type, instance: Any, *, override: bool = False) -> None:
1723
if type_ in self._container and not override:
@@ -27,6 +33,13 @@ async def get_instance(self, type_: Type[T]) -> T:
2733
if type_ in self._container:
2834
return self._container[type_]
2935

36+
if type_ not in self._factories:
37+
while self._next_deferred < len(self._deferred_activators):
38+
activate = self._deferred_activators[self._next_deferred]
39+
self._next_deferred += 1
40+
activate()
41+
if type_ in self._factories:
42+
break
3043
if type_ not in self._factories:
3144
raise ServiceNotFoundError(f"No implementation found for {type_}")
3245

finecode_extension_runner/src/finecode_extension_runner/er_server.py

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ class ErServer:
173173
"""
174174

175175
def __init__(self) -> None:
176-
self._session = finecode_jsonrpc_module.JsonRpcServerSession()
176+
self._session = finecode_jsonrpc_module.JsonRpcServerSession(tracing=er_telemetry.JsonRpcTracingHooks())
177177
self._finecode_async_tasks: list[asyncio.Task] = []
178178
self._finecode_exit_stack = contextlib.AsyncExitStack()
179179
self._finecode_file_editor_session: ifileeditor.IFileEditorSession
@@ -874,36 +874,6 @@ async def resolve_source(_server: ErServer, params: dict | None) -> dict:
874874
return {"canonicalSource": canonical}
875875

876876

877-
async def get_action_metadata_cmd(_server: ErServer, params: dict | None) -> dict:
878-
"""Handler for ``actions/getActionMetadata``.
879-
880-
Imports the action class identified by *source* and returns its
881-
``PARENT_ACTION`` and ``LANGUAGE`` class attributes as a dict.
882-
883-
Raises ``ValueError`` when the source cannot be imported.
884-
"""
885-
assert params is not None
886-
source: str = params["source"]
887-
logger.trace(f"Get action metadata for source: {source}")
888-
last_dot = source.rfind(".")
889-
if last_dot == -1:
890-
raise ValueError(f"Invalid source (no dot separator): {source!r}")
891-
import importlib
892-
module_path = source[:last_dot]
893-
attr_name = source[last_dot + 1:]
894-
try:
895-
mod = importlib.import_module(module_path)
896-
cls = getattr(mod, attr_name)
897-
except (ImportError, AttributeError) as exc:
898-
raise ValueError(f"Cannot resolve source '{source}': {exc}") from exc
899-
parent = getattr(cls, "PARENT_ACTION", None)
900-
parent_source = (
901-
f"{parent.__module__}.{parent.__qualname__}" if parent is not None else None
902-
)
903-
language = getattr(cls, "LANGUAGE", None)
904-
return {"parentActionSource": parent_source, "language": language}
905-
906-
907877
async def resolve_action_meta(server: ErServer, _params: dict | None) -> dict:
908878
"""Handler for ``finecodeRunner/resolveActionMeta``."""
909879
if server._runner_context is None:
@@ -971,7 +941,6 @@ async def _on_progress_from_wm(params: dict | None) -> None:
971941
session.on_request("actions/run", _wrap(run_action))
972942
session.on_request("actions/runHandlers", _wrap(run_handlers))
973943
session.on_request("actions/resolveSource", _wrap(resolve_source))
974-
session.on_request("actions/getActionMetadata", _wrap(get_action_metadata_cmd))
975944
session.on_request("actions/reload", _wrap(reload_action))
976945
session.on_request("packages/resolvePath", _wrap(resolve_package_path))
977946
session.on_request("actions/mergeResults", _wrap(merge_results_cmd))

finecode_extension_runner/src/finecode_extension_runner/er_telemetry.py

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,19 @@ def get_current_traceparent() -> str | None:
179179

180180

181181
@contextlib.contextmanager
182-
def handler_span(handler_name: str, action_name: str, traceparent: str | None):
182+
def handler_span(
183+
handler_name: str,
184+
action_name: str,
185+
traceparent: str | None,
186+
trigger: str = "unknown",
187+
dev_env: str = "unknown",
188+
):
189+
# traceparent is passed explicitly from the action payload (options.traceparent),
190+
# not taken from the ambient OTel context. This is intentional: JsonRpcServerSession
191+
# is a long-running, concurrent session whose event loop has no per-request span
192+
# context, so the ambient context at call time is not the correct parent.
193+
# options.traceparent carries the action_run_span identity explicitly across
194+
# WM → ER → WM → ER hops, which ambient context propagation cannot do.
183195
if traceparent is None:
184196
yield None
185197
return
@@ -194,13 +206,111 @@ def handler_span(handler_name: str, action_name: str, traceparent: str | None):
194206
attributes={
195207
"handler.name": handler_name,
196208
"action.name": action_name,
209+
"run.trigger": trigger,
210+
"run.dev_env": dev_env,
197211
},
198212
record_exception=True,
199213
set_status_on_exception=True,
200214
) as span:
201215
yield span
202216

203217

218+
@contextlib.contextmanager
219+
def handler_initialize_span(handler_name: str, action_name: str):
220+
"""Span for handler cold-start initialization — must be entered within a handler_span context.
221+
222+
Emits no span when telemetry is off or when there is no active parent span
223+
(i.e. handler_span was a no-op because traceparent was absent).
224+
"""
225+
if not _telemetry_initialized:
226+
yield None
227+
return
228+
from opentelemetry import trace
229+
if not trace.get_current_span().get_span_context().is_valid:
230+
yield None
231+
return
232+
tracer = trace.get_tracer("finecode.er")
233+
with tracer.start_as_current_span(
234+
f"handler.initialize/{handler_name}",
235+
attributes={
236+
"handler.name": handler_name,
237+
"action.name": action_name,
238+
},
239+
record_exception=True,
240+
set_status_on_exception=True,
241+
) as span:
242+
yield span
243+
244+
245+
@contextlib.contextmanager
246+
def _jsonrpc_client_span(method: str, peer_id: str):
247+
from opentelemetry import trace
248+
249+
tracer = trace.get_tracer("finecode.jsonrpc")
250+
with tracer.start_as_current_span(
251+
f"jsonrpc.client/{method}",
252+
attributes={"rpc.system": "jsonrpc", "rpc.method": method, "peer.id": peer_id},
253+
record_exception=True,
254+
set_status_on_exception=True,
255+
) as span:
256+
yield span
257+
258+
259+
@contextlib.contextmanager
260+
def _jsonrpc_server_span(method: str, traceparent: str | None):
261+
from opentelemetry import propagate, trace
262+
263+
tracer = trace.get_tracer("finecode.jsonrpc")
264+
parent_ctx = propagate.extract({"traceparent": traceparent}) if traceparent else None
265+
with tracer.start_as_current_span(
266+
f"jsonrpc.server/{method}",
267+
context=parent_ctx,
268+
attributes={"rpc.system": "jsonrpc", "rpc.method": method},
269+
record_exception=True,
270+
set_status_on_exception=True,
271+
) as span:
272+
yield span
273+
274+
275+
class JsonRpcTracingHooks:
276+
"""OTel implementation of ITracingHooks for ER processes.
277+
278+
Provides single-hop envelope tracing for JSON-RPC messages. Does not
279+
replace options.traceparent in action payloads — see ITracingHooks docstring
280+
and the comment on handler_span for the rationale.
281+
"""
282+
283+
def get_traceparent(self) -> str | None:
284+
return get_current_traceparent()
285+
286+
def client_span(self, method: str, peer_id: str):
287+
return _jsonrpc_client_span(method, peer_id)
288+
289+
def server_span(self, method: str, traceparent: str | None):
290+
return _jsonrpc_server_span(method, traceparent)
291+
292+
def notification_sent(self, method: str) -> None:
293+
from opentelemetry import trace
294+
span = trace.get_current_span()
295+
if span.is_recording():
296+
span.add_event("jsonrpc.notification.sent", {"rpc.method": method})
297+
298+
def notification_received(self, method: str, traceparent: str | None) -> None:
299+
from opentelemetry import trace
300+
span = trace.get_current_span()
301+
if span.is_recording():
302+
span.add_event("jsonrpc.notification.received", {"rpc.method": method})
303+
304+
305+
def add_span_event(name: str, attributes: dict | None = None) -> None:
306+
if not _telemetry_initialized:
307+
return
308+
from opentelemetry import trace
309+
span = trace.get_current_span()
310+
if span.is_recording():
311+
span.add_event(name, attributes or {})
312+
313+
204314
@contextlib.contextmanager
205315
def handler_metrics(handler_name: str, action_name: str):
206316
start = time.perf_counter()

finecode_extension_runner/src/finecode_extension_runner/services.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ async def resolve_action_meta(runner_context: context.RunnerContext) -> dict[str
152152
- ``runs_concurrently``: True when the action declares CONCURRENT handler
153153
execution (``HANDLER_EXECUTION = HandlerExecution.CONCURRENT``).
154154
- ``scope``: ``"project"`` or ``"workspace"`` (from ``Action.SCOPE``).
155+
- ``parentActionSource``: canonical source of the parent action class, or
156+
``None`` for top-level actions (from ``Action.PARENT_ACTION``).
157+
- ``language``: language tag this action is specific to, or ``None`` for
158+
language-agnostic actions (from ``Action.LANGUAGE``).
155159
156160
Actions that fail to import are omitted.
157161
"""
@@ -166,13 +170,20 @@ async def resolve_action_meta(runner_context: context.RunnerContext) -> dict[str
166170
cls = run_utils.import_module_member_by_source_str(action.source)
167171
if not (isinstance(cls, type) and issubclass(cls, Action)):
168172
raise TypeError(f"{action.source} is not a subclass of Action")
173+
parent = getattr(cls, "PARENT_ACTION", None)
169174
resolved[action.source] = {
170175
"canonical_source": f"{cls.__module__}.{cls.__qualname__}",
171176
"runs_concurrently": cls.HANDLER_EXECUTION == HandlerExecution.CONCURRENT,
172177
"scope": cls.SCOPE.value,
178+
"parentActionSource": (
179+
f"{parent.__module__}.{parent.__qualname__}"
180+
if parent is not None
181+
else None
182+
),
183+
"language": getattr(cls, "LANGUAGE", None),
173184
}
174185
except Exception as exception:
175-
logger.trace(f'Failed to import action {action.source}: {exception}')
186+
logger.warning(f'Failed to import action {action.source}: {exception}')
176187
return resolved
177188

178189

finecode_httpclient/src/finecode_httpclient/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def __init__(self, logger: ilogger.ILogger):
4545
async def __aenter__(self) -> Self:
4646
"""Async context manager entry. Creates and initializes the httpx client."""
4747
self.logger.debug("HTTP session opened")
48-
self._client = httpx.AsyncClient()
48+
self._client = httpx.AsyncClient(follow_redirects=True)
4949
return self
5050

5151
async def __aexit__(

finecode_jsonrpc/src/finecode_jsonrpc/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,27 @@
66
ResponseTimeout,
77
ServerFailedToStart,
88
RequestCancelledError,
9+
ServerStoppedError,
910
)
1011
from .jsonrpc_client import JsonRpcError
1112
from .transports import StdioTransport
1213
from .server_transport import ServerStdioTransport, TcpServerTransport
1314
from .jsonrpc_server import JsonRpcHandlerError, JsonRpcServerSession
15+
from .tracing import ITracingHooks
1416

1517

1618
__all__ = [
1719
"JsonRpcClient",
1820
"JsonRpcError",
1921
"JsonRpcHandlerError",
22+
"ITracingHooks",
2023
"BaseRunnerRequestException",
2124
"ErrorOnRequest",
2225
"NoResponse",
2326
"ResponseTimeout",
2427
"ServerFailedToStart",
2528
"RequestCancelledError",
29+
"ServerStoppedError",
2630
"StdioTransport",
2731
"ServerStdioTransport",
2832
"TcpServerTransport",

0 commit comments

Comments
 (0)