python-delphi-lsp 2.3.2__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.2"
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,30 +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()
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
682
735
 
683
736
  def _measure_retained_bytes(self) -> int:
684
737
  process_rss = current_process_rss_bytes()
@@ -691,8 +744,9 @@ class _CacheService:
691
744
 
692
745
  def prewarm(self) -> None:
693
746
  started = time.monotonic()
747
+ context = self._initialize_context()
694
748
  try:
695
- self.last_revision = self.context.prewarm_navigation()
749
+ self.last_revision = context.prewarm_navigation()
696
750
  self.cache_state = "warm"
697
751
  except AgentProtocolError as error:
698
752
  if error.code != "project_required":
@@ -707,17 +761,58 @@ class _CacheService:
707
761
  self.stats.evictions += 1
708
762
  self.cache_state = "compact"
709
763
  self.prewarm_seconds = time.monotonic() - started
710
- self.stats.parallel_fallbacks = self.context.parallel_stats.fallbacks
764
+ self.stats.parallel_fallbacks = context.parallel_stats.fallbacks
765
+
766
+ def start_prewarm(self) -> None:
767
+ def run() -> None:
768
+ try:
769
+ with self.lock:
770
+ self.prewarm()
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__
779
+ self.cache_state = "failed"
780
+ finally:
781
+ self.context_ready.set()
782
+
783
+ self.prewarm_thread = threading.Thread(
784
+ target=run,
785
+ name="delphi-cache-prewarm",
786
+ daemon=True,
787
+ )
788
+ self.prewarm_thread.start()
711
789
 
712
790
  def request(self, request: dict[str, object]) -> CacheClientResponse:
791
+ action = request.get("action")
792
+ if action == "status":
793
+ warning = "" if request.get("_startup_probe") is True else self._consume_warning()
794
+ return CacheClientResponse(self.status(), warning)
795
+ if action == "stop":
796
+ self.shutdown.set()
797
+ return CacheClientResponse({"stopping": True})
798
+ if self.cache_state == "warming":
799
+ self.last_activity = time.monotonic()
800
+ raise CacheClientError(
801
+ "cache_warming",
802
+ "Cache is still warming. Retry after cache status reports ready.",
803
+ )
804
+ if self.cache_state == "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)
713
815
  with self.lock:
714
- action = request.get("action")
715
- if action == "status":
716
- warning = "" if request.get("_startup_probe") is True else self._consume_warning()
717
- return CacheClientResponse(self.status(), warning)
718
- if action == "stop":
719
- self.shutdown.set()
720
- return CacheClientResponse({"stopping": True})
721
816
  self.last_activity = time.monotonic()
722
817
  before = self.last_revision
723
818
  was_warm = self.context.navigation_cache_is_warm
@@ -760,6 +855,12 @@ class _CacheService:
760
855
  def status(self) -> dict[str, object]:
761
856
  now = time.monotonic()
762
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
+ )
763
864
  return {
764
865
  "pid": self.metadata.pid, "root": self.metadata.root, "project_file": self.metadata.project_file,
765
866
  "version": self.metadata.version, "uptime": now - self.started, "idle_seconds": idle,
@@ -771,15 +872,18 @@ class _CacheService:
771
872
  "cache_state": self.cache_state, "requests": self.stats.requests, "warm_hits": self.stats.warm_hits,
772
873
  "rebuilds": self.stats.rebuilds, "invalidations": self.stats.invalidations, "evictions": self.stats.evictions,
773
874
  "workers_configured": "auto" if self.metadata.workers == 0 else self.metadata.workers,
774
- "workers_effective": self.context.parallel_stats.effective_workers,
775
- "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,
776
877
  "prewarm_seconds": self.prewarm_seconds,
777
- "parallel_seconds": self.context.parallel_stats.elapsed_seconds,
878
+ "parallel_seconds": parallel_stats.elapsed_seconds if parallel_stats else 0.0,
778
879
  "parallel_fallbacks": self.stats.parallel_fallbacks,
779
- "navigation_disk_hits": self.context.navigation_disk_hits,
780
- "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,
781
884
  "idle_timeout": self.metadata.idle_timeout, "idle_remaining": max(0.0, self.metadata.idle_timeout - idle),
782
885
  "workspace_revision": self.last_revision,
886
+ "startup_error": self.startup_error,
783
887
  }
784
888
 
785
889
 
@@ -792,7 +896,9 @@ def watch_workspace_changes(
792
896
  *,
793
897
  stop_event: threading.Event,
794
898
  on_change: Callable[[], None],
899
+ on_ready: Callable[[], None] | None = None,
795
900
  ) -> None:
901
+ ready = False
796
902
  try:
797
903
  for changes in watch(
798
904
  root,
@@ -800,20 +906,33 @@ def watch_workspace_changes(
800
906
  stop_event=stop_event,
801
907
  debounce=50,
802
908
  step=20,
909
+ rust_timeout=_WATCHER_READY_POLL_MILLISECONDS,
910
+ yield_on_timeout=True,
803
911
  recursive=True,
804
912
  raise_interrupt=False,
805
913
  ):
914
+ if not ready:
915
+ ready = True
916
+ if on_ready is not None:
917
+ on_ready()
806
918
  if changes:
807
919
  on_change()
808
920
  except (OSError, RuntimeError):
809
921
  on_change()
922
+ finally:
923
+ if not ready and on_ready is not None:
924
+ on_ready()
810
925
 
811
926
 
812
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
813
931
  watch_workspace_changes(
814
932
  service.metadata.root,
815
933
  stop_event=service.shutdown,
816
- on_change=service.context.invalidate_revision_cache,
934
+ on_change=service._context.invalidate_revision_cache,
935
+ on_ready=service.watcher_ready.set,
817
936
  )
818
937
 
819
938
 
@@ -864,8 +983,8 @@ def run_cache_daemon(
864
983
  )
865
984
  watcher: threading.Thread | None = None
866
985
  try:
867
- service = _CacheService(metadata)
868
- service.prewarm()
986
+ service = _CacheService(metadata, defer_context=True)
987
+ _write_metadata(metadata)
869
988
  watcher = threading.Thread(
870
989
  target=_watch_workspace,
871
990
  args=(service,),
@@ -873,8 +992,11 @@ def run_cache_daemon(
873
992
  daemon=True,
874
993
  )
875
994
  watcher.start()
876
- _write_metadata(metadata)
877
- while not service.shutdown.is_set() and time.monotonic() - service.last_activity < idle_timeout:
995
+ service.start_prewarm()
996
+ while (
997
+ not service.shutdown.is_set()
998
+ and time.monotonic() - service.last_activity < idle_timeout
999
+ ):
878
1000
  try:
879
1001
  connection, _ = listener.accept()
880
1002
  except socket.timeout:
@@ -885,10 +1007,10 @@ def run_cache_daemon(
885
1007
  finally:
886
1008
  if "service" in locals():
887
1009
  service.shutdown.set()
888
- if watcher is not None:
889
- watcher.join(timeout=2.0)
890
1010
  listener.close()
891
1011
  _remove_metadata_if_owned(metadata)
1012
+ if watcher is not None:
1013
+ watcher.join(timeout=2.0)
892
1014
 
893
1015
 
894
1016
  def _start_cache_unlocked(
@@ -942,6 +1064,8 @@ def _start_cache_unlocked(
942
1064
  process = subprocess.Popen(command, **options)
943
1065
  deadline = time.monotonic() + startup_timeout
944
1066
  startup_ready = False
1067
+ timed_out = False
1068
+ return_code: int | None = None
945
1069
  try:
946
1070
  while time.monotonic() < deadline:
947
1071
  metadata = _read_metadata(canonical)
@@ -955,10 +1079,12 @@ def _start_cache_unlocked(
955
1079
  if process.poll() is not None:
956
1080
  break
957
1081
  time.sleep(0.05)
958
- if process.poll() is None:
1082
+ return_code = process.poll()
1083
+ timed_out = return_code is None
1084
+ if timed_out:
959
1085
  process.kill()
960
1086
  with contextlib.suppress(Exception):
961
- process.wait()
1087
+ return_code = process.wait()
962
1088
  metadata = _read_metadata(canonical)
963
1089
  if metadata and metadata.pid == process.pid:
964
1090
  _remove_metadata_if_owned(metadata)
@@ -966,9 +1092,28 @@ def _start_cache_unlocked(
966
1092
  raw = _read_startup_tail(diagnostics)
967
1093
  message = _truncate_and_sanitize_startup_diagnostics(raw)
968
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
+ )
969
1105
  if message:
970
- raise CacheClientError("startup_failed", f"{base} {message}")
971
- 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
+ )
972
1117
  finally:
973
1118
  if not startup_ready:
974
1119
  if process.poll() is None:
@@ -992,15 +1137,16 @@ def start_cache(
992
1137
  raise ValueError("idle_timeout must be greater than zero.")
993
1138
  if not math.isfinite(startup_timeout) or startup_timeout <= 0:
994
1139
  raise ValueError("startup_timeout must be greater than zero.")
995
- with _start_lock(root, startup_timeout):
996
- return _start_cache_unlocked(
997
- root,
998
- project_file=project_file,
999
- max_memory_bytes=max_memory_bytes,
1000
- workers=workers,
1001
- idle_timeout=idle_timeout,
1002
- startup_timeout=startup_timeout,
1003
- )
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
+ )
1004
1150
 
1005
1151
 
1006
1152
  def query_cache(root: str | Path, request: dict[str, object]) -> CacheClientResponse:
@@ -1011,7 +1157,13 @@ def query_cache(root: str | Path, request: dict[str, object]) -> CacheClientResp
1011
1157
  if not _pid_alive(metadata.pid):
1012
1158
  _remove_metadata_if_owned(metadata)
1013
1159
  raise CacheClientError("cache_not_running", "Cache daemon is not running.")
1014
- return _client_exchange(metadata, request)
1160
+ while True:
1161
+ try:
1162
+ return _client_exchange(metadata, request)
1163
+ except CacheClientError as error:
1164
+ if error.code != "cache_warming":
1165
+ raise
1166
+ time.sleep(0.05)
1015
1167
 
1016
1168
 
1017
1169
  def cache_status(root: str | Path) -> dict[str, object]:
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: