python-delphi-lsp 2.0.5__py3-none-any.whl → 2.2.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.0.5"
1
+ __version__ = "2.2.0"
delphi_lsp/agent_cache.py CHANGED
@@ -6,6 +6,7 @@ import argparse
6
6
  import contextlib
7
7
  import hmac
8
8
  import json
9
+ import math
9
10
  import os
10
11
  from pathlib import Path
11
12
  import secrets
@@ -19,6 +20,8 @@ import threading
19
20
  import time
20
21
  from types import ModuleType
21
22
 
23
+ from watchfiles import watch
24
+
22
25
  from ._version import __version__
23
26
  from .agent_context import AgentContext
24
27
  from .agent_protocol import AgentProtocolError
@@ -26,13 +29,20 @@ from .agent_protocol import AgentProtocolError
26
29
 
27
30
  DEFAULT_MAX_MEMORY_BYTES = 512 * 1024**2
28
31
  WARNING_THRESHOLD_PERCENT = 80
29
- DAEMON_SCHEMA = 1
32
+ DAEMON_SCHEMA = 2
30
33
  DEFAULT_IDLE_TIMEOUT = 1800
34
+ DEFAULT_STARTUP_TIMEOUT = 120.0
31
35
  _MAX_MESSAGE_BYTES = 1024 * 1024
32
36
  _CONNECTION_TIMEOUT = 2.0
37
+ _CLIENT_RESPONSE_TIMEOUT = 120.0
33
38
  _MEMORY_SIZE = re.compile(r"^(?P<count>[1-9][0-9]*)(?P<suffix>[KMG]?)$", re.IGNORECASE)
34
39
  _STARTUP_DIAGNOSTIC_BYTES = 16 * 1024
35
40
  _STARTUP_TOKEN_RE = re.compile(r"(?i)(token\b[^\n\r]*?:?\s*['\"]?[A-Za-z0-9_-]+['\"]?|\b[a-zA-Z0-9_-]{32,})")
41
+ _START_LOCK_INCOMPLETE_GRACE_SECONDS = 1.0
42
+ _CACHE_REVISION_CHECK_INTERVAL_SECONDS = 3600.0
43
+ _WATCHED_SUFFIXES = frozenset(
44
+ {".pas", ".pp", ".inc", ".dpr", ".dpk", ".dproj", ".cfg"}
45
+ )
36
46
 
37
47
 
38
48
  def estimate_deep_size(value: object) -> int:
@@ -72,6 +82,7 @@ class CacheStats:
72
82
  rebuilds: int = 0
73
83
  invalidations: int = 0
74
84
  evictions: int = 0
85
+ parallel_fallbacks: int = 0
75
86
 
76
87
 
77
88
  @dataclass(frozen=True)
@@ -232,6 +243,7 @@ class CacheMetadata:
232
243
  version: str
233
244
  project_file: str
234
245
  max_memory_bytes: int
246
+ workers: int
235
247
  idle_timeout: int
236
248
  started_at: float
237
249
 
@@ -291,7 +303,7 @@ def _write_metadata(metadata: CacheMetadata) -> None:
291
303
  os.unlink(temporary)
292
304
 
293
305
 
294
- def _read_metadata(root: str | Path) -> CacheMetadata | None:
306
+ def _read_metadata_record(root: str | Path) -> object | None:
295
307
  path = _safe_metadata_path(root)
296
308
  if not path.exists():
297
309
  return None
@@ -309,15 +321,36 @@ def _read_metadata(root: str | Path) -> CacheMetadata | None:
309
321
  raise
310
322
  except (OSError, ValueError, UnicodeError):
311
323
  return None
324
+ return raw
325
+
326
+
327
+ def _read_metadata(root: str | Path) -> CacheMetadata | None:
328
+ raw = _read_metadata_record(root)
312
329
  required = {field.name for field in fields(CacheMetadata)}
313
330
  if not isinstance(raw, dict) or set(raw) != required:
314
331
  return None
315
- values = tuple(raw[name] for name in ("schema", "root", "pid", "port", "token", "version", "project_file", "max_memory_bytes", "idle_timeout", "started_at"))
332
+ values = tuple(
333
+ raw[name]
334
+ for name in (
335
+ "schema",
336
+ "root",
337
+ "pid",
338
+ "port",
339
+ "token",
340
+ "version",
341
+ "project_file",
342
+ "max_memory_bytes",
343
+ "workers",
344
+ "idle_timeout",
345
+ "started_at",
346
+ )
347
+ )
316
348
  if (type(values[0]) is not int or values[0] != DAEMON_SCHEMA or not isinstance(values[1], str)
317
349
  or type(values[2]) is not int or values[2] <= 0 or type(values[3]) is not int or not 0 < values[3] < 65536
318
350
  or not isinstance(values[4], str) or len(values[4]) < 32 or not isinstance(values[5], str)
319
351
  or not isinstance(values[6], str) or type(values[7]) is not int or values[7] <= 0
320
- or type(values[8]) is not int or values[8] <= 0 or type(values[9]) not in (int, float)):
352
+ or type(values[8]) is not int or not 0 <= values[8] <= 32
353
+ or type(values[9]) is not int or values[9] <= 0 or type(values[10]) not in (int, float)):
321
354
  return None
322
355
  canonical = str(Path(root).resolve())
323
356
  if values[1] != canonical:
@@ -325,15 +358,56 @@ def _read_metadata(root: str | Path) -> CacheMetadata | None:
325
358
  return CacheMetadata(*values)
326
359
 
327
360
 
361
+ def _remove_incompatible_metadata_if_stale(root: str | Path) -> None:
362
+ path = _safe_metadata_path(root)
363
+ if not path.exists():
364
+ return
365
+ raw = _read_metadata_record(root)
366
+ canonical = str(Path(root).resolve())
367
+ pid = raw.get("pid") if isinstance(raw, dict) and raw.get("root") == canonical else None
368
+ if type(pid) is int and pid > 0 and _pid_alive(pid):
369
+ raise CacheClientError("unavailable", "Live cache daemon is unavailable.")
370
+ with contextlib.suppress(FileNotFoundError):
371
+ path.unlink()
372
+
373
+
374
+ def _metadata_is_owned(metadata: CacheMetadata) -> bool:
375
+ current = _read_metadata(metadata.root)
376
+ return current is not None and current.pid == metadata.pid and hmac.compare_digest(current.token, metadata.token)
377
+
378
+
328
379
  def _remove_metadata_if_owned(metadata: CacheMetadata) -> None:
329
380
  path = _safe_metadata_path(metadata.root)
330
- current = _read_metadata(metadata.root)
331
- if current is not None and current.pid == metadata.pid and hmac.compare_digest(current.token, metadata.token):
381
+ if _metadata_is_owned(metadata):
332
382
  with contextlib.suppress(FileNotFoundError):
333
383
  path.unlink()
334
384
 
335
385
 
386
+ def _windows_pid_alive(pid: int) -> bool:
387
+ import ctypes
388
+ from ctypes import wintypes
389
+
390
+ synchronize = 0x00100000
391
+ wait_timeout = 0x00000102
392
+ kernel32 = ctypes.windll.kernel32
393
+ kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
394
+ kernel32.OpenProcess.restype = wintypes.HANDLE
395
+ kernel32.WaitForSingleObject.argtypes = (wintypes.HANDLE, wintypes.DWORD)
396
+ kernel32.WaitForSingleObject.restype = wintypes.DWORD
397
+ kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
398
+ kernel32.CloseHandle.restype = wintypes.BOOL
399
+ handle = kernel32.OpenProcess(synchronize, False, pid)
400
+ if not handle:
401
+ return False
402
+ try:
403
+ return kernel32.WaitForSingleObject(handle, 0) == wait_timeout
404
+ finally:
405
+ kernel32.CloseHandle(handle)
406
+
407
+
336
408
  def _pid_alive(pid: int) -> bool:
409
+ if os.name == "nt":
410
+ return _windows_pid_alive(pid)
337
411
  try:
338
412
  os.kill(pid, 0)
339
413
  except OSError:
@@ -341,15 +415,41 @@ def _pid_alive(pid: int) -> bool:
341
415
  return True
342
416
 
343
417
 
418
+ def _reap_child_if_exited(pid: int) -> bool:
419
+ waitpid = getattr(os, "waitpid", None)
420
+ nohang = getattr(os, "WNOHANG", None)
421
+ if not callable(waitpid) or nohang is None:
422
+ return False
423
+ try:
424
+ return waitpid(pid, nohang)[0] == pid
425
+ except (ChildProcessError, OSError):
426
+ return False
427
+
428
+
344
429
  def _start_lock_path(root: str | Path) -> Path:
345
430
  return _safe_metadata_path(root, create=True).with_name("start.lock")
346
431
 
347
432
 
433
+ def _start_lock_is_stale(path: Path) -> bool:
434
+ try:
435
+ age = max(0.0, time.time() - path.stat().st_mtime)
436
+ except OSError:
437
+ return True
438
+ try:
439
+ record = json.loads(path.read_text(encoding="utf-8"))
440
+ except (OSError, ValueError, UnicodeError):
441
+ return age >= _START_LOCK_INCOMPLETE_GRACE_SECONDS
442
+ owner = record.get("pid") if isinstance(record, dict) else None
443
+ if type(owner) is int and owner > 0:
444
+ return not _pid_alive(owner)
445
+ return age >= _START_LOCK_INCOMPLETE_GRACE_SECONDS
446
+
447
+
348
448
  @contextlib.contextmanager
349
- def _start_lock(root: str | Path):
449
+ def _start_lock(root: str | Path, timeout: float):
350
450
  path = _start_lock_path(root)
351
451
  token = secrets.token_urlsafe(24)
352
- deadline = time.monotonic() + 10
452
+ deadline = time.monotonic() + timeout
353
453
  while True:
354
454
  try:
355
455
  descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
@@ -361,14 +461,7 @@ def _start_lock(root: str | Path):
361
461
  os.close(descriptor)
362
462
  break
363
463
  except FileExistsError:
364
- try:
365
- record = json.loads(path.read_text(encoding="utf-8"))
366
- owner = record.get("pid") if isinstance(record, dict) else None
367
- started = record.get("started_at") if isinstance(record, dict) else None
368
- stale = type(owner) is not int or not _pid_alive(owner) or type(started) not in (int, float) or time.time() - started > 30
369
- except (OSError, ValueError, UnicodeError):
370
- stale = True
371
- if stale:
464
+ if _start_lock_is_stale(path):
372
465
  with contextlib.suppress(FileNotFoundError):
373
466
  path.unlink()
374
467
  continue
@@ -389,7 +482,7 @@ def _start_lock(root: str | Path):
389
482
  def _client_exchange(metadata: CacheMetadata, request: dict[str, object]) -> CacheClientResponse:
390
483
  try:
391
484
  with socket.create_connection(("127.0.0.1", metadata.port), timeout=2) as connection:
392
- connection.settimeout(3)
485
+ connection.settimeout(_CLIENT_RESPONSE_TIMEOUT)
393
486
  request_without_token = {key: value for key, value in request.items() if key != "token"}
394
487
  connection.sendall(json.dumps({"token": metadata.token, **request_without_token}, separators=(",", ":")).encode("utf-8") + b"\n")
395
488
  response = _read_line(connection)
@@ -450,7 +543,13 @@ def _read_startup_tail(diagnostics: object, *, max_bytes: int = _STARTUP_DIAGNOS
450
543
  class _CacheService:
451
544
  def __init__(self, metadata: CacheMetadata) -> None:
452
545
  self.metadata = metadata
453
- self.context = AgentContext.open(metadata.root, metadata.project_file or None)
546
+ self.context = AgentContext.open(
547
+ metadata.root,
548
+ metadata.project_file or None,
549
+ workers=metadata.workers,
550
+ worker_memory_budget_bytes=metadata.max_memory_bytes,
551
+ revision_check_interval_seconds=_CACHE_REVISION_CHECK_INTERVAL_SECONDS,
552
+ )
454
553
  self.budget = CacheBudget(metadata.max_memory_bytes)
455
554
  self.stats = CacheStats()
456
555
  self.lock = threading.Lock()
@@ -459,25 +558,28 @@ class _CacheService:
459
558
  self.cache_state = "warming"
460
559
  self.last_revision = self.context.workspace.workspace_revision
461
560
  self.last_budget = BudgetResult(0, 0.0, 0.0, False, False, False)
561
+ self.prewarm_seconds = 0.0
462
562
  self.shutdown = threading.Event()
463
563
 
464
564
  def prewarm(self) -> None:
565
+ started = time.monotonic()
465
566
  try:
466
- self.context.handle({"action": "find", "query": "", "max_items": 1, "max_chars": 256})
467
- self.last_revision = self.context.workspace.workspace_revision
567
+ self.last_revision = self.context.prewarm_navigation()
468
568
  self.cache_state = "warm"
469
569
  except AgentProtocolError as error:
470
570
  if error.code != "project_required":
471
571
  raise
472
572
  self.cache_state = "ready"
473
573
  self.last_budget = self.budget.enforce(
474
- measure=lambda: estimate_deep_size(self.context.cache_roots()),
574
+ measure=lambda: self.context.estimated_cache_bytes,
475
575
  evict_auxiliary=self.context.evict_auxiliary_caches,
476
576
  evict_navigation=self.context.evict_navigation_caches,
477
577
  )
478
578
  if self.last_budget.compacted:
479
579
  self.stats.evictions += 1
480
580
  self.cache_state = "compact"
581
+ self.prewarm_seconds = time.monotonic() - started
582
+ self.stats.parallel_fallbacks = self.context.parallel_stats.fallbacks
481
583
 
482
584
  def request(self, request: dict[str, object]) -> CacheClientResponse:
483
585
  with self.lock:
@@ -506,10 +608,11 @@ class _CacheService:
506
608
  self.stats.warm_hits += 1
507
609
  else:
508
610
  self.stats.rebuilds += 1
611
+ self.stats.parallel_fallbacks += self.context.parallel_stats.fallbacks
509
612
  if before != after:
510
613
  self.stats.invalidations += 1
511
614
  self.last_budget = self.budget.enforce(
512
- measure=lambda: estimate_deep_size(self.context.cache_roots()),
615
+ measure=lambda: self.context.estimated_cache_bytes,
513
616
  evict_auxiliary=self.context.evict_auxiliary_caches,
514
617
  evict_navigation=self.context.evict_navigation_caches,
515
618
  )
@@ -539,11 +642,38 @@ class _CacheService:
539
642
  "warning_active": self.last_budget.warning_active, "warning_threshold_percent": WARNING_THRESHOLD_PERCENT,
540
643
  "cache_state": self.cache_state, "requests": self.stats.requests, "warm_hits": self.stats.warm_hits,
541
644
  "rebuilds": self.stats.rebuilds, "invalidations": self.stats.invalidations, "evictions": self.stats.evictions,
645
+ "workers_configured": "auto" if self.metadata.workers == 0 else self.metadata.workers,
646
+ "workers_effective": self.context.parallel_stats.effective_workers,
647
+ "parallel_files_completed": self.context.parallel_stats.files_completed,
648
+ "prewarm_seconds": self.prewarm_seconds,
649
+ "parallel_seconds": self.context.parallel_stats.elapsed_seconds,
650
+ "parallel_fallbacks": self.stats.parallel_fallbacks,
542
651
  "idle_timeout": self.metadata.idle_timeout, "idle_remaining": max(0.0, self.metadata.idle_timeout - idle),
543
- "workspace_revision": self.context.workspace.workspace_revision,
652
+ "workspace_revision": self.last_revision,
544
653
  }
545
654
 
546
655
 
656
+ def _watch_filter(_change: object, path: str) -> bool:
657
+ return Path(path).suffix.casefold() in _WATCHED_SUFFIXES
658
+
659
+
660
+ def _watch_workspace(service: _CacheService) -> None:
661
+ try:
662
+ for changes in watch(
663
+ service.metadata.root,
664
+ watch_filter=_watch_filter,
665
+ stop_event=service.shutdown,
666
+ debounce=50,
667
+ step=20,
668
+ recursive=True,
669
+ raise_interrupt=False,
670
+ ):
671
+ if changes:
672
+ service.context.invalidate_revision_cache()
673
+ except (OSError, RuntimeError):
674
+ service.context.invalidate_revision_cache()
675
+
676
+
547
677
  def _serve_connection(connection: socket.socket, service: _CacheService) -> None:
548
678
  try:
549
679
  line = _read_line(connection)
@@ -562,17 +692,44 @@ def _serve_connection(connection: socket.socket, service: _CacheService) -> None
562
692
  connection.sendall(json.dumps(response, separators=(",", ":")).encode("utf-8") + b"\n")
563
693
 
564
694
 
565
- def run_cache_daemon(root: str | Path, *, project_file: str = "", max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES, idle_timeout: int = DEFAULT_IDLE_TIMEOUT) -> None:
695
+ def run_cache_daemon(
696
+ root: str | Path,
697
+ *,
698
+ project_file: str = "",
699
+ max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES,
700
+ workers: int = 0,
701
+ idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
702
+ ) -> None:
566
703
  canonical = str(Path(root).resolve())
567
704
  listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
568
705
  listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
569
706
  listener.bind(("127.0.0.1", 0))
570
707
  listener.listen(16)
571
708
  listener.settimeout(0.25)
572
- metadata = CacheMetadata(DAEMON_SCHEMA, canonical, os.getpid(), listener.getsockname()[1], secrets.token_urlsafe(32), __version__, project_file, max_memory_bytes, idle_timeout, time.time())
709
+ metadata = CacheMetadata(
710
+ DAEMON_SCHEMA,
711
+ canonical,
712
+ os.getpid(),
713
+ listener.getsockname()[1],
714
+ secrets.token_urlsafe(32),
715
+ __version__,
716
+ project_file,
717
+ max_memory_bytes,
718
+ workers,
719
+ idle_timeout,
720
+ time.time(),
721
+ )
722
+ watcher: threading.Thread | None = None
573
723
  try:
574
724
  service = _CacheService(metadata)
575
725
  service.prewarm()
726
+ watcher = threading.Thread(
727
+ target=_watch_workspace,
728
+ args=(service,),
729
+ name="delphi-cache-watcher",
730
+ daemon=True,
731
+ )
732
+ watcher.start()
576
733
  _write_metadata(metadata)
577
734
  while not service.shutdown.is_set() and time.monotonic() - service.last_activity < idle_timeout:
578
735
  try:
@@ -583,11 +740,23 @@ def run_cache_daemon(root: str | Path, *, project_file: str = "", max_memory_byt
583
740
  connection.settimeout(_CONNECTION_TIMEOUT)
584
741
  _serve_connection(connection, service)
585
742
  finally:
743
+ if "service" in locals():
744
+ service.shutdown.set()
745
+ if watcher is not None:
746
+ watcher.join(timeout=2.0)
586
747
  listener.close()
587
748
  _remove_metadata_if_owned(metadata)
588
749
 
589
750
 
590
- def _start_cache_unlocked(root: str | Path, *, project_file: str | Path | None = None, max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES, idle_timeout: int = DEFAULT_IDLE_TIMEOUT) -> CacheMetadata:
751
+ def _start_cache_unlocked(
752
+ root: str | Path,
753
+ *,
754
+ project_file: str | Path | None = None,
755
+ max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES,
756
+ workers: int = 0,
757
+ idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
758
+ startup_timeout: float = DEFAULT_STARTUP_TIMEOUT,
759
+ ) -> CacheMetadata:
591
760
  canonical = str(Path(root).resolve())
592
761
  project = str((Path(canonical) / project_file).resolve()) if project_file and not Path(project_file).is_absolute() else (str(Path(project_file).resolve()) if project_file else "")
593
762
  existing = _read_metadata(canonical)
@@ -596,12 +765,32 @@ def _start_cache_unlocked(root: str | Path, *, project_file: str | Path | None =
596
765
  _client_exchange(existing, {"action": "status", "_startup_probe": True})
597
766
  except CacheClientError as error:
598
767
  raise CacheClientError("unavailable", "Live cache daemon is unavailable.") from error
599
- if (existing.project_file, existing.max_memory_bytes, existing.idle_timeout) != (project, max_memory_bytes, idle_timeout):
768
+ if (existing.project_file, existing.max_memory_bytes, existing.workers, existing.idle_timeout) != (
769
+ project,
770
+ max_memory_bytes,
771
+ workers,
772
+ idle_timeout,
773
+ ):
600
774
  raise CacheClientError("configuration_conflict", "A live cache daemon has conflicting configuration.")
601
775
  return existing
602
776
  if existing:
603
777
  _remove_metadata_if_owned(existing)
604
- command = [sys.executable, "-m", "delphi_lsp.agent_cache", "serve", "--root", canonical, "--max-memory", str(max_memory_bytes), "--idle-timeout", str(idle_timeout)]
778
+ else:
779
+ _remove_incompatible_metadata_if_stale(canonical)
780
+ command = [
781
+ sys.executable,
782
+ "-m",
783
+ "delphi_lsp.agent_cache",
784
+ "serve",
785
+ "--root",
786
+ canonical,
787
+ "--max-memory",
788
+ str(max_memory_bytes),
789
+ "--workers",
790
+ str(workers),
791
+ "--idle-timeout",
792
+ str(idle_timeout),
793
+ ]
605
794
  if project:
606
795
  command.extend(("--project-file", project))
607
796
  options: dict[str, object] = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL}
@@ -612,7 +801,7 @@ def _start_cache_unlocked(root: str | Path, *, project_file: str | Path | None =
612
801
  with tempfile.TemporaryFile() as diagnostics:
613
802
  options["stderr"] = diagnostics
614
803
  process = subprocess.Popen(command, **options)
615
- deadline = time.monotonic() + 10
804
+ deadline = time.monotonic() + startup_timeout
616
805
  startup_ready = False
617
806
  try:
618
807
  while time.monotonic() < deadline:
@@ -649,14 +838,34 @@ def _start_cache_unlocked(root: str | Path, *, project_file: str | Path | None =
649
838
  process.wait()
650
839
 
651
840
 
652
- def start_cache(root: str | Path, *, project_file: str | Path | None = None, max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES, idle_timeout: int = DEFAULT_IDLE_TIMEOUT) -> CacheMetadata:
653
- with _start_lock(root):
654
- return _start_cache_unlocked(root, project_file=project_file, max_memory_bytes=max_memory_bytes, idle_timeout=idle_timeout)
841
+ def start_cache(
842
+ root: str | Path,
843
+ *,
844
+ project_file: str | Path | None = None,
845
+ max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES,
846
+ workers: int = 0,
847
+ idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
848
+ startup_timeout: float = DEFAULT_STARTUP_TIMEOUT,
849
+ ) -> CacheMetadata:
850
+ if type(workers) is not int or not 0 <= workers <= 32:
851
+ raise ValueError("workers must be auto or an integer from 1 through 32.")
852
+ if not math.isfinite(startup_timeout) or startup_timeout <= 0:
853
+ raise ValueError("startup_timeout must be greater than zero.")
854
+ with _start_lock(root, startup_timeout):
855
+ return _start_cache_unlocked(
856
+ root,
857
+ project_file=project_file,
858
+ max_memory_bytes=max_memory_bytes,
859
+ workers=workers,
860
+ idle_timeout=idle_timeout,
861
+ startup_timeout=startup_timeout,
862
+ )
655
863
 
656
864
 
657
865
  def query_cache(root: str | Path, request: dict[str, object]) -> CacheClientResponse:
658
866
  metadata = _read_metadata(root)
659
867
  if metadata is None:
868
+ _remove_incompatible_metadata_if_stale(root)
660
869
  raise CacheClientError("cache_not_running", "Cache daemon is not running.")
661
870
  if not _pid_alive(metadata.pid):
662
871
  _remove_metadata_if_owned(metadata)
@@ -671,6 +880,7 @@ def cache_status(root: str | Path) -> dict[str, object]:
671
880
  def stop_cache(root: str | Path) -> None:
672
881
  metadata = _read_metadata(root)
673
882
  if metadata is None:
883
+ _remove_incompatible_metadata_if_stale(root)
674
884
  return
675
885
  if not _pid_alive(metadata.pid):
676
886
  _remove_metadata_if_owned(metadata)
@@ -681,11 +891,13 @@ def stop_cache(root: str | Path) -> None:
681
891
  pass
682
892
  deadline = time.monotonic() + 3
683
893
  stopped = False
684
- while _pid_alive(metadata.pid) and time.monotonic() < deadline:
685
- with contextlib.suppress(ChildProcessError):
686
- if os.waitpid(metadata.pid, os.WNOHANG)[0] == metadata.pid:
687
- stopped = True
688
- break
894
+ while time.monotonic() < deadline:
895
+ if _reap_child_if_exited(metadata.pid):
896
+ stopped = True
897
+ break
898
+ if not _pid_alive(metadata.pid):
899
+ stopped = True
900
+ break
689
901
  time.sleep(0.05)
690
902
  if not stopped and _pid_alive(metadata.pid):
691
903
  raise CacheClientError("stop_failed", "Cache daemon did not stop.")
@@ -699,10 +911,17 @@ def main(argv: list[str] | None = None) -> int:
699
911
  serve.add_argument("--root", required=True)
700
912
  serve.add_argument("--project-file", default="")
701
913
  serve.add_argument("--max-memory", type=int, default=DEFAULT_MAX_MEMORY_BYTES)
914
+ serve.add_argument("--workers", type=int, default=0)
702
915
  serve.add_argument("--idle-timeout", type=int, default=DEFAULT_IDLE_TIMEOUT)
703
916
  args = parser.parse_args(argv)
704
917
  if args.command == "serve":
705
- run_cache_daemon(args.root, project_file=args.project_file, max_memory_bytes=args.max_memory, idle_timeout=args.idle_timeout)
918
+ run_cache_daemon(
919
+ args.root,
920
+ project_file=args.project_file,
921
+ max_memory_bytes=args.max_memory,
922
+ workers=args.workers,
923
+ idle_timeout=args.idle_timeout,
924
+ )
706
925
  return 0
707
926
 
708
927
 
delphi_lsp/agent_cli.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import json
5
+ import math
5
6
  import os
6
7
  import sys
7
8
  from pathlib import Path
@@ -12,6 +13,7 @@ from .agent_cache import (
12
13
  CacheClientError,
13
14
  DEFAULT_IDLE_TIMEOUT,
14
15
  DEFAULT_MAX_MEMORY_BYTES,
16
+ DEFAULT_STARTUP_TIMEOUT,
15
17
  parse_memory_size,
16
18
  query_cache,
17
19
  run_cache_daemon,
@@ -21,6 +23,7 @@ from .agent_cache import (
21
23
  from .agent_layers import build_codebase_index, layer_payload, render_layer
22
24
  from .agent_protocol import AgentProtocolError, SUPPORTED_ACTIONS, SUPPORTED_DETAILS, SUPPORTED_RELATIONS
23
25
  from .agent_templates import install_opencode_support, install_skill
26
+ from .parallel_outline import parse_worker_setting
24
27
 
25
28
 
26
29
  _MAX_WORKER_RECORD_BYTES = 1024 * 1024
@@ -60,12 +63,14 @@ def build_parser() -> argparse.ArgumentParser:
60
63
  view.add_argument("--query", default="")
61
64
  view.add_argument("--format", default="markdown", choices=["markdown", "json"])
62
65
  view.add_argument("--deep-projects", action="store_true", help="Deep-parse project dependencies for the projects layer.")
66
+ view.add_argument("--workers", type=parse_worker_setting, default=0)
63
67
  view.set_defaults(func=_view)
64
68
 
65
69
  index = subcommands.add_parser("index", help="Materialize a JSON codebase index.")
66
70
  index.add_argument("--root", type=Path, default=Path("."))
67
71
  index.add_argument("--project-file", type=Path)
68
72
  index.add_argument("--out", type=Path, default=Path(".delphi-lsp") / "agent-index" / "index.json")
73
+ index.add_argument("--workers", type=parse_worker_setting, default=0)
69
74
  index.set_defaults(func=_index)
70
75
 
71
76
  skill = subcommands.add_parser("skill", help="Install agent skill templates.")
@@ -95,6 +100,7 @@ def build_parser() -> argparse.ArgumentParser:
95
100
  worker = subcommands.add_parser("worker", help="Serve Protocol v2 NDJSON requests.")
96
101
  worker.add_argument("--root", type=Path, required=True)
97
102
  worker.add_argument("--project-file", type=Path)
103
+ worker.add_argument("--workers", type=parse_worker_setting, default=0)
98
104
  worker.set_defaults(func=_worker)
99
105
 
100
106
  cache = subcommands.add_parser("cache", help="Manage the shared Protocol v2 cache daemon.")
@@ -113,6 +119,7 @@ def build_parser() -> argparse.ArgumentParser:
113
119
  cache_serve.add_argument("--root", type=Path, required=True)
114
120
  cache_serve.add_argument("--project-file", type=Path)
115
121
  cache_serve.add_argument("--max-memory", type=parse_memory_size, required=True)
122
+ cache_serve.add_argument("--workers", type=parse_worker_setting, required=True)
116
123
  cache_serve.add_argument("--idle-timeout", type=int, required=True)
117
124
  cache_serve.set_defaults(func=_cache_serve)
118
125
 
@@ -135,7 +142,16 @@ def _add_cache_start_arguments(parser: argparse.ArgumentParser) -> None:
135
142
  parser.add_argument("--root", type=Path, default=Path("."))
136
143
  parser.add_argument("--project-file", type=Path)
137
144
  parser.add_argument("--max-memory", type=parse_memory_size, default=DEFAULT_MAX_MEMORY_BYTES)
145
+ parser.add_argument("--workers", type=parse_worker_setting, default=0)
138
146
  parser.add_argument("--idle-timeout", type=int, default=DEFAULT_IDLE_TIMEOUT)
147
+ parser.add_argument("--startup-timeout", type=_positive_float, default=DEFAULT_STARTUP_TIMEOUT)
148
+
149
+
150
+ def _positive_float(value: str) -> float:
151
+ parsed = float(value)
152
+ if not math.isfinite(parsed) or parsed <= 0:
153
+ raise argparse.ArgumentTypeError("value must be greater than zero")
154
+ return parsed
139
155
 
140
156
 
141
157
  def main(argv: list[str] | None = None) -> int:
@@ -173,12 +189,22 @@ def _discard_broken_stdout() -> None:
173
189
 
174
190
 
175
191
  def _view(args: argparse.Namespace) -> None:
176
- index = build_codebase_index(args.root, project_file=args.project_file, index_projects=args.deep_projects)
192
+ index = build_codebase_index(
193
+ args.root,
194
+ project_file=args.project_file,
195
+ index_projects=args.deep_projects,
196
+ workers=args.workers,
197
+ )
177
198
  sys.stdout.write(render_layer(index, args.layer, query=args.query, output_format=args.format))
178
199
 
179
200
 
180
201
  def _index(args: argparse.Namespace) -> None:
181
- index = build_codebase_index(args.root, project_file=args.project_file, index_projects=True)
202
+ index = build_codebase_index(
203
+ args.root,
204
+ project_file=args.project_file,
205
+ index_projects=True,
206
+ workers=args.workers,
207
+ )
182
208
  payload = {
183
209
  "overview": layer_payload(index, "overview"),
184
210
  "projects": layer_payload(index, "projects"),
@@ -207,7 +233,7 @@ def _opencode_install(args: argparse.Namespace) -> None:
207
233
 
208
234
 
209
235
  def _worker(args: argparse.Namespace) -> None:
210
- context = AgentContext.open(args.root, args.project_file)
236
+ context = AgentContext.open(args.root, args.project_file, workers=args.workers)
211
237
  try:
212
238
  _serve_worker(context, sys.stdin.buffer, sys.stdout.buffer, sys.stderr)
213
239
  finally:
@@ -235,7 +261,14 @@ def _cache_error(error: CacheClientError) -> int:
235
261
 
236
262
  def _cache_start(args: argparse.Namespace) -> int:
237
263
  try:
238
- start_cache(args.root, project_file=args.project_file, max_memory_bytes=args.max_memory, idle_timeout=args.idle_timeout)
264
+ start_cache(
265
+ args.root,
266
+ project_file=args.project_file,
267
+ max_memory_bytes=args.max_memory,
268
+ workers=args.workers,
269
+ idle_timeout=args.idle_timeout,
270
+ startup_timeout=args.startup_timeout,
271
+ )
239
272
  response = query_cache(args.root, {"action": "status"})
240
273
  except CacheClientError as error:
241
274
  return _cache_error(error)
@@ -283,7 +316,13 @@ def _cache_stop(args: argparse.Namespace) -> int:
283
316
 
284
317
 
285
318
  def _cache_serve(args: argparse.Namespace) -> int:
286
- run_cache_daemon(args.root, project_file=str(args.project_file or ""), max_memory_bytes=args.max_memory, idle_timeout=args.idle_timeout)
319
+ run_cache_daemon(
320
+ args.root,
321
+ project_file=str(args.project_file or ""),
322
+ max_memory_bytes=args.max_memory,
323
+ workers=args.workers,
324
+ idle_timeout=args.idle_timeout,
325
+ )
287
326
  return 0
288
327
 
289
328