python-delphi-lsp 2.0.5__py3-none-any.whl → 2.1.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.1.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
@@ -26,13 +27,15 @@ from .agent_protocol import AgentProtocolError
26
27
 
27
28
  DEFAULT_MAX_MEMORY_BYTES = 512 * 1024**2
28
29
  WARNING_THRESHOLD_PERCENT = 80
29
- DAEMON_SCHEMA = 1
30
+ DAEMON_SCHEMA = 2
30
31
  DEFAULT_IDLE_TIMEOUT = 1800
32
+ DEFAULT_STARTUP_TIMEOUT = 120.0
31
33
  _MAX_MESSAGE_BYTES = 1024 * 1024
32
34
  _CONNECTION_TIMEOUT = 2.0
33
35
  _MEMORY_SIZE = re.compile(r"^(?P<count>[1-9][0-9]*)(?P<suffix>[KMG]?)$", re.IGNORECASE)
34
36
  _STARTUP_DIAGNOSTIC_BYTES = 16 * 1024
35
37
  _STARTUP_TOKEN_RE = re.compile(r"(?i)(token\b[^\n\r]*?:?\s*['\"]?[A-Za-z0-9_-]+['\"]?|\b[a-zA-Z0-9_-]{32,})")
38
+ _START_LOCK_INCOMPLETE_GRACE_SECONDS = 1.0
36
39
 
37
40
 
38
41
  def estimate_deep_size(value: object) -> int:
@@ -72,6 +75,7 @@ class CacheStats:
72
75
  rebuilds: int = 0
73
76
  invalidations: int = 0
74
77
  evictions: int = 0
78
+ parallel_fallbacks: int = 0
75
79
 
76
80
 
77
81
  @dataclass(frozen=True)
@@ -232,6 +236,7 @@ class CacheMetadata:
232
236
  version: str
233
237
  project_file: str
234
238
  max_memory_bytes: int
239
+ workers: int
235
240
  idle_timeout: int
236
241
  started_at: float
237
242
 
@@ -291,7 +296,7 @@ def _write_metadata(metadata: CacheMetadata) -> None:
291
296
  os.unlink(temporary)
292
297
 
293
298
 
294
- def _read_metadata(root: str | Path) -> CacheMetadata | None:
299
+ def _read_metadata_record(root: str | Path) -> object | None:
295
300
  path = _safe_metadata_path(root)
296
301
  if not path.exists():
297
302
  return None
@@ -309,15 +314,36 @@ def _read_metadata(root: str | Path) -> CacheMetadata | None:
309
314
  raise
310
315
  except (OSError, ValueError, UnicodeError):
311
316
  return None
317
+ return raw
318
+
319
+
320
+ def _read_metadata(root: str | Path) -> CacheMetadata | None:
321
+ raw = _read_metadata_record(root)
312
322
  required = {field.name for field in fields(CacheMetadata)}
313
323
  if not isinstance(raw, dict) or set(raw) != required:
314
324
  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"))
325
+ values = tuple(
326
+ raw[name]
327
+ for name in (
328
+ "schema",
329
+ "root",
330
+ "pid",
331
+ "port",
332
+ "token",
333
+ "version",
334
+ "project_file",
335
+ "max_memory_bytes",
336
+ "workers",
337
+ "idle_timeout",
338
+ "started_at",
339
+ )
340
+ )
316
341
  if (type(values[0]) is not int or values[0] != DAEMON_SCHEMA or not isinstance(values[1], str)
317
342
  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
343
  or not isinstance(values[4], str) or len(values[4]) < 32 or not isinstance(values[5], str)
319
344
  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)):
345
+ or type(values[8]) is not int or not 0 <= values[8] <= 32
346
+ or type(values[9]) is not int or values[9] <= 0 or type(values[10]) not in (int, float)):
321
347
  return None
322
348
  canonical = str(Path(root).resolve())
323
349
  if values[1] != canonical:
@@ -325,15 +351,56 @@ def _read_metadata(root: str | Path) -> CacheMetadata | None:
325
351
  return CacheMetadata(*values)
326
352
 
327
353
 
354
+ def _remove_incompatible_metadata_if_stale(root: str | Path) -> None:
355
+ path = _safe_metadata_path(root)
356
+ if not path.exists():
357
+ return
358
+ raw = _read_metadata_record(root)
359
+ canonical = str(Path(root).resolve())
360
+ pid = raw.get("pid") if isinstance(raw, dict) and raw.get("root") == canonical else None
361
+ if type(pid) is int and pid > 0 and _pid_alive(pid):
362
+ raise CacheClientError("unavailable", "Live cache daemon is unavailable.")
363
+ with contextlib.suppress(FileNotFoundError):
364
+ path.unlink()
365
+
366
+
367
+ def _metadata_is_owned(metadata: CacheMetadata) -> bool:
368
+ current = _read_metadata(metadata.root)
369
+ return current is not None and current.pid == metadata.pid and hmac.compare_digest(current.token, metadata.token)
370
+
371
+
328
372
  def _remove_metadata_if_owned(metadata: CacheMetadata) -> None:
329
373
  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):
374
+ if _metadata_is_owned(metadata):
332
375
  with contextlib.suppress(FileNotFoundError):
333
376
  path.unlink()
334
377
 
335
378
 
379
+ def _windows_pid_alive(pid: int) -> bool:
380
+ import ctypes
381
+ from ctypes import wintypes
382
+
383
+ synchronize = 0x00100000
384
+ wait_timeout = 0x00000102
385
+ kernel32 = ctypes.windll.kernel32
386
+ kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
387
+ kernel32.OpenProcess.restype = wintypes.HANDLE
388
+ kernel32.WaitForSingleObject.argtypes = (wintypes.HANDLE, wintypes.DWORD)
389
+ kernel32.WaitForSingleObject.restype = wintypes.DWORD
390
+ kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
391
+ kernel32.CloseHandle.restype = wintypes.BOOL
392
+ handle = kernel32.OpenProcess(synchronize, False, pid)
393
+ if not handle:
394
+ return False
395
+ try:
396
+ return kernel32.WaitForSingleObject(handle, 0) == wait_timeout
397
+ finally:
398
+ kernel32.CloseHandle(handle)
399
+
400
+
336
401
  def _pid_alive(pid: int) -> bool:
402
+ if os.name == "nt":
403
+ return _windows_pid_alive(pid)
337
404
  try:
338
405
  os.kill(pid, 0)
339
406
  except OSError:
@@ -341,15 +408,41 @@ def _pid_alive(pid: int) -> bool:
341
408
  return True
342
409
 
343
410
 
411
+ def _reap_child_if_exited(pid: int) -> bool:
412
+ waitpid = getattr(os, "waitpid", None)
413
+ nohang = getattr(os, "WNOHANG", None)
414
+ if not callable(waitpid) or nohang is None:
415
+ return False
416
+ try:
417
+ return waitpid(pid, nohang)[0] == pid
418
+ except (ChildProcessError, OSError):
419
+ return False
420
+
421
+
344
422
  def _start_lock_path(root: str | Path) -> Path:
345
423
  return _safe_metadata_path(root, create=True).with_name("start.lock")
346
424
 
347
425
 
426
+ def _start_lock_is_stale(path: Path) -> bool:
427
+ try:
428
+ age = max(0.0, time.time() - path.stat().st_mtime)
429
+ except OSError:
430
+ return True
431
+ try:
432
+ record = json.loads(path.read_text(encoding="utf-8"))
433
+ except (OSError, ValueError, UnicodeError):
434
+ return age >= _START_LOCK_INCOMPLETE_GRACE_SECONDS
435
+ owner = record.get("pid") if isinstance(record, dict) else None
436
+ if type(owner) is int and owner > 0:
437
+ return not _pid_alive(owner)
438
+ return age >= _START_LOCK_INCOMPLETE_GRACE_SECONDS
439
+
440
+
348
441
  @contextlib.contextmanager
349
- def _start_lock(root: str | Path):
442
+ def _start_lock(root: str | Path, timeout: float):
350
443
  path = _start_lock_path(root)
351
444
  token = secrets.token_urlsafe(24)
352
- deadline = time.monotonic() + 10
445
+ deadline = time.monotonic() + timeout
353
446
  while True:
354
447
  try:
355
448
  descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
@@ -361,14 +454,7 @@ def _start_lock(root: str | Path):
361
454
  os.close(descriptor)
362
455
  break
363
456
  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:
457
+ if _start_lock_is_stale(path):
372
458
  with contextlib.suppress(FileNotFoundError):
373
459
  path.unlink()
374
460
  continue
@@ -450,7 +536,12 @@ def _read_startup_tail(diagnostics: object, *, max_bytes: int = _STARTUP_DIAGNOS
450
536
  class _CacheService:
451
537
  def __init__(self, metadata: CacheMetadata) -> None:
452
538
  self.metadata = metadata
453
- self.context = AgentContext.open(metadata.root, metadata.project_file or None)
539
+ self.context = AgentContext.open(
540
+ metadata.root,
541
+ metadata.project_file or None,
542
+ workers=metadata.workers,
543
+ worker_memory_budget_bytes=metadata.max_memory_bytes,
544
+ )
454
545
  self.budget = CacheBudget(metadata.max_memory_bytes)
455
546
  self.stats = CacheStats()
456
547
  self.lock = threading.Lock()
@@ -459,9 +550,11 @@ class _CacheService:
459
550
  self.cache_state = "warming"
460
551
  self.last_revision = self.context.workspace.workspace_revision
461
552
  self.last_budget = BudgetResult(0, 0.0, 0.0, False, False, False)
553
+ self.prewarm_seconds = 0.0
462
554
  self.shutdown = threading.Event()
463
555
 
464
556
  def prewarm(self) -> None:
557
+ started = time.monotonic()
465
558
  try:
466
559
  self.context.handle({"action": "find", "query": "", "max_items": 1, "max_chars": 256})
467
560
  self.last_revision = self.context.workspace.workspace_revision
@@ -478,6 +571,8 @@ class _CacheService:
478
571
  if self.last_budget.compacted:
479
572
  self.stats.evictions += 1
480
573
  self.cache_state = "compact"
574
+ self.prewarm_seconds = time.monotonic() - started
575
+ self.stats.parallel_fallbacks = self.context.parallel_stats.fallbacks
481
576
 
482
577
  def request(self, request: dict[str, object]) -> CacheClientResponse:
483
578
  with self.lock:
@@ -506,6 +601,7 @@ class _CacheService:
506
601
  self.stats.warm_hits += 1
507
602
  else:
508
603
  self.stats.rebuilds += 1
604
+ self.stats.parallel_fallbacks += self.context.parallel_stats.fallbacks
509
605
  if before != after:
510
606
  self.stats.invalidations += 1
511
607
  self.last_budget = self.budget.enforce(
@@ -539,6 +635,12 @@ class _CacheService:
539
635
  "warning_active": self.last_budget.warning_active, "warning_threshold_percent": WARNING_THRESHOLD_PERCENT,
540
636
  "cache_state": self.cache_state, "requests": self.stats.requests, "warm_hits": self.stats.warm_hits,
541
637
  "rebuilds": self.stats.rebuilds, "invalidations": self.stats.invalidations, "evictions": self.stats.evictions,
638
+ "workers_configured": "auto" if self.metadata.workers == 0 else self.metadata.workers,
639
+ "workers_effective": self.context.parallel_stats.effective_workers,
640
+ "parallel_files_completed": self.context.parallel_stats.files_completed,
641
+ "prewarm_seconds": self.prewarm_seconds,
642
+ "parallel_seconds": self.context.parallel_stats.elapsed_seconds,
643
+ "parallel_fallbacks": self.stats.parallel_fallbacks,
542
644
  "idle_timeout": self.metadata.idle_timeout, "idle_remaining": max(0.0, self.metadata.idle_timeout - idle),
543
645
  "workspace_revision": self.context.workspace.workspace_revision,
544
646
  }
@@ -562,14 +664,33 @@ def _serve_connection(connection: socket.socket, service: _CacheService) -> None
562
664
  connection.sendall(json.dumps(response, separators=(",", ":")).encode("utf-8") + b"\n")
563
665
 
564
666
 
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:
667
+ def run_cache_daemon(
668
+ root: str | Path,
669
+ *,
670
+ project_file: str = "",
671
+ max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES,
672
+ workers: int = 0,
673
+ idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
674
+ ) -> None:
566
675
  canonical = str(Path(root).resolve())
567
676
  listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
568
677
  listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
569
678
  listener.bind(("127.0.0.1", 0))
570
679
  listener.listen(16)
571
680
  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())
681
+ metadata = CacheMetadata(
682
+ DAEMON_SCHEMA,
683
+ canonical,
684
+ os.getpid(),
685
+ listener.getsockname()[1],
686
+ secrets.token_urlsafe(32),
687
+ __version__,
688
+ project_file,
689
+ max_memory_bytes,
690
+ workers,
691
+ idle_timeout,
692
+ time.time(),
693
+ )
573
694
  try:
574
695
  service = _CacheService(metadata)
575
696
  service.prewarm()
@@ -587,7 +708,15 @@ def run_cache_daemon(root: str | Path, *, project_file: str = "", max_memory_byt
587
708
  _remove_metadata_if_owned(metadata)
588
709
 
589
710
 
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:
711
+ def _start_cache_unlocked(
712
+ root: str | Path,
713
+ *,
714
+ project_file: str | Path | None = None,
715
+ max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES,
716
+ workers: int = 0,
717
+ idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
718
+ startup_timeout: float = DEFAULT_STARTUP_TIMEOUT,
719
+ ) -> CacheMetadata:
591
720
  canonical = str(Path(root).resolve())
592
721
  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
722
  existing = _read_metadata(canonical)
@@ -596,12 +725,32 @@ def _start_cache_unlocked(root: str | Path, *, project_file: str | Path | None =
596
725
  _client_exchange(existing, {"action": "status", "_startup_probe": True})
597
726
  except CacheClientError as error:
598
727
  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):
728
+ if (existing.project_file, existing.max_memory_bytes, existing.workers, existing.idle_timeout) != (
729
+ project,
730
+ max_memory_bytes,
731
+ workers,
732
+ idle_timeout,
733
+ ):
600
734
  raise CacheClientError("configuration_conflict", "A live cache daemon has conflicting configuration.")
601
735
  return existing
602
736
  if existing:
603
737
  _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)]
738
+ else:
739
+ _remove_incompatible_metadata_if_stale(canonical)
740
+ command = [
741
+ sys.executable,
742
+ "-m",
743
+ "delphi_lsp.agent_cache",
744
+ "serve",
745
+ "--root",
746
+ canonical,
747
+ "--max-memory",
748
+ str(max_memory_bytes),
749
+ "--workers",
750
+ str(workers),
751
+ "--idle-timeout",
752
+ str(idle_timeout),
753
+ ]
605
754
  if project:
606
755
  command.extend(("--project-file", project))
607
756
  options: dict[str, object] = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL}
@@ -612,7 +761,7 @@ def _start_cache_unlocked(root: str | Path, *, project_file: str | Path | None =
612
761
  with tempfile.TemporaryFile() as diagnostics:
613
762
  options["stderr"] = diagnostics
614
763
  process = subprocess.Popen(command, **options)
615
- deadline = time.monotonic() + 10
764
+ deadline = time.monotonic() + startup_timeout
616
765
  startup_ready = False
617
766
  try:
618
767
  while time.monotonic() < deadline:
@@ -649,14 +798,34 @@ def _start_cache_unlocked(root: str | Path, *, project_file: str | Path | None =
649
798
  process.wait()
650
799
 
651
800
 
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)
801
+ def start_cache(
802
+ root: str | Path,
803
+ *,
804
+ project_file: str | Path | None = None,
805
+ max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES,
806
+ workers: int = 0,
807
+ idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
808
+ startup_timeout: float = DEFAULT_STARTUP_TIMEOUT,
809
+ ) -> CacheMetadata:
810
+ if type(workers) is not int or not 0 <= workers <= 32:
811
+ raise ValueError("workers must be auto or an integer from 1 through 32.")
812
+ if not math.isfinite(startup_timeout) or startup_timeout <= 0:
813
+ raise ValueError("startup_timeout must be greater than zero.")
814
+ with _start_lock(root, startup_timeout):
815
+ return _start_cache_unlocked(
816
+ root,
817
+ project_file=project_file,
818
+ max_memory_bytes=max_memory_bytes,
819
+ workers=workers,
820
+ idle_timeout=idle_timeout,
821
+ startup_timeout=startup_timeout,
822
+ )
655
823
 
656
824
 
657
825
  def query_cache(root: str | Path, request: dict[str, object]) -> CacheClientResponse:
658
826
  metadata = _read_metadata(root)
659
827
  if metadata is None:
828
+ _remove_incompatible_metadata_if_stale(root)
660
829
  raise CacheClientError("cache_not_running", "Cache daemon is not running.")
661
830
  if not _pid_alive(metadata.pid):
662
831
  _remove_metadata_if_owned(metadata)
@@ -671,6 +840,7 @@ def cache_status(root: str | Path) -> dict[str, object]:
671
840
  def stop_cache(root: str | Path) -> None:
672
841
  metadata = _read_metadata(root)
673
842
  if metadata is None:
843
+ _remove_incompatible_metadata_if_stale(root)
674
844
  return
675
845
  if not _pid_alive(metadata.pid):
676
846
  _remove_metadata_if_owned(metadata)
@@ -681,11 +851,13 @@ def stop_cache(root: str | Path) -> None:
681
851
  pass
682
852
  deadline = time.monotonic() + 3
683
853
  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
854
+ while time.monotonic() < deadline:
855
+ if _reap_child_if_exited(metadata.pid):
856
+ stopped = True
857
+ break
858
+ if not _pid_alive(metadata.pid):
859
+ stopped = True
860
+ break
689
861
  time.sleep(0.05)
690
862
  if not stopped and _pid_alive(metadata.pid):
691
863
  raise CacheClientError("stop_failed", "Cache daemon did not stop.")
@@ -699,10 +871,17 @@ def main(argv: list[str] | None = None) -> int:
699
871
  serve.add_argument("--root", required=True)
700
872
  serve.add_argument("--project-file", default="")
701
873
  serve.add_argument("--max-memory", type=int, default=DEFAULT_MAX_MEMORY_BYTES)
874
+ serve.add_argument("--workers", type=int, default=0)
702
875
  serve.add_argument("--idle-timeout", type=int, default=DEFAULT_IDLE_TIMEOUT)
703
876
  args = parser.parse_args(argv)
704
877
  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)
878
+ run_cache_daemon(
879
+ args.root,
880
+ project_file=args.project_file,
881
+ max_memory_bytes=args.max_memory,
882
+ workers=args.workers,
883
+ idle_timeout=args.idle_timeout,
884
+ )
706
885
  return 0
707
886
 
708
887
 
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
 
@@ -22,12 +22,12 @@ from .agent_metrics import build_workspace_metrics, project_metric_item, unit_me
22
22
  from .agent_relations import ProjectRelationIndex, RelationTarget
23
23
  from .agent_workspace import AgentUnit, AgentWorkspace, unit_display_path, unit_source_path, unit_target_id
24
24
  from .consts import AttributeName, SyntaxNodeType
25
- from .lsp_server import build_outline_semantic_model, multiline_string_block_end
25
+ from .lsp_server import multiline_string_block_end
26
26
  from .nodes import CompoundSyntaxNode, SyntaxNode
27
27
  from .parser import DelphiParser
28
28
  from .semantic import Scope, ScopeKind, Symbol, SymbolKind
29
- from .source_reader import read_source_text
30
29
  from .metrics import ProjectMetrics
30
+ from .parallel_outline import OutlineResult, OutlineTask, ParallelBuildStats, run_outline_tasks
31
31
 
32
32
 
33
33
  _ROUTINE_KINDS = frozenset(
@@ -239,8 +239,17 @@ class _Registry:
239
239
 
240
240
 
241
241
  class AgentContext:
242
- def __init__(self, workspace: AgentWorkspace) -> None:
242
+ def __init__(
243
+ self,
244
+ workspace: AgentWorkspace,
245
+ *,
246
+ workers: int = 0,
247
+ worker_memory_budget_bytes: int | None = None,
248
+ ) -> None:
243
249
  self._workspace = workspace
250
+ self._workers = workers
251
+ self._worker_memory_budget_bytes = worker_memory_budget_bytes
252
+ self._parallel_stats = ParallelBuildStats(0, 0, 0, 0.0, 0)
244
253
  project_id = workspace.active_project_id
245
254
  self._focus = Focus(project_id=project_id) if project_id else Focus()
246
255
  self._last_revision = workspace.workspace_revision
@@ -254,8 +263,15 @@ class AgentContext:
254
263
  cls,
255
264
  root: str | Path,
256
265
  project_file: str | Path | None = None,
266
+ *,
267
+ workers: int = 0,
268
+ worker_memory_budget_bytes: int | None = None,
257
269
  ) -> AgentContext:
258
- return cls(AgentWorkspace.open(root, project_file=project_file))
270
+ return cls(
271
+ AgentWorkspace.open(root, project_file=project_file),
272
+ workers=workers,
273
+ worker_memory_budget_bytes=worker_memory_budget_bytes,
274
+ )
259
275
 
260
276
  @property
261
277
  def workspace(self) -> AgentWorkspace:
@@ -265,6 +281,10 @@ class AgentContext:
265
281
  def navigation_cache_is_warm(self) -> bool:
266
282
  return self._registry is not None
267
283
 
284
+ @property
285
+ def parallel_stats(self) -> ParallelBuildStats:
286
+ return self._parallel_stats
287
+
268
288
  def cache_roots(self) -> tuple[object, ...]:
269
289
  return (
270
290
  *self._workspace.cache_roots(),
@@ -507,7 +527,13 @@ class AgentContext:
507
527
  and self._registry.revision == revision
508
528
  ):
509
529
  return self._registry
510
- self._registry = _build_registry(self._workspace, project_id, revision)
530
+ self._registry, self._parallel_stats = _build_registry(
531
+ self._workspace,
532
+ project_id,
533
+ revision,
534
+ workers=self._workers,
535
+ worker_memory_budget_bytes=self._worker_memory_budget_bytes,
536
+ )
511
537
  if self._focus.target_id:
512
538
  focused_entry = self._registry.by_target.get(self._focus.target_id)
513
539
  if focused_entry is None:
@@ -696,38 +722,54 @@ def _validated_request(request: AgentRequest | Mapping[str, object]) -> AgentReq
696
722
  return AgentRequest.from_mapping(request)
697
723
 
698
724
 
699
- def _build_registry(workspace: AgentWorkspace, project_id: str, revision: str) -> _Registry:
725
+ def _build_registry(
726
+ workspace: AgentWorkspace,
727
+ project_id: str,
728
+ revision: str,
729
+ *,
730
+ workers: int = 0,
731
+ worker_memory_budget_bytes: int | None = None,
732
+ ) -> tuple[_Registry, ParallelBuildStats]:
700
733
  raw_symbols: list[_RawSymbol] = []
701
734
  sources: dict[Path, _SourceDocument] = {}
702
- for unit in workspace.units:
735
+ units = tuple(workspace.units)
736
+
737
+ def consume_result(result: OutlineResult) -> None:
738
+ unit = units[result.ordinal]
703
739
  source_path = unit_source_path(workspace.root, unit)
704
740
  display_path = unit_display_path(workspace.root, unit)
705
- try:
706
- text = read_source_text(source_path)
707
- except OSError as exc:
741
+ if result.read_error or result.model is None:
708
742
  raise AgentProtocolError(
709
743
  "source_unavailable",
710
- f"Could not read selected source {unit.path}: {exc}.",
711
- ) from None
744
+ f"Could not read selected source {display_path}.",
745
+ )
712
746
  document = _SourceDocument(
713
747
  source_path,
714
748
  display_path,
715
- text,
749
+ result.text,
716
750
  defines=workspace.defines,
717
751
  include_paths=workspace.include_paths,
718
752
  )
719
753
  sources[source_path] = document
720
- if workspace.defines:
721
- model = build_outline_semantic_model(
722
- text,
723
- str(source_path),
724
- defines=workspace.defines,
725
- )
726
- else:
727
- model = build_outline_semantic_model(text, str(source_path))
728
- unit_symbols = _collect_raw_symbols(model.unit_scope, unit, source_path, document)
754
+ unit_symbols = _collect_raw_symbols(result.model.unit_scope, unit, source_path, document)
729
755
  raw_symbols.extend(_exclude_routine_locals(unit_symbols, document))
730
756
 
757
+ outline_batch = run_outline_tasks(
758
+ (
759
+ OutlineTask(
760
+ ordinal,
761
+ str(unit_source_path(workspace.root, unit)),
762
+ workspace.defines,
763
+ True,
764
+ )
765
+ for ordinal, unit in enumerate(units)
766
+ ),
767
+ configured_workers=workers,
768
+ memory_budget_bytes=worker_memory_budget_bytes,
769
+ on_complete=consume_result,
770
+ retain_results=False,
771
+ )
772
+
731
773
  ordered = sorted(raw_symbols, key=_raw_sort_key)
732
774
  overload_groups: dict[tuple[str, str, str, str], list[_RawSymbol]] = {}
733
775
  for raw in raw_symbols:
@@ -817,13 +859,16 @@ def _build_registry(workspace: AgentWorkspace, project_id: str, revision: str) -
817
859
  )
818
860
 
819
861
  entries_tuple = tuple(sorted(with_parents, key=_entry_sort_key))
820
- return _Registry(
821
- project_id=project_id,
822
- revision=revision,
823
- entries=entries_tuple,
824
- by_target={entry.target_id: entry for entry in entries_tuple},
825
- sources=sources,
826
- ranked_queries={},
862
+ return (
863
+ _Registry(
864
+ project_id=project_id,
865
+ revision=revision,
866
+ entries=entries_tuple,
867
+ by_target={entry.target_id: entry for entry in entries_tuple},
868
+ sources=sources,
869
+ ranked_queries={},
870
+ ),
871
+ outline_batch.stats,
827
872
  )
828
873
 
829
874
 
@@ -5,8 +5,8 @@ from pathlib import Path
5
5
  from typing import Any, Iterable
6
6
  import json
7
7
 
8
- from .lsp_server import build_outline_semantic_model
9
8
  from .metrics import analyze_project
9
+ from .parallel_outline import OutlineResult, OutlineTask, ParallelBuildStats, run_outline_tasks
10
10
  from .project_discovery import DelphiProjectDiscovery, discover_delphi_project
11
11
  from .project_indexer import ProjectIndexResult, ProjectIndexer
12
12
  from .progress import ProgressCallback, ProgressEvent
@@ -22,6 +22,7 @@ class CodebaseIndex:
22
22
  models: dict[str, SemanticModel]
23
23
  symbol_index: SymbolIndex
24
24
  project_results: dict[str, ProjectIndexResult]
25
+ parallel_stats: ParallelBuildStats = ParallelBuildStats(0, 0, 0, 0.0, 0)
25
26
 
26
27
 
27
28
  def build_codebase_index(
@@ -30,40 +31,49 @@ def build_codebase_index(
30
31
  project_file: str | Path | None = None,
31
32
  index_projects: bool = False,
32
33
  on_progress: ProgressCallback | None = None,
34
+ workers: int = 0,
33
35
  ) -> CodebaseIndex:
34
36
  progress = _MonotonicProgress(on_progress)
35
37
  discovery = discover_delphi_project(root, project_file=project_file, on_progress=progress)
36
38
  models: dict[str, SemanticModel] = {}
37
39
  lines_processed = 0
38
40
  symbols_discovered = 0
39
- for completed, source in enumerate(discovery.source_files, start=1):
40
- path = Path(source)
41
- if path.suffix.casefold() not in {".pas", ".dpr", ".dpk", ".inc"}:
42
- continue
43
- try:
44
- text = read_source_text(path)
45
- except (OSError, UnicodeError):
46
- continue
47
- model = build_outline_semantic_model(
48
- text,
49
- source,
50
- defines=discovery.defines,
51
- )
52
- models[source] = model
53
- lines_processed += text.count("\n") + (0 if not text or text.endswith("\n") else 1)
54
- symbols_discovered += sum(len(items) for items in model.index.name_index.values())
41
+ completed_outlines = 0
42
+ outline_tasks = tuple(
43
+ OutlineTask(ordinal, source, tuple(discovery.defines), False)
44
+ for ordinal, source in enumerate(discovery.source_files)
45
+ if Path(source).suffix.casefold() in {".pas", ".dpr", ".dpk", ".inc"}
46
+ )
47
+ outline_task_count = len(outline_tasks)
48
+
49
+ def on_outline_complete(result: OutlineResult) -> None:
50
+ nonlocal completed_outlines, lines_processed, symbols_discovered
51
+ completed_outlines += 1
52
+ if not result.read_error:
53
+ lines_processed += result.lines_processed
54
+ symbols_discovered += result.symbols_discovered
55
55
  _emit_progress(
56
56
  progress,
57
57
  "outline",
58
- source,
59
- len(discovery.source_files),
60
- completed,
58
+ result.source_path,
61
59
  len(discovery.source_files),
62
- "source outlined",
60
+ completed_outlines,
61
+ outline_task_count,
62
+ "source unreadable" if result.read_error else "source outlined",
63
63
  lines_processed=lines_processed,
64
64
  symbols_discovered=symbols_discovered,
65
65
  )
66
66
 
67
+ outline_batch = run_outline_tasks(
68
+ outline_tasks,
69
+ configured_workers=workers,
70
+ on_complete=on_outline_complete,
71
+ )
72
+ for result in outline_batch.results:
73
+ if result.read_error or result.model is None:
74
+ continue
75
+ models[result.source_path] = result.model
76
+
67
77
  symbol_index = SymbolIndex()
68
78
  for model in models.values():
69
79
  symbol_index.register_unit(model.unit_scope.name, model.unit_scope)
@@ -99,6 +109,7 @@ def build_codebase_index(
99
109
  models=models,
100
110
  symbol_index=symbol_index,
101
111
  project_results=project_results,
112
+ parallel_stats=outline_batch.stats,
102
113
  )
103
114
  _emit_progress(
104
115
  progress,
@@ -0,0 +1,278 @@
1
+ from __future__ import annotations
2
+
3
+ from concurrent.futures import ProcessPoolExecutor, as_completed
4
+ from concurrent.futures.process import BrokenProcessPool
5
+ from dataclasses import dataclass
6
+ import multiprocessing
7
+ import os
8
+ from pathlib import Path
9
+ from time import perf_counter
10
+ from typing import Callable, Iterable
11
+
12
+ from .lsp_server import build_outline_semantic_model
13
+ from .semantic_builder import SemanticModel
14
+ from .source_reader import read_source_text
15
+
16
+
17
+ _MEBIBYTE = 1024 * 1024
18
+ _WORKER_MEMORY_BYTES = 128 * _MEBIBYTE
19
+
20
+
21
+ class ParallelOutlineError(RuntimeError):
22
+ """Raised when a parallel outline operation cannot complete."""
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class OutlineTask:
27
+ ordinal: int
28
+ source_path: str
29
+ defines: tuple[str, ...]
30
+ return_text: bool
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class OutlineResult:
35
+ ordinal: int
36
+ source_path: str
37
+ text: str
38
+ model: SemanticModel | None
39
+ lines_processed: int
40
+ symbols_discovered: int
41
+ read_error: str = ""
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class ParallelBuildStats:
46
+ configured_workers: int
47
+ effective_workers: int
48
+ files_completed: int
49
+ elapsed_seconds: float
50
+ fallbacks: int
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class OutlineBatch:
55
+ results: tuple[OutlineResult, ...]
56
+ stats: ParallelBuildStats
57
+
58
+
59
+ def parse_worker_setting(value: str) -> int:
60
+ if value == "auto":
61
+ return 0
62
+ try:
63
+ workers = int(value)
64
+ except ValueError as error:
65
+ raise ValueError("workers must be 'auto' or an integer from 1 through 32") from error
66
+ if not 1 <= workers <= 32:
67
+ raise ValueError("workers must be 'auto' or an integer from 1 through 32")
68
+ return workers
69
+
70
+
71
+ def resolve_worker_count(
72
+ configured_workers: int,
73
+ *,
74
+ task_count: int,
75
+ cpu_count: int | None = None,
76
+ memory_budget_bytes: int | None = None,
77
+ ) -> int:
78
+ if not 0 <= configured_workers <= 32:
79
+ raise ValueError("workers must be 'auto' or an integer from 1 through 32")
80
+ if task_count <= 0:
81
+ return 0
82
+ if configured_workers:
83
+ return min(configured_workers, task_count)
84
+
85
+ detected_cpus = os.cpu_count() if cpu_count is None else cpu_count
86
+ candidates = [task_count, 4, max(1, (detected_cpus or 1) - 1)]
87
+ if memory_budget_bytes is not None:
88
+ candidates.append(max(1, memory_budget_bytes // _WORKER_MEMORY_BYTES))
89
+ return min(candidates)
90
+
91
+
92
+ def _parse_outline_task(task: OutlineTask) -> OutlineResult:
93
+ try:
94
+ text = read_source_text(Path(task.source_path))
95
+ except (OSError, UnicodeError) as error:
96
+ return OutlineResult(
97
+ ordinal=task.ordinal,
98
+ source_path=task.source_path,
99
+ text="",
100
+ model=None,
101
+ lines_processed=0,
102
+ symbols_discovered=0,
103
+ read_error=str(error),
104
+ )
105
+
106
+ try:
107
+ model = build_outline_semantic_model(text, task.source_path, defines=task.defines)
108
+ except Exception as error:
109
+ raise ParallelOutlineError(f"failed to parse {task.source_path}: {error}") from error
110
+ return OutlineResult(
111
+ ordinal=task.ordinal,
112
+ source_path=task.source_path,
113
+ text=text if task.return_text else "",
114
+ model=model,
115
+ lines_processed=len(text.splitlines()),
116
+ symbols_discovered=sum(len(items) for items in model.index.name_index.values()),
117
+ )
118
+
119
+
120
+ def run_outline_tasks(
121
+ tasks: Iterable[OutlineTask],
122
+ *,
123
+ configured_workers: int,
124
+ memory_budget_bytes: int | None = None,
125
+ cpu_count: int | None = None,
126
+ on_complete: Callable[[OutlineResult], None] | None = None,
127
+ retain_results: bool = True,
128
+ ) -> OutlineBatch:
129
+ task_list = tuple(tasks)
130
+ started = perf_counter()
131
+ effective_workers = resolve_worker_count(
132
+ configured_workers,
133
+ task_count=len(task_list),
134
+ cpu_count=cpu_count,
135
+ memory_budget_bytes=memory_budget_bytes,
136
+ )
137
+ if effective_workers <= 1:
138
+ results, completed = _run_serial(task_list, on_complete, retain_results)
139
+ return _batch(
140
+ configured_workers,
141
+ effective_workers,
142
+ results,
143
+ started,
144
+ files_completed=completed,
145
+ fallbacks=0,
146
+ )
147
+
148
+ accepted: list[OutlineResult] = []
149
+ completed = 0
150
+ futures: dict[object, OutlineTask] = {}
151
+ executor: ProcessPoolExecutor | None = None
152
+ try:
153
+ context = multiprocessing.get_context("spawn")
154
+ executor = ProcessPoolExecutor(max_workers=effective_workers, mp_context=context)
155
+ task_iterator = iter(task_list)
156
+ for task in task_iterator:
157
+ futures[executor.submit(_parse_outline_task, task)] = task
158
+ if len(futures) == effective_workers:
159
+ break
160
+ while futures:
161
+ future = next(iter(as_completed(tuple(futures))))
162
+ task = futures.pop(future)
163
+ try:
164
+ result = future.result()
165
+ except ParallelOutlineError:
166
+ raise
167
+ except Exception as error:
168
+ wrapped = ParallelOutlineError(
169
+ f"parallel outline execution failed for {task.source_path}: {error}"
170
+ )
171
+ if configured_workers == 0 and completed == 0:
172
+ _cancel_futures(futures)
173
+ _shutdown_failed_executor(executor)
174
+ results, serial_completed = _run_serial(task_list, on_complete, retain_results)
175
+ return _batch(
176
+ configured_workers,
177
+ 1,
178
+ results,
179
+ started,
180
+ files_completed=serial_completed,
181
+ fallbacks=1,
182
+ )
183
+ raise wrapped from error
184
+ completed += 1
185
+ if on_complete is not None:
186
+ on_complete(result)
187
+ if retain_results:
188
+ accepted.append(result)
189
+ try:
190
+ next_task = next(task_iterator)
191
+ except StopIteration:
192
+ continue
193
+ futures[executor.submit(_parse_outline_task, next_task)] = next_task
194
+ except ParallelOutlineError:
195
+ _cancel_futures(futures)
196
+ _shutdown_failed_executor(executor)
197
+ raise
198
+ except (OSError, BrokenProcessPool) as error:
199
+ _cancel_futures(futures)
200
+ _shutdown_failed_executor(executor)
201
+ if configured_workers == 0 and completed == 0:
202
+ results, serial_completed = _run_serial(task_list, on_complete, retain_results)
203
+ return _batch(
204
+ configured_workers,
205
+ 1,
206
+ results,
207
+ started,
208
+ files_completed=serial_completed,
209
+ fallbacks=1,
210
+ )
211
+ raise ParallelOutlineError(f"parallel outline execution failed: {error}") from error
212
+ except BaseException:
213
+ _cancel_futures(futures)
214
+ _shutdown_failed_executor(executor)
215
+ raise
216
+
217
+ assert executor is not None
218
+ executor.shutdown(wait=True, cancel_futures=False)
219
+
220
+ return _batch(
221
+ configured_workers,
222
+ effective_workers,
223
+ accepted,
224
+ started,
225
+ files_completed=completed,
226
+ fallbacks=0,
227
+ )
228
+
229
+
230
+ def _run_serial(
231
+ tasks: tuple[OutlineTask, ...],
232
+ on_complete: Callable[[OutlineResult], None] | None,
233
+ retain_results: bool,
234
+ ) -> tuple[list[OutlineResult], int]:
235
+ results = []
236
+ for task in tasks:
237
+ result = _parse_outline_task(task)
238
+ if on_complete is not None:
239
+ on_complete(result)
240
+ if retain_results:
241
+ results.append(result)
242
+ return results, len(tasks)
243
+
244
+
245
+ def _cancel_futures(futures: dict[object, OutlineTask]) -> None:
246
+ for future in futures:
247
+ future.cancel()
248
+
249
+
250
+ def _shutdown_failed_executor(executor: ProcessPoolExecutor | None) -> None:
251
+ if executor is None:
252
+ return
253
+ try:
254
+ executor.shutdown(wait=False, cancel_futures=True)
255
+ except BaseException:
256
+ pass
257
+
258
+
259
+ def _batch(
260
+ configured_workers: int,
261
+ effective_workers: int,
262
+ results: list[OutlineResult],
263
+ started: float,
264
+ *,
265
+ files_completed: int,
266
+ fallbacks: int,
267
+ ) -> OutlineBatch:
268
+ ordered_results = tuple(sorted(results, key=lambda result: result.ordinal))
269
+ return OutlineBatch(
270
+ results=ordered_results,
271
+ stats=ParallelBuildStats(
272
+ configured_workers=configured_workers,
273
+ effective_workers=effective_workers,
274
+ files_completed=files_completed,
275
+ elapsed_seconds=perf_counter() - started,
276
+ fallbacks=fallbacks,
277
+ ),
278
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-delphi-lsp
3
- Version: 2.0.5
3
+ Version: 2.1.0
4
4
  Summary: Python Delphi/Object Pascal parser, semantic indexer, and language server.
5
5
  Author: Dark Light
6
6
  License-Expression: MPL-2.0
@@ -41,7 +41,7 @@ Dynamic: license-file
41
41
 
42
42
  `python-delphi-lsp` parses Delphi/Object Pascal, builds semantic and project
43
43
  indexes, serves LSP, and provides bounded codebase navigation for agents.
44
- Version 2.0.5 is authored by Dark Light and supports Windows, macOS, and Linux.
44
+ Version 2.1.0 is authored by Dark Light and supports Windows, macOS, and Linux.
45
45
 
46
46
  ## Install and quick start
47
47
 
@@ -186,12 +186,15 @@ problems; paths are not guessed.
186
186
 
187
187
  ```text
188
188
  delphi-lsp-agent cache start --root PATH [--project-file FILE] [--max-memory 512M]
189
+ [--workers auto|N] [--startup-timeout 120]
189
190
  [--idle-timeout 1800]
190
191
  delphi-lsp-agent cache status --root PATH [--format text|json]
191
192
  delphi-lsp-agent cache stop --root PATH
192
193
  delphi-lsp-agent view --root PATH [--project-file FILE] --layer LAYER
193
194
  [--query TEXT] [--format markdown|json] [--deep-projects]
195
+ [--workers auto|N]
194
196
  delphi-lsp-agent index --root PATH [--project-file FILE] [--out FILE]
197
+ [--workers auto|N]
195
198
  delphi-lsp-agent query --root PATH ACTION [VALUE]
196
199
  [--project-id FILE] [--detail summary|declaration|members|context|body|implementations]
197
200
  [--relation references|callers|callees|uses|used_by|inherits|implements]
@@ -199,7 +202,7 @@ delphi-lsp-agent query --root PATH ACTION [VALUE]
199
202
  delphi-lsp-agent skill install [--target PATH] [--force]
200
203
  delphi-lsp-agent opencode install [--target PATH] [--python PYTHON]
201
204
  [--force] [--write-agent|--write-config]
202
- delphi-lsp-agent worker --root PATH [--project-file FILE]
205
+ delphi-lsp-agent worker --root PATH [--project-file FILE] [--workers auto|N]
203
206
  ```
204
207
 
205
208
  The `cache` commands manage one daemon per canonical root. Use these:
@@ -233,6 +236,27 @@ fast. The cache retained-cache budget is `512 MiB` by default and tracks retaine
233
236
  cache usage only, not a hard RSS/parse peak. Warnings are emitted on stderr at or
234
237
  above 80 percent.
235
238
 
239
+ Cold builds parse independent source units in short-lived processes created with
240
+ the cross-platform `spawn` method. `--workers auto|N` defaults to `auto`.
241
+ Automatic selection uses at most four worker processes, leaves one detected CPU
242
+ free, never exceeds the source task count, and—for the cache daemon—allows one
243
+ worker per `128 MiB` of retained-cache budget. `view` and `index` use the same
244
+ task, CPU, and four-worker caps without the cache-budget term. An explicit value
245
+ from 1 through 32 overrides the automatic CPU and memory caps but is still
246
+ limited by the number of tasks.
247
+
248
+ Worker processes exit before retained-cache accounting. Their models and source
249
+ text are streamed into the parent, so transient worker memory is separate from
250
+ the retained navigation structures and the existing 80-percent warning. If an
251
+ automatic pool fails before accepting a result, one automatic serial fallback
252
+ is attempted; explicit worker counts fail instead of silently changing the
253
+ requested configuration.
254
+
255
+ `cache start` waits up to `--startup-timeout 120` seconds by default for a large
256
+ workspace to prewarm. The timeout belongs to the starting client and does not
257
+ change daemon compatibility or idle shutdown. Starting a live root with a
258
+ different worker configuration reports a configuration conflict.
259
+
236
260
  Eviction is ordered: auxiliary caches are evicted first, navigation caches second.
237
261
  If compaction removes navigable data, the daemon rebuilds the navigation state on demand
238
262
  while preserving focus state for the next request.
@@ -240,7 +264,9 @@ while preserving focus state for the next request.
240
264
  The daemon tracks a 30-minute idle timeout; idle state shows in JSON status (`cache status`).
241
265
  `source revision` changes on source edits and invalidate reused request caches.
242
266
  Workspace state appears in status as `requests`, `warm_hits`, `rebuilds`, `invalidations`,
243
- `evictions`, and `cache_state`.
267
+ `evictions`, and `cache_state`. Parallel prewarm status adds
268
+ `workers_configured`, `workers_effective`, `parallel_files_completed`,
269
+ `prewarm_seconds`, `parallel_seconds`, and `parallel_fallbacks`.
244
270
 
245
271
  Metadata is stored in `.delphi-lsp/agent-cache/daemon.json` with owner-only token and
246
272
  permissions (`daemon.json` mode 600 and parent 700). Do not copy or share this token
@@ -1,9 +1,9 @@
1
1
  delphi_lsp/__init__.py,sha256=mq3tF0NrXDgbkJXDuf5HhW6sOnmP7DQxs3zl8fGoAmo,3867
2
- delphi_lsp/_version.py,sha256=xEb7Z4b8xalXXExBg42XPAhbJKniHzcsEPjp-6S3ppg,22
3
- delphi_lsp/agent_cache.py,sha256=wWPdAnKtenNyjLXu8QDG5QO0GPo8MdR8cIltfaL8g3A,28700
4
- delphi_lsp/agent_cli.py,sha256=tr-MmWRPkevbtNKSnmcPJghTqD7zWJNPBVRvFicmb4M,15169
5
- delphi_lsp/agent_context.py,sha256=7f4J6oqs5hRlIqiTLy-OYZhqIJPAG73G-xm9BJq_MFw,78346
6
- delphi_lsp/agent_layers.py,sha256=ngFn-zH2Jv6CBjOhd3UkoyVgsrdXW0BChnQxSVmkRRc,25131
2
+ delphi_lsp/_version.py,sha256=Xybt2skBZamGMNlLuOX1IG-h4uIxqUDGAO8MIGWrJac,22
3
+ delphi_lsp/agent_cache.py,sha256=mPoT94TYuv4RXHLS0rVQWn7TmbfiU34BaEkLOXP18m4,33299
4
+ delphi_lsp/agent_cli.py,sha256=J2zmZP2D28pXi7tx06YyEqKsojbXQcO0LdZx27kGJ1M,16311
5
+ delphi_lsp/agent_context.py,sha256=IJpGWkFZGYq7FU4ijvaf-xqSWJAuvEWdn3AOeM5TcnE,79572
6
+ delphi_lsp/agent_layers.py,sha256=Waiwp47BuNxxwzM8EfGb0tvLSRn2udRHkTmuX2JE6cg,25658
7
7
  delphi_lsp/agent_metrics.py,sha256=6y9DrSnKitZbwRFQrofanw6k89YGqbvEacqgSKHgh2c,2695
8
8
  delphi_lsp/agent_protocol.py,sha256=cZ1LIMhrkOb5CqiGcjSH1eZUNqtJCYySUc54h-PCyOs,12551
9
9
  delphi_lsp/agent_relations.py,sha256=aOeRYa6pw8VO0OyQZLk1wHE529k9DIDm3ENPM5ZOJiw,32879
@@ -18,6 +18,7 @@ delphi_lsp/lark_tokens.py,sha256=xT_GES2z6p1IdN3EnoKE9RcUM6suu1RV3J-0FEJN2r8,417
18
18
  delphi_lsp/lsp_server.py,sha256=hfuv8QXGQIO2s4oWYWzOjujejXNcS_ITPQ9ikw4Ua1U,71468
19
19
  delphi_lsp/metrics.py,sha256=f70B1awypqVWZIW_SNpFHdShoS8OzDW5DWX5ftQMI0c,26166
20
20
  delphi_lsp/nodes.py,sha256=LW8KUgNCg9MHK_2NmzrgCMMJNcBwa4C76XwKoS7kt4E,13556
21
+ delphi_lsp/parallel_outline.py,sha256=nXIv-a9sn3nBeKaBtIhdD2YxGzHObdW3GxYEZ5Sy0pE,8588
21
22
  delphi_lsp/parser.py,sha256=4hckHD_fsiiUe3oeSRJwqbY8bDWqJaYLsU10NYDIW6E,6025
22
23
  delphi_lsp/preprocessor.py,sha256=aXJiabmCvWoUdrxYAA4EVVxDDQXl_IeFUezoDdMlFyU,30984
23
24
  delphi_lsp/progress.py,sha256=FF3lqdDKLvq3b7cOhuSc3P0FDzVnX0miD8U8_3xt5iY,535
@@ -28,9 +29,9 @@ delphi_lsp/semantic_builder.py,sha256=ulA7xHJpltjc6cTo5wh9yTcYazuIykM-YLMXD1h-wz
28
29
  delphi_lsp/source_reader.py,sha256=HWiw25sLVZ6s1GGpMl17uZ-o68HoxTEd3Z0__m1vsSE,520
29
30
  delphi_lsp/workspace.py,sha256=TU__SujItRlW8hB2kuYC7ZJx1WQVOj7frYxvo7rDqeI,2137
30
31
  delphi_lsp/writer.py,sha256=j-cnyiHFNOTFECL7Ehm-m43ye7ev7qFeCCscFMjrAOY,2503
31
- python_delphi_lsp-2.0.5.dist-info/licenses/LICENSE,sha256=-rPda9qyJvHAhjCx3ZF-Efy07F4eAg4sFvg6ChOGPoU,16726
32
- python_delphi_lsp-2.0.5.dist-info/METADATA,sha256=T3eZ2AtK8UFsL-6RaVdGxWCh00c8MA06urr2pgRLYHo,18327
33
- python_delphi_lsp-2.0.5.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
34
- python_delphi_lsp-2.0.5.dist-info/entry_points.txt,sha256=rh_yGH7diowge_acQcST87A-ewH1QezEsAYC8T5ineY,103
35
- python_delphi_lsp-2.0.5.dist-info/top_level.txt,sha256=0Zp0sjw5Qtic3EQX9zNeegURga8NuXk6c2e9PN-3OTs,11
36
- python_delphi_lsp-2.0.5.dist-info/RECORD,,
32
+ python_delphi_lsp-2.1.0.dist-info/licenses/LICENSE,sha256=-rPda9qyJvHAhjCx3ZF-Efy07F4eAg4sFvg6ChOGPoU,16726
33
+ python_delphi_lsp-2.1.0.dist-info/METADATA,sha256=REk-5BwUnYBx-IwSg9vYBrYl-gsEkJdrD9KEQjxPwss,19972
34
+ python_delphi_lsp-2.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
35
+ python_delphi_lsp-2.1.0.dist-info/entry_points.txt,sha256=rh_yGH7diowge_acQcST87A-ewH1QezEsAYC8T5ineY,103
36
+ python_delphi_lsp-2.1.0.dist-info/top_level.txt,sha256=0Zp0sjw5Qtic3EQX9zNeegURga8NuXk6c2e9PN-3OTs,11
37
+ python_delphi_lsp-2.1.0.dist-info/RECORD,,