codecortex 0.23.3__tar.gz → 0.23.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. {codecortex-0.23.3/src/codecortex.egg-info → codecortex-0.23.4}/PKG-INFO +1 -1
  2. {codecortex-0.23.3 → codecortex-0.23.4/src/codecortex.egg-info}/PKG-INFO +1 -1
  3. codecortex-0.23.4/src/codeintel/__init__.py +1 -0
  4. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/gateway.py +56 -13
  5. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/provider.py +1 -0
  6. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/providers/lsp.py +176 -19
  7. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_gateway.py +29 -0
  8. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_lsp_provider.py +342 -2
  9. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_null_reason_and_ripple.py +175 -0
  10. codecortex-0.23.3/src/codeintel/__init__.py +0 -1
  11. {codecortex-0.23.3 → codecortex-0.23.4}/LICENSE +0 -0
  12. {codecortex-0.23.3 → codecortex-0.23.4}/README.md +0 -0
  13. {codecortex-0.23.3 → codecortex-0.23.4}/pyproject.toml +0 -0
  14. {codecortex-0.23.3 → codecortex-0.23.4}/setup.cfg +0 -0
  15. {codecortex-0.23.3 → codecortex-0.23.4}/src/codecortex.egg-info/SOURCES.txt +0 -0
  16. {codecortex-0.23.3 → codecortex-0.23.4}/src/codecortex.egg-info/dependency_links.txt +0 -0
  17. {codecortex-0.23.3 → codecortex-0.23.4}/src/codecortex.egg-info/entry_points.txt +0 -0
  18. {codecortex-0.23.3 → codecortex-0.23.4}/src/codecortex.egg-info/requires.txt +0 -0
  19. {codecortex-0.23.3 → codecortex-0.23.4}/src/codecortex.egg-info/top_level.txt +0 -0
  20. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/__main__.py +0 -0
  21. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/agent_prompt.py +0 -0
  22. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/auth.py +0 -0
  23. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/c4.py +0 -0
  24. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/c4_check.py +0 -0
  25. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/c4_layers.py +0 -0
  26. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/cache.py +0 -0
  27. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/__init__.py +0 -0
  28. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/_common.py +0 -0
  29. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/c4.py +0 -0
  30. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/doctor.py +0 -0
  31. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/gen_token.py +0 -0
  32. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/graph.py +0 -0
  33. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/index.py +0 -0
  34. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/install.py +0 -0
  35. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/map.py +0 -0
  36. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/prompt.py +0 -0
  37. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/query.py +0 -0
  38. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/reset.py +0 -0
  39. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/serve.py +0 -0
  40. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/serve_http.py +0 -0
  41. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/setup.py +0 -0
  42. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/commands/status.py +0 -0
  43. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/config.py +0 -0
  44. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/containment.py +0 -0
  45. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/doctor.py +0 -0
  46. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/graph_backend.py +0 -0
  47. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/graph_render.py +0 -0
  48. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/graph_resolution.py +0 -0
  49. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/grapher.py +0 -0
  50. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/http_server.py +0 -0
  51. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/indexer.py +0 -0
  52. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/injector.py +0 -0
  53. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/installer.py +0 -0
  54. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/lang_config.py +0 -0
  55. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/loc.py +0 -0
  56. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/logconfig.py +0 -0
  57. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/mapper.py +0 -0
  58. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/metrics.py +0 -0
  59. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/onboarding.py +0 -0
  60. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/outcome.py +0 -0
  61. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/paths.py +0 -0
  62. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/policy.py +0 -0
  63. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/progress.py +0 -0
  64. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/providers/__init__.py +0 -0
  65. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/providers/graph.py +0 -0
  66. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/providers/none.py +0 -0
  67. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/providers/semantic.py +0 -0
  68. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/py.typed +0 -0
  69. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/query_ops.py +0 -0
  70. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/redact.py +0 -0
  71. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/reindexer.py +0 -0
  72. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/reset.py +0 -0
  73. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/searcher.py +0 -0
  74. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/semantic_db.py +0 -0
  75. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/server.py +0 -0
  76. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/source_kind.py +0 -0
  77. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/term.py +0 -0
  78. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/verify.py +0 -0
  79. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/viewer/__init__.py +0 -0
  80. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/viewer/graph_template.html +0 -0
  81. {codecortex-0.23.3 → codecortex-0.23.4}/src/codeintel/wire_text.py +0 -0
  82. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_agent_prompt.py +0 -0
  83. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_backend_reaper.py +0 -0
  84. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_background_index_failure.py +0 -0
  85. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_bench_oracle.py +0 -0
  86. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_bench_oracle_ts.py +0 -0
  87. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_c4.py +0 -0
  88. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_c4_check.py +0 -0
  89. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_c4_layers.py +0 -0
  90. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_c4_model.py +0 -0
  91. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_c4_output.py +0 -0
  92. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_cache.py +0 -0
  93. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_chunking.py +0 -0
  94. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_cli_commands.py +0 -0
  95. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_cli_help.py +0 -0
  96. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_cli_install_dry_run.py +0 -0
  97. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_cli_install_offer_injection.py +0 -0
  98. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_cold_process.py +0 -0
  99. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_config.py +0 -0
  100. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_corpus.py +0 -0
  101. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_could_not_ask_semantic.py +0 -0
  102. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_docs_ci_claims.py +0 -0
  103. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_docs_deadcode_withdrawal.py +0 -0
  104. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_doctor.py +0 -0
  105. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_e2e.py +0 -0
  106. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_edge_confidence.py +0 -0
  107. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_engine_adoption.py +0 -0
  108. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_enterprise.py +0 -0
  109. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_graph_failure_population.py +0 -0
  110. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_graph_provider.py +0 -0
  111. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_graph_real.py +0 -0
  112. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_graph_stdin.py +0 -0
  113. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_grapher.py +0 -0
  114. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_hard_exit.py +0 -0
  115. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_hardening.py +0 -0
  116. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_http_auth.py +0 -0
  117. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_http_server.py +0 -0
  118. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_incompleteness.py +0 -0
  119. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_index_failure_reason.py +0 -0
  120. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_index_progress.py +0 -0
  121. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_indexer_binary.py +0 -0
  122. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_installer.py +0 -0
  123. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_integration.py +0 -0
  124. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_lang_config.py +0 -0
  125. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_loc_census.py +0 -0
  126. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_lsp_language_coverage.py +0 -0
  127. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_lsp_real.py +0 -0
  128. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_mapper.py +0 -0
  129. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_mcp_handshake.py +0 -0
  130. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_mcp_server.py +0 -0
  131. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_model_dimension.py +0 -0
  132. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_never_raise.py +0 -0
  133. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_onboarding.py +0 -0
  134. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_rbac.py +0 -0
  135. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_redaction_boundary.py +0 -0
  136. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_reindexer.py +0 -0
  137. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_rerank.py +0 -0
  138. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_reset.py +0 -0
  139. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_semantic_provider.py +0 -0
  140. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_source_kind.py +0 -0
  141. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_staleness.py +0 -0
  142. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_symbol_context.py +0 -0
  143. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_term.py +0 -0
  144. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_treesitter.py +0 -0
  145. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_verify.py +0 -0
  146. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_verify_call.py +0 -0
  147. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_version_skew.py +0 -0
  148. {codecortex-0.23.3 → codecortex-0.23.4}/tests/test_wire_text.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.23.3
3
+ Version: 0.23.4
4
4
  Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
5
5
  Author: Shammai Hamilton
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codecortex
3
- Version: 0.23.3
3
+ Version: 0.23.4
4
4
  Summary: Local-first, MCP-native code-intelligence server — graph, LSP, and semantic search behind one safe code.query tool for coding agents.
5
5
  Author: Shammai Hamilton
6
6
  License-Expression: MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.23.4"
@@ -50,6 +50,13 @@ def _mark_reindexing(result: Result, reindexing: bool) -> Result:
50
50
  "completed pass; re-ask shortly if you have just changed this code"}
51
51
 
52
52
 
53
+ def _is_retryable(result: Result) -> bool:
54
+ return bool(result.get("retry_after_s")) or any(
55
+ isinstance(gap, dict) and bool(gap.get("retry_after_s"))
56
+ for gap in (result.get("gaps") or [])
57
+ )
58
+
59
+
53
60
  class Gateway:
54
61
  def __init__(self, graph=None, lsp=None, semantic=None, policy: TieringPolicy | None = None,
55
62
  reindexer: Reindexer | None = None, oneshot: bool = False):
@@ -198,17 +205,44 @@ class Gateway:
198
205
  if result.get("result") is None or self.lsp is None:
199
206
  return result
200
207
  gaps = result.get("gaps") or []
201
- kinds = {
202
- str(g.get("kind") or "") for g in gaps if isinstance(g, dict)
203
- }
204
- all_name_resolved = "all-rows-name-resolved" in kinds
205
- exact_target_unbound = "target-hint-unmatched" in kinds
206
- if not (all_name_resolved or exact_target_unbound):
208
+ all_name_resolved = any(
209
+ isinstance(gap, dict)
210
+ and gap.get("kind") == "all-rows-name-resolved"
211
+ and gap.get("section") == "callers"
212
+ for gap in gaps
213
+ )
214
+ exact_target_unbound = any(
215
+ isinstance(gap, dict)
216
+ and gap.get("kind") == "target-hint-unmatched"
217
+ and gap.get("section") == "callers"
218
+ for gap in gaps
219
+ )
220
+ # A file-qualified target is supposed to identify one definition. If its
221
+ # answer still contains low-confidence name matches, the backend has selected the
222
+ # destination node but has not proven the incoming property-call bindings. This is the
223
+ # mixed variant of ``all-rows-name-resolved``: a handful of structural rows can mask a
224
+ # much larger suffix-match population, so the old all-or-nothing signature missed it.
225
+ # Only auto mode reaches this method, and only an already-partial answer carrying an
226
+ # exact file hint pays for the LSP check; healthy graph answers still make no extra
227
+ # engine call. A dotted target without a file is intentionally not escalated because
228
+ # module identity is represented differently across Serena language servers.
229
+ caller_edges_unverified = any(
230
+ isinstance(gap, dict)
231
+ and gap.get("kind") == "low-confidence-edges"
232
+ and gap.get("section") == "callers"
233
+ for gap in gaps
234
+ )
235
+ _, has_at, hinted_path = target.rpartition("@")
236
+ exact_file_hint = bool(has_at and "/" in hinted_path.replace("\\", "/"))
237
+ narrowed_target_unverified = caller_edges_unverified and exact_file_hint
238
+ if not (all_name_resolved or exact_target_unbound or narrowed_target_unverified):
207
239
  return result
208
240
  graph_problem = (
209
241
  "every graph row was resolved by name"
210
242
  if all_name_resolved
211
243
  else "the graph found no caller edge for the file-qualified symbol"
244
+ if exact_target_unbound
245
+ else "the qualified graph answer contains unverified name-resolved caller edges"
212
246
  )
213
247
  probe = self._dispatch_single(
214
248
  self.lsp, "symbol", target, budget, project_root, "lsp")
@@ -229,7 +263,8 @@ class Gateway:
229
263
  str(reference_gap.get("kind") or "not-asked")
230
264
  if reference_gap is not None else str(probe.get("reason") or "no-result")
231
265
  )
232
- warming = unavailable_reason in {"warming", "timeout"}
266
+ retry_after_s = probe.get("retry_after_s")
267
+ retryable = unavailable_reason in {"warming", "timeout"} or bool(retry_after_s)
233
268
  why = (
234
269
  "the language server had not finished booting"
235
270
  if unavailable_reason == "warming"
@@ -238,7 +273,7 @@ class Gateway:
238
273
  else "the LSP engine reported nothing for this symbol"
239
274
  )
240
275
  nxt = (" Ask again once it is warm and this section will be filled in."
241
- if warming else
276
+ if retryable else
242
277
  " Check it yourself with `--engine lsp --op symbol`.")
243
278
  return {**result, "gaps": [*gaps, {
244
279
  "section": op, "kind": "cross-check-unavailable",
@@ -246,8 +281,8 @@ class Gateway:
246
281
  "reason": unavailable_reason,
247
282
  "detail": f"{graph_problem} and the LSP could not provide an independent "
248
283
  f"reference check ({why}), so the graph answer remains unverified"
249
- + (" — retry" if warming else ""),
250
- **({"retry_after_s": 2} if warming else {}),
284
+ + (" — retry" if retryable else ""),
285
+ **({"retry_after_s": retry_after_s or 2} if retryable else {}),
251
286
  }], "result": str(result["result"]) + (
252
287
  f"\n\n> Cross-check unavailable: {graph_problem}, and "
253
288
  f"{why}, so nothing here has been confirmed against a second engine.{nxt}"
@@ -333,12 +368,18 @@ class Gateway:
333
368
  )
334
369
  summary = "engines-unavailable" if all_unreachable else "no-result"
335
370
  detail = ", ".join(f"{eng}: {why}" for eng, why in sorted(reasons.items()))
336
- return safe_null_result(
371
+ merged_null = safe_null_result(
337
372
  op_str, target_str, engine=engine_str, reason=summary,
338
373
  hint=(f"no engine produced an answer — {detail}"
339
374
  + ("; this is NOT evidence the target does not exist"
340
375
  if all_unreachable else "")),
341
376
  )
377
+ retry_after = max(
378
+ (float(r.get("retry_after_s") or 0) for r in results.values()), default=0
379
+ )
380
+ if retry_after:
381
+ merged_null["retry_after_s"] = retry_after
382
+ return merged_null
342
383
 
343
384
  # A fan-out answer is only as whole as its parts. This used to hand-build a six-key envelope
344
385
  # and drop both `confidence` and `gaps` on the floor — so a `context` request (the DEFAULT
@@ -357,6 +398,8 @@ class Gateway:
357
398
  "detail": f"the {eng} engine contributed nothing to this answer "
358
399
  f"({r.get('reason') or 'no-result'})",
359
400
  "engine": eng,
401
+ **({"retry_after_s": r["retry_after_s"]}
402
+ if r.get("retry_after_s") else {}),
360
403
  })
361
404
  return attach_confidence({
362
405
  "ok": True,
@@ -538,7 +581,7 @@ class Gateway:
538
581
  engines = ["graph", "lsp"] if engine_str == "both" else ["graph", "lsp", "semantic"]
539
582
  fan_results = self._fan_out(engines, op_str, target_str, budget, project_root)
540
583
  result = self._merge(fan_results, op_str, target_str, engine_str)
541
- if not uncacheable:
584
+ if not uncacheable and not _is_retryable(result):
542
585
  self._cache.put(op_str, target_str, cache_engine, root_str, result, freshness)
543
586
  return _mark_reindexing(result, reindexing)
544
587
 
@@ -577,7 +620,7 @@ class Gateway:
577
620
  result = self._cross_check_name_resolved(
578
621
  result, op_str, target_str, budget, project_root, was_auto)
579
622
 
580
- if not uncacheable:
623
+ if not uncacheable and not _is_retryable(result):
581
624
  self._cache.put(op_str, target_str, cache_engine, root_str, result, freshness)
582
625
  return _mark_reindexing(result, reindexing)
583
626
 
@@ -36,6 +36,7 @@ class Result(TypedDict):
36
36
  outcome: NotRequired[str]
37
37
  reason: NotRequired[str]
38
38
  hint: NotRequired[str]
39
+ retry_after_s: NotRequired[float]
39
40
  # Set when the answer was served while a reindex for its project was still running, i.e. it
40
41
  # reflects the last COMPLETED index rather than the current source. Optional, and the MCP
41
42
  # tools deliberately return plain `dict` so this never becomes a required schema field.
@@ -19,6 +19,7 @@ from mcp.client.stdio import stdio_client
19
19
  from codeintel.loc import loc, span
20
20
  from codeintel.outcome import Missing, Ok, Outcome
21
21
  from codeintel.provider import Result, attach_confidence, log_swallowed, safe_null_result
22
+ from codeintel.providers.graph import _FILE_EXTENSIONS
22
23
 
23
24
  logger = logging.getLogger(__name__)
24
25
 
@@ -67,7 +68,7 @@ _BACKEND_ERROR_MARKERS = (
67
68
  )
68
69
 
69
70
 
70
- def _split_target_file_hint(target: str) -> tuple[str, str]:
71
+ def _split_target_file_hint(target: str) -> tuple[str, str, str]:
71
72
  """Return Serena's symbol pattern and an optional repo-relative file hint.
72
73
 
73
74
  Graph operations accept ``name@path`` to disambiguate duplicate symbols. The gateway may pass
@@ -77,11 +78,45 @@ def _split_target_file_hint(target: str) -> tuple[str, str]:
77
78
  """
78
79
  raw = str(target or "").strip()
79
80
  if "@" not in raw:
80
- return raw, ""
81
+ return raw, "", ""
81
82
  head, _, tail = raw.rpartition("@")
82
83
  if not head.strip() or not tail.strip():
83
- return raw, ""
84
- return head.strip(), tail.strip()
84
+ return raw, "", ""
85
+ symbol, file_hint = head.strip(), tail.strip()
86
+ # Query broadly by leaf and filter client-side. Serena splits module identity differently by
87
+ # language, while the file hint and complete symbol-path suffix together are stable.
88
+ suffix = symbol.rpartition(".")[2].lower()
89
+ qualified_hint = (
90
+ symbol.replace(".", "/")
91
+ if "." in symbol and suffix not in _FILE_EXTENSIONS
92
+ else ""
93
+ )
94
+ leaf = symbol.rpartition(".")[2] if qualified_hint else symbol
95
+ return leaf, file_hint, qualified_hint
96
+
97
+
98
+ def _name_path_suffix_score(name_path: Any, qualified_hint: str) -> int:
99
+ """Number of trailing graph/Serena symbol segments that agree."""
100
+ have = str(name_path or "").strip("/").split("/")
101
+ want = str(qualified_hint or "").strip("/").split("/")
102
+ score = 0
103
+ for left, right in zip(reversed(have), reversed(want), strict=False):
104
+ if left != right:
105
+ break
106
+ score += 1
107
+ return score
108
+
109
+
110
+ def _qualified_definition_score(match: dict, qualified_hint: str) -> int:
111
+ """Score a Serena symbol path within an already exact-file-filtered candidate set."""
112
+ symbol_score = _name_path_suffix_score(match.get("name_path"), qualified_hint)
113
+ name_parts = str(match.get("name_path") or "").strip("/").split("/")
114
+ # The whole Serena name path must be a suffix of the graph qualifier. An extra enclosing symbol
115
+ # is never a module and would make selection ambiguous. Require container + leaf: accepting a
116
+ # top-level ``resolve`` would leave ``MissingClass`` entirely unverified.
117
+ wanted_parts = str(qualified_hint or "").strip("/").split("/")
118
+ return (symbol_score if symbol_score == len(name_parts) == len(wanted_parts)
119
+ and symbol_score >= 2 else 0)
85
120
 
86
121
 
87
122
  def _file_hint_matches(file_path: Any, hint: str) -> bool:
@@ -287,26 +322,54 @@ class LspProvider:
287
322
 
288
323
  # Class-level default so a provider built via `__new__` (the test stubs do this) still has it
289
324
  # rather than raising AttributeError inside the never-raise handler.
290
- _last_backend_error: str | None = None
325
+ _backend_error_state = threading.local()
326
+ _backend_missing_state = threading.local()
291
327
  # Sections of the current answer that are known to be short of an answer. An op appends here
292
328
  # instead of quietly rendering an empty section, and `build_result` turns them into the
293
329
  # envelope's `gaps` / `confidence`. Class-level default for the same __new__ reason as above.
294
- _pending_gaps: tuple[dict[str, Any], ...] = ()
330
+ _gap_state = threading.local()
295
331
  # Same __new__ reason: `_boot_failed_hint` reads this, and a session injected straight into
296
332
  # `_sessions` reaches it without `_detect_backend` having run.
297
333
  _cmd: str | None = None
298
334
 
335
+ @property
336
+ def _last_backend_error(self) -> str | None:
337
+ value = getattr(self._backend_error_state, "value", None)
338
+ return value if isinstance(value, str) else None
339
+
340
+ @_last_backend_error.setter
341
+ def _last_backend_error(self, value: str | None) -> None:
342
+ self._backend_error_state.value = value
343
+
344
+ @property
345
+ def _pending_gaps(self) -> tuple[dict[str, Any], ...]:
346
+ value = getattr(self._gap_state, "value", ())
347
+ return value if isinstance(value, tuple) else ()
348
+
349
+ @_pending_gaps.setter
350
+ def _pending_gaps(self, value: tuple[dict[str, Any], ...]) -> None:
351
+ self._gap_state.value = value
352
+
299
353
  def __init__(self) -> None:
300
354
  self._sessions: dict[str, _LspSession] = {}
301
355
  self._sessions_lock = threading.Lock()
302
356
  self._last_backend_error = None
357
+ self._set_backend_missing(None)
303
358
  self._pending_gaps = ()
304
359
  self._detect_backend()
305
360
 
306
361
  def _clear_backend_error(self) -> None:
307
362
  self._last_backend_error = None
363
+ self._set_backend_missing(None)
308
364
  self._pending_gaps = ()
309
365
 
366
+ def _set_backend_missing(self, missing: Missing | None) -> None:
367
+ self._backend_missing_state.value = missing
368
+
369
+ def _get_backend_missing(self) -> Missing | None:
370
+ value = getattr(self._backend_missing_state, "value", None)
371
+ return value if isinstance(value, Missing) else None
372
+
310
373
  def _add_gap(self, section: str, missing: Missing) -> None:
311
374
  """Record that a named part of the answer could not be retrieved. The body text says so
312
375
  too — this is the machine-readable half of the same statement."""
@@ -605,8 +668,13 @@ class LspProvider:
605
668
  op_str, target_str, engine="lsp", reason="warming")
606
669
 
607
670
  if state == _State.FAILED:
608
- return safe_null_result(op_str, target_str, engine="lsp", reason="boot-failed",
609
- hint=self._boot_failed_hint(session))
671
+ failed = safe_null_result(
672
+ op_str, target_str, engine="lsp", reason="boot-failed",
673
+ hint=self._boot_failed_hint(session),
674
+ )
675
+ if session.attempt <= 1 and self._cmd == "uvx":
676
+ failed["retry_after_s"] = 60
677
+ return failed
610
678
 
611
679
  # READY
612
680
  # Cleared through a method rather than a direct assignment: `_dispatch` sets this as a
@@ -618,12 +686,18 @@ class LspProvider:
618
686
  # A backend failure is not an unsupported op. Reporting it as one sends the agent
619
687
  # looking for a different tool when the language server simply did not start —
620
688
  # the same misleading string the graph provider already had to stop emitting.
621
- if self._last_backend_error:
622
- return safe_null_result(
623
- op_str, target_str, engine="lsp", reason="backend-error",
624
- hint=f"{self._last_backend_error} — run `codeintel doctor --deep` to boot-"
689
+ missing = self._get_backend_missing()
690
+ if self._last_backend_error or missing:
691
+ failed = safe_null_result(
692
+ op_str, target_str, engine="lsp",
693
+ reason=missing.kind if missing is not None else "backend-error",
694
+ hint=f"{missing.describe() if missing else self._last_backend_error} — "
695
+ f"run `codeintel doctor --deep` to boot-"
625
696
  f"check serena; the full backend message is in the server log",
626
697
  )
698
+ if missing is not None and missing.retry_after_s is not None:
699
+ failed["retry_after_s"] = missing.retry_after_s
700
+ return failed
627
701
  return safe_null_result(op_str, target_str, engine="lsp", reason="unsupported-op")
628
702
 
629
703
  envelope: Result = {
@@ -797,31 +871,46 @@ class LspProvider:
797
871
  self, session: _LspSession, target: str, root: str, timeout_s: float
798
872
  ) -> str | None:
799
873
  try:
800
- symbol_target, file_hint = _split_target_file_hint(target)
874
+ deadline = time.monotonic() + timeout_s
875
+
876
+ def remaining() -> float:
877
+ return max(0.001, deadline - time.monotonic())
878
+
879
+ symbol_target, file_hint, qualified_hint = _split_target_file_hint(target)
801
880
  def_out = self._call_tool(
802
881
  session,
803
882
  "find_symbol",
804
883
  {
805
884
  "name_path_pattern": symbol_target,
806
- "include_body": True,
885
+ "include_body": not (file_hint or qualified_hint),
807
886
  # A file hint exists specifically because the name is ambiguous. Retrieve
808
887
  # enough definitions to find the requested file instead of silently selecting
809
888
  # whichever five Serena happens to return first.
810
- "max_matches": 50 if file_hint else 5,
889
+ "max_matches": 500 if (file_hint or qualified_hint) else 5,
811
890
  },
812
- timeout_s,
891
+ remaining(),
813
892
  )
814
893
  if isinstance(def_out, Missing):
815
894
  # The tool call itself failed or timed out. Rendering "(not found)" here — which is
816
895
  # what this did — states that the symbol does not exist, on no evidence whatsoever.
817
896
  # For an agent deciding whether to create something, "I could not ask" and "it is
818
897
  # not there" are opposite answers.
898
+ self._set_backend_missing(def_out)
819
899
  self._last_backend_error = def_out.describe()
820
900
  return None
821
901
  def_raw = def_out.value
822
902
  def_text = self._extract_text(def_raw)
823
903
  matches = self._loads(def_text)
824
- if isinstance(matches, list) and file_hint:
904
+ candidate_page_capped = bool(
905
+ isinstance(matches, list) and (file_hint or qualified_hint) and len(matches) >= 500
906
+ )
907
+ if candidate_page_capped:
908
+ matches = []
909
+ def_text = (
910
+ f"> The language server returned the maximum 500 definitions for `{target}`; "
911
+ "the exact definition cannot be proven from a truncated candidate page."
912
+ )
913
+ if isinstance(matches, list) and file_hint and not candidate_page_capped:
825
914
  matches = [
826
915
  match for match in matches
827
916
  if isinstance(match, dict)
@@ -836,7 +925,75 @@ class LspProvider:
836
925
  f"> The language server found no definition for `{symbol_target}` in "
837
926
  f"`{file_hint}`."
838
927
  )
839
-
928
+ if isinstance(matches, list) and qualified_hint and not candidate_page_capped:
929
+ scored = [
930
+ (match, _qualified_definition_score(match, qualified_hint))
931
+ for match in matches if isinstance(match, dict)
932
+ ]
933
+ best = max((score for _, score in scored), default=0)
934
+ # `_qualified_definition_score` admits a leaf-only symbol match only when every
935
+ # remaining qualifier segment was proven against the definition's relative path.
936
+ matches = [match for match, score in scored if score == best and score > 0]
937
+ if not matches:
938
+ def_text = (
939
+ f"> The language server found no definition matching the qualified symbol "
940
+ f"`{target}`."
941
+ )
942
+ if isinstance(matches, list) and matches and (file_hint or qualified_hint):
943
+ identities = {
944
+ (str(match.get("relative_path") or ""),
945
+ str(match.get("name_path") or ""),
946
+ json.dumps(match.get("body_location"), sort_keys=True))
947
+ for match in matches
948
+ }
949
+ if len(identities) != 1:
950
+ matches = []
951
+ def_text = (
952
+ f"> The language server found multiple definitions matching `{target}`; "
953
+ "the file hint is not specific enough to request exact references."
954
+ )
955
+ if isinstance(matches, list) and matches and (file_hint or qualified_hint):
956
+ selected = matches[0]
957
+ body_out = self._call_tool(
958
+ session,
959
+ "find_symbol",
960
+ {
961
+ "name_path_pattern": selected.get("name_path") or symbol_target,
962
+ "relative_path": selected.get("relative_path"),
963
+ "include_body": True,
964
+ "max_matches": 500,
965
+ },
966
+ remaining(),
967
+ )
968
+ if isinstance(body_out, Missing):
969
+ self._set_backend_missing(body_out)
970
+ self._last_backend_error = body_out.describe()
971
+ return None
972
+ body_text = self._extract_text(body_out.value)
973
+ body_matches = self._loads(body_text)
974
+ if isinstance(body_matches, list) and body_matches:
975
+ exact_body_matches = [
976
+ match for match in body_matches
977
+ if isinstance(match, dict)
978
+ and match.get("name_path") == selected.get("name_path")
979
+ and match.get("relative_path") == selected.get("relative_path")
980
+ and (
981
+ selected.get("body_location") is None
982
+ or match.get("body_location") == selected.get("body_location")
983
+ )
984
+ ]
985
+ if exact_body_matches:
986
+ matches = exact_body_matches
987
+ else:
988
+ self._last_backend_error = (
989
+ "the exact definition body lookup returned no matching definition"
990
+ )
991
+ return None
992
+ else:
993
+ self._last_backend_error = (
994
+ "the exact definition body lookup returned no usable definition list"
995
+ )
996
+ return None
840
997
  first: dict | None = None
841
998
  if isinstance(matches, list) and matches:
842
999
  def_section, first = self._format_matches(target, matches)
@@ -874,7 +1031,7 @@ class LspProvider:
874
1031
  "name_path": first.get("name_path") or target,
875
1032
  "relative_path": first.get("relative_path"),
876
1033
  },
877
- timeout_s,
1034
+ remaining(),
878
1035
  )
879
1036
  miss: Missing | None = None
880
1037
  parsed: object = None
@@ -112,6 +112,35 @@ def test_engine_both_graph_unavailable_returns_lsp_alone():
112
112
  assert "graph-data" not in r["result"]
113
113
 
114
114
 
115
+ def test_retryable_fanout_failure_is_not_cached():
116
+ graph = _StubProvider("graph", "graph-data")
117
+
118
+ class _EventuallyReady(_StubProvider):
119
+ def build_result(self, op, target, files, budget, project_root) -> Result:
120
+ self.call_count += 1
121
+ if self.call_count == 1:
122
+ return {
123
+ "ok": True, "op": op, "target": target, "result": None,
124
+ "engine": "lsp", "cached": False, "outcome": "failed",
125
+ "reason": "boot-failed", "retry_after_s": 60,
126
+ }
127
+ return {
128
+ "ok": True, "op": op, "target": target, "result": "lsp-data",
129
+ "engine": "lsp", "cached": False,
130
+ }
131
+
132
+ lsp = _EventuallyReady("lsp", "lsp-data")
133
+ gw = Gateway(graph=graph, lsp=lsp)
134
+
135
+ first = gw.query(op="context", target="x", engine="both")
136
+ second = gw.query(op="context", target="x", engine="both")
137
+
138
+ assert any(gap.get("retry_after_s") == 60 for gap in first["gaps"])
139
+ assert "lsp-data" in second["result"]
140
+ assert graph.call_count == 2
141
+ assert lsp.call_count == 2
142
+
143
+
115
144
  # ---------------------------------------------------------------------------
116
145
  # Test 6: engine=all merges all three
117
146
  # ---------------------------------------------------------------------------