python-delphi-lsp 2.3.3__py3-none-any.whl → 3.0.0__py3-none-any.whl

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.
delphi_lsp/_version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "2.3.3"
1
+ __version__ = "3.0.0"
delphi_lsp/agent_cache.py CHANGED
@@ -40,9 +40,13 @@ _STARTUP_DIAGNOSTIC_BYTES = 16 * 1024
40
40
  _STARTUP_TOKEN_RE = re.compile(r"(?i)(token\b[^\n\r]*?:?\s*['\"]?[A-Za-z0-9_-]+['\"]?|\b[a-zA-Z0-9_-]{32,})")
41
41
  _START_LOCK_INCOMPLETE_GRACE_SECONDS = 1.0
42
42
  _CACHE_REVISION_CHECK_INTERVAL_SECONDS = 3600.0
43
+ _WATCHER_READY_TIMEOUT_SECONDS = 1.0
44
+ _WATCHER_READY_POLL_MILLISECONDS = 50
43
45
  _WATCHED_SUFFIXES = frozenset(
44
46
  {".pas", ".pp", ".inc", ".dpr", ".dpk", ".dproj", ".cfg"}
45
47
  )
48
+ _PROCESS_START_LOCKS_GUARD = threading.Lock()
49
+ _PROCESS_START_LOCKS: dict[str, tuple[threading.Lock, int]] = {}
46
50
 
47
51
 
48
52
  def current_process_rss_bytes() -> int:
@@ -578,6 +582,30 @@ def _start_lock(root: str | Path, timeout: float):
578
582
  pass
579
583
 
580
584
 
585
+ @contextlib.contextmanager
586
+ def _process_start_lock(root: str | Path):
587
+ canonical = str(Path(root).resolve())
588
+ with _PROCESS_START_LOCKS_GUARD:
589
+ lock, users = _PROCESS_START_LOCKS.get(
590
+ canonical,
591
+ (threading.Lock(), 0),
592
+ )
593
+ _PROCESS_START_LOCKS[canonical] = (lock, users + 1)
594
+ try:
595
+ with lock:
596
+ yield
597
+ finally:
598
+ with _PROCESS_START_LOCKS_GUARD:
599
+ current_lock, current_users = _PROCESS_START_LOCKS[canonical]
600
+ if current_users == 1:
601
+ del _PROCESS_START_LOCKS[canonical]
602
+ else:
603
+ _PROCESS_START_LOCKS[canonical] = (
604
+ current_lock,
605
+ current_users - 1,
606
+ )
607
+
608
+
581
609
  def _client_exchange(metadata: CacheMetadata, request: dict[str, object]) -> CacheClientResponse:
582
610
  try:
583
611
  with socket.create_connection(("127.0.0.1", metadata.port), timeout=2) as connection:
@@ -655,31 +683,55 @@ def _daemon_process_options() -> dict[str, object]:
655
683
 
656
684
 
657
685
  class _CacheService:
658
- def __init__(self, metadata: CacheMetadata) -> None:
686
+ def __init__(
687
+ self,
688
+ metadata: CacheMetadata,
689
+ *,
690
+ defer_context: bool = False,
691
+ ) -> None:
659
692
  self.metadata = metadata
660
693
  self._rss_baseline_bytes = current_process_rss_bytes()
661
- self.context = AgentContext.open(
662
- metadata.root,
663
- metadata.project_file or None,
664
- workers=metadata.workers,
665
- worker_memory_budget_bytes=metadata.max_memory_bytes,
666
- revision_check_interval_seconds=_CACHE_REVISION_CHECK_INTERVAL_SECONDS,
667
- navigation_cache_dir=navigation_cache_path(
668
- metadata.root,
669
- create=True,
670
- ),
671
- )
694
+ self._context: AgentContext | None = None
695
+ self.context_ready = threading.Event()
696
+ self.watcher_ready = threading.Event()
672
697
  self.budget = CacheBudget(metadata.max_memory_bytes)
673
698
  self.stats = CacheStats()
674
699
  self.lock = threading.Lock()
675
700
  self.started = time.monotonic()
676
701
  self.last_activity = self.started
677
702
  self.cache_state = "warming"
678
- self.last_revision = self.context.workspace.workspace_revision
703
+ self.last_revision = ""
679
704
  self.last_budget = BudgetResult(0, 0.0, 0.0, False, False, False)
680
705
  self.prewarm_seconds = 0.0
706
+ self.startup_error = ""
681
707
  self.shutdown = threading.Event()
682
708
  self.prewarm_thread: threading.Thread | None = None
709
+ if not defer_context:
710
+ self._initialize_context()
711
+
712
+ @property
713
+ def context(self) -> AgentContext:
714
+ if self._context is None:
715
+ raise RuntimeError("Cache workspace is still being discovered.")
716
+ return self._context
717
+
718
+ def _initialize_context(self) -> AgentContext:
719
+ if self._context is None:
720
+ context = AgentContext.open(
721
+ self.metadata.root,
722
+ self.metadata.project_file or None,
723
+ workers=self.metadata.workers,
724
+ worker_memory_budget_bytes=self.metadata.max_memory_bytes,
725
+ revision_check_interval_seconds=_CACHE_REVISION_CHECK_INTERVAL_SECONDS,
726
+ navigation_cache_dir=navigation_cache_path(
727
+ self.metadata.root,
728
+ create=True,
729
+ ),
730
+ )
731
+ self._context = context
732
+ self.last_revision = context.workspace.workspace_revision
733
+ self.context_ready.set()
734
+ return self._context
683
735
 
684
736
  def _measure_retained_bytes(self) -> int:
685
737
  process_rss = current_process_rss_bytes()
@@ -692,8 +744,9 @@ class _CacheService:
692
744
 
693
745
  def prewarm(self) -> None:
694
746
  started = time.monotonic()
747
+ context = self._initialize_context()
695
748
  try:
696
- self.last_revision = self.context.prewarm_navigation()
749
+ self.last_revision = context.prewarm_navigation()
697
750
  self.cache_state = "warm"
698
751
  except AgentProtocolError as error:
699
752
  if error.code != "project_required":
@@ -708,15 +761,24 @@ class _CacheService:
708
761
  self.stats.evictions += 1
709
762
  self.cache_state = "compact"
710
763
  self.prewarm_seconds = time.monotonic() - started
711
- self.stats.parallel_fallbacks = self.context.parallel_stats.fallbacks
764
+ self.stats.parallel_fallbacks = context.parallel_stats.fallbacks
712
765
 
713
766
  def start_prewarm(self) -> None:
714
767
  def run() -> None:
715
768
  try:
716
769
  with self.lock:
717
770
  self.prewarm()
718
- except Exception:
771
+ except Exception as error:
772
+ detail = _truncate_and_sanitize_startup_diagnostics(
773
+ f"{type(error).__name__}: {error}".encode(
774
+ "utf-8",
775
+ "replace",
776
+ )
777
+ )
778
+ self.startup_error = detail or type(error).__name__
719
779
  self.cache_state = "failed"
780
+ finally:
781
+ self.context_ready.set()
720
782
 
721
783
  self.prewarm_thread = threading.Thread(
722
784
  target=run,
@@ -740,7 +802,16 @@ class _CacheService:
740
802
  "Cache is still warming. Retry after cache status reports ready.",
741
803
  )
742
804
  if self.cache_state == "failed":
743
- raise CacheClientError("cache_failed", "Cache warm-up failed.")
805
+ detail = (
806
+ f" {self.startup_error}"
807
+ if self.startup_error
808
+ else ""
809
+ )
810
+ raise CacheClientError(
811
+ "cache_failed",
812
+ f"Cache workspace discovery or warm-up failed.{detail}",
813
+ )
814
+ self.watcher_ready.wait(timeout=_WATCHER_READY_TIMEOUT_SECONDS)
744
815
  with self.lock:
745
816
  self.last_activity = time.monotonic()
746
817
  before = self.last_revision
@@ -784,6 +855,12 @@ class _CacheService:
784
855
  def status(self) -> dict[str, object]:
785
856
  now = time.monotonic()
786
857
  idle = max(0.0, now - self.last_activity)
858
+ context = self._context
859
+ parallel_stats = (
860
+ context.parallel_stats
861
+ if context is not None
862
+ else None
863
+ )
787
864
  return {
788
865
  "pid": self.metadata.pid, "root": self.metadata.root, "project_file": self.metadata.project_file,
789
866
  "version": self.metadata.version, "uptime": now - self.started, "idle_seconds": idle,
@@ -795,15 +872,18 @@ class _CacheService:
795
872
  "cache_state": self.cache_state, "requests": self.stats.requests, "warm_hits": self.stats.warm_hits,
796
873
  "rebuilds": self.stats.rebuilds, "invalidations": self.stats.invalidations, "evictions": self.stats.evictions,
797
874
  "workers_configured": "auto" if self.metadata.workers == 0 else self.metadata.workers,
798
- "workers_effective": self.context.parallel_stats.effective_workers,
799
- "parallel_files_completed": self.context.parallel_stats.files_completed,
875
+ "workers_effective": parallel_stats.effective_workers if parallel_stats else 0,
876
+ "parallel_files_completed": parallel_stats.files_completed if parallel_stats else 0,
800
877
  "prewarm_seconds": self.prewarm_seconds,
801
- "parallel_seconds": self.context.parallel_stats.elapsed_seconds,
878
+ "parallel_seconds": parallel_stats.elapsed_seconds if parallel_stats else 0.0,
802
879
  "parallel_fallbacks": self.stats.parallel_fallbacks,
803
- "navigation_disk_hits": self.context.navigation_disk_hits,
804
- "navigation_disk_misses": self.context.navigation_disk_misses,
880
+ "navigation_disk_hits": context.navigation_disk_hits if context else 0,
881
+ "navigation_disk_misses": context.navigation_disk_misses if context else 0,
882
+ "cpg_cache_entries": context.cpg_cache_entries if context else 0,
883
+ "cpg_cache_bytes": context.cpg_cache_bytes if context else 0,
805
884
  "idle_timeout": self.metadata.idle_timeout, "idle_remaining": max(0.0, self.metadata.idle_timeout - idle),
806
885
  "workspace_revision": self.last_revision,
886
+ "startup_error": self.startup_error,
807
887
  }
808
888
 
809
889
 
@@ -816,7 +896,9 @@ def watch_workspace_changes(
816
896
  *,
817
897
  stop_event: threading.Event,
818
898
  on_change: Callable[[], None],
899
+ on_ready: Callable[[], None] | None = None,
819
900
  ) -> None:
901
+ ready = False
820
902
  try:
821
903
  for changes in watch(
822
904
  root,
@@ -824,20 +906,33 @@ def watch_workspace_changes(
824
906
  stop_event=stop_event,
825
907
  debounce=50,
826
908
  step=20,
909
+ rust_timeout=_WATCHER_READY_POLL_MILLISECONDS,
910
+ yield_on_timeout=True,
827
911
  recursive=True,
828
912
  raise_interrupt=False,
829
913
  ):
914
+ if not ready:
915
+ ready = True
916
+ if on_ready is not None:
917
+ on_ready()
830
918
  if changes:
831
919
  on_change()
832
920
  except (OSError, RuntimeError):
833
921
  on_change()
922
+ finally:
923
+ if not ready and on_ready is not None:
924
+ on_ready()
834
925
 
835
926
 
836
927
  def _watch_workspace(service: _CacheService) -> None:
928
+ service.context_ready.wait()
929
+ if service.shutdown.is_set() or service._context is None:
930
+ return
837
931
  watch_workspace_changes(
838
932
  service.metadata.root,
839
933
  stop_event=service.shutdown,
840
- on_change=service.context.invalidate_revision_cache,
934
+ on_change=service._context.invalidate_revision_cache,
935
+ on_ready=service.watcher_ready.set,
841
936
  )
842
937
 
843
938
 
@@ -888,7 +983,7 @@ def run_cache_daemon(
888
983
  )
889
984
  watcher: threading.Thread | None = None
890
985
  try:
891
- service = _CacheService(metadata)
986
+ service = _CacheService(metadata, defer_context=True)
892
987
  _write_metadata(metadata)
893
988
  watcher = threading.Thread(
894
989
  target=_watch_workspace,
@@ -900,10 +995,7 @@ def run_cache_daemon(
900
995
  service.start_prewarm()
901
996
  while (
902
997
  not service.shutdown.is_set()
903
- and (
904
- service.cache_state == "warming"
905
- or time.monotonic() - service.last_activity < idle_timeout
906
- )
998
+ and time.monotonic() - service.last_activity < idle_timeout
907
999
  ):
908
1000
  try:
909
1001
  connection, _ = listener.accept()
@@ -915,10 +1007,10 @@ def run_cache_daemon(
915
1007
  finally:
916
1008
  if "service" in locals():
917
1009
  service.shutdown.set()
918
- if watcher is not None:
919
- watcher.join(timeout=2.0)
920
1010
  listener.close()
921
1011
  _remove_metadata_if_owned(metadata)
1012
+ if watcher is not None:
1013
+ watcher.join(timeout=2.0)
922
1014
 
923
1015
 
924
1016
  def _start_cache_unlocked(
@@ -972,6 +1064,8 @@ def _start_cache_unlocked(
972
1064
  process = subprocess.Popen(command, **options)
973
1065
  deadline = time.monotonic() + startup_timeout
974
1066
  startup_ready = False
1067
+ timed_out = False
1068
+ return_code: int | None = None
975
1069
  try:
976
1070
  while time.monotonic() < deadline:
977
1071
  metadata = _read_metadata(canonical)
@@ -985,10 +1079,12 @@ def _start_cache_unlocked(
985
1079
  if process.poll() is not None:
986
1080
  break
987
1081
  time.sleep(0.05)
988
- if process.poll() is None:
1082
+ return_code = process.poll()
1083
+ timed_out = return_code is None
1084
+ if timed_out:
989
1085
  process.kill()
990
1086
  with contextlib.suppress(Exception):
991
- process.wait()
1087
+ return_code = process.wait()
992
1088
  metadata = _read_metadata(canonical)
993
1089
  if metadata and metadata.pid == process.pid:
994
1090
  _remove_metadata_if_owned(metadata)
@@ -996,9 +1092,28 @@ def _start_cache_unlocked(
996
1092
  raw = _read_startup_tail(diagnostics)
997
1093
  message = _truncate_and_sanitize_startup_diagnostics(raw)
998
1094
  base = "Cache daemon did not become ready."
1095
+ if timed_out:
1096
+ reason = (
1097
+ f" Startup timed out after {startup_timeout:.1f}s before "
1098
+ "readiness metadata was published."
1099
+ )
1100
+ else:
1101
+ reason = (
1102
+ f" Child process exited with code {return_code} before "
1103
+ "readiness metadata was published."
1104
+ )
999
1105
  if message:
1000
- raise CacheClientError("startup_failed", f"{base} {message}")
1001
- raise CacheClientError("startup_failed", base)
1106
+ raise CacheClientError(
1107
+ "startup_failed",
1108
+ f"{base}{reason} Child stderr: {message}",
1109
+ )
1110
+ raise CacheClientError(
1111
+ "startup_failed",
1112
+ (
1113
+ f"{base}{reason} No child diagnostics were emitted. "
1114
+ f"Python executable: {sys.executable}. Workspace: {canonical}."
1115
+ ),
1116
+ )
1002
1117
  finally:
1003
1118
  if not startup_ready:
1004
1119
  if process.poll() is None:
@@ -1022,15 +1137,16 @@ def start_cache(
1022
1137
  raise ValueError("idle_timeout must be greater than zero.")
1023
1138
  if not math.isfinite(startup_timeout) or startup_timeout <= 0:
1024
1139
  raise ValueError("startup_timeout must be greater than zero.")
1025
- with _start_lock(root, startup_timeout):
1026
- return _start_cache_unlocked(
1027
- root,
1028
- project_file=project_file,
1029
- max_memory_bytes=max_memory_bytes,
1030
- workers=workers,
1031
- idle_timeout=idle_timeout,
1032
- startup_timeout=startup_timeout,
1033
- )
1140
+ with _process_start_lock(root):
1141
+ with _start_lock(root, startup_timeout):
1142
+ return _start_cache_unlocked(
1143
+ root,
1144
+ project_file=project_file,
1145
+ max_memory_bytes=max_memory_bytes,
1146
+ workers=workers,
1147
+ idle_timeout=idle_timeout,
1148
+ startup_timeout=startup_timeout,
1149
+ )
1034
1150
 
1035
1151
 
1036
1152
  def query_cache(root: str | Path, request: dict[str, object]) -> CacheClientResponse:
delphi_lsp/agent_cli.py CHANGED
@@ -24,7 +24,15 @@ from .agent_cache import (
24
24
  watch_workspace_changes,
25
25
  )
26
26
  from .agent_layers import build_codebase_index, layer_payload, render_layer
27
- from .agent_protocol import AgentProtocolError, SUPPORTED_ACTIONS, SUPPORTED_DETAILS, SUPPORTED_RELATIONS
27
+ from .agent_protocol import (
28
+ SCHEMA_VERSION,
29
+ AgentProtocolError,
30
+ SUPPORTED_ACTIONS,
31
+ SUPPORTED_DETAILS,
32
+ SUPPORTED_DIRECTIONS,
33
+ SUPPORTED_GRAPHS,
34
+ SUPPORTED_RELATIONS,
35
+ )
28
36
  from .agent_templates import install_opencode_support, install_skill
29
37
  from .parallel_outline import ParallelOutlineError, parse_worker_setting
30
38
 
@@ -108,13 +116,13 @@ def build_parser() -> argparse.ArgumentParser:
108
116
  )
109
117
  opencode_install.set_defaults(func=_opencode_install)
110
118
 
111
- worker = subcommands.add_parser("worker", help="Serve Protocol v2 NDJSON requests.")
119
+ worker = subcommands.add_parser("worker", help="Serve Protocol v3 NDJSON requests.")
112
120
  worker.add_argument("--root", type=Path, required=True)
113
121
  worker.add_argument("--project-file", type=Path)
114
122
  worker.add_argument("--workers", type=parse_worker_setting, default=0)
115
123
  worker.set_defaults(func=_worker)
116
124
 
117
- cache = subcommands.add_parser("cache", help="Manage the shared Protocol v2 cache daemon.")
125
+ cache = subcommands.add_parser("cache", help="Manage the shared Protocol v3 cache daemon.")
118
126
  cache_commands = cache.add_subparsers(dest="cache_command", required=True)
119
127
  cache_start = cache_commands.add_parser("start", help="Start the cache daemon if needed.")
120
128
  _add_cache_start_arguments(cache_start)
@@ -141,6 +149,9 @@ def build_parser() -> argparse.ArgumentParser:
141
149
  query.add_argument("--project-id", default="")
142
150
  query.add_argument("--detail", choices=SUPPORTED_DETAILS, default="summary")
143
151
  query.add_argument("--relation", choices=SUPPORTED_RELATIONS)
152
+ query.add_argument("--graph", choices=SUPPORTED_GRAPHS, default="full")
153
+ query.add_argument("--direction", choices=SUPPORTED_DIRECTIONS, default="out")
154
+ query.add_argument("--depth", type=_cpg_depth, default=4)
144
155
  query.add_argument("--cursor", default="")
145
156
  query.add_argument("--max-items", type=_max_items, default=12)
146
157
  query.add_argument("--max-chars", type=_max_chars, default=12000)
@@ -179,6 +190,13 @@ def _positive_integer(value: str) -> int:
179
190
  return parsed
180
191
 
181
192
 
193
+ def _cpg_depth(value: str) -> int:
194
+ parsed = int(value)
195
+ if not 1 <= parsed <= 16:
196
+ raise argparse.ArgumentTypeError("depth must be between 1 and 16")
197
+ return parsed
198
+
199
+
182
200
  def _max_items(value: str) -> int:
183
201
  parsed = int(value)
184
202
  if not 1 <= parsed <= 50:
@@ -271,6 +289,7 @@ def _index(args: argparse.Namespace) -> None:
271
289
 
272
290
 
273
291
  def _skill_install(args: argparse.Namespace) -> None:
292
+ _require_install_directory(args.target)
274
293
  try:
275
294
  skill_path = install_skill(args.target, force=args.force)
276
295
  except FileExistsError as error:
@@ -279,6 +298,7 @@ def _skill_install(args: argparse.Namespace) -> None:
279
298
 
280
299
 
281
300
  def _opencode_install(args: argparse.Namespace) -> None:
301
+ _require_install_directory(args.target)
282
302
  try:
283
303
  skill_path, plugin_path, agent_path = install_opencode_support(
284
304
  args.target,
@@ -293,6 +313,12 @@ def _opencode_install(args: argparse.Namespace) -> None:
293
313
  print(agent_path)
294
314
 
295
315
 
316
+ def _require_install_directory(target: str | Path) -> None:
317
+ path = Path(target).expanduser()
318
+ if path.exists() and not path.is_dir():
319
+ raise _CliError("io_error", f"Install target is not a directory: {path}")
320
+
321
+
296
322
  def _worker(args: argparse.Namespace) -> None:
297
323
  args.root = _workspace_root(args.root)
298
324
  context = _open_worker_context(args.root, args.project_file, args.workers)
@@ -430,13 +456,23 @@ def _query(args: argparse.Namespace) -> int:
430
456
  if args.value:
431
457
  if args.action in {"find", "metrics"}:
432
458
  request["query"] = args.value
433
- elif args.action in {"focus", "inspect", "trace"}:
459
+ elif args.action in {"focus", "inspect", "trace", "cpg"}:
434
460
  request["target_id"] = args.value
435
461
  else:
436
462
  sys.stderr.write(f"cache_error:invalid_request: {args.action} does not accept a value.\n")
437
463
  sys.stderr.flush()
438
464
  return 1
439
- for argument, field in (("project_id", "project_id"), ("detail", "detail"), ("relation", "relation"), ("cursor", "cursor"), ("max_items", "max_items"), ("max_chars", "max_chars")):
465
+ for argument, field in (
466
+ ("project_id", "project_id"),
467
+ ("detail", "detail"),
468
+ ("relation", "relation"),
469
+ ("graph", "graph"),
470
+ ("direction", "direction"),
471
+ ("depth", "depth"),
472
+ ("cursor", "cursor"),
473
+ ("max_items", "max_items"),
474
+ ("max_chars", "max_chars"),
475
+ ):
440
476
  value = getattr(args, argument)
441
477
  if value is not None:
442
478
  request[field] = value
@@ -521,7 +557,7 @@ def _serve_worker(context: AgentContext, input_stream: BinaryIO, output_stream:
521
557
  message = _worker_error("internal_error", _INTERNAL_ERROR_MESSAGE)
522
558
  _write_worker_message(output_stream, message)
523
559
  def _worker_error(code: str, message: str) -> dict[str, object]:
524
- return {"schema": 2, "error": {"code": code, "message": message}}
560
+ return {"schema": SCHEMA_VERSION, "error": {"code": code, "message": message}}
525
561
 
526
562
 
527
563
  def _write_worker_message(output_stream: BinaryIO, message: object) -> None: